diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml
index f8b22ee7033..e8d39d2c4f1 100644
--- a/.github/workflows/backend-ci.yml
+++ b/.github/workflows/backend-ci.yml
@@ -8,6 +8,13 @@ permissions:
contents: read
jobs:
+ deploy-scripts:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - name: Verify deployment guard scripts
+ run: make test-deploy-scripts
+
test:
runs-on: ubuntu-latest
steps:
@@ -20,7 +27,7 @@ jobs:
cache-dependency-path: backend/go.sum
- name: Verify Go version
run: |
- go version | grep -q 'go1.26.2'
+ go version | grep -q 'go1.26.5'
- name: Unit tests
working-directory: backend
run: make test-unit
@@ -35,7 +42,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
- version: 9
+ version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
@@ -48,6 +55,27 @@ jobs:
- name: Frontend typecheck and critical vitest
run: make test-frontend
+ frontend-windows-config:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ - name: Setup pnpm
+ uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
+ with:
+ version: 9.15.4
+ - name: Setup Node.js
+ uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
+ with:
+ node-version: '20'
+ cache: 'pnpm'
+ cache-dependency-path: frontend/pnpm-lock.yaml
+ - name: Install frontend dependencies
+ working-directory: frontend
+ run: pnpm install --frozen-lockfile
+ - name: Verify Windows GPT-5.6 customer templates
+ working-directory: frontend
+ run: pnpm exec vitest run src/components/keys/__tests__/UseKeyModal.spec.ts
+
golangci-lint:
runs-on: ubuntu-latest
steps:
@@ -60,7 +88,7 @@ jobs:
cache-dependency-path: backend/go.sum
- name: Verify Go version
run: |
- go version | grep -q 'go1.26.2'
+ go version | grep -q 'go1.26.5'
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 26ed8524141..040cdfffcc8 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -60,7 +60,7 @@ jobs:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
- version: 9
+ version: 9.15.4
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -115,7 +115,7 @@ jobs:
- name: Verify Go version
run: |
- go version | grep -q 'go1.26.2'
+ go version | grep -q 'go1.26.5'
# Docker setup for GoReleaser
- name: Set up QEMU
diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml
index 600fd2faecc..183860717d7 100644
--- a/.github/workflows/security-scan.yml
+++ b/.github/workflows/security-scan.yml
@@ -10,6 +10,50 @@ permissions:
contents: read
jobs:
+ repository-secret-scan:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+ with:
+ fetch-depth: 0
+ - name: Test tracked-file secret scanner
+ run: python3 -m unittest tools.test_secret_scan
+ - name: Scan tracked files
+ run: make secret-scan
+ - name: Scan current repository tree with gitleaks
+ run: |
+ docker run --rm \
+ -v "$PWD:/repo:ro" \
+ -w /repo \
+ zricethezav/gitleaks:v8.30.1@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f \
+ dir --redact --no-banner /repo
+ - name: Scan commits introduced by this event
+ env:
+ EVENT_NAME: ${{ github.event_name }}
+ BEFORE_SHA: ${{ github.event.before }}
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ CURRENT_SHA: ${{ github.sha }}
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ run: |
+ if [ "$EVENT_NAME" = "schedule" ]; then
+ exit 0
+ elif [ "$EVENT_NAME" = "pull_request" ] && [ -n "$BASE_SHA" ]; then
+ RANGE="$BASE_SHA..$CURRENT_SHA"
+ elif [ -n "$BEFORE_SHA" ] && ! printf '%s' "$BEFORE_SHA" | grep -Eq '^0+$'; then
+ RANGE="$BEFORE_SHA..$CURRENT_SHA"
+ elif [ -n "$DEFAULT_BRANCH" ]; then
+ git fetch --no-tags origin "$DEFAULT_BRANCH"
+ BASE_SHA="$(git merge-base "$CURRENT_SHA" "origin/$DEFAULT_BRANCH")"
+ RANGE="$BASE_SHA..$CURRENT_SHA"
+ else
+ RANGE="$CURRENT_SHA^..$CURRENT_SHA"
+ fi
+ docker run --rm \
+ -v "$PWD:/repo:ro" \
+ -w /repo \
+ zricethezav/gitleaks:v8.30.1@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f \
+ git --redact --no-banner --log-opts="$RANGE" /repo
+
backend-security:
runs-on: ubuntu-latest
timeout-minutes: 15
@@ -23,7 +67,7 @@ jobs:
cache-dependency-path: backend/go.sum
- name: Verify Go version
run: |
- go version | grep -q 'go1.26.2'
+ go version | grep -q 'go1.26.5'
- name: Run govulncheck
working-directory: backend
run: |
@@ -37,7 +81,7 @@ jobs:
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
- version: 9
+ version: 9.15.4
- name: Set up Node.js
uses: actions/setup-node@v6
with:
diff --git a/.gitignore b/.gitignore
index cf251f0715a..1608a70524f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,6 +24,8 @@ coverage.html
# 依赖(使用 go mod)
vendor/
+!frontend/vendor/
+!frontend/vendor/xlsx-0.20.3.tgz
# Go 编译缓存
backend/.gocache/
diff --git a/.gitleaksignore b/.gitleaksignore
new file mode 100644
index 00000000000..b0de757f73a
--- /dev/null
+++ b/.gitleaksignore
@@ -0,0 +1,2 @@
+/repo/backend/internal/pkg/geminicli/constants.go:generic-api-key:42
+/repo/backend/internal/pkg/antigravity/oauth.go:generic-api-key:56
diff --git a/.superpowers/sdd/second-phase-security-dependency-report.md b/.superpowers/sdd/second-phase-security-dependency-report.md
new file mode 100644
index 00000000000..f66436b6037
--- /dev/null
+++ b/.superpowers/sdd/second-phase-security-dependency-report.md
@@ -0,0 +1,91 @@
+# 第二阶段安全依赖与工具链收口
+
+## 结论
+
+- 前端生产依赖审计从 `4 high` 降为 `0 high / 0 critical`。
+- 后端 `govulncheck` 从 5 个可达漏洞降为 0。
+- Go 构建、CI、发布和安全扫描工具链统一到 `1.26.5`。
+- Go 1.26.5 下 unit、integration、vet 和全量 race 均通过。
+- 前端 596 个测试、ESLint、类型检查、生产构建和 XLSX Blob 导出 smoke 均通过。
+- 未部署、未重启、未修改生产数据;私有 503 原始记录未读取、未修改、未暂存。
+
+## 前端依赖
+
+### 修复前
+
+`pnpm audit --prod --audit-level=high`:
+
+- `xlsx`:2 个 high,原例外已过期。
+- `js-cookie`:1 个 high,无例外。
+- `form-data`:1 个 high,无例外。
+
+### 修复
+
+- SheetJS 0.20.3 官方 tarball 已 vendoring 到 `frontend/vendor/xlsx-0.20.3.tgz`,依赖使用 `file:vendor/xlsx-0.20.3.tgz`;`.gitignore` 精确放行该文件,保证干净检出和 Docker build context 都能取得依赖。
+- pnpm lock 固定该 tarball 的 SHA-512 integrity;本地重新计算与 lock 值一致。
+- pnpm override:`js-cookie=3.0.8`。
+- pnpm override:`form-data=4.0.6`。
+- 使用仓库 Dockerfile 同版本 `pnpm 9.15.4` 重建 lockfile,并用 frozen lockfile 安装验证。
+- 根 Dockerfile、部署 Dockerfile、三条 CI workflow 和 `packageManager` 全部精确固定 `pnpm 9.15.4`。
+
+### 修复后
+
+```text
+critical: 0
+high: 0
+Audit exceptions validated.
+```
+
+XLSX 写出 smoke:`ArrayBuffer` 15952 bytes,经现有 `Blob([out])` 路径生成同尺寸 Blob,PASS。
+
+说明:vendoring 后 audit 原始进程仍因 low/moderate 项返回 1;项目 CI 按既有规则只以 high/critical 异常门禁判定,当前门禁为 `0 high / 0 critical` 且 exceptions checker PASS。未隐藏 low/moderate 计数。
+
+## 后端依赖
+
+### 修复前
+
+`govulncheck ./...` 检出 5 个可达漏洞:
+
+- Go 1.26.3 标准库:3 个。
+- AWS S3/EventStream:1 个。
+- `golang.org/x/net`:1 个。
+
+### 修复
+
+- Go:`1.26.5`。
+- AWS S3:`v1.97.3`。
+- AWS EventStream:`v1.7.8`。
+- `golang.org/x/net`:`v0.55.0`。
+- `go get` 引入的最小联动模块升级由 `go mod tidy` 固定到 `go.mod/go.sum`。
+
+### 修复后
+
+```text
+No vulnerabilities found.
+Your code is affected by 0 vulnerabilities.
+go mod verify: all modules verified
+```
+
+## 回归证据
+
+- Go 1.26.5 unit:全包 PASS;`internal/service` 85.242s。
+- Go 1.26.5 integration:全包 PASS;repository 6.762s,service 45.392s。
+- Go 1.26.5 vet:PASS。
+- Go 1.26.5 race:全包 PASS。
+- Frontend Vitest:102 files / 596 tests PASS。
+- Frontend ESLint、`vue-tsc --noEmit`、Vite build:PASS。
+- Claude Code 本地 Messages identity harness:12/12 PASS。
+- deploy retention / Docker build guard:PASS。
+
+## Secret 扫描边界
+
+- gitleaks 全历史真实扫描:4379 commits、约 63.07 MB,发现 106 个 inherited 历史记录。
+- 本轮不改写 Git 历史;该项保留为生产 `NO-GO` 风险输入。
+- 最终提交后必须单独扫描 `931915e3..HEAD`,证明本轮没有新增泄漏。
+- 仓库 `make secret-scan` 引用的 `tools/secret_scan.py` 已被历史提交删除,因此该 Make target 仍为 `信息缺失`,不得虚报通过。
+
+## 生产边界
+
+- 本轮只开发、测试和构建本地制品。
+- 不运行、不推送最终镜像。
+- 不部署、不重启、不改生产配置或生产数据。
diff --git a/.superpowers/sdd/second-phase-task-1-report.md b/.superpowers/sdd/second-phase-task-1-report.md
new file mode 100644
index 00000000000..380065e11a4
--- /dev/null
+++ b/.superpowers/sdd/second-phase-task-1-report.md
@@ -0,0 +1,185 @@
+# 第二阶段 Task 1 报告:GPT-5.6 计费身份、前置定价与不可变证据
+
+## 状态
+
+实现完成,本轮代码与定向/整包测试通过。生产仍维持 **NO-GO**:本任务只解决请求进入上游前的可计价性、最终计费身份和价格快照证据,不包含钱包预占、成功上游响应后的可靠结算/outbox,也没有执行生产部署或镜像运行。
+
+## 成功标准与实际结果
+
+- 最终计费模型由 requested、compat、channel mapping、account mapping、upstream 的真实链路解析,GPT-5.6 禁止 requested billing、跨 tier/未知 tier 和非精确通配映射。
+- Responses、Responses passthrough、Chat Completions(转换及 raw)、Messages 兼容入口和 WebSocket 在 token 获取/上游 transport 前完成定价 preflight;不可计价时返回类型化错误并保持上游零调用。
+- 定价只解析一次并深拷贝为不可变 quote;结算使用该 quote,不重新读取已刷新的价格服务。
+- LiteLLM revision 使用有效价格的 canonical SHA-256 派生,不信任 `PricingService.localHash`;区间顺序、指针地址、数据库 ID/时间戳不影响 hash,有效价格变化会改变 hash。
+- WebSocket 每一 turn 重新解析 quote,连接建立时的早期 gate 不复用为结算价格。
+- `usage_logs` expand-only 增加 nullable `pricing_source`、`pricing_revision`、`pricing_hash`;仅管理员 DTO 返回,普通用户 DTO 不暴露。
+- 迁移 `172_add_usage_log_billing_model.sql` 未修改;新增 173 无回填、无默认值、无 NOT NULL、无索引。
+
+## TDD 证据
+
+### RED
+
+首次运行:
+
+```text
+/Users/markdonish/.local/share/go/go1.26.3/bin/go test ./internal/service -run 'OpenAIBillingIdentity|ResolvePricingQuote|PricingEvidenceHash|ForwardWithOptions' -count=1
+```
+
+编译按预期失败,缺失的目标符号包括:
+
+```text
+undefined: OpenAIBillingIdentityInput
+undefined: ResolveOpenAIBillingIdentity
+undefined: ErrOpenAIBillingPreflight
+undefined: ResolveQuote
+```
+
+环境补充:PATH 中没有 `go`(`zsh: command not found: go`),后续统一使用本机 Go 1.26.3 绝对路径。
+
+### GREEN
+
+```text
+go test -tags=unit ./internal/service -count=1
+ok github.com/Wei-Shaw/sub2api/internal/service 96.374s
+
+go test ./internal/service -run 'OpenAIBillingIdentity|ResolvePricingQuote|PricingEvidenceHash|ForwardWithOptions|OpenAISharedHTTPDispatch|OpenAIWSBilling|ImmutablePreflightQuote|RecordUsageUsesImmutablePreflightQuoteAndEvidence' -count=1
+ok github.com/Wei-Shaw/sub2api/internal/service 2.304s
+
+go test -tags=unit ./internal/repository -count=1
+ok github.com/Wei-Shaw/sub2api/internal/repository 2.319s
+
+go test ./internal/handler -run 'OpenAI|Responses|Messages|WebSocket|Chat' -count=1
+ok github.com/Wei-Shaw/sub2api/internal/handler 1.831s
+
+go test ./internal/handler/dto ./ent/... ./migrations -count=1
+PASS
+```
+
+另有全量无 tag 的 `internal/service` 已通过(44.711s)。独立审查修复 WS 首轮 quote、渠道 exact TOCTOU、图片证据错配和 WS quote map 并发访问后,再次运行相关 service/handler 定向回归并通过。`internal/handler` 全包仍有与本任务无关的既有 macOS `/private/var` 与 `/var` 路径断言失败 `TestResolvePageImagePath`;本轮相关 handler 测试已单独通过。
+
+## 数据库与安全审查
+
+- 173 迁移使用三个 `ADD COLUMN IF NOT EXISTS`,均 nullable;历史行保持 NULL,回滚可删除新增列但本轮不提供 destructive down。
+- PostgreSQL integration 已在隔离的 Testcontainers `postgres:18.1-alpine3.23` 与 `redis:8.4-alpine` 上执行:`TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate`、`TestUsageLogPricingEvidenceMigration173IsIdempotentAndLeavesHistoricalRowsNull` 均通过;repository 包结果 `ok ... 5.248s`。测试容器由 harness 创建并回收,未连接生产数据库。
+- `git diff --check` 通过;迁移 172 的 `git diff --exit-code` 通过。
+- 仓库 `make secret-scan` 失败,原因是 Makefile 引用的 `tools/secret_scan.py` 不存在;替代执行了本轮 diff 的常见 token、Bearer、AWS key、private key 和敏感文件名扫描,未命中。
+- 定价证据只保存 source/revision/hash,不保存请求体、token、Authorization、API key 或价格原文。
+- 独立代码审查和安全复审最终均无阻断项;账务 TOCTOU、图片证据错配、WS 首轮冻结及并发 map 问题均已修复并回归。
+- 禁止文件 `reports/HFC_PRODUCTION_503_TRIAGE_2026-07-10.md` 未读取、未修改、未暂存。
+
+## 残余风险
+
+1. 本任务不解决“上游成功但 worker/进程在持久化前失败”的扣费可靠性;这是第二阶段 Task 2 的范围,生产保持 NO-GO。
+2. 真实 PostgreSQL 迁移幂等/历史 NULL 已用隔离 Testcontainers 验证;生产迁移 rehearsal 仍未执行,继续保持 NO-GO。
+3. 没有执行生产配置修改、镜像运行、部署、推送或 canary。
+
+## 提交
+
+实现提交:`66ad9913` (`feat: add openai billing preflight evidence`)
+
+## 2026-07-10 Task 1 复审修复
+
+### 修复范围
+
+- Responses / passthrough 图片 intent 现在按请求中的最终 image tool model 与 size tier,在 token/HTTP/WS transport 前生成 image quote;不再用文本模型 quote 代替。
+- 独立 `/v1/images/generations`、`/v1/images/edits` handler 启用同一 image preflight;错误返回 400 `invalid_request_error`,不会落到 502/503。
+- API key 与 OAuth Images 的成功及部分成功结果都携带冻结 image identity/quote;`RecordUsage` 使用该 quote 的 image count、size tier 和 image multiplier 结算并保存 matching evidence,不再读取已变化的 live 价格。
+- WS 首轮及后续 turn 都从该 turn payload 判断 image intent 并冻结对应 image identity;文本 identity 禁止覆盖 image result。
+- channel image quote 必须命中本次请求的 exact size tier(或显式 default);例如 4K 请求只有 1K 价格会在上游前拒绝,不能跨 tier 或按零价结算。
+- Responses 图片 intent 同时冻结 image quote 与文本 fallback quote:`ImageCount > 0` 才消费 image quote;零图片输出使用文本 quote,避免 per-request 默认 1 导致过扣。独立 Images 零输出不会被强制按 1 张计费。
+- token quote 必须至少有一项正数、有限的有效价格;空、全零、负数、NaN、Inf 的渠道价格 fail closed。非 GPT 正数渠道价格兼容性保留。
+- image quote 明确记录实际生效来源:channel、group image、LiteLLM effective 或已知 `gpt-image-*` 的 built-in fallback;未知且无显式价格的图片模型 fail closed。
+
+### 复审 RED
+
+```text
+go test -tags=unit ./internal/service -run 'ResolvePricingQuoteRejectsEmpty|ResolvePricingQuoteAllowsExplicitPositive|ChannelImageBillingUsesImageCountAndSharedMultiplier|ImagePricingPreflightRejectsBeforeUpstream' -count=1
+
+FAIL TestResolvePricingQuoteRejectsEmptyExactChannelTokenPricing
+An error is expected but got nil.
+
+FAIL TestOpenAIGatewayServiceRecordUsage_ChannelImageBillingUsesImageCountAndSharedMultiplier
+expected frozen quote total 0.75, actual live-price total 2.40.
+```
+
+### 复审 GREEN
+
+```text
+go test ./internal/service -run 'OpenAIBillingIdentity|ResolvePricingQuote|PricingEvidenceHash|ImagePricingPreflight|ForwardImages_|RecordUsage.*(ImmutablePreflightQuote|ChannelImageBilling)|OpenAIWSBilling|AttachOpenAIBillingIdentity' -count=1
+ok github.com/Wei-Shaw/sub2api/internal/service 2.414s
+
+go test ./internal/handler -run 'OpenAI|Responses|Images|Messages|Chat|WebSocket|PricingPreflightErrorsReturn400|UnpriceableImageCloses|ImageTurnPersists' -count=1
+ok github.com/Wei-Shaw/sub2api/internal/handler 0.951s
+
+go test -tags=unit ./internal/repository -count=1
+ok github.com/Wei-Shaw/sub2api/internal/repository 2.909s
+```
+
+新增真实边界测试包括:HTTP handler preflight 返回 400 且 upstream hit=0;WS handler 对不可计价 image turn 关闭 1008 且 upstream dial=0;WS image turn 最终 usage 的 billing model/quote/evidence 一致;独立 Images 在 token/transport 前拒绝非法价格。
+
+最终复审修复后再次运行:
+
+```text
+go test ./internal/service -run 'OpenAIBillingIdentity|ResolvePricingQuote|PricingEvidenceHash|ImagePricingPreflight|ForwardImages_|RecordUsage.*(ImmutablePreflightQuote|ChannelImageBilling|ImageIntentWithoutOutput)|OpenAIWSBilling|AttachOpenAIBillingIdentity|MissingRequestedSizeTier' -count=1
+ok github.com/Wei-Shaw/sub2api/internal/service 1.374s
+
+go test ./internal/handler -run 'OpenAI|Responses|Images|Messages|Chat|WebSocket|PricingPreflightErrorsReturn400|UnpriceableImageCloses|ImageTurnPersists' -count=1
+ok github.com/Wei-Shaw/sub2api/internal/handler 0.800s
+
+go test -tags=unit ./internal/repository -count=1
+ok github.com/Wei-Shaw/sub2api/internal/repository 2.013s
+```
+
+`git diff --check`、迁移 172 不变检查、diff 级常见凭证扫描均通过。私有 503 报告继续保持未读取、未修改、未暂存。
+
+修复提交标题:`fix: close image billing preflight gaps`(本报告与修复代码同一提交)。
+
+## 2026-07-10 Task 1 最终零图与零价语义修复
+
+### 最终复审范围
+
+- API key 与 OAuth Images 的权威上游零输出均保持 `ImageCount=0`,不再回退请求参数 `n`;dedicated Images 零输出 settlement 成本为 0。
+- API-key SSE 只有看到 `[DONE]`、`image_generation.completed`、`image_edit.completed` 或 `response.completed` 才视为完整;EOF/断线且无 terminal 属于结果未知并 fail closed。完整 terminal 的零图仍按权威 0 处理。
+- 非 GPT token interval 允许未使用维度显式为 0,但每个可能命中的 interval 自身至少要有一项正有限价格;全零、负数、NaN、Inf interval 均在上游前拒绝,不能借用 base 或另一区间的正价通过。
+- settlement 按实际使用维度再次 fail closed;被使用的 input/output/cache/image 维度没有正价时返回错误。
+
+### RED 证据
+
+```text
+go test ./internal/service -run 'UpstreamZeroImagesDoesNotUseRequestedCount|DedicatedImageWithoutOutputChargesZero' -count=1
+FAIL API-key expected 0 images, got requested n=3.
+FAIL OAuth zero-output returned upstream did not return image output.
+
+go test -tags=unit ./internal/service -run 'AllowsZeroUnusedIntervalDimension' -count=1
+FAIL openai pricing unavailable for custom-zero-input.
+
+go test ./internal/service -run 'EOFWithoutTerminalIsUnknown' -count=1
+FAIL expected incomplete stream error, got nil.
+
+go test -tags=unit ./internal/service -run 'RejectsAllZeroIntervalEvenWithUsableBasePrice' -count=1
+FAIL expected ErrOpenAIPricingUnavailable, got nil.
+```
+
+### GREEN 与独立复审
+
+```text
+go test ./internal/service -run 'EOFWithoutTerminalIsUnknown|TerminalZeroImagesIsAuthoritative|UpstreamZeroImagesDoesNotUseRequestedCount|DedicatedImageWithoutOutputChargesZero' -count=1
+ok github.com/Wei-Shaw/sub2api/internal/service 0.875s
+
+go test -tags=unit ./internal/service -run 'AllowsZeroUnusedIntervalDimension|RejectsAllZeroOrInvalidIntervalPrices|RejectsAllZeroIntervalEvenWithUsableBasePrice|ValidateTokenPricingForUsage' -count=1
+ok github.com/Wei-Shaw/sub2api/internal/service 1.024s
+
+go test ./internal/service -run 'ForwardImages|RecordUsage|OpenAIBillingIdentity|ResolvePricingQuote|CalculateCostUnified' -count=1
+ok github.com/Wei-Shaw/sub2api/internal/service 0.854s
+
+go test -tags=unit ./internal/service -run 'ResolvePricingQuote|CalculateCostUnified' -count=1
+ok github.com/Wei-Shaw/sub2api/internal/service 1.551s
+
+go test ./internal/handler -run 'OpenAI|Responses|Images|Messages|Chat|WebSocket' -count=1
+ok github.com/Wei-Shaw/sub2api/internal/handler 0.951s
+```
+
+最终独立代码复审:PASS。最终独立安全复审:PASS。`git diff --check` 通过;迁移 172 未修改;私有 503 报告未读取、未修改、未暂存。
+
+合并状态 `0100cc4c` 的任务级最终复审:PASS。复审定向测试为 service unit `1.428s`、service `0.884s`、handler `0.792s`;Task 1 阻断项全部关闭。
+
+修复提交标题:`fix: preserve zero-image and zero-tier billing semantics`。
diff --git a/.superpowers/sdd/second-phase-task-2a-report.md b/.superpowers/sdd/second-phase-task-2a-report.md
new file mode 100644
index 00000000000..22b112afc4e
--- /dev/null
+++ b/.superpowers/sdd/second-phase-task-2a-report.md
@@ -0,0 +1,160 @@
+# Second-phase Task 2A report: durable usage billing outbox core
+
+Date: 2026-07-10
+
+Base: `0c2520b1`
+
+Scope: Task 2A only. This change adds the durable schema, immutable envelope,
+PostgreSQL repository, replay processor, ownership validation and tests. Handler,
+producer, worker lifecycle/Wire registration, WebSocket turn IDs and queue cutover
+remain Task 2B and are not claimed complete here.
+
+## Implemented
+
+- Forward-only migration `174_add_usage_billing_outbox.sql`:
+ - dedicated `usage_billing_outbox` table;
+ - unique `(request_id, api_key_id)`;
+ - pending/retry/completed/dead-letter lifecycle;
+ - bounded attempts, per-claim fencing token, final-attempt crash recovery and partial claim/DLQ indexes;
+ - no business foreign keys.
+- Versioned immutable `UsageBillingEnvelope`:
+ - explicit IDs, final billing model, token/image facts, price evidence,
+ multipliers and exact billing side effects;
+ - no arbitrary maps or request/client credential fields;
+ - canonical fingerprint and defensive pointer copies;
+ - validation for IDs, versions, finite/non-negative values, incompatible billing
+ paths and fingerprint tampering.
+- PostgreSQL outbox repository:
+ - same-key/same-fingerprint enqueue idempotency;
+ - an already-durable event remains idempotent after target soft-delete, while a different fingerprint still fails with a typed conflict;
+ - typed conflict without payload overwrite;
+ - `FOR UPDATE SKIP LOCKED` claim with a 30-second logical lease and random fencing token;
+ - owner-and-token-matched completion/retry/dead-letter transitions;
+ - enqueue-time row locks for API-key/user/group, routed account/group and subscription billing mode;
+ - fixed-group keys and real `group_id=NULL` wallet universal keys are validated separately.
+- Processor:
+ - fixed order `ValidateBindings -> UsageBillingRepository.Apply -> replay writer -> Complete`;
+ - calls `UsageBillingRepository.Apply` directly and has no legacy billing fallback;
+ - bounded deterministic retry backoff;
+ - poison/version/fingerprint/cross-tenant direct DLQ;
+ - max-attempt DLQ;
+ - acknowledgement failure leaves the lease for replay through billing dedup.
+ - batch processing continues after one event fails, so already-claimed siblings do not silently consume attempts;
+ - persisted errors are allow-listed and never store raw database/request error text.
+- Frozen replay commands can complete balance, API-key/account quota, monthly and wallet effects after a target is soft-deleted; direct non-outbox billing keeps its existing soft-delete behavior.
+- Narrow `UsageBillingReplayWriter` seam for Task 2B's concrete idempotent usage-log
+ writer. The Task 2A integration test proves the required ordering and replay
+ behavior without wiring handlers prematurely.
+
+## TDD evidence
+
+### RED
+
+1. Envelope:
+
+```text
+/Users/markdonish/.local/share/go/go1.26.3/bin/go test ./internal/service -run '^TestUsageBillingEnvelope_' -count=1
+```
+
+Failed at compile time because `UsageBillingEnvelopeInput`, constructor, decoder
+and typed validation errors did not exist.
+
+2. Migration on real PostgreSQL:
+
+```text
+/Users/markdonish/.local/share/go/go1.26.3/bin/go test -tags=integration ./internal/repository -run '^TestUsageBillingOutboxMigration_SchemaAndIndexes$' -count=1
+```
+
+Failed with `expected usage_billing_outbox table to exist`.
+
+3. Repository:
+
+```text
+/Users/markdonish/.local/share/go/go1.26.3/bin/go test -tags=integration ./internal/repository -run '^TestUsageBillingOutboxRepository_' -count=1
+```
+
+Failed at compile time because the repository constructor, lifecycle constants,
+lease and stale-owner error did not exist.
+
+4. Processor:
+
+```text
+/Users/markdonish/.local/share/go/go1.26.3/bin/go test ./internal/service -run '^TestUsageBillingOutboxProcessor_' -count=1
+```
+
+Failed at compile time because the processor and poison-envelope event field did
+not exist.
+
+### GREEN
+
+Envelope and processor unit tests:
+
+```text
+/Users/markdonish/.local/share/go/go1.26.3/bin/go test ./internal/service -run 'TestUsageBilling(Envelope|OutboxProcessor)' -count=1
+```
+
+Result: PASS.
+
+Broader service/repository unit tests:
+
+```text
+/Users/markdonish/.local/share/go/go1.26.3/bin/go test ./internal/service ./internal/repository -count=1
+```
+
+Latest result: PASS (`service 44.424s`, `repository 3.046s`).
+
+Targeted real PostgreSQL/Redis Testcontainers integration:
+
+```text
+/Users/markdonish/.local/share/go/go1.26.3/bin/go test -tags=integration ./internal/repository -run 'TestUsageBillingOutbox(Migration|Repository|ProcessorIntegration)' -count=1
+```
+
+Latest result: PASS (`3.738s`). Covered schema/index/checks, strict outer-envelope
+identity, enqueue idempotency/conflict, `SKIP LOCKED`, lease expiry, same-owner
+fencing, final-attempt reclaim, retry/DLQ, fixed-group and universal-wallet binding
+matrices, apply-commit then ack-crash replay, soft-deleted targets, one balance
+deduction, one usage log, monthly counters and wallet deduction/ledger semantics
+with frozen subscription IDs.
+
+Formatting/diff gate:
+
+```text
+git diff --check
+```
+
+Result: PASS.
+
+Independent completion reviews:
+
+- Code review: PASS after closing the universal wallet-key and post-delete enqueue idempotency gaps.
+- Security/data-integrity review: PASS. No blocking finding; the remaining non-blocking risk is the lack of lease renewal for unusually long processing, which is contained by billing dedup and must remain idempotent in Task 2B's replay writer/finalizer.
+
+## Security and data-integrity boundaries
+
+- Envelope JSON has a strict allow-list and tests assert absence of API key
+ material, credentials, cookies, headers, bodies, prompts, messages, tools, IP,
+ User-Agent and request payload hashes.
+- SQL values are parameterized. Dynamic SQL fragments are internal constants,
+ not external input.
+- Cross-tenant API key/user/group/account and subscription billing-mode bindings fail atomically at enqueue.
+- A `group_id=NULL` key is accepted only when its exact name marks the wallet universal-key contract and the frozen subscription is a real wallet row; arbitrary unbound keys and universal-key/monthly combinations fail closed.
+- Lease completion uses a random fencing token in addition to owner identity; a stale goroutine cannot complete a later lease held by the same process owner.
+- `last_error` contains a fixed safe message while typed `last_error_code` retains the actionable class.
+- Poison payloads remain durable as dead-letter rows; completed/dead rows are not
+ automatically deleted.
+- Migration 172 and 173 were not modified.
+- The private production 503 report was not read, modified or staged.
+
+## Residual scope: Task 2B
+
+Task 2A does not make live gateway billing durable by itself. Task 2B must:
+
+- prepare and persist the envelope before any in-memory worker submission;
+- cut all billable handler paths to outbox IDs only, with no drop/sample loss;
+- provide the concrete idempotent usage-log replay writer;
+- register processor lifecycle/Wire startup and shutdown;
+- assign deterministic per-turn WebSocket request IDs;
+- prove queue saturation, restart recovery and all handler/WS entrypoints.
+
+Production remains `NO-GO` until Task 2B and the remaining second-phase tasks are
+implemented and verified.
diff --git a/.superpowers/sdd/second-phase-task-2b-report.md b/.superpowers/sdd/second-phase-task-2b-report.md
new file mode 100644
index 00000000000..6d1b0cff10f
--- /dev/null
+++ b/.superpowers/sdd/second-phase-task-2b-report.md
@@ -0,0 +1,75 @@
+# 第二阶段 Task 2B:OpenAI durable usage 生产接线验证
+
+## 结论
+
+- 状态:代码候选已实现并通过定向单元、包级和真实 PostgreSQL outbox 全场景测试。
+- 生产:`NO-GO`,本任务未部署、未重启服务、未运行或推送镜像、未改生产数据。
+- 私有 503 原始记录:未读取、未修改、未暂存、未提交。
+
+## 已实现
+
+1. OpenAI Responses、Chat Completions、Anthropic Messages 兼容、Images、WebSocket usage 同步写入数据库 outbox,不再进入可 drop/sample 的内存 usage 队列。
+2. `UsageBillingEnvelope` 保存安全的 usage replay 快照:执行/请求/上游/计费模型、token、价格证据、费用、渠道映射和发生时间;不保存请求体、headers、IP、UA 或凭据。API Key 仅冻结 SHA-256 auth cache locator,quota effect 缺 locator 时拒绝入队。
+3. replay 固定顺序为 `Apply -> usage_logs 幂等写入 -> 幂等缓存失效 -> Complete`。
+4. 后台 worker 启动立即排空、支持 wake hint、轮询恢复、错误退避和优雅停止;服务 cleanup 先停止 worker,再关闭缓存与数据库。
+5. 路由 group 与实际计费 group 分别冻结;月卡跨组只接受明确的 `subscription_plan_groups` 覆盖,钱包和原月卡扣费语义保持不变。
+6. Claude alias 与原生 GPT 请求别名仅写入 `requested_model`,`model`、`upstream_model` 和 `billing_model` 使用实际执行 GPT。
+7. quota replay 按冻结 locator 直接可靠清除认证缓存,不依赖 active key 枚举,因此 API Key 软删除/tombstone 后仍可重试。
+8. WebSocket 每个连接/turn 使用稳定且互不冲突的账单 request ID。
+
+## 验证证据
+
+### 定向单元测试
+
+```text
+go test ./internal/service -run 'TestUsageBilling(Envelope|Replay)|TestOpenAIUsageBillingProducer' -count=1
+ok github.com/Wei-Shaw/sub2api/internal/service
+
+go test -tags=unit ./internal/service -run 'TestAPIKeyService_InvalidateAuthCacheBy(Locator|UserIDReliable)' -count=1
+ok github.com/Wei-Shaw/sub2api/internal/service
+```
+
+### 真实 PostgreSQL
+
+```text
+go test -tags=integration ./internal/repository -run '^TestUsageBillingOutbox' -count=1 -v
+PASS BalanceAckCrashReplayChargesAndLogsOnce
+PASS MonthlyAndWalletPreserveFrozenSubscription/monthly
+PASS MonthlyAndWalletPreserveFrozenSubscription/monthly_plan_coverage
+PASS MonthlyAndWalletPreserveFrozenSubscription/wallet
+PASS PendingEventSurvivesWorkerRestart
+PASS EnqueueIdempotencyAndConflict
+PASS ClaimLeaseAndStaleOwner
+PASS RetryAndDeadLetterLifecycle
+PASS EnqueueRejectsCrossTenantAndInvalidBillingMode
+PASS FinalAttemptCanBeReclaimedWithFencingToken
+PASS Migration_SchemaAndIndexes
+```
+
+验证了:进程退出前仅 enqueue、重启后恢复;ack 崩溃重放只扣一次且 usage log 一条;月卡日/周/月累计不变义;M:N 套餐覆盖与未覆盖拒绝;钱包余额和 ledger 不重复;locator 与 API Key 绑定、NULL `key_hash` fallback 和错误 locator fail closed。
+
+### 包级与 Wire
+
+```text
+go generate ./cmd/server
+wire: wrote cmd/server/wire_gen.go
+
+go test -p=1 ./internal/service ./internal/handler ./cmd/server ./internal/repository -count=1
+ok github.com/Wei-Shaw/sub2api/internal/service
+ok github.com/Wei-Shaw/sub2api/internal/handler
+ok github.com/Wei-Shaw/sub2api/cmd/server
+ok github.com/Wei-Shaw/sub2api/internal/repository
+```
+
+本轮在 Go 1.26.3 容器中串行执行,避免 Docker Desktop 资源争用造成编译器被系统杀掉;真实 PostgreSQL 运行使用 Testcontainers,未连接生产数据库。
+
+### 独立审查
+
+- 代码复审:`PASS`。确认 legacy `UsageBillingCommand` 指纹保持兼容,locator 仅受 outbox envelope 指纹保护。
+- 安全复审:`PASS`。确认 raw API Key 不落 outbox/日志,locator 绑定与软删除后可靠失效闭环成立。
+
+## 剩余边界
+
+- 整仓、race、vet、Claude Code harness、前端、迁移全量、安全扫描和最终镜像仍属于后续验收,未据此放开生产。
+- 上游已成功执行后、outbox enqueue 恰逢数据库不可用时会显式返回并记录高优先级错误,不会回退到旧扣费或内存队列;跨上游与本地数据库无法形成同一事务,此边界继续作为 canary/监控前置风险保留。
+- Windows 实机验证:`信息缺失`。
diff --git a/.superpowers/sdd/second-phase-task-3-report.md b/.superpowers/sdd/second-phase-task-3-report.md
new file mode 100644
index 00000000000..1baa3a0e201
--- /dev/null
+++ b/.superpowers/sdd/second-phase-task-3-report.md
@@ -0,0 +1,63 @@
+# 第二阶段 Task 3:GPT-5.6 403 模型隔离与稳定错误边界
+
+## 结论
+
+- 状态:代码候选已实现并通过定向、默认包级和 unit 标签测试。
+- 生产:`NO-GO`;未部署、未重启、未连接生产数据库或 Redis、未运行或推送候选镜像。
+- 私有 503 原始记录:未读取、未修改、未暂存、未提交。
+
+## RED 证据
+
+实现前定向测试确认:
+
+```text
+RateLimitService.HandleUpstreamErrorForModel undefined
+
+Responses: expected 403, actual 503
+Chat Completions: expected 403, actual 503
+Anthropic Messages compat: expected 403, actual 503
+```
+
+## 已实现
+
+1. 新增 model-aware upstream error 入口。OpenAI exact GPT-5.6 tier 的 403 只调用 `SetModelRateLimit`,不递增账号级 403 counter,不调用 `SetError`,也不设置 account-wide temp unschedulable。
+2. 模型隔离 key 使用账号 canonical tier 的 exact 映射目标;无 entitlement、跨 tier 或未知 GPT-5.6 名称不会进入新分支,继续沿用既有安全策略。
+3. 模型隔离持久化失败时仍让当前请求 failover,但禁止降级成整账号禁用。
+4. Responses、Chat Completions、Anthropic Messages、Images 和 passthrough 的 OpenAI error 路径均携带模型身份进入 model-aware policy。
+5. 新增 typed `ErrNoAvailableOpenAIAccounts`。只有 exact GPT-5.6 的首次明确无账号,或上一跳明确为 GPT-5.6 403 时,才转换为 HTTP 403 `model_not_available`;数据库、调度器及已有 5xx/429 failover 保持原错误。
+6. WebSocket 在相同无授权/未开放场景使用 1008 policy violation;原跨模型映射 1008 边界保持不变。
+7. 403 运行日志不再输出 raw body,仅记录脱敏后的 upstream message 与响应字节数。
+
+## GREEN 证据
+
+Go 1.26.3 本地容器串行执行:
+
+```text
+go test -tags=unit ./internal/service -run 'TestRateLimitService_HandleUpstreamErrorForModel|TestRateLimitService_HandleUpstreamError_OpenAI403' -count=1
+ok github.com/Wei-Shaw/sub2api/internal/service
+
+go test ./internal/handler -run 'TestOpenAIGPT56|TestOpenAIResponsesWebSocket_GPT56Unavailable' -count=1
+ok github.com/Wei-Shaw/sub2api/internal/handler
+
+go test ./internal/service -run 'Test(OpenAI|RateLimitService)' -count=1
+ok github.com/Wei-Shaw/sub2api/internal/service
+
+go test ./internal/service ./internal/handler -count=1
+ok github.com/Wei-Shaw/sub2api/internal/service 46.828s
+ok github.com/Wei-Shaw/sub2api/internal/handler 21.453s
+
+go test -tags=unit ./internal/service -count=1
+ok github.com/Wei-Shaw/sub2api/internal/service 92.938s
+```
+
+额外回归覆盖:exact tier 无 entitlement 回退旧策略;mapped model-rate-limit key 能被调度器读取;上游 500 后账号耗尽仍返回 502 `upstream_error`,不误报 403。
+
+## 独立审查
+
+- 安全复审:`PASS`。确认 exact entitlement、模型级失败隔离和无 raw body 日志满足边界。
+- 代码复审:`PASS`。确认首次无账号、上一跳 403 与已有 5xx/429 的错误分类顺序正确。
+
+## 剩余边界
+
+- 整仓、race、vet、前端、Claude Code harness、安全扫描和最终镜像仍属于后续验收。
+- Windows 实机验证:`信息缺失`。
diff --git a/.superpowers/sdd/second-phase-task-4-report.md b/.superpowers/sdd/second-phase-task-4-report.md
new file mode 100644
index 00000000000..a12ff2adbb2
--- /dev/null
+++ b/.superpowers/sdd/second-phase-task-4-report.md
@@ -0,0 +1,42 @@
+# 第二阶段 Task 4:继承测试失败修复
+
+## 结论
+
+- 仅修复陈旧 fixture、mock 和测试预期;未修改前端产品代码或 API 合约。
+- 前端完整 Vitest:102 个文件、596 个测试全部通过,0 unhandled error。
+- backend page path 测试兼容 macOS `/var` 到 `/private/var` realpath;handler 包级测试已通过。
+- 生产保持 `NO-GO`;未部署、未重启、未改生产数据。
+- 私有 503 原始记录未读取、未修改、未暂存、未提交。
+
+## 修复项
+
+1. `EmailVerifyView`:把 `AFF123` 放入当前 pending OAuth 建号场景的 `register_data`,继续明确验证返利邀请码透传;请求断言改用 `objectContaining` 兼容现行新增可选字段,但未弱化核心字段。
+2. `ModelDistributionChart` / `GroupDistributionChart`:fixture 补齐现行必填 `account_cost`。
+3. auth source defaults:fixture 和 payload 断言补齐 GitHub、Google。
+4. `usePersistedPageSize`:按现行产品行为优先有效用户持久化值;新增非法值回退系统默认测试。
+5. `WalletBalanceCard`:改为 `vue-i18n` partial mock,保留 `createI18n` 等真实导出。
+6. `DashboardView`:fixture 补齐 `today_account_cost`、`total_account_cost`,消除异步重渲染 unhandled rejection。
+7. `page_handler_test.go`:期望路径通过 `filepath.EvalSymlinks` 解析,与被测函数的 realpath 行为一致。
+
+## 验证证据
+
+```text
+./node_modules/.bin/vitest run src/views/auth/__tests__/EmailVerifyView.spec.ts --reporter=dot
+Test Files 1 passed (1)
+Tests 7 passed (7)
+
+./node_modules/.bin/vitest run --reporter=dot
+Test Files 102 passed (102)
+Tests 596 passed (596)
+Duration 11.35s
+
+./node_modules/.bin/vue-tsc --noEmit
+exit 0
+```
+
+完整套件只有既有、预期的 stderr 场景日志和 Vue `router-link` 测试警告;无失败、无未处理 Promise rejection。
+
+## 独立审查
+
+- 代码审查:`PASS`。
+- 首轮审查发现返利邀请码断言被弱化;已改为在目标 fixture 中显式提供并断言 `AFF123`,复审确认无阻断项。
diff --git a/DEV_GUIDE.md b/DEV_GUIDE.md
index d0d362e0526..fe6c409635a 100644
--- a/DEV_GUIDE.md
+++ b/DEV_GUIDE.md
@@ -53,7 +53,7 @@ npm install -g pnpm
### CI 要求
-- Go 版本必须是 **1.25.7**
+- Go 版本必须是 **1.26.5**
- 前端使用 `pnpm install --frozen-lockfile`,必须提交 `pnpm-lock.yaml`
### 本地测试命令
diff --git a/Dockerfile b/Dockerfile
index 890bda0bfa8..38301e47739 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,34 +7,49 @@
# =============================================================================
ARG NODE_IMAGE=node:24-alpine
-ARG GOLANG_IMAGE=golang:1.26.2-alpine
+ARG GOLANG_IMAGE=golang:1.26.5-alpine
ARG ALPINE_IMAGE=alpine:3.21
ARG POSTGRES_IMAGE=postgres:18-alpine
ARG GOPROXY=https://goproxy.cn,direct
ARG GOSUMDB=sum.golang.google.cn
+ARG BUILDPLATFORM=linux/amd64
+ARG TARGETPLATFORM=linux/amd64
+ARG TARGETARCH=amd64
# -----------------------------------------------------------------------------
# Stage 1: Frontend Builder
# -----------------------------------------------------------------------------
-FROM ${NODE_IMAGE} AS frontend-builder
+# --platform=$BUILDPLATFORM: 在 host 原生架构跑 (Apple Silicon arm64 / Intel amd64
+# 都不走 QEMU emulation),避免 esbuild 在 QEMU 下 crash。frontend dist 是纯静态
+# 文件,跟最终 image 架构无关。
+FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS frontend-builder
WORKDIR /app/frontend
# Install pnpm
-RUN corepack enable && corepack prepare pnpm@latest --activate
+# 固定 pnpm 9.x: pnpm@10 启用了对未批准 install scripts 的严格拒绝
+# (ERR_PNPM_IGNORED_BUILDS), 会阻塞 esbuild/vue-demi 的构建。
+# 等 frontend/package.json 加上 pnpm.onlyBuiltDependencies 白名单后再升级。
+RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
# Install dependencies first (better caching)
COPY frontend/package.json frontend/pnpm-lock.yaml ./
+COPY frontend/vendor/ ./vendor/
RUN pnpm install --frozen-lockfile
# Copy frontend source and build
COPY frontend/ ./
-RUN pnpm run build
+# FRONTEND_NODE_OPTIONS 可调: 默认 3072 适合本地 build (Mac / 大内存机);
+# VPS build 时降到 --max-old-space-size=1024 或 768 避免 OOM。
+ARG FRONTEND_NODE_OPTIONS="--max-old-space-size=3072"
+RUN NODE_OPTIONS="${FRONTEND_NODE_OPTIONS}" pnpm run build
# -----------------------------------------------------------------------------
# Stage 2: Backend Builder
# -----------------------------------------------------------------------------
-FROM ${GOLANG_IMAGE} AS backend-builder
+# --platform=$BUILDPLATFORM: host 原生跑 go (避免 QEMU 拖慢 ~3 倍);
+# 配合 GOARCH=$TARGETARCH 跨编译到目标架构,产 amd64 binary 塞进 amd64 final image。
+FROM --platform=$BUILDPLATFORM ${GOLANG_IMAGE} AS backend-builder
# Build arguments for version info (set by CI)
ARG VERSION=
@@ -42,6 +57,10 @@ ARG COMMIT=docker
ARG DATE
ARG GOPROXY
ARG GOSUMDB
+ARG GO_BUILD_GOMAXPROCS=
+ARG GO_BUILD_FLAGS=
+# buildx auto-injects TARGETARCH when --platform is set (e.g. linux/amd64 → amd64)
+ARG TARGETARCH
ENV GOPROXY=${GOPROXY}
ENV GOSUMDB=${GOSUMDB}
@@ -66,7 +85,9 @@ COPY --from=frontend-builder /app/backend/internal/web/dist ./internal/web/dist
RUN VERSION_VALUE="${VERSION}" && \
if [ -z "${VERSION_VALUE}" ]; then VERSION_VALUE="$(tr -d '\r\n' < ./cmd/server/VERSION)"; fi && \
DATE_VALUE="${DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" && \
- CGO_ENABLED=0 GOOS=linux go build \
+ if [ -n "${GO_BUILD_GOMAXPROCS}" ]; then export GOMAXPROCS="${GO_BUILD_GOMAXPROCS}"; fi && \
+ CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} go build \
+ ${GO_BUILD_FLAGS} \
-tags embed \
-ldflags="-s -w -X main.Version=${VERSION_VALUE} -X main.Commit=${COMMIT} -X main.Date=${DATE_VALUE} -X main.BuildType=release" \
-trimpath \
@@ -117,9 +138,10 @@ WORKDIR /app
# Copy binary/resources with ownership to avoid extra full-layer chown copy
COPY --from=backend-builder --chown=sub2api:sub2api /app/sub2api /app/sub2api
COPY --from=backend-builder --chown=sub2api:sub2api /app/backend/resources /app/resources
+COPY --chown=sub2api:sub2api deploy/hfc-prod-fixset-20260524 /app/.hfc-prod-fixset-20260524
# Create data directory
-RUN mkdir -p /app/data && chown sub2api:sub2api /app/data
+RUN mkdir -p /app/data && chown sub2api:sub2api /app /app/data
# Copy entrypoint script (fixes volume permissions then drops to sub2api)
COPY deploy/docker-entrypoint.sh /app/docker-entrypoint.sh
diff --git a/HFC_FRONTEND_PAGE_NAV_CHANGE_REVIEW_2026-06-04.md b/HFC_FRONTEND_PAGE_NAV_CHANGE_REVIEW_2026-06-04.md
new file mode 100644
index 00000000000..976b5a70423
--- /dev/null
+++ b/HFC_FRONTEND_PAGE_NAV_CHANGE_REVIEW_2026-06-04.md
@@ -0,0 +1,211 @@
+# HFC Frontend Page / Sidebar Change Review - 2026-06-04
+
+## Scope
+
+本报告只梳理当前升级候选里的前端页面、个人主页侧边栏、用户可见入口变化,供上线前人工审核。
+
+本轮没有生产部署、没有生产迁移、没有生产数据修改。
+
+最高边界继续不变:页面可以展示、提醒、跳转,但不能改变 HFC 计费口径、钱包账本、月卡扣费、模型价格、倍率、支付链路、分组覆盖、LoadFactor 或调度优先级。
+
+## Summary
+
+结论先说清楚:
+
+- 当前候选分支没有修改 `frontend/src/components/layout/AppSidebar.vue`。
+- 当前候选分支没有修改 `frontend/src/router/index.ts`。
+- 因此,本次候选没有新增“个人主页侧边栏菜单入口”。
+- 用户侧前端变化主要是页面内容和弹窗变化,不是侧边栏结构变化。
+- 相对 2026-06-03 safe-fusion 交接基线,用户侧只剩购买/订阅/续费页的 Opus 4.8 错误横幅删除;后台新增用户余额历史中的充值余额/订阅钱包拆分展示。
+- 相对更早的生产保护基线 `6abdf8d7`,用户侧页面变化更明显,集中在 Dashboard、购买页、订阅页、兑换页、套餐卡、续费弹窗和 Opus 4.8 展示常量。
+
+## Current Personal Sidebar
+
+普通用户当前实际侧边栏来自 `buildSelfNavItems(true)`,菜单顺序如下:
+
+| 菜单 | 路径 | 本次是否新增 | 显示条件 |
+| --- | --- | --- | --- |
+| 仪表盘 | `/dashboard` | 否 | 普通用户显示;管理员个人区不含这个,因为管理员有 `/admin/dashboard` |
+| API 密钥 | `/keys` | 否 | 始终显示 |
+| 使用记录 | `/usage` | 否 | simple mode 隐藏 |
+| 可用渠道 | `/available-channels` | 否 | simple mode 隐藏;受 `availableChannels` feature flag 控制 |
+| 渠道状态 | `/monitor` | 否 | 受 `channelMonitor` feature flag 控制 |
+| 我的订阅 | `/subscriptions` | 否 | simple mode 隐藏 |
+| 充值/订阅 | `/purchase` | 否 | simple mode 隐藏;受 `payment` feature flag 控制 |
+| 我的订单 | `/orders` | 否 | simple mode 隐藏;受 `payment` feature flag 控制 |
+| 兑换 | `/redeem` | 否 | simple mode 隐藏 |
+| 邀请返利 | `/affiliate` | 否 | simple mode 隐藏;受 `affiliate` feature flag 控制 |
+| 个人资料 | `/profile` | 否 | 始终显示 |
+| 自定义菜单 | `/custom/:id` | 否 | 后台配置 `custom_menu_items` 且 visibility=user |
+
+管理员侧边栏下方“我的账户”区复用同一组个人菜单,但不含 `/dashboard`。simple mode 下管理员的“我的账户”区会隐藏,管理区会额外保留 `/keys` 入口,避免管理员自己的 API key 管理入口消失。
+
+## User Page Changes
+
+### 1. 仪表盘 `/dashboard`
+
+用户可见变化:
+
+- 非 simple mode 且没有 wallet 订阅时,新增“当前余额”充值 CTA 卡片。
+- CTA 显示当前余额、永久有效、所有模型可用、按渠道倍率扣费。
+- 点击“立即充值 / 续费”不跳页面,打开统一的链动 SKU 弹窗。
+- wallet 订阅用户继续显示 `WalletBalanceCard` 和 `WalletModelRouteList`;点击续费也打开同一个统一弹窗。
+
+业务边界:
+
+- 只是入口和展示变化。
+- 不改余额数值来源。
+- 不改扣费、账本、倍率或月卡逻辑。
+
+### 2. 充值/订阅页 `/purchase`
+
+用户可见变化:
+
+- select 阶段顶部增加兑换码有效期提醒:
+ - “购买后如获得兑换码,请在 30 天内完成兑换;未发放的库存码不计入有效期。”
+- 通用余额充值说明增加“如订单发放兑换码,请在 30 天内兑换”。
+- 客户侧错误的 Opus 4.8 月卡开放提示横幅已删除。
+
+业务边界:
+
+- 支付仍走 HFC 现有链动/支付逻辑。
+- 不新增 Airwallex、多币种、邮件通知、OAuth 权益。
+- 不改变套餐价格、月卡覆盖、钱包账本或扣费。
+
+### 3. 我的订阅 `/subscriptions`
+
+用户可见变化:
+
+- 续费弹窗抽成共享组件 `RenewLiandongModal`,订阅页和 Dashboard 使用同一套弹窗。
+- 钱包模式继续显示钱包卡和全 group 倍率列表。
+- 老 group 订阅继续显示日/周/月用量进度、到期时间、续费按钮。
+- 客户侧错误的 Opus 4.8 套餐权益横幅已删除。
+
+业务边界:
+
+- 只是弹窗复用和文案整理。
+- 不改变订阅状态判断。
+- 不改变月卡额度、消耗、覆盖分组或钱包 fallback。
+
+### 4. 统一续费/充值弹窗 `RenewLiandongModal`
+
+用户可见变化:
+
+- 新增统一弹窗组件。
+- 弹窗内包含:
+ - 月卡 4 档。
+ - 通用余额 3 档。
+ - 自定义额度联系管理员微信。
+ - 兑换码 30 天有效期提醒。
+- 如果从某个订阅续费进入,会按当前订阅月配额高亮“同档推荐”。
+
+业务边界:
+
+- 只是前端弹窗和链动小铺跳转入口。
+- 不改变支付回调、到账、钱包账本或套餐发放逻辑。
+
+### 5. 兑换页 `/redeem`
+
+用户可见变化:
+
+- 兑换失败错误展示改用 `extractI18nErrorMessage`,能更好显示后端 i18n 错误。
+
+业务边界:
+
+- 不改变兑换码兑换规则。
+- 不改变兑换码有效期计算。
+- 不改变余额、并发或订阅发放逻辑。
+
+### 6. 套餐卡 `SubscriptionPlanCard`
+
+用户可见变化:
+
+- 非 credits 计划且 supported model scopes 包含 `claude` 时,套餐卡增加高阶模型 badge:
+ - `Opus 4.8`
+ - `受额度保护`
+
+业务边界:
+
+- 只是展示 badge。
+- 不代表自动启用官方 pricing。
+- 不改变模型价格、倍率、分组覆盖、月卡覆盖或账本。
+
+### 7. Dashboard 模型入口
+
+用户可见变化:
+
+- Dashboard 快捷模型选择中增加 `Claude Opus 4.8`。
+- 模型 ID 使用 HFC 常量:`claude-opus-4-8`。
+- 仍保留 `GPT-5.5` 作为默认模型。
+
+业务边界:
+
+- 只是快捷入口和候选展示。
+- 请求后端仍按 HFC 模型映射、账号池和计费规则处理。
+- 不套官方 platform quota 或 official pricing。
+
+### 8. Opus 4.8 常量
+
+新增 `frontend/src/constants/opus48.ts`,统一前端显示名和提示文案:
+
+- `HFC_OPUS48_MODEL_ID = claude-opus-4-8`
+- `HFC_OPUS48_DISPLAY_NAME = Claude Opus 4.8`
+- `HFC_OPUS48_BADGE_LABEL = Opus 4.8`
+
+注意:客户购买/订阅/续费页的 Opus 4.8 横幅已经删除;目前保留的是后台/套餐卡 badge/Dashboard 快捷入口这类展示。
+
+### 9. 钱包订阅显示工具
+
+`frontend/src/utils/subscriptionWallet.ts` 增强:
+
+- `hasWalletBalance`
+- `walletRemainingUsd`
+- `walletInitialUsd`
+- `walletUsedUsd`
+- `walletUsedPercent`
+- `formatWalletBalance`
+- `formatWalletUsage`
+
+业务边界:
+
+- 只用于前端显示钱包余额、初始额度、使用进度。
+- 不作为真实扣费依据。
+
+## Admin-Facing Page Changes Related To This Review
+
+虽然你这次问的是个人主页侧边栏和用户侧页面,但本次候选也有一些后台页面变化:
+
+- 后台用户余额历史弹窗新增“充值余额”和“订阅钱包”拆分展示。
+- 后台套餐编辑页保留 Opus 4.8 提示,提醒真实价格、倍率、覆盖分组仍以 HFC 后台配置为准。
+- 后台运营页、用量页、渠道监控、兑换码批量编辑、风险控制等属于前面 safe-fusion 已拍板的管理增强,不新增客户侧菜单。
+
+## What Did Not Change
+
+本次候选没有做这些前端变化:
+
+- 没有新增个人侧边栏入口。
+- 没有改变个人侧边栏菜单排序。
+- 没有把 OAuth/GitHub/Google/DingTalk/OIDC 登录入口开放到客户侧权益。
+- 没有新增邮件通知、订阅提醒、Airwallex、多币种入口。
+- 没有开放 embeddings 页面或入口。
+- 没有把官方 IP ACL 作为客户侧黑名单入口。
+- 没有把官方 platform quota 暴露给客户。
+- 没有在客户购买/订阅/续费页保留 Opus 4.8 月卡误导横幅。
+
+## Verification Notes
+
+已确认:
+
+- `frontend/src/components/layout/AppSidebar.vue` 相对当前候选交接基线无 diff。
+- `frontend/src/router/index.ts` 相对当前候选交接基线无 diff。
+- 客户侧错误 Opus 4.8 横幅在以下文件已清除:
+ - `frontend/src/components/user/RenewLiandongModal.vue`
+ - `frontend/src/views/user/PaymentView.vue`
+ - `frontend/src/views/user/SubscriptionsView.vue`
+- `pnpm --dir frontend typecheck` 已通过。
+- `pnpm --dir frontend lint:check` 已通过。
+
+仍需上线前补跑:
+
+- 前端 build / Vitest:当前 Mac 被 Rollup native optional dependency/code-signature 问题阻塞。
+- 生产上线前必须重新跑 HFC safe cutover、月卡 smoke、钱包 marker、健康检查、磁盘/Docker cache 检查。
diff --git a/HFC_IMAGE2_SAFETY_AUDIT_PLAN_2026-05-26.md b/HFC_IMAGE2_SAFETY_AUDIT_PLAN_2026-05-26.md
new file mode 100644
index 00000000000..ccf30dcb08e
--- /dev/null
+++ b/HFC_IMAGE2_SAFETY_AUDIT_PLAN_2026-05-26.md
@@ -0,0 +1,671 @@
+# HandsFreeClub Image 2 生图安全审计层完整版方案
+
+日期:2026-05-26
+适用范围:HandsFreeClub / Sub2API 中转层对 OpenAI `gpt-image-2` 生图、修图请求的重新开放方案
+目标:在重新开放大客户生图流量前,把中转层安全审计、运营处置、日志追责、上线验收标准补齐,降低上游封号、违法牵连和批量滥用风险。
+
+## 1. 结论
+
+可以重新开放,但不能只靠“Prompt 审计”上线。
+
+建议采用以下上线边界:
+
+```text
+白名单客户 -> 组级生图开关 -> 请求参数闸门 -> 输入审计 -> 上游强制标准 moderation -> 输出审计 -> 审计日志/风险分 -> 自动限权/封禁
+```
+
+MVP 上线必须具备:
+
+1. 生图只对白名单分组开放,不默认全站开放。
+2. 输入 Prompt 和编辑参考图必须同步审计,命中风险直接拦截。
+3. 禁止客户使用 `moderation=low`,中转层必须强制 `auto` 或拒绝。
+4. 初期禁止流式生图和 partial images,避免未审计图片先发给客户。
+5. 非流式输出必须先在中转层缓冲,二次审计通过后再返回。
+6. 所有请求绑定用户、API key、分组、上游 request id、审计结果和风险处置。
+7. 对严重违规和重复违规用户自动限权或封禁。
+
+如果输出审计暂时没有实现,建议不要直接全量开放,只能做“极小白名单 + 强输入审计 + 强上游 `moderation=auto` + 禁止流式 + 强日志”的灰度。
+
+## 2. 官方安全口径
+
+当前 OpenAI 图像文档显示,`gpt-image-2` 属于 GPT Image 模型,图像 API 支持 `generations` 与 `edits` 两类核心端点。OpenAI 文档也明确:prompts 和 generated images 会按内容政策过滤;GPT Image 模型支持 `moderation` 参数,其中 `auto` 是默认标准过滤,`low` 更宽松。
+
+OpenAI 安全最佳实践建议使用 Moderation API、红队测试、人工复核、KYC、限制输入/输出范围、问题上报入口和 `safety_identifier`。Moderation API 支持文本与图片输入,并覆盖 harassment、hate、illicit、self-harm、sexual、sexual/minors、violence、violence/graphic 等类别。
+
+OpenAI Usage Policies 明确禁止或限制的高风险方向包括:非自愿亲密内容、性暴力、恐怖主义或暴力、武器开发/采购/使用、违法活动、规避安全措施、未经同意使用真人肖像造成真实性混淆,以及任何剥削、危害或性化未成年人的内容。
+
+参考:
+
+- https://developers.openai.com/api/docs/guides/image-generation
+- https://developers.openai.com/api/docs/guides/safety-best-practices
+- https://developers.openai.com/api/docs/guides/moderation
+- https://openai.com/policies/usage-policies/
+
+## 3. 当前代码基线
+
+本轮只读查看本地 `sub2api-fork-codex`,未检查实时生产配置。生产实际开关、DB 行和当前运行镜像状态仍需上线前重新核对。
+
+已具备的基础能力:
+
+1. 图像路由已经独立出来:
+ - `POST /v1/images/generations`
+ - `POST /v1/images/edits`
+ - 相关实现:`backend/internal/handler/openai_images.go`
+2. 默认图像模型为 `gpt-image-2`:
+ - 相关实现:`backend/internal/service/openai_images.go`
+3. 模型边界已限制为 `gpt-image-*`:
+ - 非图像模型请求会被拒绝。
+4. 分组级生图开关已存在:
+ - `groups.allow_image_generation`
+ - `GroupAllowsImageGeneration(apiKey.Group)`
+ - 当前图像 handler 顺序是先查组级生图权限,再做内容审计。
+5. 风控中心内容审计已存在:
+ - 全局开关:`risk_control_enabled`
+ - 配置:`content_moderation_config`
+ - 模式:`off`、`observe`、`pre_block`
+ - 默认模型:`omni-moderation-latest`
+ - 审计日志表:`content_moderation_logs`
+ - 支持命中日志、非命中日志、阈值快照、输入摘要、自动封禁、邮件提醒、hash 预拦截、过期清理。
+6. 图像输入审计已经接入:
+ - `OpenAIImagesRequest.ModerationBody()` 会把 `prompt` 和 `images` 组装给内容审计。
+ - multipart 上传的参考图、mask 图会转为 data URL 进入审计。
+
+当前关键缺口:
+
+1. 输出后审计缺失或未形成强制闭环:当前非流式响应在 `handleOpenAIImagesNonStreamingResponse` 内直接写回客户端;流式响应会更早发送 partial/final image event。
+2. `moderation=low` 目前会被解析并转发,未看到强制拒绝或覆盖为 `auto` 的中转层策略。
+3. `safety_identifier` 未看到在 Images API 请求中被统一注入;OpenAI 推荐用稳定、隐私保护的用户标识帮助定位滥用。
+4. 当前 moderation 结果结构主要消费 `category_scores`,未充分利用官方返回里的 `categories` 和 `category_applied_input_types`。
+5. 初始阈值是通用内容审计阈值,不一定适合生图业务的更高风险。
+6. 对真人肖像、非自愿亲密图、未成年人年龄不确定、名人/公众人物仿冒等图像专属风险,还需要本地业务规则补强。
+
+## 4. 风险模型
+
+### 4.1 上游账号风险
+
+风险:用户通过你们的中转账号池提交违规生图请求,上游将风险归因到你们组织或账号池,导致模型访问受限、账号封禁或整组 API 能力受影响。
+
+控制目标:把违规用户和正常用户隔离;能向上游和内部审计证明你们有前置审查、用户追踪和处置机制。
+
+### 4.2 法律和平台责任风险
+
+风险:用户生成色情、血腥、暴力、非自愿亲密图、侵犯肖像权/隐私权/知识产权内容,并用于传播或违法行为,平台被认定没有尽到合理审核义务。
+
+控制目标:明确用户协议、保留必要证据、建立违规处置记录、对严重风险立即封禁。
+
+### 4.3 技术绕过风险
+
+风险:用户用多语言、错别字、谐音、分段、prompt injection、图像编辑参考图、`moderation=low`、流式输出等方式绕过审计。
+
+控制目标:不只审一个原始 Prompt,而是审计完整请求意图、参考图、关键参数和生成结果。
+
+### 4.4 成本和可用性风险
+
+风险:生图延迟长、成本高,恶意客户用大尺寸、高质量、多张并发消耗账号池和额度。
+
+控制目标:初期限制 `n`、尺寸、质量、并发、频率、每日额度,并对高风险用户降权或停权。
+
+## 5. 目标请求链路
+
+```text
+Client
+ |
+ v
+API Key / User Auth
+ |
+ v
+GroupAllowsImageGeneration
+ |
+ v
+Image Request Guard
+ - endpoint allowlist
+ - model allowlist
+ - n / size / quality / stream / partial_images limit
+ - reject or overwrite moderation=low
+ |
+ v
+Input Safety Audit
+ - prompt text
+ - edit source images
+ - mask/reference images
+ - local policy classifier
+ - OpenAI Moderation API
+ |
+ v
+Upstream Call
+ - moderation=auto
+ - safety_identifier=user_hash
+ - request id binding
+ |
+ v
+Output Hold
+ - buffer generated image
+ - extract b64/url
+ |
+ v
+Output Safety Audit
+ - generated image moderation
+ - output hash
+ - category scores
+ |
+ +--> pass: return image to client
+ |
+ +--> fail: block response, log incident, apply risk action
+```
+
+## 6. 违规风险控制原则
+
+这套安全审计层的核心目标不是“看起来做了审核”,而是围绕两类顾虑建立可验证闭环:
+
+1. 防止客户通过你们的中转链路向上游提交违规请求,导致上游账号、组织或模型能力被限制。
+2. 防止客户成功拿到违规图片并外传,让平台在法律、监管、投诉或上游追责里变成无防护的服务提供者。
+
+因此策略必须满足四个原则:
+
+1. **违规前阻断**:高风险 Prompt、参考图、参数组合在请求上游前被挡住。
+2. **违规后不出图**:即使上游生成了不合规输出,中转层也不能把图片交给客户。
+3. **违规者可追踪**:每一次命中能定位到 user、API key、group、request id、上游 request id 和审计结果。
+4. **重复违规可处置**:普通误触、反复测试边界、严重违法风险要有不同处罚动作。
+
+为了满足这四点,生图安全层必须采用 fail closed:只要审计服务不可用、输出无法解析、输出无法完成审计、客户试图降低审计强度,就拒绝生图请求,而不是放行。
+
+### 6.1 违规风险分级
+
+| 等级 | 内容类型 | 动作 | 说明 |
+| --- | --- | --- | --- |
+| S0 极严重 | 未成年人性化、CSAM、诱导未成年人、非自愿亲密图、报复性色情、性暴力、恐怖主义宣传、明确违法武器/爆炸物、诈骗/证件伪造/违法服务 | 立即拒绝;冻结生图权限;人工复核是否封号;保留证据摘要 | 这类风险直接对应上游封号和法律牵连 |
+| S1 严重 | 露骨色情、真人/名人色情化、血腥肢解、酷刑、极端暴力、仇恨威胁、规避审计/绕过安全策略 | 拒绝;累计违规;短期内多次命中自动停权 | 即使不一定违法,也足以触发上游和平台安全风险 |
+| S2 高风险边界 | 真人肖像仿冒、名人/公众人物写实图、年龄不明但有性暗示、偷拍视角、医疗/犯罪现场写实、政治/公共事件误导性图像 | 默认拒绝或进入人工复核;仅授权客户可开白特定模板 | 这类内容容易转化成侵权、隐私或虚假信息风险 |
+| S3 普通风险 | 轻微暴力、非写实战斗、成人但非露骨、夸张恐怖风格、敏感广告素材 | 根据阈值和业务模板处理;保守起步 | 灰度期宁可误杀一些,后续靠样本校准 |
+| S4 低风险 | 商品图、海报、插画、头像、UI 素材、室内设计、风景、普通角色设定 | 放行并记录最小日志 | 主要业务收入应引导到这些安全场景 |
+
+### 6.2 硬拒绝规则
+
+以下情况不进入上游请求:
+
+1. Prompt 或参考图命中 S0。
+2. Prompt 明确要求绕过审核、规避政策、降低安全过滤。
+3. 客户显式传入 `moderation=low`。
+4. 客户请求 `stream=true` 或 `partial_images`,但输出审计无法 hold-and-release。
+5. 编辑接口传入高风险参考图,即使 Prompt 本身安全。
+6. 请求缺少可追踪用户身份,或 API key 不属于允许生图的白名单分组。
+7. moderation 服务不可用、超时、无可用审核 API key,或审核结果结构异常。
+
+### 6.3 输出拦截规则
+
+以下情况即使上游已经生成,也不得把图片返回给客户:
+
+1. 输出图命中 sexual/minors、sexual、violence/graphic、self-harm、illicit/violent 等高风险分类。
+2. 输出图被本地图像规则判断为真人色情化、未成年人风险、非自愿亲密图、血腥写实或明显违法用途。
+3. 输出图片无法下载、无法解析、MIME 不可信、超过大小限制或审计失败。
+4. 流式或 partial 图片无法在发送前完成审计。
+
+输出被拦截时,客户只能收到通用错误:
+
+```json
+{
+ "error": {
+ "type": "invalid_request_error",
+ "code": "content_policy_violation",
+ "message": "内容审计命中风险规则,请调整输入后重试"
+ }
+}
+```
+
+内部日志保留完整原因,外部错误不暴露具体绕过线索。
+
+### 6.4 上游封号风险隔离
+
+为减少一个客户拖垮整个上游账号池:
+
+1. 生图客户使用独立 group,不和普通聊天客户混用。
+2. 生图上游账号池与聊天账号池尽量隔离。
+3. 高风险客户使用更低额度、更低并发、更严格阈值。
+4. 每个上游请求带脱敏 `safety_identifier`,让上游能定位终端用户而不是只看到你们组织。
+5. 同一用户连续触发违规时,先停该用户/该 API key 的生图能力,不影响其他客户。
+6. 一旦某个客户触发 S0,立即从全部生图分组移除,人工复核后再决定是否恢复。
+
+### 6.5 法律牵连风险降低
+
+这套系统不能保证“完全免责”,但能证明平台尽到了合理审核和处置义务。
+
+必须形成以下证据链:
+
+1. 客户开通时同意生图使用条款。
+2. 请求进入中转层时做了输入审计。
+3. 输出返回前做了结果审计。
+4. 命中违规时平台没有返回图片。
+5. 重复违规时平台执行了限权/封禁。
+6. 严重违规时平台保留了 hash、摘要、分类、时间、用户和 request id。
+7. 平台提供投诉/举报和人工复核入口。
+
+证据链只保留必要信息,不长期保存大量原始敏感图像,避免平台自己变成违规内容仓库。
+
+## 7. 安全审计层设计
+
+### 7.1 第一层:资格准入
+
+生图能力不要做成“所有 GPT 分组默认打开”。
+
+建议:
+
+1. 新建或复用专门的 Image 白名单分组。
+2. `allow_image_generation=false` 作为普通分组默认值。
+3. 大客户开通前至少满足:
+ - 已登录/实名或企业联系人可追溯。
+ - 已付费或签约。
+ - 明确业务场景。
+ - 接受生图使用条款。
+4. 高风险行业客户进入人工审核:
+ - 成人内容、陪聊/擦边营销、博彩、虚拟币诈骗、假证件、灰产广告、真人仿冒、批量社媒素材等。
+
+信息缺失:当前这批大客户的行业、使用场景、日流量、是否企业主体、是否有合同和违规赔付条款还未提供。
+
+### 7.2 第二层:请求参数闸门
+
+初期建议参数策略:
+
+| 参数 | 初期策略 | 原因 |
+| --- | --- | --- |
+| `model` | 只允许 `gpt-image-2` 或明确允许的 `gpt-image-*` | 防止混入非预期模型 |
+| `endpoint` | 只允许 `/v1/images/generations`、`/v1/images/edits` | 缩小攻击面 |
+| `n` | 初期强制 `1` | 降低单次违规扩散和成本 |
+| `size` | 先限制在官方常用尺寸或业务定价尺寸 | 控成本和延迟 |
+| `quality` | 默认 `medium`,高质量只对白名单开放 | 控成本 |
+| `stream` | 初期拒绝 `true` | 输出审计前不能边生成边返回 |
+| `partial_images` | 初期拒绝或强制 `0` | partial 图片未经输出审计 |
+| `moderation` | 拒绝 `low`,缺省或强制 `auto` | 避免客户主动降安全级别 |
+| `response_format` | 优先 `b64_json` 或内部可审计格式 | 便于输出审核 |
+| `images` | 限数量、限大小、限 MIME | 降低编辑图绕过和成本 |
+
+建议错误返回:
+
+```json
+{
+ "error": {
+ "type": "invalid_request_error",
+ "code": "image_safety_guardrail",
+ "message": "当前生图服务不支持该参数,请调整后重试"
+ }
+}
+```
+
+不要把具体命中词、阈值、策略细节完整返回给客户,避免帮助其绕过。
+
+### 7.3 第三层:输入审计
+
+输入审计范围:
+
+1. `prompt`
+2. `images[].image_url`
+3. multipart `image`
+4. `mask`
+5. Responses API 里 `image_generation` tool 的 `input`
+6. 任何可隐含生图意图的 `tools` / `tool_choice`
+
+已可复用能力:
+
+1. `ExtractContentModerationInput` 已支持 `ContentModerationProtocolOpenAIImages`。
+2. `ModerationBody()` 已将 prompt 和编辑图输入抽出。
+3. `ContentModerationService` 支持同步 pre-block。
+
+需要补强:
+
+1. 使用 `omni-moderation-latest` 返回的 `categories` 作为硬拦截依据,不只看 `category_scores`。
+2. 记录 `category_applied_input_types`,区分是文本命中还是图片命中。
+3. 增加图像业务规则:
+ - 未成年人性化:零容忍;年龄不确定且带性暗示时拒绝。
+ - 非自愿亲密图、偷拍、泄露、报复性色情:拒绝。
+ - 真人/名人肖像仿冒、深度伪造、误导真实性:拒绝或要求证明授权。
+ - 血腥、肢解、酷刑、极端暴力:拒绝。
+ - 武器制造、违法活动、诈骗素材:拒绝。
+ - 规避审计、要求绕过安全策略:拒绝。
+4. 对中文、英文、中英混写、谐音、拼音、分隔符、emoji 伪装做红队测试。
+
+建议模式:
+
+```text
+pre_block
+sample_rate=100
+record_non_hits=false
+record_hits=true
+pre_hash_check_enabled=true
+```
+
+### 7.4 第四层:输出审计
+
+这是重新开放生图最关键的补强点。
+
+原因:即使 Prompt 看起来安全,模型仍可能生成擦边、暴力、真人误导或其他不适合分发的图片。只审 Prompt 无法覆盖输出偏差。
+
+非流式方案:
+
+1. 上游返回后,中转层先不要立即 `c.Data()` 给客户端。
+2. 解析返回体里的图片:
+ - `data[].b64_json`
+ - data URL
+ - OpenAI 返回的 image URL
+ - Responses API `image_generation_call.result`
+3. 对每张生成图调用 moderation:
+ - data URL 可直接作为 `image_url.url`
+ - URL 输出需要服务端下载后转 data URL,或仅允许可信上游域名 URL。
+4. 审计通过:返回原响应。
+5. 审计失败:返回 `content_policy_violation`,不返回图片内容。
+6. 记录输出 hash、最高风险类别、分数、用户、分组、API key、上游 request id。
+
+流式方案:
+
+1. 初期不开放 `stream=true`。
+2. 初期不开放 `partial_images`。
+3. 后续如果要开放,必须改成服务端 hold-and-release:
+ - 先缓冲 partial/final 图片。
+ - 只把进度事件返回给客户端。
+ - 图片内容在输出审计通过后再释放。
+
+### 7.5 第五层:上游调用保护
+
+上游请求建议:
+
+1. 强制 `moderation=auto`。
+2. 注入 `safety_identifier`:
+ - 值为 `hfc_user_{sha256(user_id + salt)}` 或 `hfc_key_{sha256(api_key_id + salt)}`。
+ - 不传明文手机号、邮箱、微信、真实姓名。
+3. 绑定 request id:
+ - 本地 request id
+ - 上游 `x-request-id`
+ - 用户 ID
+ - API key ID
+ - group ID
+4. 禁止客户透传可能影响审计策略的字段。
+
+### 7.6 第六层:审计日志
+
+已有表 `content_moderation_logs` 可以继续用,但建议扩展或新增 image 专用字段。
+
+建议记录:
+
+| 字段 | 建议 |
+| --- | --- |
+| `request_id` | 本地请求 ID |
+| `upstream_request_id` | OpenAI 返回的 request id |
+| `user_id` | 必填 |
+| `api_key_id` | 必填 |
+| `group_id` | 必填 |
+| `endpoint` | generations / edits |
+| `model` | `gpt-image-2` |
+| `input_hash` | normalized prompt + input images hash |
+| `output_hashes` | 生成图 hash,不保存原图 |
+| `input_excerpt` | 脱敏摘要,长度限制 |
+| `stage` | input / output / hash_precheck |
+| `action` | allow / block / observe / error |
+| `highest_category` | 最高风险类别 |
+| `category_scores` | 完整分数 |
+| `category_flags` | 官方 categories |
+| `applied_input_types` | text / image |
+| `policy_rule` | 本地业务规则命中 |
+| `auto_banned` | 是否自动封禁 |
+
+留存建议:
+
+1. 非命中日志:最多 3 天。
+2. 命中日志:90-180 天。
+3. 严重违规:保留 hash、摘要和处置记录;除非必要,不长期保存原图。
+4. 如必须保存严重违规图片作为证据,需要单独加密存储、访问审计和删除机制。
+
+### 7.7 第七层:处罚和熔断
+
+建议初期比当前通用阈值更严格。
+
+| 场景 | 动作 |
+| --- | --- |
+| 单次普通命中 | 拦截请求,记录风险分,返回安全提示 |
+| 24 小时内 3 次命中 | 自动暂停该 API key 的生图能力 24 小时 |
+| 7 天内 5 次命中 | 自动关闭用户生图权限,进入人工复核 |
+| 未成年人性化 / CSAM / 非自愿亲密内容 / 恐怖主义 / 明确违法 | 立即封禁账号或 API key,保留证据摘要 |
+| 大客户误杀申诉 | 人工复核,必要时调阈值或加白业务模板 |
+| moderation 服务不可用 | fail closed:生图请求拒绝,不放行 |
+
+注意:文本聊天可以继续可用,但生图能力应单独冻结,避免一个客户违规影响整个服务。
+
+## 8. 推荐配置
+
+### 8.1 风控中心
+
+```json
+{
+ "risk_control_enabled": true,
+ "content_moderation_config": {
+ "enabled": true,
+ "mode": "pre_block",
+ "base_url": "https://api.openai.com",
+ "model": "omni-moderation-latest",
+ "timeout_ms": 5000,
+ "sample_rate": 100,
+ "all_groups": false,
+ "group_ids": ["只填允许生图的大客户分组"],
+ "record_non_hits": false,
+ "pre_hash_check_enabled": true,
+ "email_on_hit": true,
+ "auto_ban_enabled": true,
+ "hit_retention_days": 180,
+ "non_hit_retention_days": 3
+ }
+}
+```
+
+### 8.2 初始阈值
+
+当前默认阈值可以作为基线,但图像业务建议做更严格灰度。
+
+建议:
+
+1. `sexual/minors`、非自愿亲密、CSAM:只要官方 categories 命中或本地规则命中,直接拦截。
+2. `sexual`:对普通客户严格拦截;如确有合规业务场景,必须人工开白且不能涉及真人/未成年人/非自愿。
+3. `violence/graphic`:严格拦截。
+4. `violence`:允许非写实、非血腥、非煽动性的低风险场景,但需要阈值和样本校准。
+5. `illicit`、`illicit/violent`:严格拦截。
+6. `hate/threatening`、`harassment/threatening`:严格拦截。
+7. 所有“绕过安全审核”的 Prompt:本地规则直接拦截。
+
+阈值不要一次写死成永久规则。官方 moderation 模型会升级,`category_scores` 需要周期性校准。
+
+## 9. 研发改造清单
+
+### P0:上线前必须完成
+
+1. 参数防护:
+ - 拒绝或覆盖 `moderation=low`。
+ - 拒绝 `stream=true`。
+ - 拒绝 `partial_images`。
+ - 初期限制 `n=1`。
+2. 输出审计:
+ - 非流式图像响应先缓冲。
+ - 提取生成图。
+ - 调用 moderation。
+ - 通过后返回,失败时拦截。
+3. 审计结果结构增强:
+ - 解析 `categories`。
+ - 解析 `category_applied_input_types`。
+ - 日志记录 input/output stage。
+4. 上游安全标识:
+ - Images API / Responses image_generation 都注入 `safety_identifier`。
+5. 生图专用处罚:
+ - API key 级 image 暂停。
+ - 用户级 image 暂停。
+ - 严重违规立即禁用。
+6. 测试:
+ - 参数拒绝测试。
+ - 输入命中阻断测试。
+ - 输出命中阻断测试。
+ - moderation 服务失败时 fail closed 测试。
+ - 安全请求通过测试。
+
+### P1:灰度后增强
+
+1. 管理端增加 image 专用审计视图:
+ - 输入命中率
+ - 输出命中率
+ - 分组命中排行
+ - API key 命中排行
+ - 上游 request id 检索
+2. 客户级风控配置:
+ - 不同分组不同阈值
+ - 不同客户不同额度/质量/尺寸
+3. 人工复核队列:
+ - 大客户误杀申诉
+ - 边界类 Prompt 复核
+4. 合规模板:
+ - 电商商品图
+ - 广告海报
+ - UI 素材
+ - 插画/头像
+ - 禁止真人色情、血腥、仿冒等模板。
+
+### P2:规模化后增强
+
+1. 输出图片水印或元数据标识。
+2. 图片 hash 黑名单库。
+3. 客户行业风险画像。
+4. 异常行为检测:
+ - 短时间大量拒绝
+ - 多 API key 共享同一高风险 Prompt
+ - 多语言混淆
+ - 反复测试边界词
+5. 每周红队测试和阈值回归。
+
+## 10. 上线流程
+
+### 阶段 0:准备
+
+1. 只读确认生产状态:
+ - 当前 running image。
+ - `risk_control_enabled`。
+ - `content_moderation_config`。
+ - 所有 active group 的 `allow_image_generation`。
+ - 目标白名单 group。
+2. 准备 moderation API key,确认健康。
+3. 准备客户使用条款。
+4. 准备红队测试集。
+
+### 阶段 1:影子观察
+
+如果现有客户暂未真正生成图片,可以先开 `observe` 跑日志;但如果已经要真实接入生图,输入审计必须直接 `pre_block`。
+
+建议:
+
+1. 对目标白名单 group 开启内容审计。
+2. 先不开普通用户分组。
+3. 观察 24-48 小时:
+ - 命中类别。
+ - 误杀样本。
+ - moderation latency。
+ - 队列堆积。
+ - API key 健康。
+
+### 阶段 2:小流量开放
+
+条件:
+
+1. P0 改造完成。
+2. 输出审计已上线。
+3. 流式和 partial 已禁用。
+4. 上游 `moderation=auto` 强制生效。
+5. 日志和封禁可查。
+
+开放策略:
+
+1. 只开放 1-3 个白名单客户。
+2. 每个客户限流:
+ - 每分钟请求数。
+ - 每日图片数。
+ - 并发数。
+ - 最高质量/尺寸。
+3. 人工值守首日 2-4 小时。
+
+### 阶段 3:扩大开放
+
+条件:
+
+1. 连续 7 天无严重违规。
+2. 输出审计命中处理稳定。
+3. 客户误杀率可接受。
+4. 上游账号池健康。
+5. 账单成本可控。
+
+扩大策略:
+
+1. 分组逐步开,不做全站开关。
+2. 每批新增客户后观察 24 小时。
+3. 对高风险客户坚持人工审批。
+
+## 11. 运营和法务要求
+
+必须补充用户条款:
+
+1. 用户不得生成、上传、传播违法违规内容。
+2. 禁止未成年人性化、CSAM、非自愿亲密图、偷拍、报复性色情。
+3. 禁止仿冒真人/名人、侵犯肖像权、隐私权、著作权或商标权。
+4. 禁止恐怖主义、极端暴力、武器违法、诈骗、赌博、灰产广告。
+5. 用户对生成内容及用途承担责任。
+6. 平台有权拦截、记录、暂停、封禁、配合调查。
+7. 平台不保证所有违规内容都能被自动检测,用户仍需自行合规。
+
+信息缺失:具体合同、隐私政策、数据保留告知和法务措辞需要律师或合规顾问最终确认。
+
+## 12. 验收标准
+
+上线前必须可验证:
+
+1. 普通未授权分组请求 `/v1/images/generations` 返回 403。
+2. 白名单分组安全 Prompt 可以生成。
+3. 高风险 Prompt 在上游调用前被阻断。
+4. 编辑接口中的高风险参考图被阻断。
+5. 客户传 `moderation=low` 被拒绝或覆盖。
+6. 客户传 `stream=true` 被拒绝。
+7. 客户传 `partial_images` 被拒绝。
+8. 生成结果命中风险时不返回图片。
+9. moderation API 故障时请求不放行。
+10. 审计日志能按 user / api key / group / request id 查到。
+11. 连续违规后能自动暂停生图能力。
+12. 上游请求带有稳定、脱敏的 safety identifier。
+
+## 13. 建议的产品口径
+
+对客户可以这样表达:
+
+> 生图服务已开放给白名单客户使用。为保护账号池稳定和合规安全,平台会对生图 Prompt、参考图和生成结果进行自动安全审计。涉及未成年人性化、非自愿亲密内容、血腥暴力、违法活动、真人仿冒、侵权或绕过安全策略的请求会被拒绝;多次违规会暂停或关闭生图权限。
+
+不要承诺:
+
+1. “任何内容都能生成”
+2. “不审查”
+3. “可以绕过官方限制”
+4. “不会封号”
+5. “出了问题平台完全不承担任何责任”
+
+## 14. 最小上线任务拆分
+
+建议研发按以下顺序做:
+
+1. `image-request-guard`:参数闸门和 `moderation=low` 防护。
+2. `image-output-moderation`:非流式输出缓冲审计。
+3. `image-safety-identifier`:上游 safety identifier 注入。
+4. `moderation-result-schema`:解析 categories / applied input types。
+5. `image-abuse-actions`:生图专用限权和封禁。
+6. `image-safety-tests`:红队和回归测试。
+7. `image-whitelist-rollout`:白名单分组灰度上线。
+
+## 15. 当前判断
+
+你们现在的代码已经有不错的安全底座:分组开关、内容审计、审计日志、自动封禁、hash 预拦截、图像输入抽取都已有雏形。
+
+但如果要重新开放 `gpt-image-2` 给大客流,当前方案还差几个“上线前必补”的硬点:
+
+1. 输出审计。
+2. 禁止流式/partial 未审先发。
+3. 禁止 `moderation=low`。
+4. 上游 `safety_identifier`。
+5. 生图专用处罚阈值。
+6. 图像专属政策规则和验收测试。
+
+补齐后,可以先对白名单客户灰度开放;没补齐前,不建议全量恢复生图服务。
diff --git a/Makefile b/Makefile
index d00d0c4f5ef..e4f689fb03a 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: build build-backend build-frontend build-datamanagementd test test-backend test-frontend test-frontend-critical test-datamanagementd secret-scan
+.PHONY: build build-backend build-frontend build-datamanagementd test test-backend test-frontend test-frontend-critical test-datamanagementd test-deploy-scripts secret-scan
FRONTEND_CRITICAL_VITEST := \
src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \
@@ -40,5 +40,9 @@ test-frontend-critical:
test-datamanagementd:
@cd datamanagement && go test ./...
+test-deploy-scripts:
+ @bash deploy/test_build_image_retention.sh
+ @bash deploy/test_docker_build_guard.sh
+
secret-scan:
@python3 tools/secret_scan.py
diff --git a/PAID_TRIAL_ONCE_DEPLOY_2026-05-23.md b/PAID_TRIAL_ONCE_DEPLOY_2026-05-23.md
new file mode 100644
index 00000000000..329bfbe6a10
--- /dev/null
+++ b/PAID_TRIAL_ONCE_DEPLOY_2026-05-23.md
@@ -0,0 +1,29 @@
+# Paid Trial One-Time Purchase Deployment
+
+Date: 2026-05-23
+
+Scope:
+- Enforce one-time purchase for the 29.9 monthly paid-trial plan only.
+- Internal payment plan: `paid-trial-v3-30d`, production `plan_id=5`.
+- Liandong/redeem path: production subscription `group_id=13`.
+- No pricing, rate multiplier, wallet quota, fulfillment, account routing, or billing multiplier changes.
+
+Production commits:
+- `6281d9ab` `fix(payment): enforce trial plan one-time purchase`
+- `c7c9a6c2` `fix(payment): enforce trial redeem one-time limit`
+
+Production image:
+- `hfc/sub2api:paid-trial-redeem-once-c7c9a6c2-20260523-162451`
+
+Verification:
+- Local unit test: `go test -tags unit ./internal/service -run 'TestCheckPlanPurchaseLimit|TestCheckPaidTrialRedeemLimit|TestExecuteSubscriptionFulfillmentWalletPlanAssignsWalletSubscription'`
+- Migration dry-run for `165_enforce_paid_trial_redeem_once.sql`: passed with `CREATE FUNCTION`, `CREATE TRIGGER`, then `ROLLBACK`.
+- Deployed container: `sub2api` running and healthy on the new image.
+- Public health checks: `https://api.handsfreeclub.com/health` returned 200 with `{"status":"ok"}`; public home and pricing pages returned 200.
+- Database migrations applied: `164_enforce_paid_trial_once.sql`, `165_enforce_paid_trial_redeem_once.sql`.
+- Database safeguards present: `payment_orders_paid_trial_once_per_user` index and `trg_hfc_paid_trial_redeem_once` trigger.
+- Transaction probe confirmed a second used redeem code for the same user and group 13 is blocked; probe rows were rolled back.
+
+Residual external boundary:
+- Backend now prevents a second entitlement/order/redeem in HandsFreeClub.
+- If the Liandong product page itself must prevent payment before the user reaches HandsFreeClub, the Liandong item `https://pay.ldxp.cn/item/z80wd7` also needs item-level purchase limit set to 1 in Liandong's own backend.
diff --git a/PROD_API_KEY_CREDENTIALS_BACKFILL_2026-05-11.md b/PROD_API_KEY_CREDENTIALS_BACKFILL_2026-05-11.md
new file mode 100644
index 00000000000..3aaeb66ed8f
--- /dev/null
+++ b/PROD_API_KEY_CREDENTIALS_BACKFILL_2026-05-11.md
@@ -0,0 +1,48 @@
+# 生产 API Key 哈希与账号凭证加密 Backfill 记录
+
+日期:2026-05-11 Asia/Shanghai
+
+## 范围
+
+- 核验生产 API Key 哈希化迁移结果。
+- 对生产账号凭证执行加密 dry-run 和真实 backfill。
+- backfill 后重启 backend 容器并验证健康状态。
+
+## 生产运行态
+
+- 完成后的 backend 容器镜像:`hfc/sub2api:kiro-direct-441c51ca52ed-20260511-151454`
+- 容器健康状态:`healthy`
+- 内网健康检查:`{"status":"ok"}`
+- 公网健康检查:`{"status":"ok"}`
+
+说明:本任务执行过程中,生产机上另一个构建/部署流程把 backend 镜像推进到了 `441c51ca`。该提交已经包含 API Key 哈希化与账号凭证加密提交 `2c125d47`。本任务另外在本机构建并上传了 `linux/amd64` 镜像 `hfc/sub2api:security-keyhash-94fc9580-20260511-153800`,用于运行一次性 backfill 工具容器。
+
+## 备份
+
+- 初始上线前备份:`/opt/relay/backups/security-keyhash-20260511-142604`
+- 真实 backfill 前 DB dump:`/opt/relay/backups/security-keyhash-backfill-20260511-154549/sub2api-pre-credential-backfill.dump`
+- backfill 前 DB dump sha256 校验:`OK`
+
+## 结果
+
+- API Key 哈希化字段已存在:
+ - `api_keys.key_hash`
+ - `api_keys.key_prefix`
+ - 来自迁移 `138_add_api_key_hash_indexes_notx.sql` 的部分唯一索引
+- 有效 API Key 的 `key_hash` 缺失数:`0/88`
+- backfill 前账号凭证加密 envelope 数:`0/14`
+- 账号凭证 dry-run:`scanned=14 needs_encryption=13 updated=0`
+- 账号凭证真实 backfill:`scanned=14 needs_encryption=13 updated=13`
+- backfill 后账号凭证加密 envelope 数:`14/14`
+- backfill 后敏感凭证字段明文字符串匹配数:`0/14`
+- backfill 后二次 dry-run:`scanned=14 needs_encryption=0 updated=0`
+
+## 工具修复
+
+一次性命令 `cmd/encrypt-account-credentials` 已补充 `ent/runtime` blank import,与 server 入口保持一致。没有这个 import 时,Ent 运行时默认值和 validator 不会初始化,工具会在 security secret bootstrap 阶段 panic,尚未进入账号扫描。
+
+## 清理
+
+- 生产机临时 Go 编译镜像和 Go cache 已清理。
+- 生产机磁盘使用率从 `92%` 降到 `89%`。
+- backfill 容器运行时临时生成的 env 文件已在每次运行后删除。
diff --git a/README.md b/README.md
index 718730c63ca..bcbe4e11f60 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-[](https://golang.org/)
+[](https://golang.org/)
[](https://vuejs.org/)
[](https://www.postgresql.org/)
[](https://redis.io/)
@@ -123,7 +123,7 @@ Community projects that extend or integrate with Sub2API:
| Component | Technology |
|-----------|------------|
-| Backend | Go 1.25.7, Gin, Ent |
+| Backend | Go 1.26.5, Gin, Ent |
| Frontend | Vue 3.4+, Vite 5+, TailwindCSS |
| Database | PostgreSQL 15+ |
| Cache/Queue | Redis 7+ |
@@ -157,8 +157,14 @@ One-click installation script that downloads pre-built binaries from GitHub Rele
#### Installation Steps
+Run the installer only from a reviewed clone or release archive. The installer
+rejects `curl | bash` input.
+
```bash
-curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash
+git clone https://github.com/Wei-Shaw/sub2api.git
+cd sub2api
+git checkout
+sudo bash deploy/install.sh
```
The script will:
@@ -177,8 +183,9 @@ sudo systemctl start sub2api
# 2. Enable auto-start on boot
sudo systemctl enable sub2api
-# 3. Open Setup Wizard in browser
-# http://YOUR_SERVER_IP:8080
+# 3. Forward the loopback-only setup listener from your workstation
+ssh -L 18080:127.0.0.1:8080 USER@YOUR_SERVER_IP
+# Then open http://127.0.0.1:18080
```
The Setup Wizard will guide you through:
@@ -188,12 +195,10 @@ The Setup Wizard will guide you through:
#### Upgrade
-You can upgrade directly from the **Admin Dashboard** by clicking the **Check for Updates** button in the top-left corner.
-
-The web interface will:
-- Check for new versions automatically
-- Download and apply updates with one click
-- Support rollback if needed
+This custom build may check upstream versions, but update and rollback
+application are disabled by default. Keep `SUB2API_OFFICIAL_UPDATE_APPLY_ENABLED=false`
+until the compatibility gate, backup, restore rehearsal, and controlled rollout
+for that exact upstream release have all passed.
#### Useful Commands
@@ -207,8 +212,8 @@ sudo journalctl -u sub2api -f
# Restart service
sudo systemctl restart sub2api
-# Uninstall
-curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash -s -- uninstall -y
+# Uninstall from the same reviewed checkout
+sudo bash deploy/install.sh uninstall -y
```
---
@@ -224,14 +229,14 @@ Deploy with Docker Compose, including PostgreSQL and Redis containers.
#### Quick Start (One-Click Deployment)
-Use the automated deployment script for easy setup:
+Run the deployment script from a reviewed clone or release archive. Mutable
+`curl | bash` deployment is intentionally unsupported.
```bash
-# Create deployment directory
-mkdir -p sub2api-deploy && cd sub2api-deploy
-
-# Download and run deployment preparation script
-curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash
+# Clone a reviewed revision, then run its local script
+git clone https://github.com/Wei-Shaw/sub2api.git
+cd sub2api/deploy
+bash docker-deploy.sh
# Start services
docker compose up -d
@@ -241,11 +246,11 @@ docker compose logs -f sub2api
```
**What the script does:**
-- Downloads `docker-compose.local.yml` (saved as `docker-compose.yml`) and `.env.example`
-- Generates secure credentials (JWT_SECRET, TOTP_ENCRYPTION_KEY, POSTGRES_PASSWORD)
+- Copies the reviewed Compose and environment templates shipped beside the script
+- Generates independent JWT, TOTP, payment-resume, PostgreSQL, Redis, and admin secrets
- Creates `.env` file with auto-generated secrets
- Creates data directories (uses local directories for easy backup/migration)
-- Displays generated credentials for your reference
+- Writes secrets only to owner-readable `.env` (mode 0600); credentials are not printed
#### Manual Deployment
@@ -269,16 +274,42 @@ nano .env
# PostgreSQL password (REQUIRED)
POSTGRES_PASSWORD=your_secure_password_here
-# JWT Secret (RECOMMENDED - keeps users logged in after restart)
-JWT_SECRET=your_jwt_secret_here
+# Redis password (REQUIRED; distinct from every other secret)
+REDIS_PASSWORD=your_redis_password_here
-# TOTP Encryption Key (RECOMMENDED - preserves 2FA after restart)
-TOTP_ENCRYPTION_KEY=your_totp_key_here
+# JWT Secret (REQUIRED - keeps users logged in after restart)
+JWT_SECRET=your_jwt_secret_here
-# Optional: Admin account
+# Legacy v1/v2 root (migration only; remove after v3 cutover)
+TOTP_ENCRYPTION_KEY=
+
+# Independent application-secret roots (REQUIRED; generate every value separately)
+SECRET_ENCRYPTION_TOTP_SECRET_KEY=your_totp_secret_key_here
+SECRET_ENCRYPTION_TOTP_CACHE_KEY=your_totp_cache_key_here
+SECRET_ENCRYPTION_ACCOUNT_CREDENTIAL_KEY=your_account_credential_key_here
+SECRET_ENCRYPTION_BACKUP_S3_KEY=your_backup_s3_key_here
+SECRET_ENCRYPTION_CONTENT_MODERATION_KEY=your_content_moderation_key_here
+SECRET_ENCRYPTION_CHANNEL_MONITOR_KEY=your_channel_monitor_key_here
+SECRET_ENCRYPTION_PAYMENT_PROVIDER_KEY=your_payment_provider_key_here
+SECRET_ENCRYPTION_PROXY_CREDENTIAL_KEY=your_proxy_credential_key_here
+SECRET_ENCRYPTION_SCHEDULER_CACHE_KEY=your_scheduler_cache_key_here
+SECRET_ENCRYPTION_OAUTH_TOKEN_CACHE_KEY=your_oauth_token_cache_key_here
+SECRET_ENCRYPTION_JWT_HMAC_KEY=your_jwt_hmac_encryption_key_here
+SECRET_ENCRYPTION_SETTING_SECRET_KEY=your_setting_secret_encryption_key_here
+
+# Customer API key protection master key (REQUIRED; must be independent)
+API_KEY_ENCRYPTION_KEY=your_api_key_encryption_key_here
+
+# Payment resume signing key (REQUIRED; must be independent)
+PAYMENT_RESUME_SIGNING_KEY=your_payment_resume_key_here
+
+# Admin account password (REQUIRED)
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=your_admin_password
+# Custom fork: keep official update application disabled
+SUB2API_OFFICIAL_UPDATE_APPLY_ENABLED=false
+
# Optional: Custom port
SERVER_PORT=8080
```
@@ -288,7 +319,19 @@ SERVER_PORT=8080
# Generate JWT_SECRET
openssl rand -hex 32
-# Generate TOTP_ENCRYPTION_KEY
+# Generate each SECRET_ENCRYPTION_* key separately; retain the old legacy key only when upgrading
+openssl rand -hex 32
+
+# Generate API_KEY_ENCRYPTION_KEY
+openssl rand -hex 32
+
+# Generate PAYMENT_RESUME_SIGNING_KEY
+openssl rand -hex 32
+
+# Generate REDIS_PASSWORD
+openssl rand -hex 32
+
+# Generate ADMIN_PASSWORD
openssl rand -hex 32
# Generate POSTGRES_PASSWORD
@@ -326,10 +369,8 @@ docker compose -f docker-compose.local.yml logs -f sub2api
Open `http://YOUR_SERVER_IP:8080` in your browser.
-If admin password was auto-generated, find it in logs:
-```bash
-docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
-```
+The administrator password must be configured before startup and is never
+printed to service logs. The bundled deploy script stores it in `.env` mode 0600.
#### Upgrade
@@ -457,10 +498,10 @@ default:
Additional security-related options are available in `config.yaml`:
- `cors.allowed_origins` for CORS allowlist
-- `security.url_allowlist` for upstream/pricing/CRS host allowlists
-- `security.url_allowlist.enabled` to disable URL validation (use with caution)
+- `security.url_allowlist` for pricing/CRS host allowlists; upstream account `base_url` values are not host-allowlisted
+- `security.url_allowlist.enabled` to disable pricing/CRS host validation (use with caution)
- `security.url_allowlist.allow_insecure_http` to allow HTTP URLs when validation is disabled
-- `security.url_allowlist.allow_private_hosts` to allow private/local IP addresses
+- `security.url_allowlist.allow_private_hosts` to allow private/local IP addresses in pricing/CRS URL validation
- `security.response_headers.enabled` to enable configurable response header filtering (disabled uses default allowlist)
- `security.csp` to control Content-Security-Policy headers
- `billing.circuit_breaker` to fail closed on billing errors
@@ -469,20 +510,22 @@ Additional security-related options are available in `config.yaml`:
**⚠️ Security Warning: HTTP URL Configuration**
-When `security.url_allowlist.enabled=false`, the system performs minimal URL validation by default, **rejecting HTTP URLs** and only allowing HTTPS. To allow HTTP URLs (e.g., for development or internal testing), you must explicitly set:
+Upstream account `base_url` values accept arbitrary public hosts without requiring a host allowlist, but private/reserved addresses and DNS rebinding are rejected by default. HTTP URLs are controlled by `security.url_allowlist.allow_insecure_http`; keep both insecure opt-ins `false` for public deployments:
```yaml
security:
url_allowlist:
- enabled: false # Disable allowlist checks
- allow_insecure_http: true # Allow HTTP URLs (⚠️ INSECURE)
+ enabled: false # Disable pricing/CRS allowlist checks
+ allow_private_hosts: false # Public destinations only (secure default)
+ allow_insecure_http: false # Require HTTPS (secure default)
```
**Or via environment variable:**
```bash
SECURITY_URL_ALLOWLIST_ENABLED=false
-SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
+SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=false
+SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false
```
**Risks of allowing HTTP:**
@@ -574,6 +617,31 @@ Antigravity accounts support optional **hybrid scheduling**. When enabled, the g
In Claude Code, Plan Mode cannot exit automatically. (Normally when using the native Claude API, after planning is complete, Claude Code will pop up options for users to approve or reject the plan.)
+---
+
+## Kiro Support
+
+Kiro is exposed as a Claude-compatible upstream platform. Add upstream Kiro accounts as `platform=kiro`, `type=apikey`; they may be assigned either to Kiro groups for dedicated `/kiro/v1` canaries or to Anthropic/Claude groups when you want them to participate in the normal `/v1/messages` Claude pool.
+
+| Endpoint | Purpose |
+|----------|---------|
+| `/kiro/v1/models` | Kiro model list through the sidecar |
+| `/kiro/v1/messages` | Claude-style messages |
+| `/kiro/v1/chat/completions` | OpenAI chat completions |
+| `/kiro/v1/responses` | OpenAI responses |
+
+Kiro is disabled by default. Enable it with:
+
+```yaml
+kiro:
+ enabled: true
+ route_enabled: true
+ auto_route_on_v1: false
+ sidecar_url: "http://127.0.0.1:8787"
+```
+
+See [`docs/KIRO_CHANNEL.md`](docs/KIRO_CHANNEL.md) and [`tools/kiro-sidecar`](tools/kiro-sidecar) for the sidecar contract and local smoke test.
+
**Workaround**: Press `Shift + Tab` to manually exit Plan Mode, then type your response to approve or reject the plan.
---
diff --git a/README_CN.md b/README_CN.md
index 24600e0e524..60263170d4c 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -2,7 +2,7 @@
-[](https://golang.org/)
+[](https://golang.org/)
[](https://vuejs.org/)
[](https://www.postgresql.org/)
[](https://redis.io/)
@@ -122,7 +122,7 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的
| 组件 | 技术 |
|------|------|
-| 后端 | Go 1.25.7, Gin, Ent |
+| 后端 | Go 1.26.5, Gin, Ent |
| 前端 | Vue 3.4+, Vite 5+, TailwindCSS |
| 数据库 | PostgreSQL 15+ |
| 缓存/队列 | Redis 7+ |
@@ -156,8 +156,13 @@ Nginx 默认会丢弃名称中含下划线的请求头(如 `session_id`),
#### 安装步骤
+只能从已审查的仓库提交或发布包运行安装脚本;脚本会拒绝 `curl | bash` 输入。
+
```bash
-curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash
+git clone https://github.com/Wei-Shaw/sub2api.git
+cd sub2api
+git checkout <已审查的标签或提交>
+sudo bash deploy/install.sh
```
脚本会自动:
@@ -176,8 +181,9 @@ sudo systemctl start sub2api
# 2. 设置开机自启
sudo systemctl enable sub2api
-# 3. 在浏览器中打开设置向导
-# http://你的服务器IP:8080
+# 3. 从本机转发只监听服务器回环地址的设置向导
+ssh -L 18080:127.0.0.1:8080 用户名@服务器IP
+# 然后打开 http://127.0.0.1:18080
```
设置向导将引导你完成:
@@ -187,12 +193,9 @@ sudo systemctl enable sub2api
#### 升级
-可以直接在 **管理后台** 左上角点击 **检测更新** 按钮进行在线升级。
-
-网页升级功能支持:
-- 自动检测新版本
-- 一键下载并应用更新
-- 支持回滚
+本二改版可以检查上游版本,但默认禁止应用更新和回滚。必须保持
+`SUB2API_OFFICIAL_UPDATE_APPLY_ENABLED=false`,直到该确切上游版本通过兼容性门禁、
+备份、恢复演练和受控发布后,才可考虑开启。
#### 常用命令
@@ -206,8 +209,8 @@ 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
+# 从同一份已审查代码卸载
+sudo bash deploy/install.sh uninstall -y
```
---
@@ -223,14 +226,13 @@ curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install
#### 快速开始(一键部署)
-使用自动化部署脚本快速搭建:
+从经过审查的仓库提交或发布包运行部署脚本;不再支持从可变分支直接 `curl | bash`。
```bash
-# 创建部署目录
-mkdir -p sub2api-deploy && cd sub2api-deploy
-
-# 下载并运行部署准备脚本
-curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash
+# 克隆经过审查的版本并运行本地脚本
+git clone https://github.com/Wei-Shaw/sub2api.git
+cd sub2api/deploy
+bash docker-deploy.sh
# 启动服务
docker compose up -d
@@ -240,11 +242,11 @@ docker compose logs -f sub2api
```
**脚本功能:**
-- 下载 `docker-compose.local.yml`(本地保存为 `docker-compose.yml`)和 `.env.example`
-- 自动生成安全凭证(JWT_SECRET、TOTP_ENCRYPTION_KEY、POSTGRES_PASSWORD)
+- 仅复制脚本同目录下经过审查的 Compose 和环境模板
+- 分别生成 JWT、TOTP、支付恢复、PostgreSQL、Redis 和管理员凭证
- 创建 `.env` 文件并填充自动生成的密钥
- 创建数据目录(使用本地目录,便于备份和迁移)
-- 显示生成的凭证供你记录
+- 凭证仅写入权限为 0600 的 `.env`,不会打印到终端或日志
#### 手动部署
@@ -268,16 +270,42 @@ nano .env
# PostgreSQL 密码(必需)
POSTGRES_PASSWORD=your_secure_password_here
-# JWT 密钥(推荐 - 重启后保持用户登录状态)
-JWT_SECRET=your_jwt_secret_here
+# Redis 密码(必需;必须与其他密钥不同)
+REDIS_PASSWORD=your_redis_password_here
-# TOTP 加密密钥(推荐 - 重启后保留双因素认证)
-TOTP_ENCRYPTION_KEY=your_totp_key_here
+# JWT 密钥(必需 - 重启后保持用户登录状态)
+JWT_SECRET=your_jwt_secret_here
-# 可选:管理员账号
+# 旧 v1/v2 迁移根密钥(迁移完成后移除)
+TOTP_ENCRYPTION_KEY=
+
+# 应用秘密域独立根密钥(必需;每个值必须单独生成)
+SECRET_ENCRYPTION_TOTP_SECRET_KEY=your_totp_secret_key_here
+SECRET_ENCRYPTION_TOTP_CACHE_KEY=your_totp_cache_key_here
+SECRET_ENCRYPTION_ACCOUNT_CREDENTIAL_KEY=your_account_credential_key_here
+SECRET_ENCRYPTION_BACKUP_S3_KEY=your_backup_s3_key_here
+SECRET_ENCRYPTION_CONTENT_MODERATION_KEY=your_content_moderation_key_here
+SECRET_ENCRYPTION_CHANNEL_MONITOR_KEY=your_channel_monitor_key_here
+SECRET_ENCRYPTION_PAYMENT_PROVIDER_KEY=your_payment_provider_key_here
+SECRET_ENCRYPTION_PROXY_CREDENTIAL_KEY=your_proxy_credential_key_here
+SECRET_ENCRYPTION_SCHEDULER_CACHE_KEY=your_scheduler_cache_key_here
+SECRET_ENCRYPTION_OAUTH_TOKEN_CACHE_KEY=your_oauth_token_cache_key_here
+SECRET_ENCRYPTION_JWT_HMAC_KEY=your_jwt_hmac_encryption_key_here
+SECRET_ENCRYPTION_SETTING_SECRET_KEY=your_setting_secret_encryption_key_here
+
+# 用户 API Key 保护主密钥(必需;必须独立)
+API_KEY_ENCRYPTION_KEY=your_api_key_encryption_key_here
+
+# 支付恢复签名密钥(必需;必须独立)
+PAYMENT_RESUME_SIGNING_KEY=your_payment_resume_key_here
+
+# 管理员密码(必需)
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=your_admin_password
+# 二改版默认禁止应用官方在线更新
+SUB2API_OFFICIAL_UPDATE_APPLY_ENABLED=false
+
# 可选:自定义端口
SERVER_PORT=8080
```
@@ -287,7 +315,19 @@ SERVER_PORT=8080
# 生成 JWT_SECRET
openssl rand -hex 32
-# 生成 TOTP_ENCRYPTION_KEY
+# 分别生成每个 SECRET_ENCRYPTION_* 密钥;仅升级旧部署时保留原迁移密钥
+openssl rand -hex 32
+
+# 生成 API_KEY_ENCRYPTION_KEY
+openssl rand -hex 32
+
+# 生成 PAYMENT_RESUME_SIGNING_KEY
+openssl rand -hex 32
+
+# 生成 REDIS_PASSWORD
+openssl rand -hex 32
+
+# 生成 ADMIN_PASSWORD
openssl rand -hex 32
# 生成 POSTGRES_PASSWORD
@@ -337,10 +377,7 @@ docker compose -f docker-compose.local.yml logs -f sub2api
在浏览器中打开 `http://你的服务器IP:8080`
-如果管理员密码是自动生成的,在日志中查找:
-```bash
-docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
-```
+管理员密码必须在启动前配置,服务不会把密码打印到日志。内置部署脚本只会将其写入权限为 0600 的 `.env`。
#### 升级
@@ -489,10 +526,10 @@ gateway:
`config.yaml` 还支持以下安全相关配置:
- `cors.allowed_origins` 配置 CORS 白名单
-- `security.url_allowlist` 配置上游/价格数据/CRS 主机白名单
-- `security.url_allowlist.enabled` 可关闭 URL 校验(慎用)
+- `security.url_allowlist` 配置价格数据/CRS 主机白名单;上游账号 `base_url` 不再做主机白名单限制
+- `security.url_allowlist.enabled` 可关闭价格数据/CRS 主机校验(慎用)
- `security.url_allowlist.allow_insecure_http` 关闭校验时允许 HTTP URL
-- `security.url_allowlist.allow_private_hosts` 允许私有/本地 IP 地址
+- `security.url_allowlist.allow_private_hosts` 在价格数据/CRS URL 校验中允许私有/本地 IP 地址
- `security.response_headers.enabled` 可启用可配置响应头过滤(关闭时使用默认白名单)
- `security.csp` 配置 Content-Security-Policy
- `billing.circuit_breaker` 计费异常时 fail-closed
@@ -509,20 +546,22 @@ gateway:
**⚠️ 安全警告:HTTP URL 配置**
-当 `security.url_allowlist.enabled=false` 时,系统默认执行最小 URL 校验,**拒绝 HTTP URL**,仅允许 HTTPS。要允许 HTTP URL(例如用于开发或内网测试),必须显式设置:
+上游账号 `base_url` 不强制主机白名单,可使用任意公网主机,但默认拒绝私网/保留地址与 DNS rebinding。公开部署应把两个不安全开关都保持为 `false`:
```yaml
security:
url_allowlist:
- enabled: false # 禁用白名单检查
- allow_insecure_http: true # 允许 HTTP URL(⚠️ 不安全)
+ enabled: false # 禁用价格数据/CRS 白名单检查
+ allow_private_hosts: false # 只允许公网目标(安全默认值)
+ allow_insecure_http: false # 强制 HTTPS(安全默认值)
```
**或通过环境变量:**
```bash
SECURITY_URL_ALLOWLIST_ENABLED=false
-SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
+SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=false
+SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false
```
**允许 HTTP 的风险:**
@@ -637,6 +676,32 @@ Antigravity 账户支持可选的**混合调度**功能。开启后,通用端
### 已知问题
在 Claude Code 中,无法自动退出Plan Mode。(正常使用原生Claude Api时,Plan 完成后,Claude Code会弹出弹出选项让用户同意或拒绝Plan。)
解决办法:shift + Tab,手动退出Plan mode,然后输入内容 告诉 Claude Code 同意或拒绝 Plan
+
+---
+
+## Kiro 使用说明
+
+Kiro 作为 Claude-compatible 上游平台接入。上游 Kiro 账号需要使用 `platform=kiro`、`type=apikey` 添加;可以分配到 Kiro 分组走专用 `/kiro/v1` 做 canary,也可以分配到 Anthropic/Claude 分组,参与普通 `/v1/messages` Claude 池调度。
+
+| 端点 | 用途 |
+|------|------|
+| `/kiro/v1/models` | 通过 sidecar 获取模型列表 |
+| `/kiro/v1/messages` | Claude messages 兼容接口 |
+| `/kiro/v1/chat/completions` | OpenAI chat completions 兼容接口 |
+| `/kiro/v1/responses` | OpenAI responses 兼容接口 |
+
+默认关闭,开启示例:
+
+```yaml
+kiro:
+ enabled: true
+ route_enabled: true
+ auto_route_on_v1: false
+ sidecar_url: "http://127.0.0.1:8787"
+```
+
+详细 sidecar 协议和本地冒烟方式见 [`docs/KIRO_CHANNEL.md`](docs/KIRO_CHANNEL.md) 与 [`tools/kiro-sidecar`](tools/kiro-sidecar)。
+
---
## 项目结构
diff --git a/README_JA.md b/README_JA.md
index 1e89610c969..6daeb5514cb 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -2,7 +2,7 @@
-[](https://golang.org/)
+[](https://golang.org/)
[](https://vuejs.org/)
[](https://www.postgresql.org/)
[](https://redis.io/)
@@ -122,7 +122,7 @@ Sub2API を拡張・統合するコミュニティプロジェクト:
| コンポーネント | 技術 |
|-----------|------------|
-| バックエンド | Go 1.25.7, Gin, Ent |
+| バックエンド | Go 1.26.5, Gin, Ent |
| フロントエンド | Vue 3.4+, Vite 5+, TailwindCSS |
| データベース | PostgreSQL 15+ |
| キャッシュ/キュー | Redis 7+ |
@@ -156,8 +156,14 @@ GitHub Releases からビルド済みバイナリをダウンロードするワ
#### インストール手順
+レビュー済みのクローンまたはリリースアーカイブからのみ実行してください。
+インストーラーは `curl | bash` 入力を拒否します。
+
```bash
-curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/install.sh | sudo bash
+git clone https://github.com/Wei-Shaw/sub2api.git
+cd sub2api
+git checkout
+sudo bash deploy/install.sh
```
スクリプトは以下を実行します:
@@ -176,8 +182,9 @@ sudo systemctl start sub2api
# 2. 起動時の自動起動を有効化
sudo systemctl enable sub2api
-# 3. ブラウザでセットアップウィザードを開く
-# http://YOUR_SERVER_IP:8080
+# 3. ループバック専用のセットアップ画面をワークステーションへ転送
+ssh -L 18080:127.0.0.1:8080 USER@YOUR_SERVER_IP
+# 次に http://127.0.0.1:18080 を開く
```
セットアップウィザードでは以下の設定を行います:
@@ -187,12 +194,9 @@ sudo systemctl enable sub2api
#### アップグレード
-**管理ダッシュボード**の左上にある**アップデートを確認**ボタンをクリックすることで、ダッシュボードから直接アップグレードできます。
-
-Web インターフェースでは以下が可能です:
-- 新しいバージョンの自動確認
-- ワンクリックでのアップデートのダウンロードと適用
-- 必要に応じたロールバック
+このカスタムビルドは上流バージョンを確認できますが、更新とロールバックの適用は
+既定で無効です。対象リリースの互換性ゲート、バックアップ、復元演習、制御された
+ロールアウトが完了するまで `SUB2API_OFFICIAL_UPDATE_APPLY_ENABLED=false` を維持してください。
#### よく使うコマンド
@@ -206,8 +210,8 @@ 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
+# 同じレビュー済みチェックアウトからアンインストール
+sudo bash deploy/install.sh uninstall -y
```
---
@@ -223,14 +227,13 @@ PostgreSQL と Redis のコンテナを含む Docker Compose でデプロイし
#### クイックスタート(ワンクリックデプロイ)
-自動デプロイスクリプトを使用して簡単にセットアップできます:
+レビュー済みのクローンまたはリリースアーカイブからスクリプトを実行してください。可変ブランチからの `curl | bash` はサポートしません。
```bash
-# デプロイ用ディレクトリを作成
-mkdir -p sub2api-deploy && cd sub2api-deploy
-
-# デプロイ準備スクリプトをダウンロードして実行
-curl -sSL https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/deploy/docker-deploy.sh | bash
+# レビュー済みリビジョンをクローンしてローカルスクリプトを実行
+git clone https://github.com/Wei-Shaw/sub2api.git
+cd sub2api/deploy
+bash docker-deploy.sh
# サービスを起動
docker compose up -d
@@ -240,11 +243,11 @@ docker compose logs -f sub2api
```
**スクリプトの動作内容:**
-- `docker-compose.local.yml`(`docker-compose.yml` として保存)と `.env.example` をダウンロード
-- セキュアな認証情報(JWT_SECRET、TOTP_ENCRYPTION_KEY、POSTGRES_PASSWORD)を自動生成
+- スクリプトと同梱されたレビュー済み Compose / 環境テンプレートのみをコピー
+- JWT、TOTP、支払い再開、PostgreSQL、Redis、管理者の各シークレットを個別生成
- 自動生成されたシークレットで `.env` ファイルを作成
- データディレクトリを作成(バックアップ・移行が容易なローカルディレクトリを使用)
-- 生成された認証情報を参照用に表示
+- シークレットは権限 0600 の `.env` にのみ書き込み、端末やログには表示しない
#### 手動デプロイ
@@ -268,16 +271,39 @@ nano .env
# PostgreSQL パスワード(必須)
POSTGRES_PASSWORD=your_secure_password_here
-# JWT シークレット(推奨 - 再起動後もユーザーのログイン状態を保持)
-JWT_SECRET=your_jwt_secret_here
+# Redis パスワード(必須。他のシークレットと共用しない)
+REDIS_PASSWORD=your_redis_password_here
-# TOTP 暗号化キー(推奨 - 再起動後も二要素認証を維持)
-TOTP_ENCRYPTION_KEY=your_totp_key_here
+# JWT シークレット(必須 - 再起動後もユーザーのログイン状態を保持)
+JWT_SECRET=your_jwt_secret_here
-# オプション: 管理者アカウント
+# 旧 v1/v2 移行ルート(v3 移行後に削除)
+TOTP_ENCRYPTION_KEY=
+
+# アプリケーション秘密ドメインごとの独立ルート(必須。すべて別々に生成)
+SECRET_ENCRYPTION_TOTP_SECRET_KEY=your_totp_secret_key_here
+SECRET_ENCRYPTION_TOTP_CACHE_KEY=your_totp_cache_key_here
+SECRET_ENCRYPTION_ACCOUNT_CREDENTIAL_KEY=your_account_credential_key_here
+SECRET_ENCRYPTION_BACKUP_S3_KEY=your_backup_s3_key_here
+SECRET_ENCRYPTION_CONTENT_MODERATION_KEY=your_content_moderation_key_here
+SECRET_ENCRYPTION_CHANNEL_MONITOR_KEY=your_channel_monitor_key_here
+SECRET_ENCRYPTION_PAYMENT_PROVIDER_KEY=your_payment_provider_key_here
+SECRET_ENCRYPTION_PROXY_CREDENTIAL_KEY=your_proxy_credential_key_here
+SECRET_ENCRYPTION_SCHEDULER_CACHE_KEY=your_scheduler_cache_key_here
+SECRET_ENCRYPTION_OAUTH_TOKEN_CACHE_KEY=your_oauth_token_cache_key_here
+SECRET_ENCRYPTION_JWT_HMAC_KEY=your_jwt_hmac_encryption_key_here
+SECRET_ENCRYPTION_SETTING_SECRET_KEY=your_setting_secret_encryption_key_here
+
+# 支払い再開署名キー(必須。独立した値を使用)
+PAYMENT_RESUME_SIGNING_KEY=your_payment_resume_key_here
+
+# 管理者パスワード(必須)
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=your_admin_password
+# カスタムビルドでは公式更新の適用を無効のままにする
+SUB2API_OFFICIAL_UPDATE_APPLY_ENABLED=false
+
# オプション: カスタムポート
SERVER_PORT=8080
```
@@ -287,7 +313,16 @@ SERVER_PORT=8080
# JWT_SECRET を生成
openssl rand -hex 32
-# TOTP_ENCRYPTION_KEY を生成
+# 各 SECRET_ENCRYPTION_* キーを別々に生成し、旧キーは既存環境の移行時だけ保持
+openssl rand -hex 32
+
+# PAYMENT_RESUME_SIGNING_KEY を生成
+openssl rand -hex 32
+
+# REDIS_PASSWORD を生成
+openssl rand -hex 32
+
+# ADMIN_PASSWORD を生成
openssl rand -hex 32
# POSTGRES_PASSWORD を生成
@@ -325,10 +360,7 @@ docker compose -f docker-compose.local.yml logs -f sub2api
ブラウザで `http://YOUR_SERVER_IP:8080` を開いてください。
-管理者パスワードが自動生成された場合は、ログで確認できます:
-```bash
-docker compose -f docker-compose.local.yml logs sub2api | grep "admin password"
-```
+管理者パスワードは起動前に設定する必要があり、サービスログには出力されません。付属のデプロイスクリプトは権限 0600 の `.env` にのみ保存します。
#### アップグレード
@@ -456,10 +488,10 @@ default:
`config.yaml` では追加のセキュリティ関連オプションも利用できます:
- `cors.allowed_origins` - CORS 許可リスト
-- `security.url_allowlist` - 上流/価格/CRS ホストの許可リスト
-- `security.url_allowlist.enabled` - URL バリデーションの無効化(注意して使用)
+- `security.url_allowlist` - 価格/CRS ホストの許可リスト。上流アカウントの `base_url` にはホスト許可リストを適用しません
+- `security.url_allowlist.enabled` - 価格/CRS ホストバリデーションの無効化(注意して使用)
- `security.url_allowlist.allow_insecure_http` - バリデーション無効時に HTTP URL を許可
-- `security.url_allowlist.allow_private_hosts` - プライベート/ローカル IP アドレスを許可
+- `security.url_allowlist.allow_private_hosts` - 価格/CRS URL バリデーションでプライベート/ローカル IP アドレスを許可
- `security.response_headers.enabled` - 設定可能なレスポンスヘッダーフィルタリングを有効化(無効時はデフォルトの許可リストを使用)
- `security.csp` - Content-Security-Policy ヘッダーの制御
- `billing.circuit_breaker` - 課金エラー時にフェイルクローズ
@@ -468,20 +500,22 @@ default:
**⚠️ セキュリティ警告: HTTP URL 設定**
-`security.url_allowlist.enabled=false` の場合、システムはデフォルトで最小限の URL バリデーションを行い、**HTTP URL を拒否**して HTTPS のみを許可します。HTTP URL を許可するには(開発環境や内部テスト用など)、以下を明示的に設定する必要があります:
+上流アカウントの `base_url` は任意の公開ホストを利用できますが、プライベート/予約済みアドレスと DNS rebinding はデフォルトで拒否します。公開環境では両方の危険なオプトインを `false` のままにしてください:
```yaml
security:
url_allowlist:
enabled: false # 許可リストチェックを無効化
- allow_insecure_http: true # HTTP URL を許可(⚠️ セキュリティリスクあり)
+ allow_private_hosts: false # 公開宛先のみ(安全なデフォルト)
+ allow_insecure_http: false # HTTPS 必須(安全なデフォルト)
```
**または環境変数で設定:**
```bash
SECURITY_URL_ALLOWLIST_ENABLED=false
-SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=true
+SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS=false
+SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP=false
```
**HTTP を許可するリスク:**
diff --git a/SECURITY_API_KEY_CREDENTIALS_HARDENING_2026-05-11.md b/SECURITY_API_KEY_CREDENTIALS_HARDENING_2026-05-11.md
new file mode 100644
index 00000000000..4956d5ae247
--- /dev/null
+++ b/SECURITY_API_KEY_CREDENTIALS_HARDENING_2026-05-11.md
@@ -0,0 +1,93 @@
+# API Key 哈希化与上游凭证加密落库改造记录
+
+日期:2026-05-11
+
+## 处理结论
+
+本次已完成可兼容上线的安全改造:
+
+1. API Key 新增 `key_hash` 与 `key_prefix` 字段。
+2. API Key 认证查询改为优先按 SHA-256 哈希匹配,并保留旧明文字段兼容。
+3. 新建、删除 API Key 时同步维护哈希值与非敏感前缀。
+4. 账号 `credentials` 写入数据库前会对敏感字段加密;读取时由仓储层解密给业务层使用。
+5. 新增既有账号凭证加密 backfill 命令,默认 dry-run,便于生产环境先扫描再重写。
+
+## API Key 存储变化
+
+新增迁移:
+
+- `backend/migrations/136_add_api_key_hash_columns.sql`
+- `backend/migrations/137_backfill_api_key_hash.sql`
+- `backend/migrations/138_add_api_key_hash_indexes_notx.sql`
+
+上线后迁移会为既有 `api_keys.key` 回填:
+
+- `key_hash = sha256(key)`
+- `key_prefix = left(key, 12)`
+
+认证路径现在使用 `key_hash` 查询,并保留 `key` 查询作为兼容兜底。旧 `key` 明文字段本阶段仍保留,避免影响依赖完整 key 的旧管理脚本、列表和删除逻辑;后续可在确认无依赖后进入“明文字段清空/移除”阶段。
+
+## 上游账号凭证加密
+
+加密入口在仓储层,覆盖:
+
+- 账号创建
+- 账号更新
+- 单账号凭证更新
+- 批量凭证更新
+
+敏感字段会被封装为 AES-256-GCM 加密 envelope,非敏感配置如 `base_url`、`model_mapping` 保持可读,避免破坏路由配置。
+
+当前敏感字段集合包括:
+
+- `api_key`
+- `access_token`
+- `refresh_token`
+- `id_token`
+- `session_key`
+- `password`
+- `client_secret`
+- `private_key`
+- `service_account_json`
+- `token`
+- `auth_token`
+- `bearer_token`
+- `cookie`
+- `cookies`
+- `secret`
+- `secret_access_key`
+- `credentials`
+
+## 既有凭证处理命令
+
+默认只扫描,不改库:
+
+```bash
+go run ./cmd/encrypt-account-credentials --dry-run=true
+```
+
+确认扫描结果后再执行重写:
+
+```bash
+go run ./cmd/encrypt-account-credentials --dry-run=false
+```
+
+该命令只输出扫描数量、需要加密的账号数量和实际更新数量,不打印任何凭证明文。
+
+## 验证记录
+
+本机 Codex shell 没有本地 `go`,因此使用项目 Docker Go 镜像执行验证:
+
+```bash
+docker run --rm --user $(id -u):$(id -g) -e GOCACHE=/gocache -e GOMODCACHE=/gomod -v /tmp/sub2api-gomod:/gomod -v /tmp/sub2api-gocache:/gocache -v "$PWD":/app -w /app/backend golang:1.26.2-alpine go test ./...
+```
+
+结果:通过。
+
+## 后续收尾建议
+
+1. 生产部署后先观察 API Key 哈希认证路径和账号凭证解密路径。
+2. 执行 `cmd/encrypt-account-credentials` dry-run,确认需要加密的账号数量。
+3. 在维护窗口执行 `--dry-run=false`,完成既有上游凭证重写。
+4. backfill 后滚动重启服务或刷新调度缓存,确保所有进程重新从数据库读取解密后的凭证。
+5. 确认所有业务路径不再依赖 `api_keys.key` 明文后,再规划清空/移除旧明文字段。
diff --git a/TASK_HISTORY.md b/TASK_HISTORY.md
new file mode 100644
index 00000000000..151fce64d56
--- /dev/null
+++ b/TASK_HISTORY.md
@@ -0,0 +1,307 @@
+# Task History
+
+## 2026-06-04 - Sync paid-lite Liandong SKU into renewal modal
+
+- Added the `轻量正式版` paid-lite monthly SKU to the shared Liandong monthly tier constants: `¥99 / $400 / https://pay.ldxp.cn/item/neu4dr`.
+- Updated the renewal/top-up modal comment from 4 monthly tiers to 5 monthly tiers and added a focused frontend test that locks `matchMonthlyTier(400)` to the paid-lite SKU.
+- Added a stable admin subscription wallet marker (`data-hfc-wallet-marker="hasWalletBalance"`) so the stricter front/wallet anti-overwrite gate can detect the wallet display protection after frontend minification.
+- This is frontend display/linkage only. It does not change production data, subscription fulfillment, billing, wallet ledger, monthly-card entitlement logic, model pricing, rate multipliers, group coverage, payment webhooks, LoadFactor, or account dispatch.
+
+## 2026-06-03 - HFC safe-fusion Claude Code handoff
+
+- Added `HFC_SAFE_FUSION_CC_HANDOFF_2026-06-03.md` as the canonical Claude Code handoff for the current official safe-fusion upgrade line.
+- The handoff records the current branch, HEAD, completed safe-fusion areas, explicit HFC holds, production-deploy prohibition without fresh approval, and the required server/CI verification gates before any rollout.
+- It also includes a paste-ready Claude Code task prompt so the next agent keeps HFC billing, wallet, monthly-card, ledger, pricing, multiplier, group coverage, LoadFactor, dispatch priority, production migration, and production data untouched unless Mark explicitly approves a business-rule change.
+- No runtime code, production deploy, production data, migration, billing, wallet, model pricing, multiplier, payment, platform quota, group coverage, LoadFactor, or dispatch behavior changed.
+
+## 2026-06-01 - HFC Image 2 safety P0 fusion and guard dry run
+
+- Merged `29589052 Add Image 2 safety audit gate` into the local official-fusion branch as `17684267`, preserving HFC billing, wallet, subscription, daily/monthly cap, ledger, pricing, multiplier, scheduler, platform quota, and group-routing boundaries.
+- Added a trusted content-moderation Base URL allowlist: OpenAI official endpoint, HFC-owned HTTPS domains, and loopback-only local proxies. Untrusted third-party hosts are rejected during admin config validation and runtime audit calls.
+- Added `deploy/build_image.sh` source and binary marker guards so production-style candidate builds fail if Image 2 safety markers are missing from source or from `/app/sub2api` in the built image.
+- Added shell/source marker guard coverage and Go tests for trusted/untrusted moderation Base URL behavior.
+- Verification: `bash -n deploy/build_image.sh`, `bash -n deploy/test_build_image_retention.sh`, `bash deploy/test_build_image_retention.sh`, and `git diff --check` passed. Go tests were not run because this Mac has no `go`; Docker-based verification was not run because the Docker daemon is not running.
+- No production deploy, production config change, DB change, image cutover, real image-generation request, billing change, wallet change, account-pool change, or risk control enablement was performed.
+
+## 2026-05-30 - Codex tool-output WS continuation fusion
+
+- Fused the official Codex tool-output recognition enhancement for OpenAI WS continuation.
+- Continuation logic now treats `tool_search_output`, `custom_tool_call_output`, and `mcp_tool_call_output` like `function_call_output`, preserving `previous_response_id` / replay context when tool outputs need upstream response-chain continuity.
+- This is protocol compatibility only; it does not change HFC billing, wallet ledger, token price, model multiplier, subscription caps, group routing, account priority, or scheduler selection.
+- Added/expanded continuation tests for Codex tool-output and tool-call context item types.
+- Verification: `git diff --check` passed. Backend Go tests were not run locally because this Mac does not have `go/gofmt`.
+
+## 2026-05-30 - Claude thinking-block retry compatibility fusion
+
+- Fused the official extended-thinking retry compatibility pattern for upstream errors like `each thinking block must contain thinking`.
+- The existing retry rectifier can now recognize that error and use the already-present request-body sanitizer to drop invalid empty thinking blocks while preserving valid text content.
+- This is a compatibility retry path only; it does not change token accounting, HFC billing, wallet ledger, model pricing, subscription caps, group routing, or scheduler priority.
+- Added the official empty-thinking-block sanitizer test case.
+- Verification: `git diff --check` passed. Backend Go tests were not run locally because this Mac does not have `go/gofmt`.
+
+## 2026-05-30 - OAuth refresh_token_reused classification fusion
+
+- Fused the official `refresh_token_reused` non-retryable OAuth refresh classification.
+- OpenAI OAuth accounts with a reused refresh token now follow the existing invalid-credential path instead of retrying as a transient refresh failure.
+- This is account-health classification only; it does not change HFC billing, wallet ledger, model pricing, subscription caps, request admission, group routing, or scheduler priority.
+- Added the official classification case to the token refresh unit-test table.
+- Verification: `git diff --check` passed. Backend Go tests were not run locally because this Mac does not have `go/gofmt`.
+
+## 2026-05-30 - Scheduler account delete cache cleanup fusion
+
+- Fused the official scheduler-cache cleanup behavior for deleted accounts.
+- Account deletion now removes the single-account scheduler cache snapshot after the database delete succeeds, so sticky/session cache cannot keep a stale deleted account.
+- This is cache cleanup only; it does not change account selection order, group routing, LoadFactor capacity weighting, scheduler priority, wallet/billing, model pricing, or request admission.
+- Added an integration-test assertion path for the cache delete hook.
+- Verification: `git diff --check` passed. Backend Go tests were not run locally because this Mac does not have `go/gofmt`.
+
+## 2026-05-30 - Group account availability count fusion
+
+- Fused the official group account count display fix while preserving HFC account dispatch behavior.
+- Group detail/list counts now use the same availability filters as the existing schedulable account query: active, schedulable, not expired under auto-pause, not rate-limited, not overloaded, and not temp-unschedulable.
+- This is admin display/data accuracy only; it does not change account selection order, group routing, LoadFactor capacity weighting, wallet/billing, model pricing, or request admission.
+- Verification: `git diff --check` passed. Backend Go tests were not run locally because this Mac does not have `go`.
+
+## 2026-05-30 - API Key daily usage detail fusion
+
+- Fused the official API Key daily usage detail enhancement as read-only usage reporting.
+- `/v1/usage` can now include `daily_usage` for explicit 7/30/90 day windows, and the user-owned API key daily usage endpoint verifies key ownership before returning rows.
+- The key usage page now shows a daily detail table with requests, token breakdown, cache read/write, and actual cost; it only reads existing usage aggregates and does not alter HFC billing, wallet ledger, model pricing, rate multipliers, subscription caps, or request admission.
+- Added backend handler tests for ownership, invalid day ranges, empty data, and aggregation mapping, plus a frontend view test for rendering daily usage rows.
+- Preserved old `/v1/usage` behavior for clients that do not pass `days`, avoiding an extra daily-aggregation query on existing usage integrations.
+- Verification: `git diff --check`, frontend `pnpm typecheck`, and targeted frontend `eslint` passed. Frontend Vitest was blocked by this Mac's Rollup native package code-signing error before tests started; backend Go tests were not run locally because this Mac does not have `go`.
+
+## 2026-05-30 - HFC redeem batch update fusion
+
+- Fused the official redeem-code batch edit operator workflow while preserving the HFC issue-based expiry rule.
+- Admins can batch edit status, redeem-by time, notes, and subscription group from the management page; bulk updates reject type/value changes so redemption semantics and HFC billing logic stay unchanged.
+- Used-code protections remain in place: bulk edits cannot change status, expiry, or group for already redeemed codes.
+- Kept HFC stock-code behavior explicit in the UI: unissued stock codes do not count down; the 30-day redemption window starts when the external shop/admin issue endpoint marks the code as customer-issued.
+- Verification: `git diff --check`, frontend `pnpm typecheck`, and targeted frontend `eslint` passed. Go tests were not run locally because this Mac does not have `go`; a unit test file was added for the service validation path and should be run in the server/Docker build environment before deployment.
+
+## 2026-05-30 - Anthropic context-management beta sanitize fusion
+
+- Fused official Anthropic `context_management` body/header compatibility fix into the HFC branch.
+- Gateway now strips `body.context_management` before upstream forwarding when the final `anthropic-beta` header does not include `context-management-2025-06-27`, including direct Anthropic, API-key passthrough, Vertex, count_tokens, and Antigravity-compatible paths.
+- Preserved HFC-specific count_tokens payload filtering, ops request body markers, CCH signing order, beta-policy filtering, Claude mimic headers, billing/wallet/subscription logic, model pricing, and account dispatch priority.
+- Verification: `git diff --cached --check` passed. Backend Go tests were not run locally because this machine has no `go/gofmt`.
+
+## 2026-05-30 - Content moderation async test stability fusion
+
+- Fused official content-moderation WebSocket test stability fix into the HFC branch.
+- The test-only content moderation log repo now uses a mutex and snapshot helper, and asynchronous moderation-log assertions wait with `require.Eventually`.
+- Runtime behavior is unchanged; this does not touch request handling, billing, wallet, subscription, routing, moderation thresholds, or account dispatch.
+- Verification: `git diff --check` passed. Backend Go tests were not run locally because this machine has no `go/gofmt`.
+
+## 2026-05-30 - Usage request context preservation fusion
+
+- Fused official request-correlation preservation into the HFC generic gateway usage-record path.
+- Async usage-record tasks for Messages, Chat Completions, Responses, Gemini, Kiro, and Cursor now keep `client_request_id` / `request_id` from the inbound request context.
+- `ClientRequestID` middleware now returns `X-Client-Request-ID` on generated and preserved IDs so client/admin logs can correlate the same request end to end.
+- This is observability-only: it does not change usage numbers, token accounting, pricing, wallet ledger, monthly/daily caps, payment, model routing, or account dispatch priority.
+- Verification: `git diff --check` passed. Backend Go tests were not run locally because this machine has no `go/gofmt`.
+
+## 2026-05-30 - Registration email whitelist wildcard support
+
+- Fused official registration email-domain whitelist wildcard support while keeping email registration as the only active login/register baseline.
+- Exact domains keep the existing behavior; admins may now configure wildcard entries like `*.edu.cn` to match the base domain and subdomains.
+- Updated backend/frontend normalization, user-facing allowed-domain messages, and Settings UI copy; escaped the i18n `@` placeholder to avoid Vue I18n linked-message parsing.
+- Verification: frontend `registrationEmailPolicy.spec.ts` passed. `git diff --check` passed. Backend Go tests were not run locally because this machine has no `go/gofmt`.
+
+## 2026-05-30 - HFC content moderation agent-loop dedupe
+
+- Fused official content-moderation input extraction behavior for agent/tool-loop requests.
+- Moderation now audits only the current last user message for chat/messages/responses/gemini payloads; if the latest turn is assistant/tool/function output, it skips re-auditing historical user text.
+- This reduces duplicate moderation logs and external audit calls during tool loops while keeping first-turn and latest-user-turn moderation intact.
+- Verification: `git diff --check` passed. Backend Go tests were not run locally because this machine has no `go/gofmt`.
+
+## 2026-05-30 - HFC ops business-limit marker fusion
+
+- Fused official ops business-limit marker behavior into HFC local rejection paths without changing request admission, billing, wallet, subscription, model pricing, or account dispatch rules.
+- Added ops markers for API key IP restriction denials, ungrouped-key denials, local route feature gates, Anthropic beta policy blocks, Antigravity whitelist denials, and OpenAI fast-policy blocks across HTTP and WebSocket paths.
+- Purpose: keep expected local/HFC policy denials visible in ops logs while excluding them from SLA/upstream-failure metrics.
+- Verification: `git diff --check` passed. Backend Go tests were not run locally because this machine has no `go/gofmt`.
+
+## 2026-05-30 - HFC group custom models list fusion
+
+- Fused official group-level custom `/v1/models` display list into the HFC branch.
+- Preserved HFC default `/v1/models` behavior and Claude Code model-list hiding; the custom list only applies when an admin explicitly enables `models_list_config`.
+- Added `groups.models_list_config` migration and auth-cache snapshot version bump so API key group snapshots include the new display config.
+- Added admin create/edit UI controls and a candidate-model API for configuring the display list.
+- Verification: frontend `groupsModelsList*` Vitest specs passed and `vue-tsc --noEmit` passed. Backend Go tests were not run locally because this machine has no `go/gofmt` and Docker commands were not responding.
+
+## 2026-05-30 - HFC official fusion safe cutover attempt
+
+- Built candidate image `hfc/sub2api:official-fusion-6066bf5c-appchown-20260530-113519`.
+- Fixed candidate runtime smoke by preserving `/app` ownership for the `sub2api` user in `Dockerfile`.
+- Candidate preflight passed anti-overwrite frontend markers and candidate health checks.
+- Production cutover was not completed because stream-drain timed out after 1800 seconds with active streaming requests.
+- Production remained on `hfc/sub2api:long-request-resilience-4aedd982-20260530-030914` and stayed healthy.
+- Full handoff report: `HFC_OFFICIAL_FUSION_CUTOVER_ATTEMPT_2026-05-30.md`.
+
+## 2026-05-31 - HFC approved OpenAI WS safe fusion
+
+- Fused the approved OpenAI Responses/WebSocket compatibility subset locally only; no production deployment, migration, or production data change was performed.
+- API-key/OpenAI WS rate-limit failures now return `UpstreamFailoverError` before any downstream output, so the handler can switch to another eligible account for the same request while excluding only the failed account for this attempt.
+- Follow-up WS `response.create` frames may omit `model`; the gateway reuses the last validated client model and writes a concrete upstream model before policy, mapping, and image-permission checks.
+- Added display-only OpenAI endpoint compatibility hints to account create/edit modals. The panel explicitly holds embeddings behind HFC billing review and does not persist official endpoint capability/gating fields.
+- Preserved HFC baselines: no billing, wallet, subscription cap, ledger, model pricing, rate multiplier, payment, platform quota, LoadFactor, or dispatch-priority changes.
+- Verification: `git diff --check`, frontend `pnpm typecheck`, and frontend `pnpm lint:check` passed. Targeted Vitest was blocked by the existing Rollup native optional-dependency code-signing error; backend Go tests were not run locally because this machine has no `go/gofmt`.
+
+## 2026-05-31 - HFC approved scheduler and OAuth compatibility fusion
+
+- Fused another approved local-only compatibility slice; no production deployment, migration, or production data change was performed.
+- OpenAI OAuth upstream requests that arrive with a browser-style `User-Agent` are normalized to the Codex CLI user agent for ChatGPT internal API compatibility. API-key upstream requests preserve the caller `User-Agent`.
+- OpenAI WS v2 passthrough now carries the existing turn-state/turn-metadata headers and `prompt_cache_key` into the handshake path, matching the safer session-continuity behavior without changing usage accounting.
+- Error-marked accounts are now also marked not schedulable so the scheduler skips them until an operator explicitly reviews/re-enables scheduling. This does not delete accounts, rewrite priorities, or change LoadFactor/group routing.
+- Preserved HFC baselines: no billing, wallet, monthly/daily cap, ledger, model pricing, rate multiplier, payment, platform quota, embeddings, OAuth login, email, Airwallex, multi-currency, or endpoint-gating changes.
+- Verification: `git diff --check` passed. Backend Go tests were not run locally because this machine has no `go/gofmt`; targeted tests were added for the new helper, OAuth/API-key user-agent behavior, and error unscheduling path.
+
+## 2026-05-31 - HFC official v0.1.133 remaining upgrade report
+
+- Rechecked `upstream-official/main` at `f18451e56f15b31ef602ab238037b56c3522b19f` / `v0.1.133` against the HFC safe-fusion branch.
+- Consolidated remaining official upgrade gaps into `HFC_OFFICIAL_V0_1_133_REMAINING_UPGRADE_REPORT_2026-05-31.md`.
+- Classified remaining items into: already fused/equivalent, strict HFC holds, candidates requiring isolated testing, and low-priority admin conveniences.
+- No runtime code, database schema, migration, production data, image, deployment script, billing logic, wallet logic, model pricing, or account dispatch behavior was changed by this reporting step.
+
+## 2026-05-31 - HFC OpenAI quota guard 5h/7d threshold
+
+- Extended the existing HFC OpenAI quota guard from 7d-only to both 5h and 7d Codex usage windows.
+- When either `codex_5h_used_percent` or `codex_7d_used_percent` reaches 95%, the OAuth OpenAI account becomes temporarily unschedulable until the corresponding reset time.
+- If both windows are over threshold, the guard keeps the later reset time so weekly quota protection is not released early.
+- This is account-pool protection only: no billing, wallet, monthly-card entitlement, ledger, model pricing, multiplier, payment, platform quota, group coverage, LoadFactor, or dispatch-priority logic was changed.
+
+## 2026-06-01 - HFC monthly Opus 4.8 layout decision checklist
+
+- Added `HFC_MONTHLY_PLAN_OPUS48_LAYOUT_DECISION_CHECKLIST_2026-06-01.md` to collect the monthly-plan improvement, page-layout, and decision items before implementation.
+- Clarified that Opus 4.8 is intended to be opened, but must first use HFC-owned model IDs, pricing/multipliers, group coverage, model-log semantics, and page copy rather than official pricing/quota semantics.
+- Updated the remaining-upgrade report so Opus 4.8 is no longer described as a blanket hold; it is now a pending HFC decision-and-layout item.
+- This documentation-only step did not change runtime behavior, production data, migrations, billing, wallet, model pricing, multipliers, payment, platform quota, group coverage, LoadFactor, or dispatch priority.
+
+## 2026-06-01 - HFC Opus 4.8 page display upgrade
+
+- Added frontend display constants for `Claude Opus 4.8` and the HFC monthly-plan/account-protection notices.
+- Added visible Opus 4.8 monthly-plan notices to purchase, subscription, renewal, admin plan edit, and dashboard quick-launch surfaces; the redeem page already carries the 30-day issue-time expiry reminder from the HFC redeem batch work.
+- Added Opus 4.8 to the frontend Claude/Kiro/Cursor model candidates and dashboard quick launch while preserving GPT-5.5 as the default and Opus 4.7 as fallback.
+- This is display/candidate work only: no billing, wallet, monthly-card logic, ledger, model price, multiplier, payment, platform quota, group coverage, LoadFactor, dispatch priority, production data, migration, or deployment changed.
+
+## 2026-06-01 - HFC Opus 4.8 production read-only audit
+
+- Audited production server, production database, public frontend assets, public `/v1/models`, and the current local safe-fusion branch for Opus 4.8 status.
+- Confirmed production DB already has Opus 4.8 traffic: 6,770 matching `usage_logs` rows at audit time, from 2026-05-29 01:43:33 +08 to 2026-06-01 14:14:52 +08.
+- Confirmed account `205 / destiny` has an Anthropic account-level mapping to `claude-opus-4-8-F`, while `channels.model_mapping` and `channel_model_pricing` do not explicitly contain Opus 4.8 entries.
+- Confirmed the production worktree contains an Opus 4.8 backend patch, but the currently running image could not be proven to include it because `/app/sub2api` strings did not expose `claude-opus-4-8` and the image label lacks a usable git commit.
+- Wrote the detailed audit report to `HFC_OPUS48_PRODUCTION_AUDIT_2026-06-01.md`.
+- No production data, deployment, billing, wallet, monthly-card logic, model pricing, multiplier, payment, platform quota, group coverage, LoadFactor, or dispatch priority was changed.
+
+## 2026-06-01 - HFC Claude Code Codex plugin and model mapping fusion
+
+- Unified OpenAI OAuth Codex-compatible client detection so account-scoped Claude Code Codex plugin allow-list entries (`codex_cli_only_allowed_clients: ["claude_code"]`) now feed the same compatibility predicate used by HTTP `/responses`, OpenAI WS mode, and WS v2 passthrough.
+- Preserved the HFC layering decision: the plugin only controls client compatibility and upstream request shaping; account-level `model_mapping` remains the business routing layer and still applies to plugin traffic.
+- Preserved plugin client signatures for explicitly allowed Claude Code plugin traffic instead of rewriting the `User-Agent` back to `codex_cli_rs` in passthrough/WS paths.
+- Added targeted backend tests for compatible-client detection, model-mapping preservation, passthrough signature preservation, and WS v2 signature preservation.
+- No production data, deployment, billing, wallet, monthly-card logic, model pricing, multiplier, payment, platform quota, group coverage, LoadFactor, dispatch priority, or Opus 4.8 pricing/mapping policy was changed.
+
+## 2026-06-01 - HFC OpenAI silent-refusal failover guard
+
+- Fused the HFC-safe subset of the official OpenAI silent-refusal failover behavior for raw Chat Completions and Responses-to-Chat streaming paths.
+- Large streaming requests now buffer empty assistant/finish-only prelude chunks until real content, tool calls, reasoning, usage, or an error appears; if upstream ends with `finish_reason=stop` and no content/usage, the service returns an `UpstreamFailoverError` before committing a 200 stream to the client.
+- Failover-exhausted handlers now return a clear upstream-error message for this specific silent-refusal marker.
+- This changes only pre-output retry eligibility and ops visibility. It does not change billing, wallet, monthly-card logic, ledger, model pricing, multipliers, payment, platform quota, group coverage, LoadFactor, dispatch priority, or usage-token extraction.
+- Verification: `git diff --check` passed. Backend Go tests were not run locally because this machine has no `go/gofmt`.
+
+## 2026-06-01 - HFC OpenAI runtime cooldown fast-path fusion
+
+- Added a process-local OpenAI account runtime block fast path for HFC-safe cooldown signals: 429 rate limits, 529 overloads, OpenAI 403 temporary cooldowns, OAuth 401 temporary unscheduling, temporary-unschedulable rules, stream timeouts, privacy-required errors, and the HFC 95% Codex 5h/7d quota guard.
+- OpenAI account selection now skips runtime-blocked accounts across previous-response sticky routing, session sticky routing, load-aware selection, fallback wait plans, and direct gateway selection.
+- Admin clear-account-error and RateLimitService clear paths now clear the runtime scheduling block alongside rate-limit/temp-unschedulable state.
+- This is only a skip/cooldown/clear fast path. It does not auto-delete accounts, does not change HFC LoadFactor weighting, does not rewrite dispatch priority, and does not touch billing, wallet, monthly-card logic, ledger, model pricing, multipliers, payment, platform quota, group coverage, production data, migrations, or deployment.
+- Verification: `git diff --check` passed. Backend Go tests and `gofmt` were not run locally because this machine has no `go`/`gofmt`.
+
+## 2026-06-09 - HFC GPT compatibility visible model closure prep
+
+- Added a server-side OpenAI `/v1/messages` compatibility guard for Claude-compatible request model names that actually execute on GPT upstream models. The guard tells the upstream model to answer identity, knowledge cutoff, training cutoff, and built-in knowledge date questions from the real upstream GPT model instead of the Claude-compatible protocol shell.
+- Scoped the guard to `Claude-compatible request -> gpt* upstream` only, preserving native GPT requests, real Claude upstreams, customer request JSON, response shape, billing fields, routing model, `requested_model`, and `model_mapping_chain`.
+- Added production replay SQL `deploy/backfill_gpt_compat_usage_model_20260609.sql` plus rollback SQL to back up and update historical OpenAI/GPT `/v1/messages` rows where `usage_logs.model` still showed `claude*` while `upstream_model` was `gpt*`.
+- Verification: `gofmt` passed for changed Go files; `go test -tags=unit ./internal/service ./internal/repository ./internal/handler/admin ./internal/pkg/usagestats` passed locally with temporary Go 1.26.3.
+
+## 2026-06-01 - HFC OpenAI image moderation error passthrough fusion
+
+- Fused the HFC-safe subset of the official OpenAI image moderation error surfacing change.
+- OAuth Images/Responses non-streaming and streaming paths now detect upstream `error` / `response.failed` SSE payloads, including `moderation_blocked` and `image_generation_user_error`, and return a clear client-facing error type, code, and sanitized message instead of falling through to a generic "upstream did not return image output" failure.
+- Ops upstream error context is set for these upstream moderation/user errors, and the images handler treats them as non-account-fault user/safety errors so they do not trigger account failover.
+- This is visibility only: HFC Image 2 request/output audit, adult/minor/violence/image-generation high-risk policy, billing, wallet, monthly-card logic, ledger, model pricing, multipliers, payment, platform quota, group coverage, LoadFactor, dispatch priority, production data, migrations, and deployment were not changed.
+
+## 2026-06-02 - HFC admin proxy resource link
+
+- Fused the low-risk official admin proxy IP resource link as a compact frontend-only helper on proxy creation and account proxy selection surfaces.
+- Added shared `ProxyAdBanner` plus Chinese/English copy; the link opens in a new tab with `noopener noreferrer`.
+- This is a display-only admin convenience: no backend API, proxy credentials, account routing, billing, wallet, monthly-card logic, ledger, model pricing, multipliers, payment, platform quota, group coverage, LoadFactor, dispatch priority, production data, migrations, or deployment changed.
+
+## 2026-06-02 - HFC Bedrock Claude Code compatibility toggle
+
+- Fused a Bedrock-only Claude Code compatibility switch under channel feature config as `bedrock_cc_compat`.
+- When enabled for an Anthropic channel and the selected account is actually Bedrock, Bedrock request preparation cleans up Claude Code shapes that AWS rejects: Opus 4.7+ `thinking.type=enabled` becomes `adaptive`, non-Opus-4.7 enabled thinking receives a default budget, and `tool_use.id` / `tool_result.tool_use_id` characters outside Bedrock's allowed set are normalized.
+- The switch defaults off and is scoped to Bedrock request preparation only. No Anthropic/OpenAI main routing, billing, wallet, monthly-card logic, ledger, model pricing, multipliers, payment, platform quota, group coverage, LoadFactor, dispatch priority, production data, migrations, or deployment changed.
+- Verification: `git diff --check`, `pnpm --dir frontend typecheck`, and `pnpm --dir frontend lint:check` passed. `pnpm --dir frontend build` was blocked by the local Rollup native optional dependency code-signature failure for `@rollup/rollup-darwin-arm64`. Backend Go tests and `gofmt` were not run locally because this machine has no `go`/`gofmt`.
+
+## 2026-06-02 - HFC Claude Code mimic tool_use name compatibility
+
+- Fused the safe request-shape subset of the official mimic tool-name compatibility fix.
+- Claude Code mimic request rewriting now keeps `tools[]`, `tool_choice.name`, and historical `messages[].content[].name` tool_use blocks self-consistent after tool-name obfuscation, preventing Anthropic/Bedrock-style upstream 400 errors where a message references a tool name that is no longer declared in `tools[]`.
+- The change is limited to request body compatibility before upstream forwarding. No billing, wallet, monthly-card logic, ledger, model pricing, multipliers, payment, platform quota, group coverage, LoadFactor, dispatch priority, production data, migrations, or deployment changed.
+- Verification: `git diff --check`, `pnpm --dir frontend typecheck`, and `pnpm --dir frontend lint:check` passed. Backend Go tests and `gofmt` were not run locally because this machine has no `go`/`gofmt`.
+
+## 2026-06-02 - HFC official safe-fusion offline completion pass
+
+- Re-audited the local safe-fusion branch against `upstream-official/main` after the latest compatibility commits and updated `HFC_OFFICIAL_V0_1_133_REMAINING_UPGRADE_REPORT_2026-05-31.md` so it reflects the current state: approved local/offline safe-fusion candidates are handled; remaining official items are either explicit HFC holds or deployment-time verification gates.
+- Confirmed the already-present safe subsets cover Gemini Messages tool-use stream ordering, `count_tokens` generation-field filtering, OpenAI WS terminal-event first-token protection, channel monitor API mode / Responses extraction, account created-at display, Claude Code mimic tool-name consistency, Bedrock CC compat toggle, image moderation surfacing, content audit observe/alert flow, and HFC runtime account cooldown / 95% guard boundaries.
+- Explicitly preserved all HFC baselines: no production deploy, no production migration or data change, no payment/OAuth/email/Airwallex/multicurrency/platform-quota enablement, no embeddings opening, no official pricing override, no wallet/monthly-card/ledger/model-price/multiplier/group-coverage/LoadFactor/dispatch-priority rewrite.
+- Verification: `git diff --check` passed. Frontend typecheck/lint were rerun for this pass. Backend Go tests and `gofmt` still require a server or CI environment with Go because this Mac does not expose `go`/`gofmt`; Docker availability is also not assumed for production readiness.
+
+## 2026-06-04 - HFC safe-fusion progress recheck after CC work
+
+- Rechecked the current candidate branch `codex/hfc-admin-wallet-ui-split-20260604` after CC/Codex follow-up work and wrote `HFC_SAFE_FUSION_PROGRESS_RECHECK_2026-06-04.md`.
+- Confirmed CC/Codex commit `783dcd81` adds admin-only display of active subscription wallet summary beside recharge balance in the user balance-history modal. This is read-only display work and does not change billing, wallet ledger, monthly-card deduction, prices, multipliers, payment, platform quota, group coverage, LoadFactor, or dispatch priority.
+- Cherry-picked the safe intent from CC branch commit `435236ce` into the current candidate as `5a1e0aea`, removing incorrect Opus 4.8 monthly-card banners from customer purchase, subscription, and renewal surfaces while keeping backend/admin/dashboard model display surfaces intact.
+- Confirmed the active 4AM automation `hfc-4am-safe-upgrade-deployment-gate` exists as a safety-gated check: it should deploy only when all production gates pass, otherwise report blockers instead of blindly cutting over.
+- Verification: `git diff --check`, `pnpm --dir frontend typecheck`, and `pnpm --dir frontend lint:check` passed. Targeted Vitest remains blocked by the local Rollup native optional dependency/code-signature issue. Backend Go tests and `gofmt` were not run locally because this Mac has no `go`/`gofmt`.
+
+## 2026-06-04 - HFC frontend page and sidebar change review
+
+- Wrote `HFC_FRONTEND_PAGE_NAV_CHANGE_REVIEW_2026-06-04.md` for Mark's frontend audit before deployment.
+- Confirmed the current candidate does not change `frontend/src/components/layout/AppSidebar.vue` or `frontend/src/router/index.ts`; no personal sidebar menu entry is newly added by this candidate.
+- Catalogued user-facing page changes: Dashboard balance CTA and shared recharge/renew modal, purchase-page redeem-code expiry reminder, subscription-page shared modal reuse, redeem-page localized error extraction, Opus 4.8 dashboard/model badge display, and wallet display helpers.
+- Reconfirmed the removed Opus 4.8 customer-side monthly-card banners stay removed from purchase, subscription, and renewal surfaces; retained Opus 4.8 display only for backend/admin/dashboard/model-entry contexts.
+
+## 2026-06-04 - HFC wallet renewal entry always visible
+
+- Fixed the wallet dashboard renewal entry so users with an active wallet monthly card can always see a `Renew` / `续费` button, even when the remaining wallet balance is still above the low-balance warning threshold.
+- Kept the low-balance / exhausted warning as a reminder only; it no longer controls whether the renewal entry exists.
+- Added a frontend marker `data-hfc-renew-entry="wallet"` and a targeted component test covering both normal-balance and low-balance wallet states.
+- This is frontend display/entry behavior only. It does not change billing, wallet ledger, monthly-card deduction, prices, multipliers, payment callback handling, platform quota, group coverage, LoadFactor, dispatch priority, production data, or migrations.
+
+## 2026-06-05 - HFC paid-lite subscription group sync
+
+- Synced production admin subscription assignment data for the 99 yuan paid-lite tier by adding/updating `paid-lite-v3` as an active subscription group with 400 USD monthly quota, 50 USD daily cap, and sort order between `paid-trial-v3` and `paid-standard-v3`.
+- Corrected `paid-lite-v3-30d` plan coverage so the plan maps to `paid-lite-v3` instead of `paid-trial-v3`, while preserving its shared coverage groups `cc-default`, `openai-default`, `gemini-default`, and `cc-antigravity`.
+- Added `deploy/sync_paid_lite_subscription_group_20260605.sql` as an idempotent production replay script for the admin subscription-group sync.
+- Production verification: groups query showed `paid-lite-v3` active at sort order 102, `paid-lite-v3-30d` remained price 99 / wallet quota 400 / for_sale=true / sort order 2, public admin/API probes returned 200, and the anti-overwrite gate plus monthly billing smoke passed with the dedicated zero-balance monthly test account still at balance 0.00000000.
+
+## 2026-06-01 - HFC GPT-group Image2 production recovery
+
+- Cleaned content-moderation audit non-hit policy names so allowed input/output audit rows record `moderation_pass_input` / `moderation_pass_output` instead of legacy `moderation_flagged_*` fallback labels.
+- Enabled the production Codex `/responses` image-generation bridge with `GATEWAY_CODEX_IMAGE_GENERATION_BRIDGE_ENABLED=true`, so Codex official clients receive the native `image_generation` tool and bridge instructions when the API key group allows image generation.
+- Recovered a stuck production cutover by returning `sub2api` to a healthy container, then deployed the running `hfc/sub2api:admin-wallet-display-82ab0ca0-20260601-215803` image with the Image2 audit fixes and bridge environment.
+- Customer key canaries passed without exposing the full key: direct `/v1/images/generations` using `gpt-image-2` returned HTTP 200 with one b64 image, and Codex-style `/responses` returned HTTP 200 with one `image_generation_call` and image result.
+- Production billing and audit evidence: customer usage rows billed to subscription `157`; direct Image2 cost recorded as `0.0500000000`, Responses bridge image cost as `0.1000000000`; content moderation logs recorded `moderation_pass_input` and `moderation_pass_output`.
+- Anti-overwrite gate passed on the running image: wallet markers passed, monthly billing smoke passed with zero-balance monthly test account still at balance `0.00000000`; the `sub2api-monthly-billing-smoke.timer` remained active/enabled and the 22:46 CST automatic run also passed.
+- Verification: production HTTP `/health` returned 200 after the final restart, bridge injection logs showed `/responses image_generation` tool injection and bridge instructions, and `git diff --check` passed locally. Backend Go tests and `gofmt` were not run locally because this machine has no `go`/`gofmt`; the production canaries above cover the live customer path.
+
+## 2026-06-08 - HFC paid-lite OpenAI default runtime coverage hotfix
+
+- Diagnosed customer `yxf757179796@qq.com` (`user_id=198`) `/responses` 403 `GROUP_NOT_IN_SUBSCRIPTION` after the OpenAI default key could be created: the DB trigger had been updated for M:N plan coverage, but the live Go runtime still looked only at legacy `subscription_plans.group_id`, while `paid-lite-v3-30d` is wallet/M:N mode with `group_id=NULL`.
+- Added a production DB compatibility shim plan `paid-lite-v3-runtime-coverage-shim-20260608` (`id=19`, `group_id=31`, `for_sale=false`, `price=0`) with plan groups `1,3,4,5,31`, so the existing live runtime can resolve paid-lite subscription group `31` covering `openai-default` group `3` without changing user balances, keys, subscriptions, accounts, scheduler state, or the running image.
+- Kept a local long-term code fix in `user_subscription_repo.go` so future runtime builds also understand M:N subscription anchor groups directly, plus an integration regression test for `plan.group_id=NULL` coverage. Production source tracked changes were reverted after the Dockerized integration test cold compile exceeded 15 minutes on the server, avoiding an unverified dirty production build tree.
+- Production backup: `/opt/relay/ai-relay-infra/backups/yxf198-runtime-group-coverage-shim-20260608_2228/subscription_plans_and_groups_before.sql`.
+- Verification: old-runtime SQL lookup for user `198` and target group `3` now resolves `runtime_subscription_id=177`; shim is hidden (`for_sale=false`) and covers `1,3,4,5,31`; public `/health` returned `{"status":"ok"}`; monthly billing smoke passed with `usage_log_id=336507`, `actual_cost=0.0000001380`, and dedicated test balance still `0.00000000`; no new `/responses` `GROUP_NOT_IN_SUBSCRIPTION` ops errors appeared after 22:10 CST (last observed 21:47:57); the 10-minute monthly smoke timer remained active.
diff --git a/backend/Dockerfile b/backend/Dockerfile
index aeb20fdb667..20112fe8da6 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.25.7-alpine
+FROM golang:1.26.5-alpine
WORKDIR /app
diff --git a/backend/cmd/encrypt-account-credentials/main.go b/backend/cmd/encrypt-account-credentials/main.go
new file mode 100644
index 00000000000..3c9f19ef7e7
--- /dev/null
+++ b/backend/cmd/encrypt-account-credentials/main.go
@@ -0,0 +1,49 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "log"
+ "time"
+
+ _ "github.com/Wei-Shaw/sub2api/ent/runtime"
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/repository"
+)
+
+func main() {
+ dryRun := flag.Bool("dry-run", true, "scan only; set --dry-run=false to rewrite plaintext account credentials")
+ timeout := flag.Duration("timeout", 10*time.Minute, "maximum runtime for the encryption pass")
+ flag.Parse()
+
+ cfg, err := config.ProvideConfig()
+ if err != nil {
+ log.Fatalf("load config: %v", err)
+ }
+
+ client, err := repository.ProvideEnt(cfg)
+ if err != nil {
+ log.Fatalf("connect database: %v", err)
+ }
+ defer func() { _ = client.Close() }()
+
+ encryptor, err := repository.NewAESEncryptor(cfg)
+ if err != nil {
+ log.Fatalf("create encryptor: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), *timeout)
+ defer cancel()
+
+ result, err := repository.EncryptPlaintextAccountCredentials(ctx, client, encryptor, *dryRun)
+ if err != nil {
+ log.Fatalf("encrypt account credentials: %v", err)
+ }
+
+ log.Printf("account credentials encryption complete: dry_run=%t scanned=%d needs_encryption=%d updated=%d",
+ *dryRun,
+ result.Scanned,
+ result.NeedsEncryption,
+ result.Updated,
+ )
+}
diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION
index 025c3166600..5076ee8063a 100644
--- a/backend/cmd/server/VERSION
+++ b/backend/cmd/server/VERSION
@@ -1 +1 @@
-0.1.121
+0.1.125
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 46edcb692e5..f84077b4787 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -7,10 +7,13 @@ import (
_ "embed"
"errors"
"flag"
+ "fmt"
"log"
+ "net"
"net/http"
"os"
"os/signal"
+ "strconv"
"strings"
"syscall"
"time"
@@ -18,8 +21,11 @@ import (
_ "github.com/Wei-Shaw/sub2api/ent/runtime"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
+ "github.com/Wei-Shaw/sub2api/internal/repository"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/setup"
"github.com/Wei-Shaw/sub2api/internal/web"
@@ -39,6 +45,16 @@ var (
BuildType = "source" // "source" for manual builds, "release" for CI builds (set by ldflags)
)
+const defaultServerShutdownTimeout = 30 * time.Minute
+
+const (
+ apiKeySecurityMigrationTimeout = 30 * time.Minute
+ paymentConfigMigrationTimeout = 5 * time.Minute
+ domainSecretMigrationTimeout = 30 * time.Minute
+ schedulerCachePurgeTimeout = 2 * time.Minute
+ oauthTokenCachePurgeTimeout = 2 * time.Minute
+)
+
func init() {
// 如果 Version 已通过 ldflags 注入(例如 -X main.Version=...),则不要覆盖。
if strings.TrimSpace(Version) != "" {
@@ -110,9 +126,10 @@ func runSetupServer() {
r.Use(web.ServeEmbeddedFrontend())
}
- // Get server address from config.yaml or environment variables (SERVER_HOST, SERVER_PORT)
- // This allows users to run setup on a different address if needed
- addr := config.GetServerAddress()
+ // Setup can test arbitrary database/Redis destinations and create the first
+ // administrator, so it is intentionally loopback-only. Use an SSH tunnel for
+ // remote administration instead of exposing the bootstrap surface.
+ addr := setupServerAddress()
log.Printf("Setup wizard available at http://%s", addr)
log.Println("Complete the setup wizard to configure Sub2API")
@@ -128,6 +145,15 @@ func runSetupServer() {
}
}
+func setupServerAddress() string {
+ configured := config.GetServerAddress()
+ _, port, err := net.SplitHostPort(configured)
+ if err != nil || strings.TrimSpace(port) == "" {
+ port = "8080"
+ }
+ return net.JoinHostPort("127.0.0.1", port)
+}
+
func runMainServer() {
cfg, err := config.LoadForBootstrap()
if err != nil {
@@ -145,6 +171,45 @@ func runMainServer() {
BuildType: BuildType,
}
+ purgedSchedulerCacheSecrets, err := prepareEncryptedSchedulerCacheBeforeWorkers(cfg)
+ if err != nil {
+ log.Fatalf("Failed scheduler cache security preflight: %v", err)
+ }
+ if purgedSchedulerCacheSecrets > 0 {
+ log.Printf("Removed %d legacy plaintext scheduler cache record(s)", purgedSchedulerCacheSecrets)
+ }
+
+ purgedOAuthTokenCacheSecrets, err := prepareEncryptedOAuthTokenCacheBeforeWorkers(cfg)
+ if err != nil {
+ log.Fatalf("Failed OAuth token cache security preflight: %v", err)
+ }
+ if purgedOAuthTokenCacheSecrets > 0 {
+ log.Printf("Removed %d legacy plaintext OAuth access token cache record(s)", purgedOAuthTokenCacheSecrets)
+ }
+
+ migratedAPIKeys, migratedProviderConfigs, migratedDomainSecrets, err := migrateSecuritySecretsBeforeWorkers(cfg)
+ if err != nil {
+ log.Fatalf("Failed security migration preflight: %v", err)
+ }
+ if migratedAPIKeys > 0 {
+ log.Printf("Encrypted %d legacy plaintext API key(s)", migratedAPIKeys)
+ }
+ if migratedProviderConfigs > 0 {
+ log.Printf("Encrypted %d legacy plaintext payment provider config(s)", migratedProviderConfigs)
+ }
+ if migratedDomainSecrets != (repository.DomainSecretMigrationResult{}) {
+ log.Printf("Domain-bound legacy secrets: accounts=%d totp=%d channel_monitor_keys=%d channel_monitor_payloads=%d backup_s3=%d content_moderation=%d settings=%d proxies=%d",
+ migratedDomainSecrets.AccountCredentials,
+ migratedDomainSecrets.TOTPSecrets,
+ migratedDomainSecrets.ChannelMonitorKeys,
+ migratedDomainSecrets.ChannelMonitorPayloads,
+ migratedDomainSecrets.BackupS3Configs,
+ migratedDomainSecrets.ContentModerationConfigs,
+ migratedDomainSecrets.SettingSecrets,
+ migratedDomainSecrets.ProxyCredentials,
+ )
+ }
+
app, err := initializeApplication(buildInfo)
if err != nil {
log.Fatalf("Failed to initialize application: %v", err)
@@ -167,7 +232,10 @@ func runMainServer() {
log.Println("Shutting down server...")
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ shutdownTimeout := serverShutdownTimeout()
+ log.Printf("Waiting up to %s for in-flight requests to finish", shutdownTimeout)
+
+ ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := app.Server.Shutdown(ctx); err != nil {
@@ -176,3 +244,107 @@ func runMainServer() {
log.Println("Server exited")
}
+
+func prepareEncryptedSchedulerCacheBeforeWorkers(cfg *config.Config) (int64, error) {
+ rdb := repository.InitRedis(cfg)
+ defer func() { _ = rdb.Close() }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), schedulerCachePurgeTimeout)
+ defer cancel()
+ removed, err := repository.PurgeLegacySchedulerCacheSecrets(ctx, rdb)
+ if err != nil {
+ return removed, fmt.Errorf("purge legacy plaintext scheduler cache: %w", err)
+ }
+ if _, err := repository.SeedSchedulerCacheV2Watermark(ctx, rdb); err != nil {
+ return removed, fmt.Errorf("seed encrypted scheduler cache watermark: %w", err)
+ }
+ return removed, nil
+}
+
+func prepareEncryptedOAuthTokenCacheBeforeWorkers(cfg *config.Config) (int64, error) {
+ rdb := repository.InitRedis(cfg)
+ defer func() { _ = rdb.Close() }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), oauthTokenCachePurgeTimeout)
+ defer cancel()
+ removed, err := repository.PurgeLegacyOAuthTokenCacheSecrets(ctx, rdb)
+ if err != nil {
+ return removed, fmt.Errorf("purge legacy plaintext OAuth token cache: %w", err)
+ }
+ return removed, nil
+}
+
+// migrateSecuritySecretsBeforeWorkers constructs only database-backed migration
+// dependencies. The full Wire graph starts background workers in provider
+// constructors, so it must not be built until these fail-closed rewrites pass.
+func migrateSecuritySecretsBeforeWorkers(cfg *config.Config) (int, int, repository.DomainSecretMigrationResult, error) {
+ client, _, err := repository.InitEnt(cfg)
+ if err != nil {
+ return 0, 0, repository.DomainSecretMigrationResult{}, fmt.Errorf("initialize migration database: %w", err)
+ }
+ defer func() { _ = client.Close() }()
+
+ db, err := repository.ProvideSQLDB(client)
+ if err != nil {
+ return 0, 0, repository.DomainSecretMigrationResult{}, err
+ }
+ protector, err := repository.NewAPIKeyProtector(cfg)
+ if err != nil {
+ return 0, 0, repository.DomainSecretMigrationResult{}, err
+ }
+ apiKeyRepo := repository.NewAPIKeyRepository(client, db, protector)
+ migrator, ok := apiKeyRepo.(service.APIKeyPlaintextMigrator)
+ if !ok {
+ return 0, 0, repository.DomainSecretMigrationResult{}, errors.New("API key plaintext migrator is unavailable")
+ }
+
+ apiKeyCtx, cancelAPIKeyMigration := context.WithTimeout(context.Background(), apiKeySecurityMigrationTimeout)
+ migratedAPIKeys, err := migrator.MigratePlaintextAPIKeysToEncrypted(apiKeyCtx)
+ cancelAPIKeyMigration()
+ if err != nil {
+ return migratedAPIKeys, 0, repository.DomainSecretMigrationResult{}, fmt.Errorf("secure API keys: %w", err)
+ }
+
+ secretEncryptor, err := repository.NewAESEncryptor(cfg)
+ if err != nil {
+ return migratedAPIKeys, 0, repository.DomainSecretMigrationResult{}, err
+ }
+ domainCtx, cancelDomainMigration := context.WithTimeout(context.Background(), domainSecretMigrationTimeout)
+ migratedDomainSecrets, err := repository.MigrateDomainBoundSecrets(domainCtx, client, secretEncryptor)
+ cancelDomainMigration()
+ if err != nil {
+ return migratedAPIKeys, 0, migratedDomainSecrets, fmt.Errorf("bind persistent secrets to domains: %w", err)
+ }
+
+ paymentKey, err := payment.ProvideEncryptionKey(cfg)
+ if err != nil {
+ return migratedAPIKeys, 0, migratedDomainSecrets, err
+ }
+ legacyPaymentKey, err := payment.ProvideLegacyEncryptionKey(cfg)
+ if err != nil {
+ return migratedAPIKeys, 0, migratedDomainSecrets, err
+ }
+ paymentConfig := service.NewPaymentConfigService(client, repository.NewSettingRepository(client, secretEncryptor), []byte(paymentKey))
+ paymentCtx, cancelPaymentMigration := context.WithTimeout(context.Background(), paymentConfigMigrationTimeout)
+ migratedProviderConfigs, err := paymentConfig.MigrateProviderConfigsToEncrypted(paymentCtx, []byte(legacyPaymentKey))
+ cancelPaymentMigration()
+ if err != nil {
+ return migratedAPIKeys, migratedProviderConfigs, migratedDomainSecrets, fmt.Errorf("secure payment provider configs: %w", err)
+ }
+ return migratedAPIKeys, migratedProviderConfigs, migratedDomainSecrets, nil
+}
+
+func serverShutdownTimeout() time.Duration {
+ raw := strings.TrimSpace(os.Getenv("SERVER_SHUTDOWN_TIMEOUT_SECONDS"))
+ if raw == "" {
+ return defaultServerShutdownTimeout
+ }
+
+ seconds, err := strconv.Atoi(raw)
+ if err != nil || seconds <= 0 {
+ log.Printf("Invalid SERVER_SHUTDOWN_TIMEOUT_SECONDS=%q; using default %s", raw, defaultServerShutdownTimeout)
+ return defaultServerShutdownTimeout
+ }
+
+ return time.Duration(seconds) * time.Second
+}
diff --git a/backend/cmd/server/main_test.go b/backend/cmd/server/main_test.go
new file mode 100644
index 00000000000..8fba027fc10
--- /dev/null
+++ b/backend/cmd/server/main_test.go
@@ -0,0 +1,72 @@
+package main
+
+import (
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestServerShutdownTimeoutDefault(t *testing.T) {
+ t.Setenv("SERVER_SHUTDOWN_TIMEOUT_SECONDS", "")
+
+ if got := serverShutdownTimeout(); got != defaultServerShutdownTimeout {
+ t.Fatalf("serverShutdownTimeout() = %s, want %s", got, defaultServerShutdownTimeout)
+ }
+}
+
+func TestSecurityMigrationPrecedesWorkerGraphConstruction(t *testing.T) {
+ source, err := os.ReadFile("main.go")
+ if err != nil {
+ t.Fatal(err)
+ }
+ text := string(source)
+ migration := strings.Index(text, "migrateSecuritySecretsBeforeWorkers(cfg)")
+ workers := strings.Index(text, "initializeApplication(buildInfo)")
+ if migration < 0 || workers < 0 || migration >= workers {
+ t.Fatalf("security migration must run before Wire constructs background workers")
+ }
+ if !strings.Contains(text, "repository.MigrateDomainBoundSecrets(domainCtx, client, secretEncryptor)") {
+ t.Fatal("security preflight must migrate domain-bound secrets before workers start")
+ }
+ if !strings.Contains(text, "migratedDomainSecrets.ProxyCredentials") {
+ t.Fatal("security preflight must report migrated proxy credentials")
+ }
+ purge := strings.Index(text, "prepareEncryptedSchedulerCacheBeforeWorkers(cfg)")
+ if purge < 0 || purge >= workers {
+ t.Fatal("legacy plaintext scheduler-cache purge must run before Wire constructs background workers")
+ }
+ oauthPurge := strings.Index(text, "prepareEncryptedOAuthTokenCacheBeforeWorkers(cfg)")
+ if oauthPurge < 0 || oauthPurge >= workers {
+ t.Fatal("legacy plaintext oauth-token-cache purge must run before Wire constructs background workers")
+ }
+}
+
+func TestServerShutdownTimeoutFromEnv(t *testing.T) {
+ t.Setenv("SERVER_SHUTDOWN_TIMEOUT_SECONDS", "42")
+
+ if got := serverShutdownTimeout(); got != 42*time.Second {
+ t.Fatalf("serverShutdownTimeout() = %s, want 42s", got)
+ }
+}
+
+func TestServerShutdownTimeoutInvalidEnvFallsBack(t *testing.T) {
+ for _, value := range []string{"0", "-1", "not-a-number"} {
+ t.Run(value, func(t *testing.T) {
+ t.Setenv("SERVER_SHUTDOWN_TIMEOUT_SECONDS", value)
+
+ if got := serverShutdownTimeout(); got != defaultServerShutdownTimeout {
+ t.Fatalf("serverShutdownTimeout() = %s, want %s", got, defaultServerShutdownTimeout)
+ }
+ })
+ }
+}
+
+func TestSetupServerAddressIgnoresPublicHostAndBindsLoopback(t *testing.T) {
+ t.Setenv("SERVER_HOST", "0.0.0.0")
+ t.Setenv("SERVER_PORT", "9123")
+
+ if got := setupServerAddress(); got != "127.0.0.1:9123" {
+ t.Fatalf("setupServerAddress()=%q, want loopback-only address", got)
+ }
+}
diff --git a/backend/cmd/server/wire.go b/backend/cmd/server/wire.go
index 9bfa27174db..08364bd5019 100644
--- a/backend/cmd/server/wire.go
+++ b/backend/cmd/server/wire.go
@@ -24,8 +24,10 @@ import (
)
type Application struct {
- Server *http.Server
- Cleanup func()
+ Server *http.Server
+ Cleanup func()
+ PaymentConfigService *service.PaymentConfigService
+ APIKeyService *service.APIKeyService
}
func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
@@ -53,7 +55,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
provideCleanup,
// Application struct
- wire.Struct(new(Application), "Server", "Cleanup"),
+ wire.Struct(new(Application), "Server", "Cleanup", "PaymentConfigService", "APIKeyService"),
)
return nil, nil
}
@@ -82,12 +84,14 @@ func provideCleanup(
tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
subscriptionExpiry *service.SubscriptionExpiryService,
+ walletReconcile *service.WalletReconcileService,
usageCleanup *service.UsageCleanupService,
idempotencyCleanup *service.IdempotencyCleanupService,
pricing *service.PricingService,
emailQueue *service.EmailQueueService,
billingCache *service.BillingCacheService,
usageRecordWorkerPool *service.UsageRecordWorkerPool,
+ usageBillingOutboxWorker *service.UsageBillingOutboxWorker,
subscriptionService *service.SubscriptionService,
oauth *service.OAuthService,
openaiOAuth *service.OpenAIOAuthService,
@@ -102,6 +106,11 @@ func provideCleanup(
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
+ if usageBillingOutboxWorker != nil {
+ if err := usageBillingOutboxWorker.Stop(ctx); err != nil {
+ log.Printf("[Cleanup] UsageBillingOutboxWorker stop failed: %v", err)
+ }
+ }
type cleanupStep struct {
name string
@@ -176,6 +185,12 @@ func provideCleanup(
subscriptionExpiry.Stop()
return nil
}},
+ {"WalletReconcileService", func() error {
+ if walletReconcile != nil {
+ walletReconcile.Stop()
+ }
+ return nil
+ }},
{"SubscriptionService", func() error {
if subscriptionService != nil {
subscriptionService.Stop()
diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go
index 40f0191ce5a..e48110b1ec9 100644
--- a/backend/cmd/server/wire_gen.go
+++ b/backend/cmd/server/wire_gen.go
@@ -48,9 +48,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
redeemCodeRepository := repository.NewRedeemCodeRepository(client)
redisClient := repository.ProvideRedis(configConfig)
refreshTokenCache := repository.NewRefreshTokenCache(redisClient)
- settingRepository := repository.NewSettingRepository(client)
+ secretEncryptor, err := repository.NewAESEncryptor(configConfig)
+ if err != nil {
+ return nil, err
+ }
+ settingRepository := repository.NewSettingRepository(client, secretEncryptor)
groupRepository := repository.NewGroupRepository(client, db)
- proxyRepository := repository.NewProxyRepository(client, db)
+ proxyRepository := repository.NewProxyRepository(client, db, secretEncryptor)
settingService := service.ProvideSettingService(settingRepository, groupRepository, proxyRepository, configConfig)
emailCache := repository.NewEmailCache(redisClient)
emailService := service.NewEmailService(settingRepository, emailCache)
@@ -60,7 +64,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
promoCodeRepository := repository.NewPromoCodeRepository(client)
billingCache := repository.NewBillingCache(redisClient)
userSubscriptionRepository := repository.NewUserSubscriptionRepository(client)
- apiKeyRepository := repository.NewAPIKeyRepository(client, db)
+ apiKeyProtector, err := repository.NewAPIKeyProtector(configConfig)
+ if err != nil {
+ return nil, err
+ }
+ apiKeyRepository := repository.NewAPIKeyRepository(client, db, apiKeyProtector)
userRPMCache := repository.NewUserRPMCache(redisClient)
userGroupRateRepository := repository.NewUserGroupRateRepository(db)
billingCacheService := service.ProvideBillingCacheService(billingCache, userRepository, userSubscriptionRepository, apiKeyRepository, userRPMCache, userGroupRateRepository, configConfig)
@@ -68,22 +76,24 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
apiKeyService := service.ProvideAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, userGroupRateRepository, apiKeyCache, configConfig, billingCacheService)
apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService)
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
- subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig)
+ walletRepository := repository.NewWalletRepository(client, db)
+ walletService := service.NewWalletService(walletRepository)
+ subscriptionService := service.ProvideSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig, apiKeyService, walletService)
affiliateRepository := repository.NewAffiliateRepository(client, db)
affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService)
- authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService)
+ hfcAbuseRiskRepository := repository.NewHFCAbuseRiskRepository(db)
+ hfcAbuseRiskService := service.NewHFCAbuseRiskService(hfcAbuseRiskRepository, apiKeyRepository, userRepository, apiKeyAuthCacheInvalidator)
+ authService := service.ProvideAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService, hfcAbuseRiskService)
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
redeemCache := repository.NewRedeemCache(redisClient)
- redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator)
- secretEncryptor, err := repository.NewAESEncryptor(configConfig)
- if err != nil {
- return nil, err
- }
- totpCache := repository.NewTotpCache(redisClient)
+ redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService)
+ totpCache := repository.NewTotpCache(redisClient, secretEncryptor)
totpService := service.NewTotpService(userRepository, secretEncryptor, totpCache, settingService, emailService, emailQueueService)
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService)
userHandler := handler.NewUserHandler(userService, authService, emailService, emailCache, affiliateService)
- apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
+ modelRouterGroupRepository := service.ProvideModelRouterGroupRepository(groupRepository)
+ modelRouterService := service.NewModelRouterService(modelRouterGroupRepository)
+ apiKeyHandler := handler.ProvideAPIKeyHandler(apiKeyService, modelRouterService, totpService)
usageLogRepository := repository.NewUsageLogRepository(client, db)
usageService := service.NewUsageService(usageLogRepository, userRepository, client, apiKeyAuthCacheInvalidator)
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
@@ -105,8 +115,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
}
dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, configConfig)
dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService)
- schedulerCache := repository.ProvideSchedulerCache(redisClient, configConfig)
- accountRepository := repository.NewAccountRepository(client, db, schedulerCache)
+ schedulerCache, err := repository.ProvideSchedulerCache(redisClient, configConfig, secretEncryptor)
+ if err != nil {
+ return nil, err
+ }
+ accountRepository := repository.NewAccountRepository(client, db, schedulerCache, secretEncryptor)
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
privacyClientFactory := providePrivacyClientFactory()
@@ -131,7 +144,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
tempUnschedCache := repository.NewTempUnschedCache(redisClient)
timeoutCounterCache := repository.NewTimeoutCounterCache(redisClient)
openAI403CounterCache := repository.NewOpenAI403CounterCache(redisClient)
- geminiTokenCache := repository.NewGeminiTokenCache(redisClient)
+ geminiTokenCache, err := repository.NewGeminiTokenCache(redisClient, secretEncryptor)
+ if err != nil {
+ return nil, err
+ }
compositeTokenCacheInvalidator := service.NewCompositeTokenCacheInvalidator(geminiTokenCache)
rateLimitService := service.ProvideRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache, timeoutCounterCache, openAI403CounterCache, settingService, compositeTokenCacheInvalidator)
httpUpstream := repository.NewHTTPUpstream(configConfig)
@@ -177,16 +193,29 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
return nil, err
}
billingService := service.NewBillingService(configConfig, pricingService)
- identityService := service.NewIdentityService(identityCache)
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
digestSessionStore := service.NewDigestSessionStore()
channelRepository := repository.NewChannelRepository(db)
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository)
- gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService)
+ durableUsageBillingOutboxRepository, err := repository.NewDurableUsageBillingOutboxRepository(db)
+ if err != nil {
+ return nil, err
+ }
+ usageBillingReplayWriter := service.NewUsageBillingReplayWriter(usageLogRepository)
+ usageBillingReplayCacheInvalidator := service.ProvideUsageBillingReplayCacheInvalidator(billingCacheService)
+ usageBillingReplayAuthCacheInvalidator := service.ProvideUsageBillingReplayAuthCacheInvalidator(apiKeyService)
+ usageBillingReplayAccountToucher := service.ProvideUsageBillingReplayAccountToucher(deferredService)
+ usageBillingReplayFinalizer := service.NewUsageBillingReplayFinalizer(usageBillingReplayCacheInvalidator, usageBillingReplayAuthCacheInvalidator, usageBillingReplayAccountToucher)
+ usageBillingOutboxProcessor := service.ProvideUsageBillingOutboxProcessor(durableUsageBillingOutboxRepository, durableUsageBillingOutboxRepository, usageBillingRepository, usageBillingReplayWriter, usageBillingReplayFinalizer)
+ usageBillingOutboxWorker, err := service.ProvideUsageBillingOutboxWorker(usageBillingOutboxProcessor)
+ if err != nil {
+ return nil, err
+ }
+ gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, walletRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService, durableUsageBillingOutboxRepository, usageBillingOutboxWorker, durableUsageBillingOutboxRepository)
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI)
- openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService)
+ openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, walletRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, durableUsageBillingOutboxRepository, usageBillingOutboxWorker, durableUsageBillingOutboxRepository)
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
opsSystemLogSink := service.ProvideOpsSystemLogSink(opsRepository)
opsService := service.NewOpsService(opsRepository, settingRepository, configConfig, accountRepository, userRepository, concurrencyService, gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, opsSystemLogSink)
@@ -207,10 +236,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
idempotencyRepository := repository.NewIdempotencyRepository(client, db)
systemOperationLockService := service.ProvideSystemOperationLockService(idempotencyRepository, configConfig)
systemHandler := handler.ProvideSystemHandler(updateService, systemOperationLockService)
- adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService)
+ adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService, affiliateService)
usageCleanupRepository := repository.NewUsageCleanupRepository(client, db)
usageCleanupService := service.ProvideUsageCleanupService(usageCleanupRepository, timingWheelService, dashboardAggregationService, configConfig)
- adminUsageHandler := admin.NewUsageHandler(usageService, apiKeyService, adminService, usageCleanupService)
+ usageBillingReconciliationService := service.NewUsageBillingReconciliationService(durableUsageBillingOutboxRepository)
+ adminUsageHandler := admin.NewUsageHandler(usageService, apiKeyService, adminService, usageCleanupService, usageBillingReconciliationService)
userAttributeDefinitionRepository := repository.NewUserAttributeDefinitionRepository(client)
userAttributeValueRepository := repository.NewUserAttributeValueRepository(client)
userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository)
@@ -226,21 +256,25 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
scheduledTestService := service.ProvideScheduledTestService(scheduledTestPlanRepository, scheduledTestResultRepository)
scheduledTestHandler := admin.NewScheduledTestHandler(scheduledTestService)
channelHandler := admin.NewChannelHandler(channelService, billingService)
- channelMonitorHandler := admin.NewChannelMonitorHandler(channelMonitorService)
channelMonitorRequestTemplateRepository := repository.NewChannelMonitorRequestTemplateRepository(client, db)
- channelMonitorRequestTemplateService := service.NewChannelMonitorRequestTemplateService(channelMonitorRequestTemplateRepository)
+ channelMonitorRequestTemplateService := service.NewChannelMonitorRequestTemplateService(channelMonitorRequestTemplateRepository, secretEncryptor)
+ channelMonitorHandler := admin.ProvideChannelMonitorHandler(channelMonitorService, apiKeyService, channelMonitorRequestTemplateService)
channelMonitorRequestTemplateHandler := admin.NewChannelMonitorRequestTemplateHandler(channelMonitorRequestTemplateService)
+ contentModerationRepository := repository.NewContentModerationRepository(db)
+ contentModerationHashCache := repository.NewContentModerationHashCache(redisClient)
+ contentModerationService := service.ProvideContentModerationService(settingRepository, contentModerationRepository, contentModerationHashCache, groupRepository, userRepository, apiKeyAuthCacheInvalidator, emailService, secretEncryptor, hfcAbuseRiskService)
+ contentModerationHandler := admin.NewContentModerationHandler(contentModerationService, hfcAbuseRiskService)
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService)
- adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, paymentHandler, affiliateHandler)
+ adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
- gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, userMessageQueueService, configConfig, settingService)
- openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, configConfig)
+ gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService)
+ openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, configConfig)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
totpHandler := handler.NewTotpHandler(totpService)
- handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService)
+ handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService, settingService)
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
@@ -248,24 +282,27 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, idempotencyCoordinator, idempotencyCleanupService)
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
- apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
+ apiKeyAuthMiddleware := middleware.ProvideAPIKeyAuthMiddleware(apiKeyService, subscriptionService, modelRouterService, groupRepository, configConfig)
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, redisClient)
httpServer := server.ProvideHTTPServer(configConfig, engine)
opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig)
opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig)
opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig)
- opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig, channelMonitorService)
+ opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig, channelMonitorService, settingRepository, opsService)
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI)
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository)
+ walletReconcileService := service.ProvideWalletReconcileService(walletRepository)
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
- v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner)
+ v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, schedulerSnapshotService, tokenRefreshService, accountExpiryService, subscriptionExpiryService, walletReconcileService, usageCleanupService, idempotencyCleanupService, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, usageBillingOutboxWorker, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner)
application := &Application{
- Server: httpServer,
- Cleanup: v,
+ Server: httpServer,
+ Cleanup: v,
+ PaymentConfigService: paymentConfigService,
+ APIKeyService: apiKeyService,
}
return application, nil
}
@@ -273,8 +310,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// wire.go:
type Application struct {
- Server *http.Server
- Cleanup func()
+ Server *http.Server
+ Cleanup func()
+ PaymentConfigService *service.PaymentConfigService
+ APIKeyService *service.APIKeyService
}
func providePrivacyClientFactory() service.PrivacyClientFactory {
@@ -301,12 +340,14 @@ func provideCleanup(
tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
subscriptionExpiry *service.SubscriptionExpiryService,
+ walletReconcile *service.WalletReconcileService,
usageCleanup *service.UsageCleanupService,
idempotencyCleanup *service.IdempotencyCleanupService,
pricing *service.PricingService,
emailQueue *service.EmailQueueService,
billingCache *service.BillingCacheService,
usageRecordWorkerPool *service.UsageRecordWorkerPool,
+ usageBillingOutboxWorker *service.UsageBillingOutboxWorker,
subscriptionService *service.SubscriptionService,
oauth *service.OAuthService,
openaiOAuth *service.OpenAIOAuthService,
@@ -321,6 +362,11 @@ func provideCleanup(
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
+ if usageBillingOutboxWorker != nil {
+ if err := usageBillingOutboxWorker.Stop(ctx); err != nil {
+ log.Printf("[Cleanup] UsageBillingOutboxWorker stop failed: %v", err)
+ }
+ }
type cleanupStep struct {
name string
@@ -394,6 +440,12 @@ func provideCleanup(
subscriptionExpiry.Stop()
return nil
}},
+ {"WalletReconcileService", func() error {
+ if walletReconcile != nil {
+ walletReconcile.Stop()
+ }
+ return nil
+ }},
{"SubscriptionService", func() error {
if subscriptionService != nil {
subscriptionService.Stop()
diff --git a/backend/cmd/server/wire_gen_test.go b/backend/cmd/server/wire_gen_test.go
index 5ccd67fb5cd..6a3c81c2725 100644
--- a/backend/cmd/server/wire_gen_test.go
+++ b/backend/cmd/server/wire_gen_test.go
@@ -41,6 +41,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
)
accountExpirySvc := service.NewAccountExpiryService(nil, time.Second)
subscriptionExpirySvc := service.NewSubscriptionExpiryService(nil, time.Second)
+ walletReconcileSvc := service.NewWalletReconcileService(nil, 0, 0)
pricingSvc := service.NewPricingService(cfg, nil)
emailQueueSvc := service.NewEmailQueueService(nil, 1)
billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg)
@@ -61,12 +62,14 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
tokenRefreshSvc,
accountExpirySvc,
subscriptionExpirySvc,
+ walletReconcileSvc,
&service.UsageCleanupService{},
idempotencyCleanupSvc,
pricingSvc,
emailQueueSvc,
billingCacheSvc,
&service.UsageRecordWorkerPool{},
+ nil, // usageBillingOutboxWorker
&service.SubscriptionService{},
oauthSvc,
openAIOAuthSvc,
diff --git a/backend/ent/apikey.go b/backend/ent/apikey.go
index 9ee660c2da6..56ecb900ffa 100644
--- a/backend/ent/apikey.go
+++ b/backend/ent/apikey.go
@@ -28,10 +28,16 @@ type APIKey struct {
DeletedAt *time.Time `json:"deleted_at,omitempty"`
// UserID holds the value of the "user_id" field.
UserID int64 `json:"user_id,omitempty"`
- // Key holds the value of the "key" field.
- Key string `json:"key,omitempty"`
+ // Versioned AES-GCM ciphertext for the customer API key; never plaintext
+ Key string `json:"-"`
+ // HMAC-SHA-256 lookup locator derived from the API-key protection key
+ KeyHash *string `json:"-"`
+ // Non-secret API key prefix for search/display
+ KeyPrefix string `json:"key_prefix,omitempty"`
// Name holds the value of the "name" field.
Name string `json:"name,omitempty"`
+ // Immutable security identity: standard or wallet_universal
+ Purpose string `json:"purpose,omitempty"`
// GroupID holds the value of the "group_id" field.
GroupID *int64 `json:"group_id,omitempty"`
// Status holds the value of the "status" field.
@@ -127,7 +133,7 @@ func (*APIKey) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullFloat64)
case apikey.FieldID, apikey.FieldUserID, apikey.FieldGroupID:
values[i] = new(sql.NullInt64)
- case apikey.FieldKey, apikey.FieldName, apikey.FieldStatus:
+ case apikey.FieldKey, apikey.FieldKeyHash, apikey.FieldKeyPrefix, apikey.FieldName, apikey.FieldPurpose, apikey.FieldStatus:
values[i] = new(sql.NullString)
case apikey.FieldCreatedAt, apikey.FieldUpdatedAt, apikey.FieldDeletedAt, apikey.FieldLastUsedAt, apikey.FieldExpiresAt, apikey.FieldWindow5hStart, apikey.FieldWindow1dStart, apikey.FieldWindow7dStart:
values[i] = new(sql.NullTime)
@@ -183,12 +189,31 @@ func (_m *APIKey) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.Key = value.String
}
+ case apikey.FieldKeyHash:
+ if value, ok := values[i].(*sql.NullString); !ok {
+ return fmt.Errorf("unexpected type %T for field key_hash", values[i])
+ } else if value.Valid {
+ _m.KeyHash = new(string)
+ *_m.KeyHash = value.String
+ }
+ case apikey.FieldKeyPrefix:
+ if value, ok := values[i].(*sql.NullString); !ok {
+ return fmt.Errorf("unexpected type %T for field key_prefix", values[i])
+ } else if value.Valid {
+ _m.KeyPrefix = value.String
+ }
case apikey.FieldName:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field name", values[i])
} else if value.Valid {
_m.Name = value.String
}
+ case apikey.FieldPurpose:
+ if value, ok := values[i].(*sql.NullString); !ok {
+ return fmt.Errorf("unexpected type %T for field purpose", values[i])
+ } else if value.Valid {
+ _m.Purpose = value.String
+ }
case apikey.FieldGroupID:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field group_id", values[i])
@@ -366,12 +391,19 @@ func (_m *APIKey) String() string {
builder.WriteString("user_id=")
builder.WriteString(fmt.Sprintf("%v", _m.UserID))
builder.WriteString(", ")
- builder.WriteString("key=")
- builder.WriteString(_m.Key)
+ builder.WriteString("key=")
+ builder.WriteString(", ")
+ builder.WriteString("key_hash=")
+ builder.WriteString(", ")
+ builder.WriteString("key_prefix=")
+ builder.WriteString(_m.KeyPrefix)
builder.WriteString(", ")
builder.WriteString("name=")
builder.WriteString(_m.Name)
builder.WriteString(", ")
+ builder.WriteString("purpose=")
+ builder.WriteString(_m.Purpose)
+ builder.WriteString(", ")
if v := _m.GroupID; v != nil {
builder.WriteString("group_id=")
builder.WriteString(fmt.Sprintf("%v", *v))
diff --git a/backend/ent/apikey/apikey.go b/backend/ent/apikey/apikey.go
index d398a027b86..3d6dbda7d24 100644
--- a/backend/ent/apikey/apikey.go
+++ b/backend/ent/apikey/apikey.go
@@ -25,8 +25,14 @@ const (
FieldUserID = "user_id"
// FieldKey holds the string denoting the key field in the database.
FieldKey = "key"
+ // FieldKeyHash holds the string denoting the key_hash field in the database.
+ FieldKeyHash = "key_hash"
+ // FieldKeyPrefix holds the string denoting the key_prefix field in the database.
+ FieldKeyPrefix = "key_prefix"
// FieldName holds the string denoting the name field in the database.
FieldName = "name"
+ // FieldPurpose holds the string denoting the purpose field in the database.
+ FieldPurpose = "purpose"
// FieldGroupID holds the string denoting the group_id field in the database.
FieldGroupID = "group_id"
// FieldStatus holds the string denoting the status field in the database.
@@ -100,7 +106,10 @@ var Columns = []string{
FieldDeletedAt,
FieldUserID,
FieldKey,
+ FieldKeyHash,
+ FieldKeyPrefix,
FieldName,
+ FieldPurpose,
FieldGroupID,
FieldStatus,
FieldLastUsedAt,
@@ -146,8 +155,18 @@ var (
UpdateDefaultUpdatedAt func() time.Time
// KeyValidator is a validator for the "key" field. It is called by the builders before save.
KeyValidator func(string) error
+ // KeyHashValidator is a validator for the "key_hash" field. It is called by the builders before save.
+ KeyHashValidator func(string) error
+ // DefaultKeyPrefix holds the default value on creation for the "key_prefix" field.
+ DefaultKeyPrefix string
+ // KeyPrefixValidator is a validator for the "key_prefix" field. It is called by the builders before save.
+ KeyPrefixValidator func(string) error
// NameValidator is a validator for the "name" field. It is called by the builders before save.
NameValidator func(string) error
+ // DefaultPurpose holds the default value on creation for the "purpose" field.
+ DefaultPurpose string
+ // PurposeValidator is a validator for the "purpose" field. It is called by the builders before save.
+ PurposeValidator func(string) error
// DefaultStatus holds the default value on creation for the "status" field.
DefaultStatus string
// StatusValidator is a validator for the "status" field. It is called by the builders before save.
@@ -203,11 +222,26 @@ func ByKey(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldKey, opts...).ToFunc()
}
+// ByKeyHash orders the results by the key_hash field.
+func ByKeyHash(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldKeyHash, opts...).ToFunc()
+}
+
+// ByKeyPrefix orders the results by the key_prefix field.
+func ByKeyPrefix(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldKeyPrefix, opts...).ToFunc()
+}
+
// ByName orders the results by the name field.
func ByName(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldName, opts...).ToFunc()
}
+// ByPurpose orders the results by the purpose field.
+func ByPurpose(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldPurpose, opts...).ToFunc()
+}
+
// ByGroupID orders the results by the group_id field.
func ByGroupID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldGroupID, opts...).ToFunc()
diff --git a/backend/ent/apikey/where.go b/backend/ent/apikey/where.go
index edd2652baae..4ff2f37d34a 100644
--- a/backend/ent/apikey/where.go
+++ b/backend/ent/apikey/where.go
@@ -80,11 +80,26 @@ func Key(v string) predicate.APIKey {
return predicate.APIKey(sql.FieldEQ(FieldKey, v))
}
+// KeyHash applies equality check predicate on the "key_hash" field. It's identical to KeyHashEQ.
+func KeyHash(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldEQ(FieldKeyHash, v))
+}
+
+// KeyPrefix applies equality check predicate on the "key_prefix" field. It's identical to KeyPrefixEQ.
+func KeyPrefix(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldEQ(FieldKeyPrefix, v))
+}
+
// Name applies equality check predicate on the "name" field. It's identical to NameEQ.
func Name(v string) predicate.APIKey {
return predicate.APIKey(sql.FieldEQ(FieldName, v))
}
+// Purpose applies equality check predicate on the "purpose" field. It's identical to PurposeEQ.
+func Purpose(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldEQ(FieldPurpose, v))
+}
+
// GroupID applies equality check predicate on the "group_id" field. It's identical to GroupIDEQ.
func GroupID(v int64) predicate.APIKey {
return predicate.APIKey(sql.FieldEQ(FieldGroupID, v))
@@ -375,6 +390,146 @@ func KeyContainsFold(v string) predicate.APIKey {
return predicate.APIKey(sql.FieldContainsFold(FieldKey, v))
}
+// KeyHashEQ applies the EQ predicate on the "key_hash" field.
+func KeyHashEQ(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldEQ(FieldKeyHash, v))
+}
+
+// KeyHashNEQ applies the NEQ predicate on the "key_hash" field.
+func KeyHashNEQ(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldNEQ(FieldKeyHash, v))
+}
+
+// KeyHashIn applies the In predicate on the "key_hash" field.
+func KeyHashIn(vs ...string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldIn(FieldKeyHash, vs...))
+}
+
+// KeyHashNotIn applies the NotIn predicate on the "key_hash" field.
+func KeyHashNotIn(vs ...string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldNotIn(FieldKeyHash, vs...))
+}
+
+// KeyHashGT applies the GT predicate on the "key_hash" field.
+func KeyHashGT(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldGT(FieldKeyHash, v))
+}
+
+// KeyHashGTE applies the GTE predicate on the "key_hash" field.
+func KeyHashGTE(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldGTE(FieldKeyHash, v))
+}
+
+// KeyHashLT applies the LT predicate on the "key_hash" field.
+func KeyHashLT(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldLT(FieldKeyHash, v))
+}
+
+// KeyHashLTE applies the LTE predicate on the "key_hash" field.
+func KeyHashLTE(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldLTE(FieldKeyHash, v))
+}
+
+// KeyHashContains applies the Contains predicate on the "key_hash" field.
+func KeyHashContains(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldContains(FieldKeyHash, v))
+}
+
+// KeyHashHasPrefix applies the HasPrefix predicate on the "key_hash" field.
+func KeyHashHasPrefix(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldHasPrefix(FieldKeyHash, v))
+}
+
+// KeyHashHasSuffix applies the HasSuffix predicate on the "key_hash" field.
+func KeyHashHasSuffix(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldHasSuffix(FieldKeyHash, v))
+}
+
+// KeyHashIsNil applies the IsNil predicate on the "key_hash" field.
+func KeyHashIsNil() predicate.APIKey {
+ return predicate.APIKey(sql.FieldIsNull(FieldKeyHash))
+}
+
+// KeyHashNotNil applies the NotNil predicate on the "key_hash" field.
+func KeyHashNotNil() predicate.APIKey {
+ return predicate.APIKey(sql.FieldNotNull(FieldKeyHash))
+}
+
+// KeyHashEqualFold applies the EqualFold predicate on the "key_hash" field.
+func KeyHashEqualFold(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldEqualFold(FieldKeyHash, v))
+}
+
+// KeyHashContainsFold applies the ContainsFold predicate on the "key_hash" field.
+func KeyHashContainsFold(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldContainsFold(FieldKeyHash, v))
+}
+
+// KeyPrefixEQ applies the EQ predicate on the "key_prefix" field.
+func KeyPrefixEQ(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldEQ(FieldKeyPrefix, v))
+}
+
+// KeyPrefixNEQ applies the NEQ predicate on the "key_prefix" field.
+func KeyPrefixNEQ(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldNEQ(FieldKeyPrefix, v))
+}
+
+// KeyPrefixIn applies the In predicate on the "key_prefix" field.
+func KeyPrefixIn(vs ...string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldIn(FieldKeyPrefix, vs...))
+}
+
+// KeyPrefixNotIn applies the NotIn predicate on the "key_prefix" field.
+func KeyPrefixNotIn(vs ...string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldNotIn(FieldKeyPrefix, vs...))
+}
+
+// KeyPrefixGT applies the GT predicate on the "key_prefix" field.
+func KeyPrefixGT(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldGT(FieldKeyPrefix, v))
+}
+
+// KeyPrefixGTE applies the GTE predicate on the "key_prefix" field.
+func KeyPrefixGTE(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldGTE(FieldKeyPrefix, v))
+}
+
+// KeyPrefixLT applies the LT predicate on the "key_prefix" field.
+func KeyPrefixLT(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldLT(FieldKeyPrefix, v))
+}
+
+// KeyPrefixLTE applies the LTE predicate on the "key_prefix" field.
+func KeyPrefixLTE(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldLTE(FieldKeyPrefix, v))
+}
+
+// KeyPrefixContains applies the Contains predicate on the "key_prefix" field.
+func KeyPrefixContains(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldContains(FieldKeyPrefix, v))
+}
+
+// KeyPrefixHasPrefix applies the HasPrefix predicate on the "key_prefix" field.
+func KeyPrefixHasPrefix(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldHasPrefix(FieldKeyPrefix, v))
+}
+
+// KeyPrefixHasSuffix applies the HasSuffix predicate on the "key_prefix" field.
+func KeyPrefixHasSuffix(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldHasSuffix(FieldKeyPrefix, v))
+}
+
+// KeyPrefixEqualFold applies the EqualFold predicate on the "key_prefix" field.
+func KeyPrefixEqualFold(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldEqualFold(FieldKeyPrefix, v))
+}
+
+// KeyPrefixContainsFold applies the ContainsFold predicate on the "key_prefix" field.
+func KeyPrefixContainsFold(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldContainsFold(FieldKeyPrefix, v))
+}
+
// NameEQ applies the EQ predicate on the "name" field.
func NameEQ(v string) predicate.APIKey {
return predicate.APIKey(sql.FieldEQ(FieldName, v))
@@ -440,6 +595,71 @@ func NameContainsFold(v string) predicate.APIKey {
return predicate.APIKey(sql.FieldContainsFold(FieldName, v))
}
+// PurposeEQ applies the EQ predicate on the "purpose" field.
+func PurposeEQ(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldEQ(FieldPurpose, v))
+}
+
+// PurposeNEQ applies the NEQ predicate on the "purpose" field.
+func PurposeNEQ(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldNEQ(FieldPurpose, v))
+}
+
+// PurposeIn applies the In predicate on the "purpose" field.
+func PurposeIn(vs ...string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldIn(FieldPurpose, vs...))
+}
+
+// PurposeNotIn applies the NotIn predicate on the "purpose" field.
+func PurposeNotIn(vs ...string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldNotIn(FieldPurpose, vs...))
+}
+
+// PurposeGT applies the GT predicate on the "purpose" field.
+func PurposeGT(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldGT(FieldPurpose, v))
+}
+
+// PurposeGTE applies the GTE predicate on the "purpose" field.
+func PurposeGTE(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldGTE(FieldPurpose, v))
+}
+
+// PurposeLT applies the LT predicate on the "purpose" field.
+func PurposeLT(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldLT(FieldPurpose, v))
+}
+
+// PurposeLTE applies the LTE predicate on the "purpose" field.
+func PurposeLTE(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldLTE(FieldPurpose, v))
+}
+
+// PurposeContains applies the Contains predicate on the "purpose" field.
+func PurposeContains(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldContains(FieldPurpose, v))
+}
+
+// PurposeHasPrefix applies the HasPrefix predicate on the "purpose" field.
+func PurposeHasPrefix(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldHasPrefix(FieldPurpose, v))
+}
+
+// PurposeHasSuffix applies the HasSuffix predicate on the "purpose" field.
+func PurposeHasSuffix(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldHasSuffix(FieldPurpose, v))
+}
+
+// PurposeEqualFold applies the EqualFold predicate on the "purpose" field.
+func PurposeEqualFold(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldEqualFold(FieldPurpose, v))
+}
+
+// PurposeContainsFold applies the ContainsFold predicate on the "purpose" field.
+func PurposeContainsFold(v string) predicate.APIKey {
+ return predicate.APIKey(sql.FieldContainsFold(FieldPurpose, v))
+}
+
// GroupIDEQ applies the EQ predicate on the "group_id" field.
func GroupIDEQ(v int64) predicate.APIKey {
return predicate.APIKey(sql.FieldEQ(FieldGroupID, v))
diff --git a/backend/ent/apikey_create.go b/backend/ent/apikey_create.go
index 4ec8aeaae4e..fd83fbb1359 100644
--- a/backend/ent/apikey_create.go
+++ b/backend/ent/apikey_create.go
@@ -79,12 +79,54 @@ func (_c *APIKeyCreate) SetKey(v string) *APIKeyCreate {
return _c
}
+// SetKeyHash sets the "key_hash" field.
+func (_c *APIKeyCreate) SetKeyHash(v string) *APIKeyCreate {
+ _c.mutation.SetKeyHash(v)
+ return _c
+}
+
+// SetNillableKeyHash sets the "key_hash" field if the given value is not nil.
+func (_c *APIKeyCreate) SetNillableKeyHash(v *string) *APIKeyCreate {
+ if v != nil {
+ _c.SetKeyHash(*v)
+ }
+ return _c
+}
+
+// SetKeyPrefix sets the "key_prefix" field.
+func (_c *APIKeyCreate) SetKeyPrefix(v string) *APIKeyCreate {
+ _c.mutation.SetKeyPrefix(v)
+ return _c
+}
+
+// SetNillableKeyPrefix sets the "key_prefix" field if the given value is not nil.
+func (_c *APIKeyCreate) SetNillableKeyPrefix(v *string) *APIKeyCreate {
+ if v != nil {
+ _c.SetKeyPrefix(*v)
+ }
+ return _c
+}
+
// SetName sets the "name" field.
func (_c *APIKeyCreate) SetName(v string) *APIKeyCreate {
_c.mutation.SetName(v)
return _c
}
+// SetPurpose sets the "purpose" field.
+func (_c *APIKeyCreate) SetPurpose(v string) *APIKeyCreate {
+ _c.mutation.SetPurpose(v)
+ return _c
+}
+
+// SetNillablePurpose sets the "purpose" field if the given value is not nil.
+func (_c *APIKeyCreate) SetNillablePurpose(v *string) *APIKeyCreate {
+ if v != nil {
+ _c.SetPurpose(*v)
+ }
+ return _c
+}
+
// SetGroupID sets the "group_id" field.
func (_c *APIKeyCreate) SetGroupID(v int64) *APIKeyCreate {
_c.mutation.SetGroupID(v)
@@ -383,6 +425,14 @@ func (_c *APIKeyCreate) defaults() error {
v := apikey.DefaultUpdatedAt()
_c.mutation.SetUpdatedAt(v)
}
+ if _, ok := _c.mutation.KeyPrefix(); !ok {
+ v := apikey.DefaultKeyPrefix
+ _c.mutation.SetKeyPrefix(v)
+ }
+ if _, ok := _c.mutation.Purpose(); !ok {
+ v := apikey.DefaultPurpose
+ _c.mutation.SetPurpose(v)
+ }
if _, ok := _c.mutation.Status(); !ok {
v := apikey.DefaultStatus
_c.mutation.SetStatus(v)
@@ -441,6 +491,19 @@ func (_c *APIKeyCreate) check() error {
return &ValidationError{Name: "key", err: fmt.Errorf(`ent: validator failed for field "APIKey.key": %w`, err)}
}
}
+ if v, ok := _c.mutation.KeyHash(); ok {
+ if err := apikey.KeyHashValidator(v); err != nil {
+ return &ValidationError{Name: "key_hash", err: fmt.Errorf(`ent: validator failed for field "APIKey.key_hash": %w`, err)}
+ }
+ }
+ if _, ok := _c.mutation.KeyPrefix(); !ok {
+ return &ValidationError{Name: "key_prefix", err: errors.New(`ent: missing required field "APIKey.key_prefix"`)}
+ }
+ if v, ok := _c.mutation.KeyPrefix(); ok {
+ if err := apikey.KeyPrefixValidator(v); err != nil {
+ return &ValidationError{Name: "key_prefix", err: fmt.Errorf(`ent: validator failed for field "APIKey.key_prefix": %w`, err)}
+ }
+ }
if _, ok := _c.mutation.Name(); !ok {
return &ValidationError{Name: "name", err: errors.New(`ent: missing required field "APIKey.name"`)}
}
@@ -449,6 +512,14 @@ func (_c *APIKeyCreate) check() error {
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "APIKey.name": %w`, err)}
}
}
+ if _, ok := _c.mutation.Purpose(); !ok {
+ return &ValidationError{Name: "purpose", err: errors.New(`ent: missing required field "APIKey.purpose"`)}
+ }
+ if v, ok := _c.mutation.Purpose(); ok {
+ if err := apikey.PurposeValidator(v); err != nil {
+ return &ValidationError{Name: "purpose", err: fmt.Errorf(`ent: validator failed for field "APIKey.purpose": %w`, err)}
+ }
+ }
if _, ok := _c.mutation.Status(); !ok {
return &ValidationError{Name: "status", err: errors.New(`ent: missing required field "APIKey.status"`)}
}
@@ -527,10 +598,22 @@ func (_c *APIKeyCreate) createSpec() (*APIKey, *sqlgraph.CreateSpec) {
_spec.SetField(apikey.FieldKey, field.TypeString, value)
_node.Key = value
}
+ if value, ok := _c.mutation.KeyHash(); ok {
+ _spec.SetField(apikey.FieldKeyHash, field.TypeString, value)
+ _node.KeyHash = &value
+ }
+ if value, ok := _c.mutation.KeyPrefix(); ok {
+ _spec.SetField(apikey.FieldKeyPrefix, field.TypeString, value)
+ _node.KeyPrefix = value
+ }
if value, ok := _c.mutation.Name(); ok {
_spec.SetField(apikey.FieldName, field.TypeString, value)
_node.Name = value
}
+ if value, ok := _c.mutation.Purpose(); ok {
+ _spec.SetField(apikey.FieldPurpose, field.TypeString, value)
+ _node.Purpose = value
+ }
if value, ok := _c.mutation.Status(); ok {
_spec.SetField(apikey.FieldStatus, field.TypeString, value)
_node.Status = value
@@ -751,6 +834,36 @@ func (u *APIKeyUpsert) UpdateKey() *APIKeyUpsert {
return u
}
+// SetKeyHash sets the "key_hash" field.
+func (u *APIKeyUpsert) SetKeyHash(v string) *APIKeyUpsert {
+ u.Set(apikey.FieldKeyHash, v)
+ return u
+}
+
+// UpdateKeyHash sets the "key_hash" field to the value that was provided on create.
+func (u *APIKeyUpsert) UpdateKeyHash() *APIKeyUpsert {
+ u.SetExcluded(apikey.FieldKeyHash)
+ return u
+}
+
+// ClearKeyHash clears the value of the "key_hash" field.
+func (u *APIKeyUpsert) ClearKeyHash() *APIKeyUpsert {
+ u.SetNull(apikey.FieldKeyHash)
+ return u
+}
+
+// SetKeyPrefix sets the "key_prefix" field.
+func (u *APIKeyUpsert) SetKeyPrefix(v string) *APIKeyUpsert {
+ u.Set(apikey.FieldKeyPrefix, v)
+ return u
+}
+
+// UpdateKeyPrefix sets the "key_prefix" field to the value that was provided on create.
+func (u *APIKeyUpsert) UpdateKeyPrefix() *APIKeyUpsert {
+ u.SetExcluded(apikey.FieldKeyPrefix)
+ return u
+}
+
// SetName sets the "name" field.
func (u *APIKeyUpsert) SetName(v string) *APIKeyUpsert {
u.Set(apikey.FieldName, v)
@@ -1077,6 +1190,9 @@ func (u *APIKeyUpsertOne) UpdateNewValues() *APIKeyUpsertOne {
if _, exists := u.create.mutation.CreatedAt(); exists {
s.SetIgnore(apikey.FieldCreatedAt)
}
+ if _, exists := u.create.mutation.Purpose(); exists {
+ s.SetIgnore(apikey.FieldPurpose)
+ }
}))
return u
}
@@ -1171,6 +1287,41 @@ func (u *APIKeyUpsertOne) UpdateKey() *APIKeyUpsertOne {
})
}
+// SetKeyHash sets the "key_hash" field.
+func (u *APIKeyUpsertOne) SetKeyHash(v string) *APIKeyUpsertOne {
+ return u.Update(func(s *APIKeyUpsert) {
+ s.SetKeyHash(v)
+ })
+}
+
+// UpdateKeyHash sets the "key_hash" field to the value that was provided on create.
+func (u *APIKeyUpsertOne) UpdateKeyHash() *APIKeyUpsertOne {
+ return u.Update(func(s *APIKeyUpsert) {
+ s.UpdateKeyHash()
+ })
+}
+
+// ClearKeyHash clears the value of the "key_hash" field.
+func (u *APIKeyUpsertOne) ClearKeyHash() *APIKeyUpsertOne {
+ return u.Update(func(s *APIKeyUpsert) {
+ s.ClearKeyHash()
+ })
+}
+
+// SetKeyPrefix sets the "key_prefix" field.
+func (u *APIKeyUpsertOne) SetKeyPrefix(v string) *APIKeyUpsertOne {
+ return u.Update(func(s *APIKeyUpsert) {
+ s.SetKeyPrefix(v)
+ })
+}
+
+// UpdateKeyPrefix sets the "key_prefix" field to the value that was provided on create.
+func (u *APIKeyUpsertOne) UpdateKeyPrefix() *APIKeyUpsertOne {
+ return u.Update(func(s *APIKeyUpsert) {
+ s.UpdateKeyPrefix()
+ })
+}
+
// SetName sets the "name" field.
func (u *APIKeyUpsertOne) SetName(v string) *APIKeyUpsertOne {
return u.Update(func(s *APIKeyUpsert) {
@@ -1714,6 +1865,9 @@ func (u *APIKeyUpsertBulk) UpdateNewValues() *APIKeyUpsertBulk {
if _, exists := b.mutation.CreatedAt(); exists {
s.SetIgnore(apikey.FieldCreatedAt)
}
+ if _, exists := b.mutation.Purpose(); exists {
+ s.SetIgnore(apikey.FieldPurpose)
+ }
}
}))
return u
@@ -1809,6 +1963,41 @@ func (u *APIKeyUpsertBulk) UpdateKey() *APIKeyUpsertBulk {
})
}
+// SetKeyHash sets the "key_hash" field.
+func (u *APIKeyUpsertBulk) SetKeyHash(v string) *APIKeyUpsertBulk {
+ return u.Update(func(s *APIKeyUpsert) {
+ s.SetKeyHash(v)
+ })
+}
+
+// UpdateKeyHash sets the "key_hash" field to the value that was provided on create.
+func (u *APIKeyUpsertBulk) UpdateKeyHash() *APIKeyUpsertBulk {
+ return u.Update(func(s *APIKeyUpsert) {
+ s.UpdateKeyHash()
+ })
+}
+
+// ClearKeyHash clears the value of the "key_hash" field.
+func (u *APIKeyUpsertBulk) ClearKeyHash() *APIKeyUpsertBulk {
+ return u.Update(func(s *APIKeyUpsert) {
+ s.ClearKeyHash()
+ })
+}
+
+// SetKeyPrefix sets the "key_prefix" field.
+func (u *APIKeyUpsertBulk) SetKeyPrefix(v string) *APIKeyUpsertBulk {
+ return u.Update(func(s *APIKeyUpsert) {
+ s.SetKeyPrefix(v)
+ })
+}
+
+// UpdateKeyPrefix sets the "key_prefix" field to the value that was provided on create.
+func (u *APIKeyUpsertBulk) UpdateKeyPrefix() *APIKeyUpsertBulk {
+ return u.Update(func(s *APIKeyUpsert) {
+ s.UpdateKeyPrefix()
+ })
+}
+
// SetName sets the "name" field.
func (u *APIKeyUpsertBulk) SetName(v string) *APIKeyUpsertBulk {
return u.Update(func(s *APIKeyUpsert) {
diff --git a/backend/ent/apikey_update.go b/backend/ent/apikey_update.go
index db341e4c9d5..f44fc7fb381 100644
--- a/backend/ent/apikey_update.go
+++ b/backend/ent/apikey_update.go
@@ -86,6 +86,40 @@ func (_u *APIKeyUpdate) SetNillableKey(v *string) *APIKeyUpdate {
return _u
}
+// SetKeyHash sets the "key_hash" field.
+func (_u *APIKeyUpdate) SetKeyHash(v string) *APIKeyUpdate {
+ _u.mutation.SetKeyHash(v)
+ return _u
+}
+
+// SetNillableKeyHash sets the "key_hash" field if the given value is not nil.
+func (_u *APIKeyUpdate) SetNillableKeyHash(v *string) *APIKeyUpdate {
+ if v != nil {
+ _u.SetKeyHash(*v)
+ }
+ return _u
+}
+
+// ClearKeyHash clears the value of the "key_hash" field.
+func (_u *APIKeyUpdate) ClearKeyHash() *APIKeyUpdate {
+ _u.mutation.ClearKeyHash()
+ return _u
+}
+
+// SetKeyPrefix sets the "key_prefix" field.
+func (_u *APIKeyUpdate) SetKeyPrefix(v string) *APIKeyUpdate {
+ _u.mutation.SetKeyPrefix(v)
+ return _u
+}
+
+// SetNillableKeyPrefix sets the "key_prefix" field if the given value is not nil.
+func (_u *APIKeyUpdate) SetNillableKeyPrefix(v *string) *APIKeyUpdate {
+ if v != nil {
+ _u.SetKeyPrefix(*v)
+ }
+ return _u
+}
+
// SetName sets the "name" field.
func (_u *APIKeyUpdate) SetName(v string) *APIKeyUpdate {
_u.mutation.SetName(v)
@@ -550,6 +584,16 @@ func (_u *APIKeyUpdate) check() error {
return &ValidationError{Name: "key", err: fmt.Errorf(`ent: validator failed for field "APIKey.key": %w`, err)}
}
}
+ if v, ok := _u.mutation.KeyHash(); ok {
+ if err := apikey.KeyHashValidator(v); err != nil {
+ return &ValidationError{Name: "key_hash", err: fmt.Errorf(`ent: validator failed for field "APIKey.key_hash": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.KeyPrefix(); ok {
+ if err := apikey.KeyPrefixValidator(v); err != nil {
+ return &ValidationError{Name: "key_prefix", err: fmt.Errorf(`ent: validator failed for field "APIKey.key_prefix": %w`, err)}
+ }
+ }
if v, ok := _u.mutation.Name(); ok {
if err := apikey.NameValidator(v); err != nil {
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "APIKey.name": %w`, err)}
@@ -590,6 +634,15 @@ func (_u *APIKeyUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.Key(); ok {
_spec.SetField(apikey.FieldKey, field.TypeString, value)
}
+ if value, ok := _u.mutation.KeyHash(); ok {
+ _spec.SetField(apikey.FieldKeyHash, field.TypeString, value)
+ }
+ if _u.mutation.KeyHashCleared() {
+ _spec.ClearField(apikey.FieldKeyHash, field.TypeString)
+ }
+ if value, ok := _u.mutation.KeyPrefix(); ok {
+ _spec.SetField(apikey.FieldKeyPrefix, field.TypeString, value)
+ }
if value, ok := _u.mutation.Name(); ok {
_spec.SetField(apikey.FieldName, field.TypeString, value)
}
@@ -873,6 +926,40 @@ func (_u *APIKeyUpdateOne) SetNillableKey(v *string) *APIKeyUpdateOne {
return _u
}
+// SetKeyHash sets the "key_hash" field.
+func (_u *APIKeyUpdateOne) SetKeyHash(v string) *APIKeyUpdateOne {
+ _u.mutation.SetKeyHash(v)
+ return _u
+}
+
+// SetNillableKeyHash sets the "key_hash" field if the given value is not nil.
+func (_u *APIKeyUpdateOne) SetNillableKeyHash(v *string) *APIKeyUpdateOne {
+ if v != nil {
+ _u.SetKeyHash(*v)
+ }
+ return _u
+}
+
+// ClearKeyHash clears the value of the "key_hash" field.
+func (_u *APIKeyUpdateOne) ClearKeyHash() *APIKeyUpdateOne {
+ _u.mutation.ClearKeyHash()
+ return _u
+}
+
+// SetKeyPrefix sets the "key_prefix" field.
+func (_u *APIKeyUpdateOne) SetKeyPrefix(v string) *APIKeyUpdateOne {
+ _u.mutation.SetKeyPrefix(v)
+ return _u
+}
+
+// SetNillableKeyPrefix sets the "key_prefix" field if the given value is not nil.
+func (_u *APIKeyUpdateOne) SetNillableKeyPrefix(v *string) *APIKeyUpdateOne {
+ if v != nil {
+ _u.SetKeyPrefix(*v)
+ }
+ return _u
+}
+
// SetName sets the "name" field.
func (_u *APIKeyUpdateOne) SetName(v string) *APIKeyUpdateOne {
_u.mutation.SetName(v)
@@ -1350,6 +1437,16 @@ func (_u *APIKeyUpdateOne) check() error {
return &ValidationError{Name: "key", err: fmt.Errorf(`ent: validator failed for field "APIKey.key": %w`, err)}
}
}
+ if v, ok := _u.mutation.KeyHash(); ok {
+ if err := apikey.KeyHashValidator(v); err != nil {
+ return &ValidationError{Name: "key_hash", err: fmt.Errorf(`ent: validator failed for field "APIKey.key_hash": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.KeyPrefix(); ok {
+ if err := apikey.KeyPrefixValidator(v); err != nil {
+ return &ValidationError{Name: "key_prefix", err: fmt.Errorf(`ent: validator failed for field "APIKey.key_prefix": %w`, err)}
+ }
+ }
if v, ok := _u.mutation.Name(); ok {
if err := apikey.NameValidator(v); err != nil {
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "APIKey.name": %w`, err)}
@@ -1407,6 +1504,15 @@ func (_u *APIKeyUpdateOne) sqlSave(ctx context.Context) (_node *APIKey, err erro
if value, ok := _u.mutation.Key(); ok {
_spec.SetField(apikey.FieldKey, field.TypeString, value)
}
+ if value, ok := _u.mutation.KeyHash(); ok {
+ _spec.SetField(apikey.FieldKeyHash, field.TypeString, value)
+ }
+ if _u.mutation.KeyHashCleared() {
+ _spec.ClearField(apikey.FieldKeyHash, field.TypeString)
+ }
+ if value, ok := _u.mutation.KeyPrefix(); ok {
+ _spec.SetField(apikey.FieldKeyPrefix, field.TypeString, value)
+ }
if value, ok := _u.mutation.Name(); ok {
_spec.SetField(apikey.FieldName, field.TypeString, value)
}
diff --git a/backend/ent/client.go b/backend/ent/client.go
index df20ddfa341..aaf63c9a7da 100644
--- a/backend/ent/client.go
+++ b/backend/ent/client.go
@@ -41,6 +41,8 @@ import (
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
"github.com/Wei-Shaw/sub2api/ent/setting"
"github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
"github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
@@ -110,6 +112,10 @@ type Client struct {
Setting *SettingClient
// SubscriptionPlan is the client for interacting with the SubscriptionPlan builders.
SubscriptionPlan *SubscriptionPlanClient
+ // SubscriptionPlanGroup is the client for interacting with the SubscriptionPlanGroup builders.
+ SubscriptionPlanGroup *SubscriptionPlanGroupClient
+ // SubscriptionWalletLedger is the client for interacting with the SubscriptionWalletLedger builders.
+ SubscriptionWalletLedger *SubscriptionWalletLedgerClient
// TLSFingerprintProfile is the client for interacting with the TLSFingerprintProfile builders.
TLSFingerprintProfile *TLSFingerprintProfileClient
// UsageCleanupTask is the client for interacting with the UsageCleanupTask builders.
@@ -163,6 +169,8 @@ func (c *Client) init() {
c.SecuritySecret = NewSecuritySecretClient(c.config)
c.Setting = NewSettingClient(c.config)
c.SubscriptionPlan = NewSubscriptionPlanClient(c.config)
+ c.SubscriptionPlanGroup = NewSubscriptionPlanGroupClient(c.config)
+ c.SubscriptionWalletLedger = NewSubscriptionWalletLedgerClient(c.config)
c.TLSFingerprintProfile = NewTLSFingerprintProfileClient(c.config)
c.UsageCleanupTask = NewUsageCleanupTaskClient(c.config)
c.UsageLog = NewUsageLogClient(c.config)
@@ -289,6 +297,8 @@ func (c *Client) Tx(ctx context.Context) (*Tx, error) {
SecuritySecret: NewSecuritySecretClient(cfg),
Setting: NewSettingClient(cfg),
SubscriptionPlan: NewSubscriptionPlanClient(cfg),
+ SubscriptionPlanGroup: NewSubscriptionPlanGroupClient(cfg),
+ SubscriptionWalletLedger: NewSubscriptionWalletLedgerClient(cfg),
TLSFingerprintProfile: NewTLSFingerprintProfileClient(cfg),
UsageCleanupTask: NewUsageCleanupTaskClient(cfg),
UsageLog: NewUsageLogClient(cfg),
@@ -342,6 +352,8 @@ func (c *Client) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error)
SecuritySecret: NewSecuritySecretClient(cfg),
Setting: NewSettingClient(cfg),
SubscriptionPlan: NewSubscriptionPlanClient(cfg),
+ SubscriptionPlanGroup: NewSubscriptionPlanGroupClient(cfg),
+ SubscriptionWalletLedger: NewSubscriptionWalletLedgerClient(cfg),
TLSFingerprintProfile: NewTLSFingerprintProfileClient(cfg),
UsageCleanupTask: NewUsageCleanupTaskClient(cfg),
UsageLog: NewUsageLogClient(cfg),
@@ -386,8 +398,9 @@ func (c *Client) Use(hooks ...Hook) {
c.IdempotencyRecord, c.IdentityAdoptionDecision, c.PaymentAuditLog,
c.PaymentOrder, c.PaymentProviderInstance, c.PendingAuthSession, c.PromoCode,
c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting,
- c.SubscriptionPlan, c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog,
- c.User, c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
+ c.SubscriptionPlan, c.SubscriptionPlanGroup, c.SubscriptionWalletLedger,
+ c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, c.User,
+ c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
c.UserSubscription,
} {
n.Use(hooks...)
@@ -405,8 +418,9 @@ func (c *Client) Intercept(interceptors ...Interceptor) {
c.IdempotencyRecord, c.IdentityAdoptionDecision, c.PaymentAuditLog,
c.PaymentOrder, c.PaymentProviderInstance, c.PendingAuthSession, c.PromoCode,
c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting,
- c.SubscriptionPlan, c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog,
- c.User, c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
+ c.SubscriptionPlan, c.SubscriptionPlanGroup, c.SubscriptionWalletLedger,
+ c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, c.User,
+ c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue,
c.UserSubscription,
} {
n.Intercept(interceptors...)
@@ -468,6 +482,10 @@ func (c *Client) Mutate(ctx context.Context, m Mutation) (Value, error) {
return c.Setting.mutate(ctx, m)
case *SubscriptionPlanMutation:
return c.SubscriptionPlan.mutate(ctx, m)
+ case *SubscriptionPlanGroupMutation:
+ return c.SubscriptionPlanGroup.mutate(ctx, m)
+ case *SubscriptionWalletLedgerMutation:
+ return c.SubscriptionWalletLedger.mutate(ctx, m)
case *TLSFingerprintProfileMutation:
return c.TLSFingerprintProfile.mutate(ctx, m)
case *UsageCleanupTaskMutation:
@@ -2564,6 +2582,22 @@ func (c *GroupClient) QueryUsageLogs(_m *Group) *UsageLogQuery {
return query
}
+// QueryPlanGroups queries the plan_groups edge of a Group.
+func (c *GroupClient) QueryPlanGroups(_m *Group) *SubscriptionPlanGroupQuery {
+ query := (&SubscriptionPlanGroupClient{config: c.config}).Query()
+ query.path = func(context.Context) (fromV *sql.Selector, _ error) {
+ id := _m.ID
+ step := sqlgraph.NewStep(
+ sqlgraph.From(group.Table, group.FieldID, id),
+ sqlgraph.To(subscriptionplangroup.Table, subscriptionplangroup.FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, group.PlanGroupsTable, group.PlanGroupsColumn),
+ )
+ fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
+ return fromV, nil
+ }
+ return query
+}
+
// QueryAccounts queries the accounts edge of a Group.
func (c *GroupClient) QueryAccounts(_m *Group) *AccountQuery {
query := (&AccountClient{config: c.config}).Query()
@@ -4537,6 +4571,22 @@ func (c *SubscriptionPlanClient) GetX(ctx context.Context, id int64) *Subscripti
return obj
}
+// QueryPlanGroups queries the plan_groups edge of a SubscriptionPlan.
+func (c *SubscriptionPlanClient) QueryPlanGroups(_m *SubscriptionPlan) *SubscriptionPlanGroupQuery {
+ query := (&SubscriptionPlanGroupClient{config: c.config}).Query()
+ query.path = func(context.Context) (fromV *sql.Selector, _ error) {
+ id := _m.ID
+ step := sqlgraph.NewStep(
+ sqlgraph.From(subscriptionplan.Table, subscriptionplan.FieldID, id),
+ sqlgraph.To(subscriptionplangroup.Table, subscriptionplangroup.FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, subscriptionplan.PlanGroupsTable, subscriptionplan.PlanGroupsColumn),
+ )
+ fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
+ return fromV, nil
+ }
+ return query
+}
+
// Hooks returns the client hooks.
func (c *SubscriptionPlanClient) Hooks() []Hook {
return c.hooks.SubscriptionPlan
@@ -4562,6 +4612,352 @@ func (c *SubscriptionPlanClient) mutate(ctx context.Context, m *SubscriptionPlan
}
}
+// SubscriptionPlanGroupClient is a client for the SubscriptionPlanGroup schema.
+type SubscriptionPlanGroupClient struct {
+ config
+}
+
+// NewSubscriptionPlanGroupClient returns a client for the SubscriptionPlanGroup from the given config.
+func NewSubscriptionPlanGroupClient(c config) *SubscriptionPlanGroupClient {
+ return &SubscriptionPlanGroupClient{config: c}
+}
+
+// Use adds a list of mutation hooks to the hooks stack.
+// A call to `Use(f, g, h)` equals to `subscriptionplangroup.Hooks(f(g(h())))`.
+func (c *SubscriptionPlanGroupClient) Use(hooks ...Hook) {
+ c.hooks.SubscriptionPlanGroup = append(c.hooks.SubscriptionPlanGroup, hooks...)
+}
+
+// Intercept adds a list of query interceptors to the interceptors stack.
+// A call to `Intercept(f, g, h)` equals to `subscriptionplangroup.Intercept(f(g(h())))`.
+func (c *SubscriptionPlanGroupClient) Intercept(interceptors ...Interceptor) {
+ c.inters.SubscriptionPlanGroup = append(c.inters.SubscriptionPlanGroup, interceptors...)
+}
+
+// Create returns a builder for creating a SubscriptionPlanGroup entity.
+func (c *SubscriptionPlanGroupClient) Create() *SubscriptionPlanGroupCreate {
+ mutation := newSubscriptionPlanGroupMutation(c.config, OpCreate)
+ return &SubscriptionPlanGroupCreate{config: c.config, hooks: c.Hooks(), mutation: mutation}
+}
+
+// CreateBulk returns a builder for creating a bulk of SubscriptionPlanGroup entities.
+func (c *SubscriptionPlanGroupClient) CreateBulk(builders ...*SubscriptionPlanGroupCreate) *SubscriptionPlanGroupCreateBulk {
+ return &SubscriptionPlanGroupCreateBulk{config: c.config, builders: builders}
+}
+
+// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates
+// a builder and applies setFunc on it.
+func (c *SubscriptionPlanGroupClient) MapCreateBulk(slice any, setFunc func(*SubscriptionPlanGroupCreate, int)) *SubscriptionPlanGroupCreateBulk {
+ rv := reflect.ValueOf(slice)
+ if rv.Kind() != reflect.Slice {
+ return &SubscriptionPlanGroupCreateBulk{err: fmt.Errorf("calling to SubscriptionPlanGroupClient.MapCreateBulk with wrong type %T, need slice", slice)}
+ }
+ builders := make([]*SubscriptionPlanGroupCreate, rv.Len())
+ for i := 0; i < rv.Len(); i++ {
+ builders[i] = c.Create()
+ setFunc(builders[i], i)
+ }
+ return &SubscriptionPlanGroupCreateBulk{config: c.config, builders: builders}
+}
+
+// Update returns an update builder for SubscriptionPlanGroup.
+func (c *SubscriptionPlanGroupClient) Update() *SubscriptionPlanGroupUpdate {
+ mutation := newSubscriptionPlanGroupMutation(c.config, OpUpdate)
+ return &SubscriptionPlanGroupUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation}
+}
+
+// UpdateOne returns an update builder for the given entity.
+func (c *SubscriptionPlanGroupClient) UpdateOne(_m *SubscriptionPlanGroup) *SubscriptionPlanGroupUpdateOne {
+ mutation := newSubscriptionPlanGroupMutation(c.config, OpUpdateOne, withSubscriptionPlanGroup(_m))
+ return &SubscriptionPlanGroupUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation}
+}
+
+// UpdateOneID returns an update builder for the given id.
+func (c *SubscriptionPlanGroupClient) UpdateOneID(id int64) *SubscriptionPlanGroupUpdateOne {
+ mutation := newSubscriptionPlanGroupMutation(c.config, OpUpdateOne, withSubscriptionPlanGroupID(id))
+ return &SubscriptionPlanGroupUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation}
+}
+
+// Delete returns a delete builder for SubscriptionPlanGroup.
+func (c *SubscriptionPlanGroupClient) Delete() *SubscriptionPlanGroupDelete {
+ mutation := newSubscriptionPlanGroupMutation(c.config, OpDelete)
+ return &SubscriptionPlanGroupDelete{config: c.config, hooks: c.Hooks(), mutation: mutation}
+}
+
+// DeleteOne returns a builder for deleting the given entity.
+func (c *SubscriptionPlanGroupClient) DeleteOne(_m *SubscriptionPlanGroup) *SubscriptionPlanGroupDeleteOne {
+ return c.DeleteOneID(_m.ID)
+}
+
+// DeleteOneID returns a builder for deleting the given entity by its id.
+func (c *SubscriptionPlanGroupClient) DeleteOneID(id int64) *SubscriptionPlanGroupDeleteOne {
+ builder := c.Delete().Where(subscriptionplangroup.ID(id))
+ builder.mutation.id = &id
+ builder.mutation.op = OpDeleteOne
+ return &SubscriptionPlanGroupDeleteOne{builder}
+}
+
+// Query returns a query builder for SubscriptionPlanGroup.
+func (c *SubscriptionPlanGroupClient) Query() *SubscriptionPlanGroupQuery {
+ return &SubscriptionPlanGroupQuery{
+ config: c.config,
+ ctx: &QueryContext{Type: TypeSubscriptionPlanGroup},
+ inters: c.Interceptors(),
+ }
+}
+
+// Get returns a SubscriptionPlanGroup entity by its id.
+func (c *SubscriptionPlanGroupClient) Get(ctx context.Context, id int64) (*SubscriptionPlanGroup, error) {
+ return c.Query().Where(subscriptionplangroup.ID(id)).Only(ctx)
+}
+
+// GetX is like Get, but panics if an error occurs.
+func (c *SubscriptionPlanGroupClient) GetX(ctx context.Context, id int64) *SubscriptionPlanGroup {
+ obj, err := c.Get(ctx, id)
+ if err != nil {
+ panic(err)
+ }
+ return obj
+}
+
+// QueryPlan queries the plan edge of a SubscriptionPlanGroup.
+func (c *SubscriptionPlanGroupClient) QueryPlan(_m *SubscriptionPlanGroup) *SubscriptionPlanQuery {
+ query := (&SubscriptionPlanClient{config: c.config}).Query()
+ query.path = func(context.Context) (fromV *sql.Selector, _ error) {
+ id := _m.ID
+ step := sqlgraph.NewStep(
+ sqlgraph.From(subscriptionplangroup.Table, subscriptionplangroup.FieldID, id),
+ sqlgraph.To(subscriptionplan.Table, subscriptionplan.FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, subscriptionplangroup.PlanTable, subscriptionplangroup.PlanColumn),
+ )
+ fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
+ return fromV, nil
+ }
+ return query
+}
+
+// QueryGroup queries the group edge of a SubscriptionPlanGroup.
+func (c *SubscriptionPlanGroupClient) QueryGroup(_m *SubscriptionPlanGroup) *GroupQuery {
+ query := (&GroupClient{config: c.config}).Query()
+ query.path = func(context.Context) (fromV *sql.Selector, _ error) {
+ id := _m.ID
+ step := sqlgraph.NewStep(
+ sqlgraph.From(subscriptionplangroup.Table, subscriptionplangroup.FieldID, id),
+ sqlgraph.To(group.Table, group.FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, subscriptionplangroup.GroupTable, subscriptionplangroup.GroupColumn),
+ )
+ fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
+ return fromV, nil
+ }
+ return query
+}
+
+// Hooks returns the client hooks.
+func (c *SubscriptionPlanGroupClient) Hooks() []Hook {
+ return c.hooks.SubscriptionPlanGroup
+}
+
+// Interceptors returns the client interceptors.
+func (c *SubscriptionPlanGroupClient) Interceptors() []Interceptor {
+ return c.inters.SubscriptionPlanGroup
+}
+
+func (c *SubscriptionPlanGroupClient) mutate(ctx context.Context, m *SubscriptionPlanGroupMutation) (Value, error) {
+ switch m.Op() {
+ case OpCreate:
+ return (&SubscriptionPlanGroupCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
+ case OpUpdate:
+ return (&SubscriptionPlanGroupUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
+ case OpUpdateOne:
+ return (&SubscriptionPlanGroupUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
+ case OpDelete, OpDeleteOne:
+ return (&SubscriptionPlanGroupDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx)
+ default:
+ return nil, fmt.Errorf("ent: unknown SubscriptionPlanGroup mutation op: %q", m.Op())
+ }
+}
+
+// SubscriptionWalletLedgerClient is a client for the SubscriptionWalletLedger schema.
+type SubscriptionWalletLedgerClient struct {
+ config
+}
+
+// NewSubscriptionWalletLedgerClient returns a client for the SubscriptionWalletLedger from the given config.
+func NewSubscriptionWalletLedgerClient(c config) *SubscriptionWalletLedgerClient {
+ return &SubscriptionWalletLedgerClient{config: c}
+}
+
+// Use adds a list of mutation hooks to the hooks stack.
+// A call to `Use(f, g, h)` equals to `subscriptionwalletledger.Hooks(f(g(h())))`.
+func (c *SubscriptionWalletLedgerClient) Use(hooks ...Hook) {
+ c.hooks.SubscriptionWalletLedger = append(c.hooks.SubscriptionWalletLedger, hooks...)
+}
+
+// Intercept adds a list of query interceptors to the interceptors stack.
+// A call to `Intercept(f, g, h)` equals to `subscriptionwalletledger.Intercept(f(g(h())))`.
+func (c *SubscriptionWalletLedgerClient) Intercept(interceptors ...Interceptor) {
+ c.inters.SubscriptionWalletLedger = append(c.inters.SubscriptionWalletLedger, interceptors...)
+}
+
+// Create returns a builder for creating a SubscriptionWalletLedger entity.
+func (c *SubscriptionWalletLedgerClient) Create() *SubscriptionWalletLedgerCreate {
+ mutation := newSubscriptionWalletLedgerMutation(c.config, OpCreate)
+ return &SubscriptionWalletLedgerCreate{config: c.config, hooks: c.Hooks(), mutation: mutation}
+}
+
+// CreateBulk returns a builder for creating a bulk of SubscriptionWalletLedger entities.
+func (c *SubscriptionWalletLedgerClient) CreateBulk(builders ...*SubscriptionWalletLedgerCreate) *SubscriptionWalletLedgerCreateBulk {
+ return &SubscriptionWalletLedgerCreateBulk{config: c.config, builders: builders}
+}
+
+// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates
+// a builder and applies setFunc on it.
+func (c *SubscriptionWalletLedgerClient) MapCreateBulk(slice any, setFunc func(*SubscriptionWalletLedgerCreate, int)) *SubscriptionWalletLedgerCreateBulk {
+ rv := reflect.ValueOf(slice)
+ if rv.Kind() != reflect.Slice {
+ return &SubscriptionWalletLedgerCreateBulk{err: fmt.Errorf("calling to SubscriptionWalletLedgerClient.MapCreateBulk with wrong type %T, need slice", slice)}
+ }
+ builders := make([]*SubscriptionWalletLedgerCreate, rv.Len())
+ for i := 0; i < rv.Len(); i++ {
+ builders[i] = c.Create()
+ setFunc(builders[i], i)
+ }
+ return &SubscriptionWalletLedgerCreateBulk{config: c.config, builders: builders}
+}
+
+// Update returns an update builder for SubscriptionWalletLedger.
+func (c *SubscriptionWalletLedgerClient) Update() *SubscriptionWalletLedgerUpdate {
+ mutation := newSubscriptionWalletLedgerMutation(c.config, OpUpdate)
+ return &SubscriptionWalletLedgerUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation}
+}
+
+// UpdateOne returns an update builder for the given entity.
+func (c *SubscriptionWalletLedgerClient) UpdateOne(_m *SubscriptionWalletLedger) *SubscriptionWalletLedgerUpdateOne {
+ mutation := newSubscriptionWalletLedgerMutation(c.config, OpUpdateOne, withSubscriptionWalletLedger(_m))
+ return &SubscriptionWalletLedgerUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation}
+}
+
+// UpdateOneID returns an update builder for the given id.
+func (c *SubscriptionWalletLedgerClient) UpdateOneID(id int64) *SubscriptionWalletLedgerUpdateOne {
+ mutation := newSubscriptionWalletLedgerMutation(c.config, OpUpdateOne, withSubscriptionWalletLedgerID(id))
+ return &SubscriptionWalletLedgerUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation}
+}
+
+// Delete returns a delete builder for SubscriptionWalletLedger.
+func (c *SubscriptionWalletLedgerClient) Delete() *SubscriptionWalletLedgerDelete {
+ mutation := newSubscriptionWalletLedgerMutation(c.config, OpDelete)
+ return &SubscriptionWalletLedgerDelete{config: c.config, hooks: c.Hooks(), mutation: mutation}
+}
+
+// DeleteOne returns a builder for deleting the given entity.
+func (c *SubscriptionWalletLedgerClient) DeleteOne(_m *SubscriptionWalletLedger) *SubscriptionWalletLedgerDeleteOne {
+ return c.DeleteOneID(_m.ID)
+}
+
+// DeleteOneID returns a builder for deleting the given entity by its id.
+func (c *SubscriptionWalletLedgerClient) DeleteOneID(id int64) *SubscriptionWalletLedgerDeleteOne {
+ builder := c.Delete().Where(subscriptionwalletledger.ID(id))
+ builder.mutation.id = &id
+ builder.mutation.op = OpDeleteOne
+ return &SubscriptionWalletLedgerDeleteOne{builder}
+}
+
+// Query returns a query builder for SubscriptionWalletLedger.
+func (c *SubscriptionWalletLedgerClient) Query() *SubscriptionWalletLedgerQuery {
+ return &SubscriptionWalletLedgerQuery{
+ config: c.config,
+ ctx: &QueryContext{Type: TypeSubscriptionWalletLedger},
+ inters: c.Interceptors(),
+ }
+}
+
+// Get returns a SubscriptionWalletLedger entity by its id.
+func (c *SubscriptionWalletLedgerClient) Get(ctx context.Context, id int64) (*SubscriptionWalletLedger, error) {
+ return c.Query().Where(subscriptionwalletledger.ID(id)).Only(ctx)
+}
+
+// GetX is like Get, but panics if an error occurs.
+func (c *SubscriptionWalletLedgerClient) GetX(ctx context.Context, id int64) *SubscriptionWalletLedger {
+ obj, err := c.Get(ctx, id)
+ if err != nil {
+ panic(err)
+ }
+ return obj
+}
+
+// QuerySubscription queries the subscription edge of a SubscriptionWalletLedger.
+func (c *SubscriptionWalletLedgerClient) QuerySubscription(_m *SubscriptionWalletLedger) *UserSubscriptionQuery {
+ query := (&UserSubscriptionClient{config: c.config}).Query()
+ query.path = func(context.Context) (fromV *sql.Selector, _ error) {
+ id := _m.ID
+ step := sqlgraph.NewStep(
+ sqlgraph.From(subscriptionwalletledger.Table, subscriptionwalletledger.FieldID, id),
+ sqlgraph.To(usersubscription.Table, usersubscription.FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, subscriptionwalletledger.SubscriptionTable, subscriptionwalletledger.SubscriptionColumn),
+ )
+ fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
+ return fromV, nil
+ }
+ return query
+}
+
+// QueryUsageLog queries the usage_log edge of a SubscriptionWalletLedger.
+func (c *SubscriptionWalletLedgerClient) QueryUsageLog(_m *SubscriptionWalletLedger) *UsageLogQuery {
+ query := (&UsageLogClient{config: c.config}).Query()
+ query.path = func(context.Context) (fromV *sql.Selector, _ error) {
+ id := _m.ID
+ step := sqlgraph.NewStep(
+ sqlgraph.From(subscriptionwalletledger.Table, subscriptionwalletledger.FieldID, id),
+ sqlgraph.To(usagelog.Table, usagelog.FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, subscriptionwalletledger.UsageLogTable, subscriptionwalletledger.UsageLogColumn),
+ )
+ fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
+ return fromV, nil
+ }
+ return query
+}
+
+// QueryOperator queries the operator edge of a SubscriptionWalletLedger.
+func (c *SubscriptionWalletLedgerClient) QueryOperator(_m *SubscriptionWalletLedger) *UserQuery {
+ query := (&UserClient{config: c.config}).Query()
+ query.path = func(context.Context) (fromV *sql.Selector, _ error) {
+ id := _m.ID
+ step := sqlgraph.NewStep(
+ sqlgraph.From(subscriptionwalletledger.Table, subscriptionwalletledger.FieldID, id),
+ sqlgraph.To(user.Table, user.FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, subscriptionwalletledger.OperatorTable, subscriptionwalletledger.OperatorColumn),
+ )
+ fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
+ return fromV, nil
+ }
+ return query
+}
+
+// Hooks returns the client hooks.
+func (c *SubscriptionWalletLedgerClient) Hooks() []Hook {
+ return c.hooks.SubscriptionWalletLedger
+}
+
+// Interceptors returns the client interceptors.
+func (c *SubscriptionWalletLedgerClient) Interceptors() []Interceptor {
+ return c.inters.SubscriptionWalletLedger
+}
+
+func (c *SubscriptionWalletLedgerClient) mutate(ctx context.Context, m *SubscriptionWalletLedgerMutation) (Value, error) {
+ switch m.Op() {
+ case OpCreate:
+ return (&SubscriptionWalletLedgerCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
+ case OpUpdate:
+ return (&SubscriptionWalletLedgerUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
+ case OpUpdateOne:
+ return (&SubscriptionWalletLedgerUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
+ case OpDelete, OpDeleteOne:
+ return (&SubscriptionWalletLedgerDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx)
+ default:
+ return nil, fmt.Errorf("ent: unknown SubscriptionWalletLedger mutation op: %q", m.Op())
+ }
+}
+
// TLSFingerprintProfileClient is a client for the TLSFingerprintProfile schema.
type TLSFingerprintProfileClient struct {
config
@@ -5016,6 +5412,22 @@ func (c *UsageLogClient) QuerySubscription(_m *UsageLog) *UserSubscriptionQuery
return query
}
+// QueryWalletLedgerEntries queries the wallet_ledger_entries edge of a UsageLog.
+func (c *UsageLogClient) QueryWalletLedgerEntries(_m *UsageLog) *SubscriptionWalletLedgerQuery {
+ query := (&SubscriptionWalletLedgerClient{config: c.config}).Query()
+ query.path = func(context.Context) (fromV *sql.Selector, _ error) {
+ id := _m.ID
+ step := sqlgraph.NewStep(
+ sqlgraph.From(usagelog.Table, usagelog.FieldID, id),
+ sqlgraph.To(subscriptionwalletledger.Table, subscriptionwalletledger.FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, usagelog.WalletLedgerEntriesTable, usagelog.WalletLedgerEntriesColumn),
+ )
+ fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
+ return fromV, nil
+ }
+ return query
+}
+
// Hooks returns the client hooks.
func (c *UsageLogClient) Hooks() []Hook {
return c.hooks.UsageLog
@@ -5213,6 +5625,22 @@ func (c *UserClient) QueryAssignedSubscriptions(_m *User) *UserSubscriptionQuery
return query
}
+// QueryWalletLedgerOperations queries the wallet_ledger_operations edge of a User.
+func (c *UserClient) QueryWalletLedgerOperations(_m *User) *SubscriptionWalletLedgerQuery {
+ query := (&SubscriptionWalletLedgerClient{config: c.config}).Query()
+ query.path = func(context.Context) (fromV *sql.Selector, _ error) {
+ id := _m.ID
+ step := sqlgraph.NewStep(
+ sqlgraph.From(user.Table, user.FieldID, id),
+ sqlgraph.To(subscriptionwalletledger.Table, subscriptionwalletledger.FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, user.WalletLedgerOperationsTable, user.WalletLedgerOperationsColumn),
+ )
+ fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
+ return fromV, nil
+ }
+ return query
+}
+
// QueryAnnouncementReads queries the announcement_reads edge of a User.
func (c *UserClient) QueryAnnouncementReads(_m *User) *AnnouncementReadQuery {
query := (&AnnouncementReadClient{config: c.config}).Query()
@@ -5988,6 +6416,22 @@ func (c *UserSubscriptionClient) QueryUsageLogs(_m *UserSubscription) *UsageLogQ
return query
}
+// QueryWalletLedgerEntries queries the wallet_ledger_entries edge of a UserSubscription.
+func (c *UserSubscriptionClient) QueryWalletLedgerEntries(_m *UserSubscription) *SubscriptionWalletLedgerQuery {
+ query := (&SubscriptionWalletLedgerClient{config: c.config}).Query()
+ query.path = func(context.Context) (fromV *sql.Selector, _ error) {
+ id := _m.ID
+ step := sqlgraph.NewStep(
+ sqlgraph.From(usersubscription.Table, usersubscription.FieldID, id),
+ sqlgraph.To(subscriptionwalletledger.Table, subscriptionwalletledger.FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, usersubscription.WalletLedgerEntriesTable, usersubscription.WalletLedgerEntriesColumn),
+ )
+ fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
+ return fromV, nil
+ }
+ return query
+}
+
// Hooks returns the client hooks.
func (c *UserSubscriptionClient) Hooks() []Hook {
hooks := c.hooks.UserSubscription
@@ -6024,8 +6468,9 @@ type (
Group, IdempotencyRecord, IdentityAdoptionDecision, PaymentAuditLog,
PaymentOrder, PaymentProviderInstance, PendingAuthSession, PromoCode,
PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, SubscriptionPlan,
- TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, UserAllowedGroup,
- UserAttributeDefinition, UserAttributeValue, UserSubscription []ent.Hook
+ SubscriptionPlanGroup, SubscriptionWalletLedger, TLSFingerprintProfile,
+ UsageCleanupTask, UsageLog, User, UserAllowedGroup, UserAttributeDefinition,
+ UserAttributeValue, UserSubscription []ent.Hook
}
inters struct {
APIKey, Account, AccountGroup, Announcement, AnnouncementRead, AuthIdentity,
@@ -6034,8 +6479,9 @@ type (
Group, IdempotencyRecord, IdentityAdoptionDecision, PaymentAuditLog,
PaymentOrder, PaymentProviderInstance, PendingAuthSession, PromoCode,
PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, SubscriptionPlan,
- TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, UserAllowedGroup,
- UserAttributeDefinition, UserAttributeValue, UserSubscription []ent.Interceptor
+ SubscriptionPlanGroup, SubscriptionWalletLedger, TLSFingerprintProfile,
+ UsageCleanupTask, UsageLog, User, UserAllowedGroup, UserAttributeDefinition,
+ UserAttributeValue, UserSubscription []ent.Interceptor
}
)
diff --git a/backend/ent/ent.go b/backend/ent/ent.go
index c9fcc314e95..7c4507b8649 100644
--- a/backend/ent/ent.go
+++ b/backend/ent/ent.go
@@ -38,6 +38,8 @@ import (
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
"github.com/Wei-Shaw/sub2api/ent/setting"
"github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
"github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
@@ -132,6 +134,8 @@ func checkColumn(t, c string) error {
securitysecret.Table: securitysecret.ValidColumn,
setting.Table: setting.ValidColumn,
subscriptionplan.Table: subscriptionplan.ValidColumn,
+ subscriptionplangroup.Table: subscriptionplangroup.ValidColumn,
+ subscriptionwalletledger.Table: subscriptionwalletledger.ValidColumn,
tlsfingerprintprofile.Table: tlsfingerprintprofile.ValidColumn,
usagecleanuptask.Table: usagecleanuptask.ValidColumn,
usagelog.Table: usagelog.ValidColumn,
diff --git a/backend/ent/group.go b/backend/ent/group.go
index 5d9ae2ed203..502dfefb1e0 100644
--- a/backend/ent/group.go
+++ b/backend/ent/group.go
@@ -47,6 +47,12 @@ type Group struct {
MonthlyLimitUsd *float64 `json:"monthly_limit_usd,omitempty"`
// DefaultValidityDays holds the value of the "default_validity_days" field.
DefaultValidityDays int `json:"default_validity_days,omitempty"`
+ // 是否允许该分组使用图片生成能力
+ AllowImageGeneration bool `json:"allow_image_generation,omitempty"`
+ // 图片生成是否使用独立倍率;false 表示共享分组有效倍率
+ ImageRateIndependent bool `json:"image_rate_independent,omitempty"`
+ // 图片生成独立倍率,仅 image_rate_independent=true 时生效
+ ImageRateMultiplier float64 `json:"image_rate_multiplier,omitempty"`
// ImagePrice1k holds the value of the "image_price_1k" field.
ImagePrice1k *float64 `json:"image_price_1k,omitempty"`
// ImagePrice2k holds the value of the "image_price_2k" field.
@@ -97,6 +103,8 @@ type GroupEdges struct {
Subscriptions []*UserSubscription `json:"subscriptions,omitempty"`
// UsageLogs holds the value of the usage_logs edge.
UsageLogs []*UsageLog `json:"usage_logs,omitempty"`
+ // PlanGroups holds the value of the plan_groups edge.
+ PlanGroups []*SubscriptionPlanGroup `json:"plan_groups,omitempty"`
// Accounts holds the value of the accounts edge.
Accounts []*Account `json:"accounts,omitempty"`
// AllowedUsers holds the value of the allowed_users edge.
@@ -107,7 +115,7 @@ type GroupEdges struct {
UserAllowedGroups []*UserAllowedGroup `json:"user_allowed_groups,omitempty"`
// loadedTypes holds the information for reporting if a
// type was loaded (or requested) in eager-loading or not.
- loadedTypes [8]bool
+ loadedTypes [9]bool
}
// APIKeysOrErr returns the APIKeys value or an error if the edge
@@ -146,10 +154,19 @@ func (e GroupEdges) UsageLogsOrErr() ([]*UsageLog, error) {
return nil, &NotLoadedError{edge: "usage_logs"}
}
+// PlanGroupsOrErr returns the PlanGroups value or an error if the edge
+// was not loaded in eager-loading.
+func (e GroupEdges) PlanGroupsOrErr() ([]*SubscriptionPlanGroup, error) {
+ if e.loadedTypes[4] {
+ return e.PlanGroups, nil
+ }
+ return nil, &NotLoadedError{edge: "plan_groups"}
+}
+
// AccountsOrErr returns the Accounts value or an error if the edge
// was not loaded in eager-loading.
func (e GroupEdges) AccountsOrErr() ([]*Account, error) {
- if e.loadedTypes[4] {
+ if e.loadedTypes[5] {
return e.Accounts, nil
}
return nil, &NotLoadedError{edge: "accounts"}
@@ -158,7 +175,7 @@ func (e GroupEdges) AccountsOrErr() ([]*Account, error) {
// AllowedUsersOrErr returns the AllowedUsers value or an error if the edge
// was not loaded in eager-loading.
func (e GroupEdges) AllowedUsersOrErr() ([]*User, error) {
- if e.loadedTypes[5] {
+ if e.loadedTypes[6] {
return e.AllowedUsers, nil
}
return nil, &NotLoadedError{edge: "allowed_users"}
@@ -167,7 +184,7 @@ func (e GroupEdges) AllowedUsersOrErr() ([]*User, error) {
// AccountGroupsOrErr returns the AccountGroups value or an error if the edge
// was not loaded in eager-loading.
func (e GroupEdges) AccountGroupsOrErr() ([]*AccountGroup, error) {
- if e.loadedTypes[6] {
+ if e.loadedTypes[7] {
return e.AccountGroups, nil
}
return nil, &NotLoadedError{edge: "account_groups"}
@@ -176,7 +193,7 @@ func (e GroupEdges) AccountGroupsOrErr() ([]*AccountGroup, error) {
// UserAllowedGroupsOrErr returns the UserAllowedGroups value or an error if the edge
// was not loaded in eager-loading.
func (e GroupEdges) UserAllowedGroupsOrErr() ([]*UserAllowedGroup, error) {
- if e.loadedTypes[7] {
+ if e.loadedTypes[8] {
return e.UserAllowedGroups, nil
}
return nil, &NotLoadedError{edge: "user_allowed_groups"}
@@ -189,9 +206,9 @@ func (*Group) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig:
values[i] = new([]byte)
- case group.FieldIsExclusive, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet:
+ case group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldImageRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet:
values[i] = new(sql.NullBool)
- case group.FieldRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k:
+ case group.FieldRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k:
values[i] = new(sql.NullFloat64)
case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit:
values[i] = new(sql.NullInt64)
@@ -309,6 +326,24 @@ func (_m *Group) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.DefaultValidityDays = int(value.Int64)
}
+ case group.FieldAllowImageGeneration:
+ if value, ok := values[i].(*sql.NullBool); !ok {
+ return fmt.Errorf("unexpected type %T for field allow_image_generation", values[i])
+ } else if value.Valid {
+ _m.AllowImageGeneration = value.Bool
+ }
+ case group.FieldImageRateIndependent:
+ if value, ok := values[i].(*sql.NullBool); !ok {
+ return fmt.Errorf("unexpected type %T for field image_rate_independent", values[i])
+ } else if value.Valid {
+ _m.ImageRateIndependent = value.Bool
+ }
+ case group.FieldImageRateMultiplier:
+ if value, ok := values[i].(*sql.NullFloat64); !ok {
+ return fmt.Errorf("unexpected type %T for field image_rate_multiplier", values[i])
+ } else if value.Valid {
+ _m.ImageRateMultiplier = value.Float64
+ }
case group.FieldImagePrice1k:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field image_price_1k", values[i])
@@ -455,6 +490,11 @@ func (_m *Group) QueryUsageLogs() *UsageLogQuery {
return NewGroupClient(_m.config).QueryUsageLogs(_m)
}
+// QueryPlanGroups queries the "plan_groups" edge of the Group entity.
+func (_m *Group) QueryPlanGroups() *SubscriptionPlanGroupQuery {
+ return NewGroupClient(_m.config).QueryPlanGroups(_m)
+}
+
// QueryAccounts queries the "accounts" edge of the Group entity.
func (_m *Group) QueryAccounts() *AccountQuery {
return NewGroupClient(_m.config).QueryAccounts(_m)
@@ -550,6 +590,15 @@ func (_m *Group) String() string {
builder.WriteString("default_validity_days=")
builder.WriteString(fmt.Sprintf("%v", _m.DefaultValidityDays))
builder.WriteString(", ")
+ builder.WriteString("allow_image_generation=")
+ builder.WriteString(fmt.Sprintf("%v", _m.AllowImageGeneration))
+ builder.WriteString(", ")
+ builder.WriteString("image_rate_independent=")
+ builder.WriteString(fmt.Sprintf("%v", _m.ImageRateIndependent))
+ builder.WriteString(", ")
+ builder.WriteString("image_rate_multiplier=")
+ builder.WriteString(fmt.Sprintf("%v", _m.ImageRateMultiplier))
+ builder.WriteString(", ")
if v := _m.ImagePrice1k; v != nil {
builder.WriteString("image_price_1k=")
builder.WriteString(fmt.Sprintf("%v", *v))
diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go
index 24bd9c13d63..f35006f8868 100644
--- a/backend/ent/group/group.go
+++ b/backend/ent/group/group.go
@@ -44,6 +44,12 @@ const (
FieldMonthlyLimitUsd = "monthly_limit_usd"
// FieldDefaultValidityDays holds the string denoting the default_validity_days field in the database.
FieldDefaultValidityDays = "default_validity_days"
+ // FieldAllowImageGeneration holds the string denoting the allow_image_generation field in the database.
+ FieldAllowImageGeneration = "allow_image_generation"
+ // FieldImageRateIndependent holds the string denoting the image_rate_independent field in the database.
+ FieldImageRateIndependent = "image_rate_independent"
+ // FieldImageRateMultiplier holds the string denoting the image_rate_multiplier field in the database.
+ FieldImageRateMultiplier = "image_rate_multiplier"
// FieldImagePrice1k holds the string denoting the image_price_1k field in the database.
FieldImagePrice1k = "image_price_1k"
// FieldImagePrice2k holds the string denoting the image_price_2k field in the database.
@@ -86,6 +92,8 @@ const (
EdgeSubscriptions = "subscriptions"
// EdgeUsageLogs holds the string denoting the usage_logs edge name in mutations.
EdgeUsageLogs = "usage_logs"
+ // EdgePlanGroups holds the string denoting the plan_groups edge name in mutations.
+ EdgePlanGroups = "plan_groups"
// EdgeAccounts holds the string denoting the accounts edge name in mutations.
EdgeAccounts = "accounts"
// EdgeAllowedUsers holds the string denoting the allowed_users edge name in mutations.
@@ -124,6 +132,13 @@ const (
UsageLogsInverseTable = "usage_logs"
// UsageLogsColumn is the table column denoting the usage_logs relation/edge.
UsageLogsColumn = "group_id"
+ // PlanGroupsTable is the table that holds the plan_groups relation/edge.
+ PlanGroupsTable = "subscription_plan_groups"
+ // PlanGroupsInverseTable is the table name for the SubscriptionPlanGroup entity.
+ // It exists in this package in order to avoid circular dependency with the "subscriptionplangroup" package.
+ PlanGroupsInverseTable = "subscription_plan_groups"
+ // PlanGroupsColumn is the table column denoting the plan_groups relation/edge.
+ PlanGroupsColumn = "group_id"
// AccountsTable is the table that holds the accounts relation/edge. The primary key declared below.
AccountsTable = "account_groups"
// AccountsInverseTable is the table name for the Account entity.
@@ -167,6 +182,9 @@ var Columns = []string{
FieldWeeklyLimitUsd,
FieldMonthlyLimitUsd,
FieldDefaultValidityDays,
+ FieldAllowImageGeneration,
+ FieldImageRateIndependent,
+ FieldImageRateMultiplier,
FieldImagePrice1k,
FieldImagePrice2k,
FieldImagePrice4k,
@@ -239,6 +257,12 @@ var (
SubscriptionTypeValidator func(string) error
// DefaultDefaultValidityDays holds the default value on creation for the "default_validity_days" field.
DefaultDefaultValidityDays int
+ // DefaultAllowImageGeneration holds the default value on creation for the "allow_image_generation" field.
+ DefaultAllowImageGeneration bool
+ // DefaultImageRateIndependent holds the default value on creation for the "image_rate_independent" field.
+ DefaultImageRateIndependent bool
+ // DefaultImageRateMultiplier holds the default value on creation for the "image_rate_multiplier" field.
+ DefaultImageRateMultiplier float64
// DefaultClaudeCodeOnly holds the default value on creation for the "claude_code_only" field.
DefaultClaudeCodeOnly bool
// DefaultModelRoutingEnabled holds the default value on creation for the "model_routing_enabled" field.
@@ -343,6 +367,21 @@ func ByDefaultValidityDays(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldDefaultValidityDays, opts...).ToFunc()
}
+// ByAllowImageGeneration orders the results by the allow_image_generation field.
+func ByAllowImageGeneration(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldAllowImageGeneration, opts...).ToFunc()
+}
+
+// ByImageRateIndependent orders the results by the image_rate_independent field.
+func ByImageRateIndependent(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldImageRateIndependent, opts...).ToFunc()
+}
+
+// ByImageRateMultiplier orders the results by the image_rate_multiplier field.
+func ByImageRateMultiplier(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldImageRateMultiplier, opts...).ToFunc()
+}
+
// ByImagePrice1k orders the results by the image_price_1k field.
func ByImagePrice1k(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldImagePrice1k, opts...).ToFunc()
@@ -469,6 +508,20 @@ func ByUsageLogs(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
}
}
+// ByPlanGroupsCount orders the results by plan_groups count.
+func ByPlanGroupsCount(opts ...sql.OrderTermOption) OrderOption {
+ return func(s *sql.Selector) {
+ sqlgraph.OrderByNeighborsCount(s, newPlanGroupsStep(), opts...)
+ }
+}
+
+// ByPlanGroups orders the results by plan_groups terms.
+func ByPlanGroups(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
+ return func(s *sql.Selector) {
+ sqlgraph.OrderByNeighborTerms(s, newPlanGroupsStep(), append([]sql.OrderTerm{term}, terms...)...)
+ }
+}
+
// ByAccountsCount orders the results by accounts count.
func ByAccountsCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
@@ -552,6 +605,13 @@ func newUsageLogsStep() *sqlgraph.Step {
sqlgraph.Edge(sqlgraph.O2M, false, UsageLogsTable, UsageLogsColumn),
)
}
+func newPlanGroupsStep() *sqlgraph.Step {
+ return sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.To(PlanGroupsInverseTable, FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, PlanGroupsTable, PlanGroupsColumn),
+ )
+}
func newAccountsStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go
index 2814d130f13..720acb512e3 100644
--- a/backend/ent/group/where.go
+++ b/backend/ent/group/where.go
@@ -125,6 +125,21 @@ func DefaultValidityDays(v int) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldDefaultValidityDays, v))
}
+// AllowImageGeneration applies equality check predicate on the "allow_image_generation" field. It's identical to AllowImageGenerationEQ.
+func AllowImageGeneration(v bool) predicate.Group {
+ return predicate.Group(sql.FieldEQ(FieldAllowImageGeneration, v))
+}
+
+// ImageRateIndependent applies equality check predicate on the "image_rate_independent" field. It's identical to ImageRateIndependentEQ.
+func ImageRateIndependent(v bool) predicate.Group {
+ return predicate.Group(sql.FieldEQ(FieldImageRateIndependent, v))
+}
+
+// ImageRateMultiplier applies equality check predicate on the "image_rate_multiplier" field. It's identical to ImageRateMultiplierEQ.
+func ImageRateMultiplier(v float64) predicate.Group {
+ return predicate.Group(sql.FieldEQ(FieldImageRateMultiplier, v))
+}
+
// ImagePrice1k applies equality check predicate on the "image_price_1k" field. It's identical to ImagePrice1kEQ.
func ImagePrice1k(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldImagePrice1k, v))
@@ -900,6 +915,66 @@ func DefaultValidityDaysLTE(v int) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldDefaultValidityDays, v))
}
+// AllowImageGenerationEQ applies the EQ predicate on the "allow_image_generation" field.
+func AllowImageGenerationEQ(v bool) predicate.Group {
+ return predicate.Group(sql.FieldEQ(FieldAllowImageGeneration, v))
+}
+
+// AllowImageGenerationNEQ applies the NEQ predicate on the "allow_image_generation" field.
+func AllowImageGenerationNEQ(v bool) predicate.Group {
+ return predicate.Group(sql.FieldNEQ(FieldAllowImageGeneration, v))
+}
+
+// ImageRateIndependentEQ applies the EQ predicate on the "image_rate_independent" field.
+func ImageRateIndependentEQ(v bool) predicate.Group {
+ return predicate.Group(sql.FieldEQ(FieldImageRateIndependent, v))
+}
+
+// ImageRateIndependentNEQ applies the NEQ predicate on the "image_rate_independent" field.
+func ImageRateIndependentNEQ(v bool) predicate.Group {
+ return predicate.Group(sql.FieldNEQ(FieldImageRateIndependent, v))
+}
+
+// ImageRateMultiplierEQ applies the EQ predicate on the "image_rate_multiplier" field.
+func ImageRateMultiplierEQ(v float64) predicate.Group {
+ return predicate.Group(sql.FieldEQ(FieldImageRateMultiplier, v))
+}
+
+// ImageRateMultiplierNEQ applies the NEQ predicate on the "image_rate_multiplier" field.
+func ImageRateMultiplierNEQ(v float64) predicate.Group {
+ return predicate.Group(sql.FieldNEQ(FieldImageRateMultiplier, v))
+}
+
+// ImageRateMultiplierIn applies the In predicate on the "image_rate_multiplier" field.
+func ImageRateMultiplierIn(vs ...float64) predicate.Group {
+ return predicate.Group(sql.FieldIn(FieldImageRateMultiplier, vs...))
+}
+
+// ImageRateMultiplierNotIn applies the NotIn predicate on the "image_rate_multiplier" field.
+func ImageRateMultiplierNotIn(vs ...float64) predicate.Group {
+ return predicate.Group(sql.FieldNotIn(FieldImageRateMultiplier, vs...))
+}
+
+// ImageRateMultiplierGT applies the GT predicate on the "image_rate_multiplier" field.
+func ImageRateMultiplierGT(v float64) predicate.Group {
+ return predicate.Group(sql.FieldGT(FieldImageRateMultiplier, v))
+}
+
+// ImageRateMultiplierGTE applies the GTE predicate on the "image_rate_multiplier" field.
+func ImageRateMultiplierGTE(v float64) predicate.Group {
+ return predicate.Group(sql.FieldGTE(FieldImageRateMultiplier, v))
+}
+
+// ImageRateMultiplierLT applies the LT predicate on the "image_rate_multiplier" field.
+func ImageRateMultiplierLT(v float64) predicate.Group {
+ return predicate.Group(sql.FieldLT(FieldImageRateMultiplier, v))
+}
+
+// ImageRateMultiplierLTE applies the LTE predicate on the "image_rate_multiplier" field.
+func ImageRateMultiplierLTE(v float64) predicate.Group {
+ return predicate.Group(sql.FieldLTE(FieldImageRateMultiplier, v))
+}
+
// ImagePrice1kEQ applies the EQ predicate on the "image_price_1k" field.
func ImagePrice1kEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldImagePrice1k, v))
@@ -1457,6 +1532,29 @@ func HasUsageLogsWith(preds ...predicate.UsageLog) predicate.Group {
})
}
+// HasPlanGroups applies the HasEdge predicate on the "plan_groups" edge.
+func HasPlanGroups() predicate.Group {
+ return predicate.Group(func(s *sql.Selector) {
+ step := sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, PlanGroupsTable, PlanGroupsColumn),
+ )
+ sqlgraph.HasNeighbors(s, step)
+ })
+}
+
+// HasPlanGroupsWith applies the HasEdge predicate on the "plan_groups" edge with a given conditions (other predicates).
+func HasPlanGroupsWith(preds ...predicate.SubscriptionPlanGroup) predicate.Group {
+ return predicate.Group(func(s *sql.Selector) {
+ step := newPlanGroupsStep()
+ sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
+ for _, p := range preds {
+ p(s)
+ }
+ })
+ })
+}
+
// HasAccounts applies the HasEdge predicate on the "accounts" edge.
func HasAccounts() predicate.Group {
return predicate.Group(func(s *sql.Selector) {
diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go
index 20ea0a0fe71..b6241054029 100644
--- a/backend/ent/group_create.go
+++ b/backend/ent/group_create.go
@@ -15,6 +15,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/apikey"
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
@@ -217,6 +218,48 @@ func (_c *GroupCreate) SetNillableDefaultValidityDays(v *int) *GroupCreate {
return _c
}
+// SetAllowImageGeneration sets the "allow_image_generation" field.
+func (_c *GroupCreate) SetAllowImageGeneration(v bool) *GroupCreate {
+ _c.mutation.SetAllowImageGeneration(v)
+ return _c
+}
+
+// SetNillableAllowImageGeneration sets the "allow_image_generation" field if the given value is not nil.
+func (_c *GroupCreate) SetNillableAllowImageGeneration(v *bool) *GroupCreate {
+ if v != nil {
+ _c.SetAllowImageGeneration(*v)
+ }
+ return _c
+}
+
+// SetImageRateIndependent sets the "image_rate_independent" field.
+func (_c *GroupCreate) SetImageRateIndependent(v bool) *GroupCreate {
+ _c.mutation.SetImageRateIndependent(v)
+ return _c
+}
+
+// SetNillableImageRateIndependent sets the "image_rate_independent" field if the given value is not nil.
+func (_c *GroupCreate) SetNillableImageRateIndependent(v *bool) *GroupCreate {
+ if v != nil {
+ _c.SetImageRateIndependent(*v)
+ }
+ return _c
+}
+
+// SetImageRateMultiplier sets the "image_rate_multiplier" field.
+func (_c *GroupCreate) SetImageRateMultiplier(v float64) *GroupCreate {
+ _c.mutation.SetImageRateMultiplier(v)
+ return _c
+}
+
+// SetNillableImageRateMultiplier sets the "image_rate_multiplier" field if the given value is not nil.
+func (_c *GroupCreate) SetNillableImageRateMultiplier(v *float64) *GroupCreate {
+ if v != nil {
+ _c.SetImageRateMultiplier(*v)
+ }
+ return _c
+}
+
// SetImagePrice1k sets the "image_price_1k" field.
func (_c *GroupCreate) SetImagePrice1k(v float64) *GroupCreate {
_c.mutation.SetImagePrice1k(v)
@@ -499,6 +542,21 @@ func (_c *GroupCreate) AddUsageLogs(v ...*UsageLog) *GroupCreate {
return _c.AddUsageLogIDs(ids...)
}
+// AddPlanGroupIDs adds the "plan_groups" edge to the SubscriptionPlanGroup entity by IDs.
+func (_c *GroupCreate) AddPlanGroupIDs(ids ...int64) *GroupCreate {
+ _c.mutation.AddPlanGroupIDs(ids...)
+ return _c
+}
+
+// AddPlanGroups adds the "plan_groups" edges to the SubscriptionPlanGroup entity.
+func (_c *GroupCreate) AddPlanGroups(v ...*SubscriptionPlanGroup) *GroupCreate {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _c.AddPlanGroupIDs(ids...)
+}
+
// AddAccountIDs adds the "accounts" edge to the Account entity by IDs.
func (_c *GroupCreate) AddAccountIDs(ids ...int64) *GroupCreate {
_c.mutation.AddAccountIDs(ids...)
@@ -604,6 +662,18 @@ func (_c *GroupCreate) defaults() error {
v := group.DefaultDefaultValidityDays
_c.mutation.SetDefaultValidityDays(v)
}
+ if _, ok := _c.mutation.AllowImageGeneration(); !ok {
+ v := group.DefaultAllowImageGeneration
+ _c.mutation.SetAllowImageGeneration(v)
+ }
+ if _, ok := _c.mutation.ImageRateIndependent(); !ok {
+ v := group.DefaultImageRateIndependent
+ _c.mutation.SetImageRateIndependent(v)
+ }
+ if _, ok := _c.mutation.ImageRateMultiplier(); !ok {
+ v := group.DefaultImageRateMultiplier
+ _c.mutation.SetImageRateMultiplier(v)
+ }
if _, ok := _c.mutation.ClaudeCodeOnly(); !ok {
v := group.DefaultClaudeCodeOnly
_c.mutation.SetClaudeCodeOnly(v)
@@ -700,6 +770,15 @@ func (_c *GroupCreate) check() error {
if _, ok := _c.mutation.DefaultValidityDays(); !ok {
return &ValidationError{Name: "default_validity_days", err: errors.New(`ent: missing required field "Group.default_validity_days"`)}
}
+ if _, ok := _c.mutation.AllowImageGeneration(); !ok {
+ return &ValidationError{Name: "allow_image_generation", err: errors.New(`ent: missing required field "Group.allow_image_generation"`)}
+ }
+ if _, ok := _c.mutation.ImageRateIndependent(); !ok {
+ return &ValidationError{Name: "image_rate_independent", err: errors.New(`ent: missing required field "Group.image_rate_independent"`)}
+ }
+ if _, ok := _c.mutation.ImageRateMultiplier(); !ok {
+ return &ValidationError{Name: "image_rate_multiplier", err: errors.New(`ent: missing required field "Group.image_rate_multiplier"`)}
+ }
if _, ok := _c.mutation.ClaudeCodeOnly(); !ok {
return &ValidationError{Name: "claude_code_only", err: errors.New(`ent: missing required field "Group.claude_code_only"`)}
}
@@ -821,6 +900,18 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldDefaultValidityDays, field.TypeInt, value)
_node.DefaultValidityDays = value
}
+ if value, ok := _c.mutation.AllowImageGeneration(); ok {
+ _spec.SetField(group.FieldAllowImageGeneration, field.TypeBool, value)
+ _node.AllowImageGeneration = value
+ }
+ if value, ok := _c.mutation.ImageRateIndependent(); ok {
+ _spec.SetField(group.FieldImageRateIndependent, field.TypeBool, value)
+ _node.ImageRateIndependent = value
+ }
+ if value, ok := _c.mutation.ImageRateMultiplier(); ok {
+ _spec.SetField(group.FieldImageRateMultiplier, field.TypeFloat64, value)
+ _node.ImageRateMultiplier = value
+ }
if value, ok := _c.mutation.ImagePrice1k(); ok {
_spec.SetField(group.FieldImagePrice1k, field.TypeFloat64, value)
_node.ImagePrice1k = &value
@@ -953,6 +1044,22 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
}
_spec.Edges = append(_spec.Edges, edge)
}
+ if nodes := _c.mutation.PlanGroupsIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: group.PlanGroupsTable,
+ Columns: []string{group.PlanGroupsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges = append(_spec.Edges, edge)
+ }
if nodes := _c.mutation.AccountsIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
@@ -1261,6 +1368,48 @@ func (u *GroupUpsert) AddDefaultValidityDays(v int) *GroupUpsert {
return u
}
+// SetAllowImageGeneration sets the "allow_image_generation" field.
+func (u *GroupUpsert) SetAllowImageGeneration(v bool) *GroupUpsert {
+ u.Set(group.FieldAllowImageGeneration, v)
+ return u
+}
+
+// UpdateAllowImageGeneration sets the "allow_image_generation" field to the value that was provided on create.
+func (u *GroupUpsert) UpdateAllowImageGeneration() *GroupUpsert {
+ u.SetExcluded(group.FieldAllowImageGeneration)
+ return u
+}
+
+// SetImageRateIndependent sets the "image_rate_independent" field.
+func (u *GroupUpsert) SetImageRateIndependent(v bool) *GroupUpsert {
+ u.Set(group.FieldImageRateIndependent, v)
+ return u
+}
+
+// UpdateImageRateIndependent sets the "image_rate_independent" field to the value that was provided on create.
+func (u *GroupUpsert) UpdateImageRateIndependent() *GroupUpsert {
+ u.SetExcluded(group.FieldImageRateIndependent)
+ return u
+}
+
+// SetImageRateMultiplier sets the "image_rate_multiplier" field.
+func (u *GroupUpsert) SetImageRateMultiplier(v float64) *GroupUpsert {
+ u.Set(group.FieldImageRateMultiplier, v)
+ return u
+}
+
+// UpdateImageRateMultiplier sets the "image_rate_multiplier" field to the value that was provided on create.
+func (u *GroupUpsert) UpdateImageRateMultiplier() *GroupUpsert {
+ u.SetExcluded(group.FieldImageRateMultiplier)
+ return u
+}
+
+// AddImageRateMultiplier adds v to the "image_rate_multiplier" field.
+func (u *GroupUpsert) AddImageRateMultiplier(v float64) *GroupUpsert {
+ u.Add(group.FieldImageRateMultiplier, v)
+ return u
+}
+
// SetImagePrice1k sets the "image_price_1k" field.
func (u *GroupUpsert) SetImagePrice1k(v float64) *GroupUpsert {
u.Set(group.FieldImagePrice1k, v)
@@ -1840,6 +1989,55 @@ func (u *GroupUpsertOne) UpdateDefaultValidityDays() *GroupUpsertOne {
})
}
+// SetAllowImageGeneration sets the "allow_image_generation" field.
+func (u *GroupUpsertOne) SetAllowImageGeneration(v bool) *GroupUpsertOne {
+ return u.Update(func(s *GroupUpsert) {
+ s.SetAllowImageGeneration(v)
+ })
+}
+
+// UpdateAllowImageGeneration sets the "allow_image_generation" field to the value that was provided on create.
+func (u *GroupUpsertOne) UpdateAllowImageGeneration() *GroupUpsertOne {
+ return u.Update(func(s *GroupUpsert) {
+ s.UpdateAllowImageGeneration()
+ })
+}
+
+// SetImageRateIndependent sets the "image_rate_independent" field.
+func (u *GroupUpsertOne) SetImageRateIndependent(v bool) *GroupUpsertOne {
+ return u.Update(func(s *GroupUpsert) {
+ s.SetImageRateIndependent(v)
+ })
+}
+
+// UpdateImageRateIndependent sets the "image_rate_independent" field to the value that was provided on create.
+func (u *GroupUpsertOne) UpdateImageRateIndependent() *GroupUpsertOne {
+ return u.Update(func(s *GroupUpsert) {
+ s.UpdateImageRateIndependent()
+ })
+}
+
+// SetImageRateMultiplier sets the "image_rate_multiplier" field.
+func (u *GroupUpsertOne) SetImageRateMultiplier(v float64) *GroupUpsertOne {
+ return u.Update(func(s *GroupUpsert) {
+ s.SetImageRateMultiplier(v)
+ })
+}
+
+// AddImageRateMultiplier adds v to the "image_rate_multiplier" field.
+func (u *GroupUpsertOne) AddImageRateMultiplier(v float64) *GroupUpsertOne {
+ return u.Update(func(s *GroupUpsert) {
+ s.AddImageRateMultiplier(v)
+ })
+}
+
+// UpdateImageRateMultiplier sets the "image_rate_multiplier" field to the value that was provided on create.
+func (u *GroupUpsertOne) UpdateImageRateMultiplier() *GroupUpsertOne {
+ return u.Update(func(s *GroupUpsert) {
+ s.UpdateImageRateMultiplier()
+ })
+}
+
// SetImagePrice1k sets the "image_price_1k" field.
func (u *GroupUpsertOne) SetImagePrice1k(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
@@ -2632,6 +2830,55 @@ func (u *GroupUpsertBulk) UpdateDefaultValidityDays() *GroupUpsertBulk {
})
}
+// SetAllowImageGeneration sets the "allow_image_generation" field.
+func (u *GroupUpsertBulk) SetAllowImageGeneration(v bool) *GroupUpsertBulk {
+ return u.Update(func(s *GroupUpsert) {
+ s.SetAllowImageGeneration(v)
+ })
+}
+
+// UpdateAllowImageGeneration sets the "allow_image_generation" field to the value that was provided on create.
+func (u *GroupUpsertBulk) UpdateAllowImageGeneration() *GroupUpsertBulk {
+ return u.Update(func(s *GroupUpsert) {
+ s.UpdateAllowImageGeneration()
+ })
+}
+
+// SetImageRateIndependent sets the "image_rate_independent" field.
+func (u *GroupUpsertBulk) SetImageRateIndependent(v bool) *GroupUpsertBulk {
+ return u.Update(func(s *GroupUpsert) {
+ s.SetImageRateIndependent(v)
+ })
+}
+
+// UpdateImageRateIndependent sets the "image_rate_independent" field to the value that was provided on create.
+func (u *GroupUpsertBulk) UpdateImageRateIndependent() *GroupUpsertBulk {
+ return u.Update(func(s *GroupUpsert) {
+ s.UpdateImageRateIndependent()
+ })
+}
+
+// SetImageRateMultiplier sets the "image_rate_multiplier" field.
+func (u *GroupUpsertBulk) SetImageRateMultiplier(v float64) *GroupUpsertBulk {
+ return u.Update(func(s *GroupUpsert) {
+ s.SetImageRateMultiplier(v)
+ })
+}
+
+// AddImageRateMultiplier adds v to the "image_rate_multiplier" field.
+func (u *GroupUpsertBulk) AddImageRateMultiplier(v float64) *GroupUpsertBulk {
+ return u.Update(func(s *GroupUpsert) {
+ s.AddImageRateMultiplier(v)
+ })
+}
+
+// UpdateImageRateMultiplier sets the "image_rate_multiplier" field to the value that was provided on create.
+func (u *GroupUpsertBulk) UpdateImageRateMultiplier() *GroupUpsertBulk {
+ return u.Update(func(s *GroupUpsert) {
+ s.UpdateImageRateMultiplier()
+ })
+}
+
// SetImagePrice1k sets the "image_price_1k" field.
func (u *GroupUpsertBulk) SetImagePrice1k(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
diff --git a/backend/ent/group_query.go b/backend/ent/group_query.go
index d4cc4f8df98..5d90341f345 100644
--- a/backend/ent/group_query.go
+++ b/backend/ent/group_query.go
@@ -19,6 +19,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/predicate"
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
@@ -36,6 +37,7 @@ type GroupQuery struct {
withRedeemCodes *RedeemCodeQuery
withSubscriptions *UserSubscriptionQuery
withUsageLogs *UsageLogQuery
+ withPlanGroups *SubscriptionPlanGroupQuery
withAccounts *AccountQuery
withAllowedUsers *UserQuery
withAccountGroups *AccountGroupQuery
@@ -165,6 +167,28 @@ func (_q *GroupQuery) QueryUsageLogs() *UsageLogQuery {
return query
}
+// QueryPlanGroups chains the current query on the "plan_groups" edge.
+func (_q *GroupQuery) QueryPlanGroups() *SubscriptionPlanGroupQuery {
+ query := (&SubscriptionPlanGroupClient{config: _q.config}).Query()
+ query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
+ if err := _q.prepareQuery(ctx); err != nil {
+ return nil, err
+ }
+ selector := _q.sqlQuery(ctx)
+ if err := selector.Err(); err != nil {
+ return nil, err
+ }
+ step := sqlgraph.NewStep(
+ sqlgraph.From(group.Table, group.FieldID, selector),
+ sqlgraph.To(subscriptionplangroup.Table, subscriptionplangroup.FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, group.PlanGroupsTable, group.PlanGroupsColumn),
+ )
+ fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
+ return fromU, nil
+ }
+ return query
+}
+
// QueryAccounts chains the current query on the "accounts" edge.
func (_q *GroupQuery) QueryAccounts() *AccountQuery {
query := (&AccountClient{config: _q.config}).Query()
@@ -449,6 +473,7 @@ func (_q *GroupQuery) Clone() *GroupQuery {
withRedeemCodes: _q.withRedeemCodes.Clone(),
withSubscriptions: _q.withSubscriptions.Clone(),
withUsageLogs: _q.withUsageLogs.Clone(),
+ withPlanGroups: _q.withPlanGroups.Clone(),
withAccounts: _q.withAccounts.Clone(),
withAllowedUsers: _q.withAllowedUsers.Clone(),
withAccountGroups: _q.withAccountGroups.Clone(),
@@ -503,6 +528,17 @@ func (_q *GroupQuery) WithUsageLogs(opts ...func(*UsageLogQuery)) *GroupQuery {
return _q
}
+// WithPlanGroups tells the query-builder to eager-load the nodes that are connected to
+// the "plan_groups" edge. The optional arguments are used to configure the query builder of the edge.
+func (_q *GroupQuery) WithPlanGroups(opts ...func(*SubscriptionPlanGroupQuery)) *GroupQuery {
+ query := (&SubscriptionPlanGroupClient{config: _q.config}).Query()
+ for _, opt := range opts {
+ opt(query)
+ }
+ _q.withPlanGroups = query
+ return _q
+}
+
// WithAccounts tells the query-builder to eager-load the nodes that are connected to
// the "accounts" edge. The optional arguments are used to configure the query builder of the edge.
func (_q *GroupQuery) WithAccounts(opts ...func(*AccountQuery)) *GroupQuery {
@@ -625,11 +661,12 @@ func (_q *GroupQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Group,
var (
nodes = []*Group{}
_spec = _q.querySpec()
- loadedTypes = [8]bool{
+ loadedTypes = [9]bool{
_q.withAPIKeys != nil,
_q.withRedeemCodes != nil,
_q.withSubscriptions != nil,
_q.withUsageLogs != nil,
+ _q.withPlanGroups != nil,
_q.withAccounts != nil,
_q.withAllowedUsers != nil,
_q.withAccountGroups != nil,
@@ -685,6 +722,13 @@ func (_q *GroupQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Group,
return nil, err
}
}
+ if query := _q.withPlanGroups; query != nil {
+ if err := _q.loadPlanGroups(ctx, query, nodes,
+ func(n *Group) { n.Edges.PlanGroups = []*SubscriptionPlanGroup{} },
+ func(n *Group, e *SubscriptionPlanGroup) { n.Edges.PlanGroups = append(n.Edges.PlanGroups, e) }); err != nil {
+ return nil, err
+ }
+ }
if query := _q.withAccounts; query != nil {
if err := _q.loadAccounts(ctx, query, nodes,
func(n *Group) { n.Edges.Accounts = []*Account{} },
@@ -804,9 +848,12 @@ func (_q *GroupQuery) loadSubscriptions(ctx context.Context, query *UserSubscrip
}
for _, n := range neighbors {
fk := n.GroupID
- node, ok := nodeids[fk]
+ if fk == nil {
+ return fmt.Errorf(`foreign-key "group_id" is nil for node %v`, n.ID)
+ }
+ node, ok := nodeids[*fk]
if !ok {
- return fmt.Errorf(`unexpected referenced foreign-key "group_id" returned %v for node %v`, fk, n.ID)
+ return fmt.Errorf(`unexpected referenced foreign-key "group_id" returned %v for node %v`, *fk, n.ID)
}
assign(node, n)
}
@@ -845,6 +892,36 @@ func (_q *GroupQuery) loadUsageLogs(ctx context.Context, query *UsageLogQuery, n
}
return nil
}
+func (_q *GroupQuery) loadPlanGroups(ctx context.Context, query *SubscriptionPlanGroupQuery, nodes []*Group, init func(*Group), assign func(*Group, *SubscriptionPlanGroup)) error {
+ fks := make([]driver.Value, 0, len(nodes))
+ nodeids := make(map[int64]*Group)
+ for i := range nodes {
+ fks = append(fks, nodes[i].ID)
+ nodeids[nodes[i].ID] = nodes[i]
+ if init != nil {
+ init(nodes[i])
+ }
+ }
+ if len(query.ctx.Fields) > 0 {
+ query.ctx.AppendFieldOnce(subscriptionplangroup.FieldGroupID)
+ }
+ query.Where(predicate.SubscriptionPlanGroup(func(s *sql.Selector) {
+ s.Where(sql.InValues(s.C(group.PlanGroupsColumn), fks...))
+ }))
+ neighbors, err := query.All(ctx)
+ if err != nil {
+ return err
+ }
+ for _, n := range neighbors {
+ fk := n.GroupID
+ node, ok := nodeids[fk]
+ if !ok {
+ return fmt.Errorf(`unexpected referenced foreign-key "group_id" returned %v for node %v`, fk, n.ID)
+ }
+ assign(node, n)
+ }
+ return nil
+}
func (_q *GroupQuery) loadAccounts(ctx context.Context, query *AccountQuery, nodes []*Group, init func(*Group), assign func(*Group, *Account)) error {
edgeIDs := make([]driver.Value, len(nodes))
byID := make(map[int64]*Group)
diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go
index cc14f897d62..0aec3898f10 100644
--- a/backend/ent/group_update.go
+++ b/backend/ent/group_update.go
@@ -17,6 +17,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/predicate"
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
@@ -275,6 +276,55 @@ func (_u *GroupUpdate) AddDefaultValidityDays(v int) *GroupUpdate {
return _u
}
+// SetAllowImageGeneration sets the "allow_image_generation" field.
+func (_u *GroupUpdate) SetAllowImageGeneration(v bool) *GroupUpdate {
+ _u.mutation.SetAllowImageGeneration(v)
+ return _u
+}
+
+// SetNillableAllowImageGeneration sets the "allow_image_generation" field if the given value is not nil.
+func (_u *GroupUpdate) SetNillableAllowImageGeneration(v *bool) *GroupUpdate {
+ if v != nil {
+ _u.SetAllowImageGeneration(*v)
+ }
+ return _u
+}
+
+// SetImageRateIndependent sets the "image_rate_independent" field.
+func (_u *GroupUpdate) SetImageRateIndependent(v bool) *GroupUpdate {
+ _u.mutation.SetImageRateIndependent(v)
+ return _u
+}
+
+// SetNillableImageRateIndependent sets the "image_rate_independent" field if the given value is not nil.
+func (_u *GroupUpdate) SetNillableImageRateIndependent(v *bool) *GroupUpdate {
+ if v != nil {
+ _u.SetImageRateIndependent(*v)
+ }
+ return _u
+}
+
+// SetImageRateMultiplier sets the "image_rate_multiplier" field.
+func (_u *GroupUpdate) SetImageRateMultiplier(v float64) *GroupUpdate {
+ _u.mutation.ResetImageRateMultiplier()
+ _u.mutation.SetImageRateMultiplier(v)
+ return _u
+}
+
+// SetNillableImageRateMultiplier sets the "image_rate_multiplier" field if the given value is not nil.
+func (_u *GroupUpdate) SetNillableImageRateMultiplier(v *float64) *GroupUpdate {
+ if v != nil {
+ _u.SetImageRateMultiplier(*v)
+ }
+ return _u
+}
+
+// AddImageRateMultiplier adds value to the "image_rate_multiplier" field.
+func (_u *GroupUpdate) AddImageRateMultiplier(v float64) *GroupUpdate {
+ _u.mutation.AddImageRateMultiplier(v)
+ return _u
+}
+
// SetImagePrice1k sets the "image_price_1k" field.
func (_u *GroupUpdate) SetImagePrice1k(v float64) *GroupUpdate {
_u.mutation.ResetImagePrice1k()
@@ -648,6 +698,21 @@ func (_u *GroupUpdate) AddUsageLogs(v ...*UsageLog) *GroupUpdate {
return _u.AddUsageLogIDs(ids...)
}
+// AddPlanGroupIDs adds the "plan_groups" edge to the SubscriptionPlanGroup entity by IDs.
+func (_u *GroupUpdate) AddPlanGroupIDs(ids ...int64) *GroupUpdate {
+ _u.mutation.AddPlanGroupIDs(ids...)
+ return _u
+}
+
+// AddPlanGroups adds the "plan_groups" edges to the SubscriptionPlanGroup entity.
+func (_u *GroupUpdate) AddPlanGroups(v ...*SubscriptionPlanGroup) *GroupUpdate {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.AddPlanGroupIDs(ids...)
+}
+
// AddAccountIDs adds the "accounts" edge to the Account entity by IDs.
func (_u *GroupUpdate) AddAccountIDs(ids ...int64) *GroupUpdate {
_u.mutation.AddAccountIDs(ids...)
@@ -767,6 +832,27 @@ func (_u *GroupUpdate) RemoveUsageLogs(v ...*UsageLog) *GroupUpdate {
return _u.RemoveUsageLogIDs(ids...)
}
+// ClearPlanGroups clears all "plan_groups" edges to the SubscriptionPlanGroup entity.
+func (_u *GroupUpdate) ClearPlanGroups() *GroupUpdate {
+ _u.mutation.ClearPlanGroups()
+ return _u
+}
+
+// RemovePlanGroupIDs removes the "plan_groups" edge to SubscriptionPlanGroup entities by IDs.
+func (_u *GroupUpdate) RemovePlanGroupIDs(ids ...int64) *GroupUpdate {
+ _u.mutation.RemovePlanGroupIDs(ids...)
+ return _u
+}
+
+// RemovePlanGroups removes "plan_groups" edges to SubscriptionPlanGroup entities.
+func (_u *GroupUpdate) RemovePlanGroups(v ...*SubscriptionPlanGroup) *GroupUpdate {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.RemovePlanGroupIDs(ids...)
+}
+
// ClearAccounts clears all "accounts" edges to the Account entity.
func (_u *GroupUpdate) ClearAccounts() *GroupUpdate {
_u.mutation.ClearAccounts()
@@ -962,6 +1048,18 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.AddedDefaultValidityDays(); ok {
_spec.AddField(group.FieldDefaultValidityDays, field.TypeInt, value)
}
+ if value, ok := _u.mutation.AllowImageGeneration(); ok {
+ _spec.SetField(group.FieldAllowImageGeneration, field.TypeBool, value)
+ }
+ if value, ok := _u.mutation.ImageRateIndependent(); ok {
+ _spec.SetField(group.FieldImageRateIndependent, field.TypeBool, value)
+ }
+ if value, ok := _u.mutation.ImageRateMultiplier(); ok {
+ _spec.SetField(group.FieldImageRateMultiplier, field.TypeFloat64, value)
+ }
+ if value, ok := _u.mutation.AddedImageRateMultiplier(); ok {
+ _spec.AddField(group.FieldImageRateMultiplier, field.TypeFloat64, value)
+ }
if value, ok := _u.mutation.ImagePrice1k(); ok {
_spec.SetField(group.FieldImagePrice1k, field.TypeFloat64, value)
}
@@ -1237,6 +1335,51 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
+ if _u.mutation.PlanGroupsCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: group.PlanGroupsTable,
+ Columns: []string{group.PlanGroupsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.RemovedPlanGroupsIDs(); len(nodes) > 0 && !_u.mutation.PlanGroupsCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: group.PlanGroupsTable,
+ Columns: []string{group.PlanGroupsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.PlanGroupsIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: group.PlanGroupsTable,
+ Columns: []string{group.PlanGroupsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
if _u.mutation.AccountsCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
@@ -1610,6 +1753,55 @@ func (_u *GroupUpdateOne) AddDefaultValidityDays(v int) *GroupUpdateOne {
return _u
}
+// SetAllowImageGeneration sets the "allow_image_generation" field.
+func (_u *GroupUpdateOne) SetAllowImageGeneration(v bool) *GroupUpdateOne {
+ _u.mutation.SetAllowImageGeneration(v)
+ return _u
+}
+
+// SetNillableAllowImageGeneration sets the "allow_image_generation" field if the given value is not nil.
+func (_u *GroupUpdateOne) SetNillableAllowImageGeneration(v *bool) *GroupUpdateOne {
+ if v != nil {
+ _u.SetAllowImageGeneration(*v)
+ }
+ return _u
+}
+
+// SetImageRateIndependent sets the "image_rate_independent" field.
+func (_u *GroupUpdateOne) SetImageRateIndependent(v bool) *GroupUpdateOne {
+ _u.mutation.SetImageRateIndependent(v)
+ return _u
+}
+
+// SetNillableImageRateIndependent sets the "image_rate_independent" field if the given value is not nil.
+func (_u *GroupUpdateOne) SetNillableImageRateIndependent(v *bool) *GroupUpdateOne {
+ if v != nil {
+ _u.SetImageRateIndependent(*v)
+ }
+ return _u
+}
+
+// SetImageRateMultiplier sets the "image_rate_multiplier" field.
+func (_u *GroupUpdateOne) SetImageRateMultiplier(v float64) *GroupUpdateOne {
+ _u.mutation.ResetImageRateMultiplier()
+ _u.mutation.SetImageRateMultiplier(v)
+ return _u
+}
+
+// SetNillableImageRateMultiplier sets the "image_rate_multiplier" field if the given value is not nil.
+func (_u *GroupUpdateOne) SetNillableImageRateMultiplier(v *float64) *GroupUpdateOne {
+ if v != nil {
+ _u.SetImageRateMultiplier(*v)
+ }
+ return _u
+}
+
+// AddImageRateMultiplier adds value to the "image_rate_multiplier" field.
+func (_u *GroupUpdateOne) AddImageRateMultiplier(v float64) *GroupUpdateOne {
+ _u.mutation.AddImageRateMultiplier(v)
+ return _u
+}
+
// SetImagePrice1k sets the "image_price_1k" field.
func (_u *GroupUpdateOne) SetImagePrice1k(v float64) *GroupUpdateOne {
_u.mutation.ResetImagePrice1k()
@@ -1983,6 +2175,21 @@ func (_u *GroupUpdateOne) AddUsageLogs(v ...*UsageLog) *GroupUpdateOne {
return _u.AddUsageLogIDs(ids...)
}
+// AddPlanGroupIDs adds the "plan_groups" edge to the SubscriptionPlanGroup entity by IDs.
+func (_u *GroupUpdateOne) AddPlanGroupIDs(ids ...int64) *GroupUpdateOne {
+ _u.mutation.AddPlanGroupIDs(ids...)
+ return _u
+}
+
+// AddPlanGroups adds the "plan_groups" edges to the SubscriptionPlanGroup entity.
+func (_u *GroupUpdateOne) AddPlanGroups(v ...*SubscriptionPlanGroup) *GroupUpdateOne {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.AddPlanGroupIDs(ids...)
+}
+
// AddAccountIDs adds the "accounts" edge to the Account entity by IDs.
func (_u *GroupUpdateOne) AddAccountIDs(ids ...int64) *GroupUpdateOne {
_u.mutation.AddAccountIDs(ids...)
@@ -2102,6 +2309,27 @@ func (_u *GroupUpdateOne) RemoveUsageLogs(v ...*UsageLog) *GroupUpdateOne {
return _u.RemoveUsageLogIDs(ids...)
}
+// ClearPlanGroups clears all "plan_groups" edges to the SubscriptionPlanGroup entity.
+func (_u *GroupUpdateOne) ClearPlanGroups() *GroupUpdateOne {
+ _u.mutation.ClearPlanGroups()
+ return _u
+}
+
+// RemovePlanGroupIDs removes the "plan_groups" edge to SubscriptionPlanGroup entities by IDs.
+func (_u *GroupUpdateOne) RemovePlanGroupIDs(ids ...int64) *GroupUpdateOne {
+ _u.mutation.RemovePlanGroupIDs(ids...)
+ return _u
+}
+
+// RemovePlanGroups removes "plan_groups" edges to SubscriptionPlanGroup entities.
+func (_u *GroupUpdateOne) RemovePlanGroups(v ...*SubscriptionPlanGroup) *GroupUpdateOne {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.RemovePlanGroupIDs(ids...)
+}
+
// ClearAccounts clears all "accounts" edges to the Account entity.
func (_u *GroupUpdateOne) ClearAccounts() *GroupUpdateOne {
_u.mutation.ClearAccounts()
@@ -2327,6 +2555,18 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if value, ok := _u.mutation.AddedDefaultValidityDays(); ok {
_spec.AddField(group.FieldDefaultValidityDays, field.TypeInt, value)
}
+ if value, ok := _u.mutation.AllowImageGeneration(); ok {
+ _spec.SetField(group.FieldAllowImageGeneration, field.TypeBool, value)
+ }
+ if value, ok := _u.mutation.ImageRateIndependent(); ok {
+ _spec.SetField(group.FieldImageRateIndependent, field.TypeBool, value)
+ }
+ if value, ok := _u.mutation.ImageRateMultiplier(); ok {
+ _spec.SetField(group.FieldImageRateMultiplier, field.TypeFloat64, value)
+ }
+ if value, ok := _u.mutation.AddedImageRateMultiplier(); ok {
+ _spec.AddField(group.FieldImageRateMultiplier, field.TypeFloat64, value)
+ }
if value, ok := _u.mutation.ImagePrice1k(); ok {
_spec.SetField(group.FieldImagePrice1k, field.TypeFloat64, value)
}
@@ -2602,6 +2842,51 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
+ if _u.mutation.PlanGroupsCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: group.PlanGroupsTable,
+ Columns: []string{group.PlanGroupsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.RemovedPlanGroupsIDs(); len(nodes) > 0 && !_u.mutation.PlanGroupsCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: group.PlanGroupsTable,
+ Columns: []string{group.PlanGroupsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.PlanGroupsIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: group.PlanGroupsTable,
+ Columns: []string{group.PlanGroupsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
if _u.mutation.AccountsCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
diff --git a/backend/ent/hook/hook.go b/backend/ent/hook/hook.go
index 414eba242c6..15ccb0ab0c1 100644
--- a/backend/ent/hook/hook.go
+++ b/backend/ent/hook/hook.go
@@ -321,6 +321,30 @@ func (f SubscriptionPlanFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.V
return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.SubscriptionPlanMutation", m)
}
+// The SubscriptionPlanGroupFunc type is an adapter to allow the use of ordinary
+// function as SubscriptionPlanGroup mutator.
+type SubscriptionPlanGroupFunc func(context.Context, *ent.SubscriptionPlanGroupMutation) (ent.Value, error)
+
+// Mutate calls f(ctx, m).
+func (f SubscriptionPlanGroupFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) {
+ if mv, ok := m.(*ent.SubscriptionPlanGroupMutation); ok {
+ return f(ctx, mv)
+ }
+ return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.SubscriptionPlanGroupMutation", m)
+}
+
+// The SubscriptionWalletLedgerFunc type is an adapter to allow the use of ordinary
+// function as SubscriptionWalletLedger mutator.
+type SubscriptionWalletLedgerFunc func(context.Context, *ent.SubscriptionWalletLedgerMutation) (ent.Value, error)
+
+// Mutate calls f(ctx, m).
+func (f SubscriptionWalletLedgerFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) {
+ if mv, ok := m.(*ent.SubscriptionWalletLedgerMutation); ok {
+ return f(ctx, mv)
+ }
+ return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.SubscriptionWalletLedgerMutation", m)
+}
+
// The TLSFingerprintProfileFunc type is an adapter to allow the use of ordinary
// function as TLSFingerprintProfile mutator.
type TLSFingerprintProfileFunc func(context.Context, *ent.TLSFingerprintProfileMutation) (ent.Value, error)
diff --git a/backend/ent/intercept/intercept.go b/backend/ent/intercept/intercept.go
index 95b68e097a3..217ce92f6eb 100644
--- a/backend/ent/intercept/intercept.go
+++ b/backend/ent/intercept/intercept.go
@@ -35,6 +35,8 @@ import (
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
"github.com/Wei-Shaw/sub2api/ent/setting"
"github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
"github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
@@ -803,6 +805,60 @@ func (f TraverseSubscriptionPlan) Traverse(ctx context.Context, q ent.Query) err
return fmt.Errorf("unexpected query type %T. expect *ent.SubscriptionPlanQuery", q)
}
+// The SubscriptionPlanGroupFunc type is an adapter to allow the use of ordinary function as a Querier.
+type SubscriptionPlanGroupFunc func(context.Context, *ent.SubscriptionPlanGroupQuery) (ent.Value, error)
+
+// Query calls f(ctx, q).
+func (f SubscriptionPlanGroupFunc) Query(ctx context.Context, q ent.Query) (ent.Value, error) {
+ if q, ok := q.(*ent.SubscriptionPlanGroupQuery); ok {
+ return f(ctx, q)
+ }
+ return nil, fmt.Errorf("unexpected query type %T. expect *ent.SubscriptionPlanGroupQuery", q)
+}
+
+// The TraverseSubscriptionPlanGroup type is an adapter to allow the use of ordinary function as Traverser.
+type TraverseSubscriptionPlanGroup func(context.Context, *ent.SubscriptionPlanGroupQuery) error
+
+// Intercept is a dummy implementation of Intercept that returns the next Querier in the pipeline.
+func (f TraverseSubscriptionPlanGroup) Intercept(next ent.Querier) ent.Querier {
+ return next
+}
+
+// Traverse calls f(ctx, q).
+func (f TraverseSubscriptionPlanGroup) Traverse(ctx context.Context, q ent.Query) error {
+ if q, ok := q.(*ent.SubscriptionPlanGroupQuery); ok {
+ return f(ctx, q)
+ }
+ return fmt.Errorf("unexpected query type %T. expect *ent.SubscriptionPlanGroupQuery", q)
+}
+
+// The SubscriptionWalletLedgerFunc type is an adapter to allow the use of ordinary function as a Querier.
+type SubscriptionWalletLedgerFunc func(context.Context, *ent.SubscriptionWalletLedgerQuery) (ent.Value, error)
+
+// Query calls f(ctx, q).
+func (f SubscriptionWalletLedgerFunc) Query(ctx context.Context, q ent.Query) (ent.Value, error) {
+ if q, ok := q.(*ent.SubscriptionWalletLedgerQuery); ok {
+ return f(ctx, q)
+ }
+ return nil, fmt.Errorf("unexpected query type %T. expect *ent.SubscriptionWalletLedgerQuery", q)
+}
+
+// The TraverseSubscriptionWalletLedger type is an adapter to allow the use of ordinary function as Traverser.
+type TraverseSubscriptionWalletLedger func(context.Context, *ent.SubscriptionWalletLedgerQuery) error
+
+// Intercept is a dummy implementation of Intercept that returns the next Querier in the pipeline.
+func (f TraverseSubscriptionWalletLedger) Intercept(next ent.Querier) ent.Querier {
+ return next
+}
+
+// Traverse calls f(ctx, q).
+func (f TraverseSubscriptionWalletLedger) Traverse(ctx context.Context, q ent.Query) error {
+ if q, ok := q.(*ent.SubscriptionWalletLedgerQuery); ok {
+ return f(ctx, q)
+ }
+ return fmt.Errorf("unexpected query type %T. expect *ent.SubscriptionWalletLedgerQuery", q)
+}
+
// The TLSFingerprintProfileFunc type is an adapter to allow the use of ordinary function as a Querier.
type TLSFingerprintProfileFunc func(context.Context, *ent.TLSFingerprintProfileQuery) (ent.Value, error)
@@ -1074,6 +1130,10 @@ func NewQuery(q ent.Query) (Query, error) {
return &query[*ent.SettingQuery, predicate.Setting, setting.OrderOption]{typ: ent.TypeSetting, tq: q}, nil
case *ent.SubscriptionPlanQuery:
return &query[*ent.SubscriptionPlanQuery, predicate.SubscriptionPlan, subscriptionplan.OrderOption]{typ: ent.TypeSubscriptionPlan, tq: q}, nil
+ case *ent.SubscriptionPlanGroupQuery:
+ return &query[*ent.SubscriptionPlanGroupQuery, predicate.SubscriptionPlanGroup, subscriptionplangroup.OrderOption]{typ: ent.TypeSubscriptionPlanGroup, tq: q}, nil
+ case *ent.SubscriptionWalletLedgerQuery:
+ return &query[*ent.SubscriptionWalletLedgerQuery, predicate.SubscriptionWalletLedger, subscriptionwalletledger.OrderOption]{typ: ent.TypeSubscriptionWalletLedger, tq: q}, nil
case *ent.TLSFingerprintProfileQuery:
return &query[*ent.TLSFingerprintProfileQuery, predicate.TLSFingerprintProfile, tlsfingerprintprofile.OrderOption]{typ: ent.TypeTLSFingerprintProfile, tq: q}, nil
case *ent.UsageCleanupTaskQuery:
diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go
index 178ae170846..639e0a9669e 100644
--- a/backend/ent/migrate/schema.go
+++ b/backend/ent/migrate/schema.go
@@ -15,8 +15,11 @@ var (
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
- {Name: "key", Type: field.TypeString, Unique: true, Size: 128},
+ {Name: "key", Type: field.TypeString, Unique: true, Size: 512},
+ {Name: "key_hash", Type: field.TypeString, Nullable: true, Size: 64},
+ {Name: "key_prefix", Type: field.TypeString, Size: 16, Default: ""},
{Name: "name", Type: field.TypeString, Size: 100},
+ {Name: "purpose", Type: field.TypeString, Size: 32, Default: "standard"},
{Name: "status", Type: field.TypeString, Size: 20, Default: "active"},
{Name: "last_used_at", Type: field.TypeTime, Nullable: true},
{Name: "ip_whitelist", Type: field.TypeJSON, Nullable: true},
@@ -44,32 +47,48 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "api_keys_groups_api_keys",
- Columns: []*schema.Column{APIKeysColumns[22]},
+ Columns: []*schema.Column{APIKeysColumns[25]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "api_keys_users_api_keys",
- Columns: []*schema.Column{APIKeysColumns[23]},
+ Columns: []*schema.Column{APIKeysColumns[26]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.NoAction,
},
},
Indexes: []*schema.Index{
+ {
+ Name: "apikey_key_hash",
+ Unique: true,
+ Columns: []*schema.Column{APIKeysColumns[5]},
+ Annotation: &entsql.IndexAnnotation{
+ Where: "deleted_at IS NULL AND key_hash IS NOT NULL",
+ },
+ },
{
Name: "apikey_user_id",
Unique: false,
- Columns: []*schema.Column{APIKeysColumns[23]},
+ Columns: []*schema.Column{APIKeysColumns[26]},
+ },
+ {
+ Name: "apikey_user_id_purpose",
+ Unique: true,
+ Columns: []*schema.Column{APIKeysColumns[26], APIKeysColumns[8]},
+ Annotation: &entsql.IndexAnnotation{
+ Where: "deleted_at IS NULL AND purpose = 'wallet_universal'",
+ },
},
{
Name: "apikey_group_id",
Unique: false,
- Columns: []*schema.Column{APIKeysColumns[22]},
+ Columns: []*schema.Column{APIKeysColumns[25]},
},
{
Name: "apikey_status",
Unique: false,
- Columns: []*schema.Column{APIKeysColumns[6]},
+ Columns: []*schema.Column{APIKeysColumns[9]},
},
{
Name: "apikey_deleted_at",
@@ -79,17 +98,17 @@ var (
{
Name: "apikey_last_used_at",
Unique: false,
- Columns: []*schema.Column{APIKeysColumns[7]},
+ Columns: []*schema.Column{APIKeysColumns[10]},
},
{
Name: "apikey_quota_quota_used",
Unique: false,
- Columns: []*schema.Column{APIKeysColumns[10], APIKeysColumns[11]},
+ Columns: []*schema.Column{APIKeysColumns[13], APIKeysColumns[14]},
},
{
Name: "apikey_expires_at",
Unique: false,
- Columns: []*schema.Column{APIKeysColumns[12]},
+ Columns: []*schema.Column{APIKeysColumns[15]},
},
},
}
@@ -598,7 +617,7 @@ var (
{Name: "platforms", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "passthrough_code", Type: field.TypeBool, Default: true},
{Name: "response_code", Type: field.TypeInt, Nullable: true},
- {Name: "passthrough_body", Type: field.TypeBool, Default: true},
+ {Name: "passthrough_body", Type: field.TypeBool, Default: false},
{Name: "custom_message", Type: field.TypeString, Nullable: true, Size: 2147483647},
{Name: "skip_monitoring", Type: field.TypeBool, Default: false},
{Name: "description", Type: field.TypeString, Nullable: true, Size: 2147483647},
@@ -638,6 +657,9 @@ var (
{Name: "weekly_limit_usd", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "monthly_limit_usd", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "default_validity_days", Type: field.TypeInt, Default: 30},
+ {Name: "allow_image_generation", Type: field.TypeBool, Default: true},
+ {Name: "image_rate_independent", Type: field.TypeBool, Default: false},
+ {Name: "image_rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
{Name: "image_price_1k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "image_price_2k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "image_price_4k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
@@ -690,7 +712,7 @@ var (
{
Name: "group_sort_order",
Unique: false,
- Columns: []*schema.Column{GroupsColumns[25]},
+ Columns: []*schema.Column{GroupsColumns[28]},
},
},
}
@@ -897,6 +919,14 @@ var (
Unique: false,
Columns: []*schema.Column{PaymentOrdersColumns[14]},
},
+ {
+ Name: "paymentorder_provider_instance_id_created_at",
+ Unique: false,
+ Columns: []*schema.Column{PaymentOrdersColumns[18], PaymentOrdersColumns[37]},
+ Annotation: &entsql.IndexAnnotation{
+ Where: "provider_instance_id IS NOT NULL",
+ },
+ },
},
}
// PaymentProviderInstancesColumns holds the columns for the "payment_provider_instances" table.
@@ -1086,7 +1116,7 @@ var (
{Name: "host", Type: field.TypeString, Size: 255},
{Name: "port", Type: field.TypeInt},
{Name: "username", Type: field.TypeString, Nullable: true, Size: 100},
- {Name: "password", Type: field.TypeString, Nullable: true, Size: 100},
+ {Name: "password", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}},
{Name: "status", Type: field.TypeString, Size: 20, Default: "active"},
}
// ProxiesTable holds the schema information for the "proxies" table.
@@ -1118,6 +1148,7 @@ var (
{Name: "notes", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}},
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "validity_days", Type: field.TypeInt, Default: 30},
+ {Name: "plan_id", Type: field.TypeInt64, Nullable: true},
{Name: "group_id", Type: field.TypeInt64, Nullable: true},
{Name: "used_by", Type: field.TypeInt64, Nullable: true},
}
@@ -1129,13 +1160,13 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "redeem_codes_groups_redeem_codes",
- Columns: []*schema.Column{RedeemCodesColumns[9]},
+ Columns: []*schema.Column{RedeemCodesColumns[10]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "redeem_codes_users_redeem_codes",
- Columns: []*schema.Column{RedeemCodesColumns[10]},
+ Columns: []*schema.Column{RedeemCodesColumns[11]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.SetNull,
},
@@ -1149,12 +1180,12 @@ var (
{
Name: "redeemcode_used_by",
Unique: false,
- Columns: []*schema.Column{RedeemCodesColumns[10]},
+ Columns: []*schema.Column{RedeemCodesColumns[11]},
},
{
Name: "redeemcode_group_id",
Unique: false,
- Columns: []*schema.Column{RedeemCodesColumns[9]},
+ Columns: []*schema.Column{RedeemCodesColumns[10]},
},
},
}
@@ -1188,7 +1219,9 @@ var (
// SubscriptionPlansColumns holds the columns for the "subscription_plans" table.
SubscriptionPlansColumns = []*schema.Column{
{Name: "id", Type: field.TypeInt64, Increment: true},
- {Name: "group_id", Type: field.TypeInt64},
+ {Name: "group_id", Type: field.TypeInt64, Nullable: true},
+ {Name: "wallet_quota_usd", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
+ {Name: "plan_type", Type: field.TypeString, Size: 16, Default: "subscription"},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "description", Type: field.TypeString, Default: "", SchemaType: map[string]string{"postgres": "text"}},
{Name: "price", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,2)"}},
@@ -1216,7 +1249,110 @@ var (
{
Name: "subscriptionplan_for_sale",
Unique: false,
- Columns: []*schema.Column{SubscriptionPlansColumns[10]},
+ Columns: []*schema.Column{SubscriptionPlansColumns[12]},
+ },
+ },
+ }
+ // SubscriptionPlanGroupsColumns holds the columns for the "subscription_plan_groups" table.
+ SubscriptionPlanGroupsColumns = []*schema.Column{
+ {Name: "id", Type: field.TypeInt64, Increment: true},
+ {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
+ {Name: "group_id", Type: field.TypeInt64},
+ {Name: "plan_id", Type: field.TypeInt64},
+ }
+ // SubscriptionPlanGroupsTable holds the schema information for the "subscription_plan_groups" table.
+ SubscriptionPlanGroupsTable = &schema.Table{
+ Name: "subscription_plan_groups",
+ Columns: SubscriptionPlanGroupsColumns,
+ PrimaryKey: []*schema.Column{SubscriptionPlanGroupsColumns[0]},
+ ForeignKeys: []*schema.ForeignKey{
+ {
+ Symbol: "subscription_plan_groups_groups_plan_groups",
+ Columns: []*schema.Column{SubscriptionPlanGroupsColumns[2]},
+ RefColumns: []*schema.Column{GroupsColumns[0]},
+ OnDelete: schema.NoAction,
+ },
+ {
+ Symbol: "subscription_plan_groups_subscription_plans_plan_groups",
+ Columns: []*schema.Column{SubscriptionPlanGroupsColumns[3]},
+ RefColumns: []*schema.Column{SubscriptionPlansColumns[0]},
+ OnDelete: schema.NoAction,
+ },
+ },
+ Indexes: []*schema.Index{
+ {
+ Name: "subscriptionplangroup_plan_id_group_id",
+ Unique: true,
+ Columns: []*schema.Column{SubscriptionPlanGroupsColumns[3], SubscriptionPlanGroupsColumns[2]},
+ },
+ {
+ Name: "subscriptionplangroup_group_id",
+ Unique: false,
+ Columns: []*schema.Column{SubscriptionPlanGroupsColumns[2]},
+ },
+ },
+ }
+ // SubscriptionWalletLedgerColumns holds the columns for the "subscription_wallet_ledger" table.
+ SubscriptionWalletLedgerColumns = []*schema.Column{
+ {Name: "id", Type: field.TypeInt64, Increment: true},
+ {Name: "delta_usd", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,10)"}},
+ {Name: "balance_after", Type: field.TypeFloat64, SchemaType: map[string]string{"postgres": "decimal(20,10)"}},
+ {Name: "reason", Type: field.TypeString, Size: 32},
+ {Name: "payment_order_id", Type: field.TypeInt64, Nullable: true},
+ {Name: "notes", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}},
+ {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
+ {Name: "usage_log_id", Type: field.TypeInt64, Nullable: true},
+ {Name: "operator_id", Type: field.TypeInt64, Nullable: true},
+ {Name: "subscription_id", Type: field.TypeInt64},
+ }
+ // SubscriptionWalletLedgerTable holds the schema information for the "subscription_wallet_ledger" table.
+ SubscriptionWalletLedgerTable = &schema.Table{
+ Name: "subscription_wallet_ledger",
+ Columns: SubscriptionWalletLedgerColumns,
+ PrimaryKey: []*schema.Column{SubscriptionWalletLedgerColumns[0]},
+ ForeignKeys: []*schema.ForeignKey{
+ {
+ Symbol: "subscription_wallet_ledger_usage_logs_wallet_ledger_entries",
+ Columns: []*schema.Column{SubscriptionWalletLedgerColumns[7]},
+ RefColumns: []*schema.Column{UsageLogsColumns[0]},
+ OnDelete: schema.SetNull,
+ },
+ {
+ Symbol: "subscription_wallet_ledger_users_wallet_ledger_operations",
+ Columns: []*schema.Column{SubscriptionWalletLedgerColumns[8]},
+ RefColumns: []*schema.Column{UsersColumns[0]},
+ OnDelete: schema.SetNull,
+ },
+ {
+ Symbol: "subscription_wallet_ledger_user_subscriptions_wallet_ledger_entries",
+ Columns: []*schema.Column{SubscriptionWalletLedgerColumns[9]},
+ RefColumns: []*schema.Column{UserSubscriptionsColumns[0]},
+ OnDelete: schema.NoAction,
+ },
+ },
+ Indexes: []*schema.Index{
+ {
+ Name: "subscriptionwalletledger_subscription_id_created_at",
+ Unique: false,
+ Columns: []*schema.Column{SubscriptionWalletLedgerColumns[9], SubscriptionWalletLedgerColumns[6]},
+ },
+ {
+ Name: "subscriptionwalletledger_usage_log_id",
+ Unique: true,
+ Columns: []*schema.Column{SubscriptionWalletLedgerColumns[7]},
+ Annotation: &entsql.IndexAnnotation{
+ Where: "usage_log_id IS NOT NULL AND reason = 'usage'",
+ },
+ },
+ {
+ Name: "subscriptionwalletledger_payment_order_id",
+ Unique: false,
+ Columns: []*schema.Column{SubscriptionWalletLedgerColumns[4]},
+ },
+ {
+ Name: "subscriptionwalletledger_reason",
+ Unique: false,
+ Columns: []*schema.Column{SubscriptionWalletLedgerColumns[3]},
},
},
}
@@ -1289,6 +1425,10 @@ var (
{Name: "model", Type: field.TypeString, Size: 100},
{Name: "requested_model", Type: field.TypeString, Nullable: true, Size: 100},
{Name: "upstream_model", Type: field.TypeString, Nullable: true, Size: 100},
+ {Name: "billing_model", Type: field.TypeString, Nullable: true, Size: 100},
+ {Name: "pricing_source", Type: field.TypeString, Nullable: true, Size: 50},
+ {Name: "pricing_revision", Type: field.TypeString, Nullable: true, Size: 100},
+ {Name: "pricing_hash", Type: field.TypeString, Nullable: true, Size: 64},
{Name: "channel_id", Type: field.TypeInt64, Nullable: true},
{Name: "model_mapping_chain", Type: field.TypeString, Nullable: true, Size: 500},
{Name: "billing_tier", Type: field.TypeString, Nullable: true, Size: 50},
@@ -1331,31 +1471,31 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "usage_logs_api_keys_usage_logs",
- Columns: []*schema.Column{UsageLogsColumns[33]},
+ Columns: []*schema.Column{UsageLogsColumns[37]},
RefColumns: []*schema.Column{APIKeysColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_accounts_usage_logs",
- Columns: []*schema.Column{UsageLogsColumns[34]},
+ Columns: []*schema.Column{UsageLogsColumns[38]},
RefColumns: []*schema.Column{AccountsColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_groups_usage_logs",
- Columns: []*schema.Column{UsageLogsColumns[35]},
+ Columns: []*schema.Column{UsageLogsColumns[39]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "usage_logs_users_usage_logs",
- Columns: []*schema.Column{UsageLogsColumns[36]},
+ Columns: []*schema.Column{UsageLogsColumns[40]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_user_subscriptions_usage_logs",
- Columns: []*schema.Column{UsageLogsColumns[37]},
+ Columns: []*schema.Column{UsageLogsColumns[41]},
RefColumns: []*schema.Column{UserSubscriptionsColumns[0]},
OnDelete: schema.SetNull,
},
@@ -1364,32 +1504,32 @@ var (
{
Name: "usagelog_user_id",
Unique: false,
- Columns: []*schema.Column{UsageLogsColumns[36]},
+ Columns: []*schema.Column{UsageLogsColumns[40]},
},
{
Name: "usagelog_api_key_id",
Unique: false,
- Columns: []*schema.Column{UsageLogsColumns[33]},
+ Columns: []*schema.Column{UsageLogsColumns[37]},
},
{
Name: "usagelog_account_id",
Unique: false,
- Columns: []*schema.Column{UsageLogsColumns[34]},
+ Columns: []*schema.Column{UsageLogsColumns[38]},
},
{
Name: "usagelog_group_id",
Unique: false,
- Columns: []*schema.Column{UsageLogsColumns[35]},
+ Columns: []*schema.Column{UsageLogsColumns[39]},
},
{
Name: "usagelog_subscription_id",
Unique: false,
- Columns: []*schema.Column{UsageLogsColumns[37]},
+ Columns: []*schema.Column{UsageLogsColumns[41]},
},
{
Name: "usagelog_created_at",
Unique: false,
- Columns: []*schema.Column{UsageLogsColumns[32]},
+ Columns: []*schema.Column{UsageLogsColumns[36]},
},
{
Name: "usagelog_model",
@@ -1409,17 +1549,17 @@ var (
{
Name: "usagelog_user_id_created_at",
Unique: false,
- Columns: []*schema.Column{UsageLogsColumns[36], UsageLogsColumns[32]},
+ Columns: []*schema.Column{UsageLogsColumns[40], UsageLogsColumns[36]},
},
{
Name: "usagelog_api_key_id_created_at",
Unique: false,
- Columns: []*schema.Column{UsageLogsColumns[33], UsageLogsColumns[32]},
+ Columns: []*schema.Column{UsageLogsColumns[37], UsageLogsColumns[36]},
},
{
Name: "usagelog_group_id_created_at",
Unique: false,
- Columns: []*schema.Column{UsageLogsColumns[35], UsageLogsColumns[32]},
+ Columns: []*schema.Column{UsageLogsColumns[39], UsageLogsColumns[36]},
},
},
}
@@ -1449,6 +1589,14 @@ var (
{Name: "balance_notify_extra_emails", Type: field.TypeString, Default: "[]", SchemaType: map[string]string{"postgres": "text"}},
{Name: "total_recharged", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "rpm_limit", Type: field.TypeInt, Default: 0},
+ {Name: "signup_ip", Type: field.TypeString, Size: 64, Default: ""},
+ {Name: "signup_ip_prefix", Type: field.TypeString, Size: 64, Default: ""},
+ {Name: "signup_user_agent_hash", Type: field.TypeString, Size: 64, Default: ""},
+ {Name: "signup_device_fingerprint_hash", Type: field.TypeString, Size: 64, Default: ""},
+ {Name: "trial_bonus_eligible", Type: field.TypeBool, Default: true},
+ {Name: "trial_bonus_hold_reason", Type: field.TypeString, Size: 80, Default: ""},
+ {Name: "trial_bonus_risk_score", Type: field.TypeInt, Default: 0},
+ {Name: "token_version", Type: field.TypeInt64, Default: 0},
}
// UsersTable holds the schema information for the "users" table.
UsersTable = &schema.Table{
@@ -1602,9 +1750,12 @@ var (
{Name: "daily_usage_usd", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,10)"}},
{Name: "weekly_usage_usd", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,10)"}},
{Name: "monthly_usage_usd", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(20,10)"}},
+ {Name: "wallet_balance_usd", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,10)"}},
+ {Name: "wallet_initial_usd", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,10)"}},
+ {Name: "locked_rates", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "assigned_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "notes", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}},
- {Name: "group_id", Type: field.TypeInt64},
+ {Name: "group_id", Type: field.TypeInt64, Nullable: true},
{Name: "user_id", Type: field.TypeInt64},
{Name: "assigned_by", Type: field.TypeInt64, Nullable: true},
}
@@ -1616,19 +1767,19 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "user_subscriptions_groups_subscriptions",
- Columns: []*schema.Column{UserSubscriptionsColumns[15]},
+ Columns: []*schema.Column{UserSubscriptionsColumns[18]},
RefColumns: []*schema.Column{GroupsColumns[0]},
- OnDelete: schema.NoAction,
+ OnDelete: schema.SetNull,
},
{
Symbol: "user_subscriptions_users_subscriptions",
- Columns: []*schema.Column{UserSubscriptionsColumns[16]},
+ Columns: []*schema.Column{UserSubscriptionsColumns[19]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "user_subscriptions_users_assigned_subscriptions",
- Columns: []*schema.Column{UserSubscriptionsColumns[17]},
+ Columns: []*schema.Column{UserSubscriptionsColumns[20]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.SetNull,
},
@@ -1637,12 +1788,12 @@ var (
{
Name: "usersubscription_user_id",
Unique: false,
- Columns: []*schema.Column{UserSubscriptionsColumns[16]},
+ Columns: []*schema.Column{UserSubscriptionsColumns[19]},
},
{
Name: "usersubscription_group_id",
Unique: false,
- Columns: []*schema.Column{UserSubscriptionsColumns[15]},
+ Columns: []*schema.Column{UserSubscriptionsColumns[18]},
},
{
Name: "usersubscription_status",
@@ -1657,17 +1808,17 @@ var (
{
Name: "usersubscription_user_id_status_expires_at",
Unique: false,
- Columns: []*schema.Column{UserSubscriptionsColumns[16], UserSubscriptionsColumns[6], UserSubscriptionsColumns[5]},
+ Columns: []*schema.Column{UserSubscriptionsColumns[19], UserSubscriptionsColumns[6], UserSubscriptionsColumns[5]},
},
{
Name: "usersubscription_assigned_by",
Unique: false,
- Columns: []*schema.Column{UserSubscriptionsColumns[17]},
+ Columns: []*schema.Column{UserSubscriptionsColumns[20]},
},
{
Name: "usersubscription_user_id_group_id",
Unique: false,
- Columns: []*schema.Column{UserSubscriptionsColumns[16], UserSubscriptionsColumns[15]},
+ Columns: []*schema.Column{UserSubscriptionsColumns[19], UserSubscriptionsColumns[18]},
},
{
Name: "usersubscription_deleted_at",
@@ -1704,6 +1855,8 @@ var (
SecuritySecretsTable,
SettingsTable,
SubscriptionPlansTable,
+ SubscriptionPlanGroupsTable,
+ SubscriptionWalletLedgerTable,
TLSFingerprintProfilesTable,
UsageCleanupTasksTable,
UsageLogsTable,
@@ -1814,6 +1967,17 @@ func init() {
SubscriptionPlansTable.Annotation = &entsql.Annotation{
Table: "subscription_plans",
}
+ SubscriptionPlanGroupsTable.ForeignKeys[0].RefTable = GroupsTable
+ SubscriptionPlanGroupsTable.ForeignKeys[1].RefTable = SubscriptionPlansTable
+ SubscriptionPlanGroupsTable.Annotation = &entsql.Annotation{
+ Table: "subscription_plan_groups",
+ }
+ SubscriptionWalletLedgerTable.ForeignKeys[0].RefTable = UsageLogsTable
+ SubscriptionWalletLedgerTable.ForeignKeys[1].RefTable = UsersTable
+ SubscriptionWalletLedgerTable.ForeignKeys[2].RefTable = UserSubscriptionsTable
+ SubscriptionWalletLedgerTable.Annotation = &entsql.Annotation{
+ Table: "subscription_wallet_ledger",
+ }
TLSFingerprintProfilesTable.Annotation = &entsql.Annotation{
Table: "tls_fingerprint_profiles",
}
diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go
index d616e4ae12f..644d7ced9d6 100644
--- a/backend/ent/mutation.go
+++ b/backend/ent/mutation.go
@@ -39,6 +39,8 @@ import (
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
"github.com/Wei-Shaw/sub2api/ent/setting"
"github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
"github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
@@ -85,6 +87,8 @@ const (
TypeSecuritySecret = "SecuritySecret"
TypeSetting = "Setting"
TypeSubscriptionPlan = "SubscriptionPlan"
+ TypeSubscriptionPlanGroup = "SubscriptionPlanGroup"
+ TypeSubscriptionWalletLedger = "SubscriptionWalletLedger"
TypeTLSFingerprintProfile = "TLSFingerprintProfile"
TypeUsageCleanupTask = "UsageCleanupTask"
TypeUsageLog = "UsageLog"
@@ -105,7 +109,10 @@ type APIKeyMutation struct {
updated_at *time.Time
deleted_at *time.Time
key *string
+ key_hash *string
+ key_prefix *string
name *string
+ purpose *string
status *string
last_used_at *time.Time
ip_whitelist *[]string
@@ -436,6 +443,91 @@ func (m *APIKeyMutation) ResetKey() {
m.key = nil
}
+// SetKeyHash sets the "key_hash" field.
+func (m *APIKeyMutation) SetKeyHash(s string) {
+ m.key_hash = &s
+}
+
+// KeyHash returns the value of the "key_hash" field in the mutation.
+func (m *APIKeyMutation) KeyHash() (r string, exists bool) {
+ v := m.key_hash
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldKeyHash returns the old "key_hash" field's value of the APIKey entity.
+// If the APIKey object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *APIKeyMutation) OldKeyHash(ctx context.Context) (v *string, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldKeyHash is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldKeyHash requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldKeyHash: %w", err)
+ }
+ return oldValue.KeyHash, nil
+}
+
+// ClearKeyHash clears the value of the "key_hash" field.
+func (m *APIKeyMutation) ClearKeyHash() {
+ m.key_hash = nil
+ m.clearedFields[apikey.FieldKeyHash] = struct{}{}
+}
+
+// KeyHashCleared returns if the "key_hash" field was cleared in this mutation.
+func (m *APIKeyMutation) KeyHashCleared() bool {
+ _, ok := m.clearedFields[apikey.FieldKeyHash]
+ return ok
+}
+
+// ResetKeyHash resets all changes to the "key_hash" field.
+func (m *APIKeyMutation) ResetKeyHash() {
+ m.key_hash = nil
+ delete(m.clearedFields, apikey.FieldKeyHash)
+}
+
+// SetKeyPrefix sets the "key_prefix" field.
+func (m *APIKeyMutation) SetKeyPrefix(s string) {
+ m.key_prefix = &s
+}
+
+// KeyPrefix returns the value of the "key_prefix" field in the mutation.
+func (m *APIKeyMutation) KeyPrefix() (r string, exists bool) {
+ v := m.key_prefix
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldKeyPrefix returns the old "key_prefix" field's value of the APIKey entity.
+// If the APIKey object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *APIKeyMutation) OldKeyPrefix(ctx context.Context) (v string, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldKeyPrefix is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldKeyPrefix requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldKeyPrefix: %w", err)
+ }
+ return oldValue.KeyPrefix, nil
+}
+
+// ResetKeyPrefix resets all changes to the "key_prefix" field.
+func (m *APIKeyMutation) ResetKeyPrefix() {
+ m.key_prefix = nil
+}
+
// SetName sets the "name" field.
func (m *APIKeyMutation) SetName(s string) {
m.name = &s
@@ -472,6 +564,42 @@ func (m *APIKeyMutation) ResetName() {
m.name = nil
}
+// SetPurpose sets the "purpose" field.
+func (m *APIKeyMutation) SetPurpose(s string) {
+ m.purpose = &s
+}
+
+// Purpose returns the value of the "purpose" field in the mutation.
+func (m *APIKeyMutation) Purpose() (r string, exists bool) {
+ v := m.purpose
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldPurpose returns the old "purpose" field's value of the APIKey entity.
+// If the APIKey object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *APIKeyMutation) OldPurpose(ctx context.Context) (v string, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldPurpose is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldPurpose requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldPurpose: %w", err)
+ }
+ return oldValue.Purpose, nil
+}
+
+// ResetPurpose resets all changes to the "purpose" field.
+func (m *APIKeyMutation) ResetPurpose() {
+ m.purpose = nil
+}
+
// SetGroupID sets the "group_id" field.
func (m *APIKeyMutation) SetGroupID(i int64) {
m.group = &i
@@ -1522,7 +1650,7 @@ func (m *APIKeyMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *APIKeyMutation) Fields() []string {
- fields := make([]string, 0, 23)
+ fields := make([]string, 0, 26)
if m.created_at != nil {
fields = append(fields, apikey.FieldCreatedAt)
}
@@ -1538,9 +1666,18 @@ func (m *APIKeyMutation) Fields() []string {
if m.key != nil {
fields = append(fields, apikey.FieldKey)
}
+ if m.key_hash != nil {
+ fields = append(fields, apikey.FieldKeyHash)
+ }
+ if m.key_prefix != nil {
+ fields = append(fields, apikey.FieldKeyPrefix)
+ }
if m.name != nil {
fields = append(fields, apikey.FieldName)
}
+ if m.purpose != nil {
+ fields = append(fields, apikey.FieldPurpose)
+ }
if m.group != nil {
fields = append(fields, apikey.FieldGroupID)
}
@@ -1610,8 +1747,14 @@ func (m *APIKeyMutation) Field(name string) (ent.Value, bool) {
return m.UserID()
case apikey.FieldKey:
return m.Key()
+ case apikey.FieldKeyHash:
+ return m.KeyHash()
+ case apikey.FieldKeyPrefix:
+ return m.KeyPrefix()
case apikey.FieldName:
return m.Name()
+ case apikey.FieldPurpose:
+ return m.Purpose()
case apikey.FieldGroupID:
return m.GroupID()
case apikey.FieldStatus:
@@ -1665,8 +1808,14 @@ func (m *APIKeyMutation) OldField(ctx context.Context, name string) (ent.Value,
return m.OldUserID(ctx)
case apikey.FieldKey:
return m.OldKey(ctx)
+ case apikey.FieldKeyHash:
+ return m.OldKeyHash(ctx)
+ case apikey.FieldKeyPrefix:
+ return m.OldKeyPrefix(ctx)
case apikey.FieldName:
return m.OldName(ctx)
+ case apikey.FieldPurpose:
+ return m.OldPurpose(ctx)
case apikey.FieldGroupID:
return m.OldGroupID(ctx)
case apikey.FieldStatus:
@@ -1745,6 +1894,20 @@ func (m *APIKeyMutation) SetField(name string, value ent.Value) error {
}
m.SetKey(v)
return nil
+ case apikey.FieldKeyHash:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetKeyHash(v)
+ return nil
+ case apikey.FieldKeyPrefix:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetKeyPrefix(v)
+ return nil
case apikey.FieldName:
v, ok := value.(string)
if !ok {
@@ -1752,6 +1915,13 @@ func (m *APIKeyMutation) SetField(name string, value ent.Value) error {
}
m.SetName(v)
return nil
+ case apikey.FieldPurpose:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetPurpose(v)
+ return nil
case apikey.FieldGroupID:
v, ok := value.(int64)
if !ok {
@@ -2003,6 +2173,9 @@ func (m *APIKeyMutation) ClearedFields() []string {
if m.FieldCleared(apikey.FieldDeletedAt) {
fields = append(fields, apikey.FieldDeletedAt)
}
+ if m.FieldCleared(apikey.FieldKeyHash) {
+ fields = append(fields, apikey.FieldKeyHash)
+ }
if m.FieldCleared(apikey.FieldGroupID) {
fields = append(fields, apikey.FieldGroupID)
}
@@ -2044,6 +2217,9 @@ func (m *APIKeyMutation) ClearField(name string) error {
case apikey.FieldDeletedAt:
m.ClearDeletedAt()
return nil
+ case apikey.FieldKeyHash:
+ m.ClearKeyHash()
+ return nil
case apikey.FieldGroupID:
m.ClearGroupID()
return nil
@@ -2091,9 +2267,18 @@ func (m *APIKeyMutation) ResetField(name string) error {
case apikey.FieldKey:
m.ResetKey()
return nil
+ case apikey.FieldKeyHash:
+ m.ResetKeyHash()
+ return nil
+ case apikey.FieldKeyPrefix:
+ m.ResetKeyPrefix()
+ return nil
case apikey.FieldName:
m.ResetName()
return nil
+ case apikey.FieldPurpose:
+ m.ResetPurpose()
+ return nil
case apikey.FieldGroupID:
m.ResetGroupID()
return nil
@@ -14764,6 +14949,10 @@ type GroupMutation struct {
addmonthly_limit_usd *float64
default_validity_days *int
adddefault_validity_days *int
+ allow_image_generation *bool
+ image_rate_independent *bool
+ image_rate_multiplier *float64
+ addimage_rate_multiplier *float64
image_price_1k *float64
addimage_price_1k *float64
image_price_2k *float64
@@ -14802,6 +14991,9 @@ type GroupMutation struct {
usage_logs map[int64]struct{}
removedusage_logs map[int64]struct{}
clearedusage_logs bool
+ plan_groups map[int64]struct{}
+ removedplan_groups map[int64]struct{}
+ clearedplan_groups bool
accounts map[int64]struct{}
removedaccounts map[int64]struct{}
clearedaccounts bool
@@ -15583,6 +15775,134 @@ func (m *GroupMutation) ResetDefaultValidityDays() {
m.adddefault_validity_days = nil
}
+// SetAllowImageGeneration sets the "allow_image_generation" field.
+func (m *GroupMutation) SetAllowImageGeneration(b bool) {
+ m.allow_image_generation = &b
+}
+
+// AllowImageGeneration returns the value of the "allow_image_generation" field in the mutation.
+func (m *GroupMutation) AllowImageGeneration() (r bool, exists bool) {
+ v := m.allow_image_generation
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldAllowImageGeneration returns the old "allow_image_generation" field's value of the Group entity.
+// If the Group object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *GroupMutation) OldAllowImageGeneration(ctx context.Context) (v bool, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldAllowImageGeneration is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldAllowImageGeneration requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldAllowImageGeneration: %w", err)
+ }
+ return oldValue.AllowImageGeneration, nil
+}
+
+// ResetAllowImageGeneration resets all changes to the "allow_image_generation" field.
+func (m *GroupMutation) ResetAllowImageGeneration() {
+ m.allow_image_generation = nil
+}
+
+// SetImageRateIndependent sets the "image_rate_independent" field.
+func (m *GroupMutation) SetImageRateIndependent(b bool) {
+ m.image_rate_independent = &b
+}
+
+// ImageRateIndependent returns the value of the "image_rate_independent" field in the mutation.
+func (m *GroupMutation) ImageRateIndependent() (r bool, exists bool) {
+ v := m.image_rate_independent
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldImageRateIndependent returns the old "image_rate_independent" field's value of the Group entity.
+// If the Group object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *GroupMutation) OldImageRateIndependent(ctx context.Context) (v bool, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldImageRateIndependent is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldImageRateIndependent requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldImageRateIndependent: %w", err)
+ }
+ return oldValue.ImageRateIndependent, nil
+}
+
+// ResetImageRateIndependent resets all changes to the "image_rate_independent" field.
+func (m *GroupMutation) ResetImageRateIndependent() {
+ m.image_rate_independent = nil
+}
+
+// SetImageRateMultiplier sets the "image_rate_multiplier" field.
+func (m *GroupMutation) SetImageRateMultiplier(f float64) {
+ m.image_rate_multiplier = &f
+ m.addimage_rate_multiplier = nil
+}
+
+// ImageRateMultiplier returns the value of the "image_rate_multiplier" field in the mutation.
+func (m *GroupMutation) ImageRateMultiplier() (r float64, exists bool) {
+ v := m.image_rate_multiplier
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldImageRateMultiplier returns the old "image_rate_multiplier" field's value of the Group entity.
+// If the Group object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *GroupMutation) OldImageRateMultiplier(ctx context.Context) (v float64, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldImageRateMultiplier is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldImageRateMultiplier requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldImageRateMultiplier: %w", err)
+ }
+ return oldValue.ImageRateMultiplier, nil
+}
+
+// AddImageRateMultiplier adds f to the "image_rate_multiplier" field.
+func (m *GroupMutation) AddImageRateMultiplier(f float64) {
+ if m.addimage_rate_multiplier != nil {
+ *m.addimage_rate_multiplier += f
+ } else {
+ m.addimage_rate_multiplier = &f
+ }
+}
+
+// AddedImageRateMultiplier returns the value that was added to the "image_rate_multiplier" field in this mutation.
+func (m *GroupMutation) AddedImageRateMultiplier() (r float64, exists bool) {
+ v := m.addimage_rate_multiplier
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// ResetImageRateMultiplier resets all changes to the "image_rate_multiplier" field.
+func (m *GroupMutation) ResetImageRateMultiplier() {
+ m.image_rate_multiplier = nil
+ m.addimage_rate_multiplier = nil
+}
+
// SetImagePrice1k sets the "image_price_1k" field.
func (m *GroupMutation) SetImagePrice1k(f float64) {
m.image_price_1k = &f
@@ -16649,6 +16969,60 @@ func (m *GroupMutation) ResetUsageLogs() {
m.removedusage_logs = nil
}
+// AddPlanGroupIDs adds the "plan_groups" edge to the SubscriptionPlanGroup entity by ids.
+func (m *GroupMutation) AddPlanGroupIDs(ids ...int64) {
+ if m.plan_groups == nil {
+ m.plan_groups = make(map[int64]struct{})
+ }
+ for i := range ids {
+ m.plan_groups[ids[i]] = struct{}{}
+ }
+}
+
+// ClearPlanGroups clears the "plan_groups" edge to the SubscriptionPlanGroup entity.
+func (m *GroupMutation) ClearPlanGroups() {
+ m.clearedplan_groups = true
+}
+
+// PlanGroupsCleared reports if the "plan_groups" edge to the SubscriptionPlanGroup entity was cleared.
+func (m *GroupMutation) PlanGroupsCleared() bool {
+ return m.clearedplan_groups
+}
+
+// RemovePlanGroupIDs removes the "plan_groups" edge to the SubscriptionPlanGroup entity by IDs.
+func (m *GroupMutation) RemovePlanGroupIDs(ids ...int64) {
+ if m.removedplan_groups == nil {
+ m.removedplan_groups = make(map[int64]struct{})
+ }
+ for i := range ids {
+ delete(m.plan_groups, ids[i])
+ m.removedplan_groups[ids[i]] = struct{}{}
+ }
+}
+
+// RemovedPlanGroups returns the removed IDs of the "plan_groups" edge to the SubscriptionPlanGroup entity.
+func (m *GroupMutation) RemovedPlanGroupsIDs() (ids []int64) {
+ for id := range m.removedplan_groups {
+ ids = append(ids, id)
+ }
+ return
+}
+
+// PlanGroupsIDs returns the "plan_groups" edge IDs in the mutation.
+func (m *GroupMutation) PlanGroupsIDs() (ids []int64) {
+ for id := range m.plan_groups {
+ ids = append(ids, id)
+ }
+ return
+}
+
+// ResetPlanGroups resets all changes to the "plan_groups" edge.
+func (m *GroupMutation) ResetPlanGroups() {
+ m.plan_groups = nil
+ m.clearedplan_groups = false
+ m.removedplan_groups = nil
+}
+
// AddAccountIDs adds the "accounts" edge to the Account entity by ids.
func (m *GroupMutation) AddAccountIDs(ids ...int64) {
if m.accounts == nil {
@@ -16791,7 +17165,7 @@ func (m *GroupMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *GroupMutation) Fields() []string {
- fields := make([]string, 0, 31)
+ fields := make([]string, 0, 34)
if m.created_at != nil {
fields = append(fields, group.FieldCreatedAt)
}
@@ -16834,6 +17208,15 @@ func (m *GroupMutation) Fields() []string {
if m.default_validity_days != nil {
fields = append(fields, group.FieldDefaultValidityDays)
}
+ if m.allow_image_generation != nil {
+ fields = append(fields, group.FieldAllowImageGeneration)
+ }
+ if m.image_rate_independent != nil {
+ fields = append(fields, group.FieldImageRateIndependent)
+ }
+ if m.image_rate_multiplier != nil {
+ fields = append(fields, group.FieldImageRateMultiplier)
+ }
if m.image_price_1k != nil {
fields = append(fields, group.FieldImagePrice1k)
}
@@ -16921,6 +17304,12 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
return m.MonthlyLimitUsd()
case group.FieldDefaultValidityDays:
return m.DefaultValidityDays()
+ case group.FieldAllowImageGeneration:
+ return m.AllowImageGeneration()
+ case group.FieldImageRateIndependent:
+ return m.ImageRateIndependent()
+ case group.FieldImageRateMultiplier:
+ return m.ImageRateMultiplier()
case group.FieldImagePrice1k:
return m.ImagePrice1k()
case group.FieldImagePrice2k:
@@ -16992,6 +17381,12 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
return m.OldMonthlyLimitUsd(ctx)
case group.FieldDefaultValidityDays:
return m.OldDefaultValidityDays(ctx)
+ case group.FieldAllowImageGeneration:
+ return m.OldAllowImageGeneration(ctx)
+ case group.FieldImageRateIndependent:
+ return m.OldImageRateIndependent(ctx)
+ case group.FieldImageRateMultiplier:
+ return m.OldImageRateMultiplier(ctx)
case group.FieldImagePrice1k:
return m.OldImagePrice1k(ctx)
case group.FieldImagePrice2k:
@@ -17133,6 +17528,27 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetDefaultValidityDays(v)
return nil
+ case group.FieldAllowImageGeneration:
+ v, ok := value.(bool)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetAllowImageGeneration(v)
+ return nil
+ case group.FieldImageRateIndependent:
+ v, ok := value.(bool)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetImageRateIndependent(v)
+ return nil
+ case group.FieldImageRateMultiplier:
+ v, ok := value.(float64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetImageRateMultiplier(v)
+ return nil
case group.FieldImagePrice1k:
v, ok := value.(float64)
if !ok {
@@ -17275,6 +17691,9 @@ func (m *GroupMutation) AddedFields() []string {
if m.adddefault_validity_days != nil {
fields = append(fields, group.FieldDefaultValidityDays)
}
+ if m.addimage_rate_multiplier != nil {
+ fields = append(fields, group.FieldImageRateMultiplier)
+ }
if m.addimage_price_1k != nil {
fields = append(fields, group.FieldImagePrice1k)
}
@@ -17314,6 +17733,8 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) {
return m.AddedMonthlyLimitUsd()
case group.FieldDefaultValidityDays:
return m.AddedDefaultValidityDays()
+ case group.FieldImageRateMultiplier:
+ return m.AddedImageRateMultiplier()
case group.FieldImagePrice1k:
return m.AddedImagePrice1k()
case group.FieldImagePrice2k:
@@ -17372,6 +17793,13 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error {
}
m.AddDefaultValidityDays(v)
return nil
+ case group.FieldImageRateMultiplier:
+ v, ok := value.(float64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.AddImageRateMultiplier(v)
+ return nil
case group.FieldImagePrice1k:
v, ok := value.(float64)
if !ok {
@@ -17559,6 +17987,15 @@ func (m *GroupMutation) ResetField(name string) error {
case group.FieldDefaultValidityDays:
m.ResetDefaultValidityDays()
return nil
+ case group.FieldAllowImageGeneration:
+ m.ResetAllowImageGeneration()
+ return nil
+ case group.FieldImageRateIndependent:
+ m.ResetImageRateIndependent()
+ return nil
+ case group.FieldImageRateMultiplier:
+ m.ResetImageRateMultiplier()
+ return nil
case group.FieldImagePrice1k:
m.ResetImagePrice1k()
return nil
@@ -17616,7 +18053,7 @@ func (m *GroupMutation) ResetField(name string) error {
// AddedEdges returns all edge names that were set/added in this mutation.
func (m *GroupMutation) AddedEdges() []string {
- edges := make([]string, 0, 6)
+ edges := make([]string, 0, 7)
if m.api_keys != nil {
edges = append(edges, group.EdgeAPIKeys)
}
@@ -17629,6 +18066,9 @@ func (m *GroupMutation) AddedEdges() []string {
if m.usage_logs != nil {
edges = append(edges, group.EdgeUsageLogs)
}
+ if m.plan_groups != nil {
+ edges = append(edges, group.EdgePlanGroups)
+ }
if m.accounts != nil {
edges = append(edges, group.EdgeAccounts)
}
@@ -17666,6 +18106,12 @@ func (m *GroupMutation) AddedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
+ case group.EdgePlanGroups:
+ ids := make([]ent.Value, 0, len(m.plan_groups))
+ for id := range m.plan_groups {
+ ids = append(ids, id)
+ }
+ return ids
case group.EdgeAccounts:
ids := make([]ent.Value, 0, len(m.accounts))
for id := range m.accounts {
@@ -17684,7 +18130,7 @@ func (m *GroupMutation) AddedIDs(name string) []ent.Value {
// RemovedEdges returns all edge names that were removed in this mutation.
func (m *GroupMutation) RemovedEdges() []string {
- edges := make([]string, 0, 6)
+ edges := make([]string, 0, 7)
if m.removedapi_keys != nil {
edges = append(edges, group.EdgeAPIKeys)
}
@@ -17697,6 +18143,9 @@ func (m *GroupMutation) RemovedEdges() []string {
if m.removedusage_logs != nil {
edges = append(edges, group.EdgeUsageLogs)
}
+ if m.removedplan_groups != nil {
+ edges = append(edges, group.EdgePlanGroups)
+ }
if m.removedaccounts != nil {
edges = append(edges, group.EdgeAccounts)
}
@@ -17734,6 +18183,12 @@ func (m *GroupMutation) RemovedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
+ case group.EdgePlanGroups:
+ ids := make([]ent.Value, 0, len(m.removedplan_groups))
+ for id := range m.removedplan_groups {
+ ids = append(ids, id)
+ }
+ return ids
case group.EdgeAccounts:
ids := make([]ent.Value, 0, len(m.removedaccounts))
for id := range m.removedaccounts {
@@ -17752,7 +18207,7 @@ func (m *GroupMutation) RemovedIDs(name string) []ent.Value {
// ClearedEdges returns all edge names that were cleared in this mutation.
func (m *GroupMutation) ClearedEdges() []string {
- edges := make([]string, 0, 6)
+ edges := make([]string, 0, 7)
if m.clearedapi_keys {
edges = append(edges, group.EdgeAPIKeys)
}
@@ -17765,6 +18220,9 @@ func (m *GroupMutation) ClearedEdges() []string {
if m.clearedusage_logs {
edges = append(edges, group.EdgeUsageLogs)
}
+ if m.clearedplan_groups {
+ edges = append(edges, group.EdgePlanGroups)
+ }
if m.clearedaccounts {
edges = append(edges, group.EdgeAccounts)
}
@@ -17786,6 +18244,8 @@ func (m *GroupMutation) EdgeCleared(name string) bool {
return m.clearedsubscriptions
case group.EdgeUsageLogs:
return m.clearedusage_logs
+ case group.EdgePlanGroups:
+ return m.clearedplan_groups
case group.EdgeAccounts:
return m.clearedaccounts
case group.EdgeAllowedUsers:
@@ -17818,6 +18278,9 @@ func (m *GroupMutation) ResetEdge(name string) error {
case group.EdgeUsageLogs:
m.ResetUsageLogs()
return nil
+ case group.EdgePlanGroups:
+ m.ResetPlanGroups()
+ return nil
case group.EdgeAccounts:
m.ResetAccounts()
return nil
@@ -28409,6 +28872,8 @@ type RedeemCodeMutation struct {
created_at *time.Time
validity_days *int
addvalidity_days *int
+ plan_id *int64
+ addplan_id *int64
clearedFields map[string]struct{}
user *int64
cleareduser bool
@@ -28969,6 +29434,76 @@ func (m *RedeemCodeMutation) ResetValidityDays() {
m.addvalidity_days = nil
}
+// SetPlanID sets the "plan_id" field.
+func (m *RedeemCodeMutation) SetPlanID(i int64) {
+ m.plan_id = &i
+ m.addplan_id = nil
+}
+
+// PlanID returns the value of the "plan_id" field in the mutation.
+func (m *RedeemCodeMutation) PlanID() (r int64, exists bool) {
+ v := m.plan_id
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldPlanID returns the old "plan_id" field's value of the RedeemCode entity.
+// If the RedeemCode object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *RedeemCodeMutation) OldPlanID(ctx context.Context) (v *int64, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldPlanID is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldPlanID requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldPlanID: %w", err)
+ }
+ return oldValue.PlanID, nil
+}
+
+// AddPlanID adds i to the "plan_id" field.
+func (m *RedeemCodeMutation) AddPlanID(i int64) {
+ if m.addplan_id != nil {
+ *m.addplan_id += i
+ } else {
+ m.addplan_id = &i
+ }
+}
+
+// AddedPlanID returns the value that was added to the "plan_id" field in this mutation.
+func (m *RedeemCodeMutation) AddedPlanID() (r int64, exists bool) {
+ v := m.addplan_id
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// ClearPlanID clears the value of the "plan_id" field.
+func (m *RedeemCodeMutation) ClearPlanID() {
+ m.plan_id = nil
+ m.addplan_id = nil
+ m.clearedFields[redeemcode.FieldPlanID] = struct{}{}
+}
+
+// PlanIDCleared returns if the "plan_id" field was cleared in this mutation.
+func (m *RedeemCodeMutation) PlanIDCleared() bool {
+ _, ok := m.clearedFields[redeemcode.FieldPlanID]
+ return ok
+}
+
+// ResetPlanID resets all changes to the "plan_id" field.
+func (m *RedeemCodeMutation) ResetPlanID() {
+ m.plan_id = nil
+ m.addplan_id = nil
+ delete(m.clearedFields, redeemcode.FieldPlanID)
+}
+
// SetUserID sets the "user" edge to the User entity by id.
func (m *RedeemCodeMutation) SetUserID(id int64) {
m.user = &id
@@ -29070,7 +29605,7 @@ func (m *RedeemCodeMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *RedeemCodeMutation) Fields() []string {
- fields := make([]string, 0, 10)
+ fields := make([]string, 0, 11)
if m.code != nil {
fields = append(fields, redeemcode.FieldCode)
}
@@ -29101,6 +29636,9 @@ func (m *RedeemCodeMutation) Fields() []string {
if m.validity_days != nil {
fields = append(fields, redeemcode.FieldValidityDays)
}
+ if m.plan_id != nil {
+ fields = append(fields, redeemcode.FieldPlanID)
+ }
return fields
}
@@ -29129,6 +29667,8 @@ func (m *RedeemCodeMutation) Field(name string) (ent.Value, bool) {
return m.GroupID()
case redeemcode.FieldValidityDays:
return m.ValidityDays()
+ case redeemcode.FieldPlanID:
+ return m.PlanID()
}
return nil, false
}
@@ -29158,6 +29698,8 @@ func (m *RedeemCodeMutation) OldField(ctx context.Context, name string) (ent.Val
return m.OldGroupID(ctx)
case redeemcode.FieldValidityDays:
return m.OldValidityDays(ctx)
+ case redeemcode.FieldPlanID:
+ return m.OldPlanID(ctx)
}
return nil, fmt.Errorf("unknown RedeemCode field %s", name)
}
@@ -29237,6 +29779,13 @@ func (m *RedeemCodeMutation) SetField(name string, value ent.Value) error {
}
m.SetValidityDays(v)
return nil
+ case redeemcode.FieldPlanID:
+ v, ok := value.(int64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetPlanID(v)
+ return nil
}
return fmt.Errorf("unknown RedeemCode field %s", name)
}
@@ -29251,6 +29800,9 @@ func (m *RedeemCodeMutation) AddedFields() []string {
if m.addvalidity_days != nil {
fields = append(fields, redeemcode.FieldValidityDays)
}
+ if m.addplan_id != nil {
+ fields = append(fields, redeemcode.FieldPlanID)
+ }
return fields
}
@@ -29263,6 +29815,8 @@ func (m *RedeemCodeMutation) AddedField(name string) (ent.Value, bool) {
return m.AddedValue()
case redeemcode.FieldValidityDays:
return m.AddedValidityDays()
+ case redeemcode.FieldPlanID:
+ return m.AddedPlanID()
}
return nil, false
}
@@ -29286,6 +29840,13 @@ func (m *RedeemCodeMutation) AddField(name string, value ent.Value) error {
}
m.AddValidityDays(v)
return nil
+ case redeemcode.FieldPlanID:
+ v, ok := value.(int64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.AddPlanID(v)
+ return nil
}
return fmt.Errorf("unknown RedeemCode numeric field %s", name)
}
@@ -29306,6 +29867,9 @@ func (m *RedeemCodeMutation) ClearedFields() []string {
if m.FieldCleared(redeemcode.FieldGroupID) {
fields = append(fields, redeemcode.FieldGroupID)
}
+ if m.FieldCleared(redeemcode.FieldPlanID) {
+ fields = append(fields, redeemcode.FieldPlanID)
+ }
return fields
}
@@ -29332,6 +29896,9 @@ func (m *RedeemCodeMutation) ClearField(name string) error {
case redeemcode.FieldGroupID:
m.ClearGroupID()
return nil
+ case redeemcode.FieldPlanID:
+ m.ClearPlanID()
+ return nil
}
return fmt.Errorf("unknown RedeemCode nullable field %s", name)
}
@@ -29370,6 +29937,9 @@ func (m *RedeemCodeMutation) ResetField(name string) error {
case redeemcode.FieldValidityDays:
m.ResetValidityDays()
return nil
+ case redeemcode.FieldPlanID:
+ m.ResetPlanID()
+ return nil
}
return fmt.Errorf("unknown RedeemCode field %s", name)
}
@@ -30391,31 +30961,37 @@ func (m *SettingMutation) ResetEdge(name string) error {
// SubscriptionPlanMutation represents an operation that mutates the SubscriptionPlan nodes in the graph.
type SubscriptionPlanMutation struct {
config
- op Op
- typ string
- id *int64
- group_id *int64
- addgroup_id *int64
- name *string
- description *string
- price *float64
- addprice *float64
- original_price *float64
- addoriginal_price *float64
- validity_days *int
- addvalidity_days *int
- validity_unit *string
- features *string
- product_name *string
- for_sale *bool
- sort_order *int
- addsort_order *int
- created_at *time.Time
- updated_at *time.Time
- clearedFields map[string]struct{}
- done bool
- oldValue func(context.Context) (*SubscriptionPlan, error)
- predicates []predicate.SubscriptionPlan
+ op Op
+ typ string
+ id *int64
+ group_id *int64
+ addgroup_id *int64
+ wallet_quota_usd *float64
+ addwallet_quota_usd *float64
+ plan_type *string
+ name *string
+ description *string
+ price *float64
+ addprice *float64
+ original_price *float64
+ addoriginal_price *float64
+ validity_days *int
+ addvalidity_days *int
+ validity_unit *string
+ features *string
+ product_name *string
+ for_sale *bool
+ sort_order *int
+ addsort_order *int
+ created_at *time.Time
+ updated_at *time.Time
+ clearedFields map[string]struct{}
+ plan_groups map[int64]struct{}
+ removedplan_groups map[int64]struct{}
+ clearedplan_groups bool
+ done bool
+ oldValue func(context.Context) (*SubscriptionPlan, error)
+ predicates []predicate.SubscriptionPlan
}
var _ ent.Mutation = (*SubscriptionPlanMutation)(nil)
@@ -30534,7 +31110,7 @@ func (m *SubscriptionPlanMutation) GroupID() (r int64, exists bool) {
// OldGroupID returns the old "group_id" field's value of the SubscriptionPlan entity.
// If the SubscriptionPlan object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
-func (m *SubscriptionPlanMutation) OldGroupID(ctx context.Context) (v int64, err error) {
+func (m *SubscriptionPlanMutation) OldGroupID(ctx context.Context) (v *int64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldGroupID is only allowed on UpdateOne operations")
}
@@ -30566,10 +31142,130 @@ func (m *SubscriptionPlanMutation) AddedGroupID() (r int64, exists bool) {
return *v, true
}
+// ClearGroupID clears the value of the "group_id" field.
+func (m *SubscriptionPlanMutation) ClearGroupID() {
+ m.group_id = nil
+ m.addgroup_id = nil
+ m.clearedFields[subscriptionplan.FieldGroupID] = struct{}{}
+}
+
+// GroupIDCleared returns if the "group_id" field was cleared in this mutation.
+func (m *SubscriptionPlanMutation) GroupIDCleared() bool {
+ _, ok := m.clearedFields[subscriptionplan.FieldGroupID]
+ return ok
+}
+
// ResetGroupID resets all changes to the "group_id" field.
func (m *SubscriptionPlanMutation) ResetGroupID() {
m.group_id = nil
m.addgroup_id = nil
+ delete(m.clearedFields, subscriptionplan.FieldGroupID)
+}
+
+// SetWalletQuotaUsd sets the "wallet_quota_usd" field.
+func (m *SubscriptionPlanMutation) SetWalletQuotaUsd(f float64) {
+ m.wallet_quota_usd = &f
+ m.addwallet_quota_usd = nil
+}
+
+// WalletQuotaUsd returns the value of the "wallet_quota_usd" field in the mutation.
+func (m *SubscriptionPlanMutation) WalletQuotaUsd() (r float64, exists bool) {
+ v := m.wallet_quota_usd
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldWalletQuotaUsd returns the old "wallet_quota_usd" field's value of the SubscriptionPlan entity.
+// If the SubscriptionPlan object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *SubscriptionPlanMutation) OldWalletQuotaUsd(ctx context.Context) (v *float64, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldWalletQuotaUsd is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldWalletQuotaUsd requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldWalletQuotaUsd: %w", err)
+ }
+ return oldValue.WalletQuotaUsd, nil
+}
+
+// AddWalletQuotaUsd adds f to the "wallet_quota_usd" field.
+func (m *SubscriptionPlanMutation) AddWalletQuotaUsd(f float64) {
+ if m.addwallet_quota_usd != nil {
+ *m.addwallet_quota_usd += f
+ } else {
+ m.addwallet_quota_usd = &f
+ }
+}
+
+// AddedWalletQuotaUsd returns the value that was added to the "wallet_quota_usd" field in this mutation.
+func (m *SubscriptionPlanMutation) AddedWalletQuotaUsd() (r float64, exists bool) {
+ v := m.addwallet_quota_usd
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// ClearWalletQuotaUsd clears the value of the "wallet_quota_usd" field.
+func (m *SubscriptionPlanMutation) ClearWalletQuotaUsd() {
+ m.wallet_quota_usd = nil
+ m.addwallet_quota_usd = nil
+ m.clearedFields[subscriptionplan.FieldWalletQuotaUsd] = struct{}{}
+}
+
+// WalletQuotaUsdCleared returns if the "wallet_quota_usd" field was cleared in this mutation.
+func (m *SubscriptionPlanMutation) WalletQuotaUsdCleared() bool {
+ _, ok := m.clearedFields[subscriptionplan.FieldWalletQuotaUsd]
+ return ok
+}
+
+// ResetWalletQuotaUsd resets all changes to the "wallet_quota_usd" field.
+func (m *SubscriptionPlanMutation) ResetWalletQuotaUsd() {
+ m.wallet_quota_usd = nil
+ m.addwallet_quota_usd = nil
+ delete(m.clearedFields, subscriptionplan.FieldWalletQuotaUsd)
+}
+
+// SetPlanType sets the "plan_type" field.
+func (m *SubscriptionPlanMutation) SetPlanType(s string) {
+ m.plan_type = &s
+}
+
+// PlanType returns the value of the "plan_type" field in the mutation.
+func (m *SubscriptionPlanMutation) PlanType() (r string, exists bool) {
+ v := m.plan_type
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldPlanType returns the old "plan_type" field's value of the SubscriptionPlan entity.
+// If the SubscriptionPlan object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *SubscriptionPlanMutation) OldPlanType(ctx context.Context) (v string, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldPlanType is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldPlanType requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldPlanType: %w", err)
+ }
+ return oldValue.PlanType, nil
+}
+
+// ResetPlanType resets all changes to the "plan_type" field.
+func (m *SubscriptionPlanMutation) ResetPlanType() {
+ m.plan_type = nil
}
// SetName sets the "name" field.
@@ -31027,12 +31723,1750 @@ func (m *SubscriptionPlanMutation) ResetSortOrder() {
}
// SetCreatedAt sets the "created_at" field.
-func (m *SubscriptionPlanMutation) SetCreatedAt(t time.Time) {
+func (m *SubscriptionPlanMutation) SetCreatedAt(t time.Time) {
+ m.created_at = &t
+}
+
+// CreatedAt returns the value of the "created_at" field in the mutation.
+func (m *SubscriptionPlanMutation) CreatedAt() (r time.Time, exists bool) {
+ v := m.created_at
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldCreatedAt returns the old "created_at" field's value of the SubscriptionPlan entity.
+// If the SubscriptionPlan object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *SubscriptionPlanMutation) OldCreatedAt(ctx context.Context) (v time.Time, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldCreatedAt is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldCreatedAt requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldCreatedAt: %w", err)
+ }
+ return oldValue.CreatedAt, nil
+}
+
+// ResetCreatedAt resets all changes to the "created_at" field.
+func (m *SubscriptionPlanMutation) ResetCreatedAt() {
+ m.created_at = nil
+}
+
+// SetUpdatedAt sets the "updated_at" field.
+func (m *SubscriptionPlanMutation) SetUpdatedAt(t time.Time) {
+ m.updated_at = &t
+}
+
+// UpdatedAt returns the value of the "updated_at" field in the mutation.
+func (m *SubscriptionPlanMutation) UpdatedAt() (r time.Time, exists bool) {
+ v := m.updated_at
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldUpdatedAt returns the old "updated_at" field's value of the SubscriptionPlan entity.
+// If the SubscriptionPlan object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *SubscriptionPlanMutation) OldUpdatedAt(ctx context.Context) (v time.Time, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldUpdatedAt is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldUpdatedAt requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldUpdatedAt: %w", err)
+ }
+ return oldValue.UpdatedAt, nil
+}
+
+// ResetUpdatedAt resets all changes to the "updated_at" field.
+func (m *SubscriptionPlanMutation) ResetUpdatedAt() {
+ m.updated_at = nil
+}
+
+// AddPlanGroupIDs adds the "plan_groups" edge to the SubscriptionPlanGroup entity by ids.
+func (m *SubscriptionPlanMutation) AddPlanGroupIDs(ids ...int64) {
+ if m.plan_groups == nil {
+ m.plan_groups = make(map[int64]struct{})
+ }
+ for i := range ids {
+ m.plan_groups[ids[i]] = struct{}{}
+ }
+}
+
+// ClearPlanGroups clears the "plan_groups" edge to the SubscriptionPlanGroup entity.
+func (m *SubscriptionPlanMutation) ClearPlanGroups() {
+ m.clearedplan_groups = true
+}
+
+// PlanGroupsCleared reports if the "plan_groups" edge to the SubscriptionPlanGroup entity was cleared.
+func (m *SubscriptionPlanMutation) PlanGroupsCleared() bool {
+ return m.clearedplan_groups
+}
+
+// RemovePlanGroupIDs removes the "plan_groups" edge to the SubscriptionPlanGroup entity by IDs.
+func (m *SubscriptionPlanMutation) RemovePlanGroupIDs(ids ...int64) {
+ if m.removedplan_groups == nil {
+ m.removedplan_groups = make(map[int64]struct{})
+ }
+ for i := range ids {
+ delete(m.plan_groups, ids[i])
+ m.removedplan_groups[ids[i]] = struct{}{}
+ }
+}
+
+// RemovedPlanGroups returns the removed IDs of the "plan_groups" edge to the SubscriptionPlanGroup entity.
+func (m *SubscriptionPlanMutation) RemovedPlanGroupsIDs() (ids []int64) {
+ for id := range m.removedplan_groups {
+ ids = append(ids, id)
+ }
+ return
+}
+
+// PlanGroupsIDs returns the "plan_groups" edge IDs in the mutation.
+func (m *SubscriptionPlanMutation) PlanGroupsIDs() (ids []int64) {
+ for id := range m.plan_groups {
+ ids = append(ids, id)
+ }
+ return
+}
+
+// ResetPlanGroups resets all changes to the "plan_groups" edge.
+func (m *SubscriptionPlanMutation) ResetPlanGroups() {
+ m.plan_groups = nil
+ m.clearedplan_groups = false
+ m.removedplan_groups = nil
+}
+
+// Where appends a list predicates to the SubscriptionPlanMutation builder.
+func (m *SubscriptionPlanMutation) Where(ps ...predicate.SubscriptionPlan) {
+ m.predicates = append(m.predicates, ps...)
+}
+
+// WhereP appends storage-level predicates to the SubscriptionPlanMutation builder. Using this method,
+// users can use type-assertion to append predicates that do not depend on any generated package.
+func (m *SubscriptionPlanMutation) WhereP(ps ...func(*sql.Selector)) {
+ p := make([]predicate.SubscriptionPlan, len(ps))
+ for i := range ps {
+ p[i] = ps[i]
+ }
+ m.Where(p...)
+}
+
+// Op returns the operation name.
+func (m *SubscriptionPlanMutation) Op() Op {
+ return m.op
+}
+
+// SetOp allows setting the mutation operation.
+func (m *SubscriptionPlanMutation) SetOp(op Op) {
+ m.op = op
+}
+
+// Type returns the node type of this mutation (SubscriptionPlan).
+func (m *SubscriptionPlanMutation) Type() string {
+ return m.typ
+}
+
+// Fields returns all fields that were changed during this mutation. Note that in
+// order to get all numeric fields that were incremented/decremented, call
+// AddedFields().
+func (m *SubscriptionPlanMutation) Fields() []string {
+ fields := make([]string, 0, 15)
+ if m.group_id != nil {
+ fields = append(fields, subscriptionplan.FieldGroupID)
+ }
+ if m.wallet_quota_usd != nil {
+ fields = append(fields, subscriptionplan.FieldWalletQuotaUsd)
+ }
+ if m.plan_type != nil {
+ fields = append(fields, subscriptionplan.FieldPlanType)
+ }
+ if m.name != nil {
+ fields = append(fields, subscriptionplan.FieldName)
+ }
+ if m.description != nil {
+ fields = append(fields, subscriptionplan.FieldDescription)
+ }
+ if m.price != nil {
+ fields = append(fields, subscriptionplan.FieldPrice)
+ }
+ if m.original_price != nil {
+ fields = append(fields, subscriptionplan.FieldOriginalPrice)
+ }
+ if m.validity_days != nil {
+ fields = append(fields, subscriptionplan.FieldValidityDays)
+ }
+ if m.validity_unit != nil {
+ fields = append(fields, subscriptionplan.FieldValidityUnit)
+ }
+ if m.features != nil {
+ fields = append(fields, subscriptionplan.FieldFeatures)
+ }
+ if m.product_name != nil {
+ fields = append(fields, subscriptionplan.FieldProductName)
+ }
+ if m.for_sale != nil {
+ fields = append(fields, subscriptionplan.FieldForSale)
+ }
+ if m.sort_order != nil {
+ fields = append(fields, subscriptionplan.FieldSortOrder)
+ }
+ if m.created_at != nil {
+ fields = append(fields, subscriptionplan.FieldCreatedAt)
+ }
+ if m.updated_at != nil {
+ fields = append(fields, subscriptionplan.FieldUpdatedAt)
+ }
+ return fields
+}
+
+// Field returns the value of a field with the given name. The second boolean
+// return value indicates that this field was not set, or was not defined in the
+// schema.
+func (m *SubscriptionPlanMutation) Field(name string) (ent.Value, bool) {
+ switch name {
+ case subscriptionplan.FieldGroupID:
+ return m.GroupID()
+ case subscriptionplan.FieldWalletQuotaUsd:
+ return m.WalletQuotaUsd()
+ case subscriptionplan.FieldPlanType:
+ return m.PlanType()
+ case subscriptionplan.FieldName:
+ return m.Name()
+ case subscriptionplan.FieldDescription:
+ return m.Description()
+ case subscriptionplan.FieldPrice:
+ return m.Price()
+ case subscriptionplan.FieldOriginalPrice:
+ return m.OriginalPrice()
+ case subscriptionplan.FieldValidityDays:
+ return m.ValidityDays()
+ case subscriptionplan.FieldValidityUnit:
+ return m.ValidityUnit()
+ case subscriptionplan.FieldFeatures:
+ return m.Features()
+ case subscriptionplan.FieldProductName:
+ return m.ProductName()
+ case subscriptionplan.FieldForSale:
+ return m.ForSale()
+ case subscriptionplan.FieldSortOrder:
+ return m.SortOrder()
+ case subscriptionplan.FieldCreatedAt:
+ return m.CreatedAt()
+ case subscriptionplan.FieldUpdatedAt:
+ return m.UpdatedAt()
+ }
+ return nil, false
+}
+
+// OldField returns the old value of the field from the database. An error is
+// returned if the mutation operation is not UpdateOne, or the query to the
+// database failed.
+func (m *SubscriptionPlanMutation) OldField(ctx context.Context, name string) (ent.Value, error) {
+ switch name {
+ case subscriptionplan.FieldGroupID:
+ return m.OldGroupID(ctx)
+ case subscriptionplan.FieldWalletQuotaUsd:
+ return m.OldWalletQuotaUsd(ctx)
+ case subscriptionplan.FieldPlanType:
+ return m.OldPlanType(ctx)
+ case subscriptionplan.FieldName:
+ return m.OldName(ctx)
+ case subscriptionplan.FieldDescription:
+ return m.OldDescription(ctx)
+ case subscriptionplan.FieldPrice:
+ return m.OldPrice(ctx)
+ case subscriptionplan.FieldOriginalPrice:
+ return m.OldOriginalPrice(ctx)
+ case subscriptionplan.FieldValidityDays:
+ return m.OldValidityDays(ctx)
+ case subscriptionplan.FieldValidityUnit:
+ return m.OldValidityUnit(ctx)
+ case subscriptionplan.FieldFeatures:
+ return m.OldFeatures(ctx)
+ case subscriptionplan.FieldProductName:
+ return m.OldProductName(ctx)
+ case subscriptionplan.FieldForSale:
+ return m.OldForSale(ctx)
+ case subscriptionplan.FieldSortOrder:
+ return m.OldSortOrder(ctx)
+ case subscriptionplan.FieldCreatedAt:
+ return m.OldCreatedAt(ctx)
+ case subscriptionplan.FieldUpdatedAt:
+ return m.OldUpdatedAt(ctx)
+ }
+ return nil, fmt.Errorf("unknown SubscriptionPlan field %s", name)
+}
+
+// SetField sets the value of a field with the given name. It returns an error if
+// the field is not defined in the schema, or if the type mismatched the field
+// type.
+func (m *SubscriptionPlanMutation) SetField(name string, value ent.Value) error {
+ switch name {
+ case subscriptionplan.FieldGroupID:
+ v, ok := value.(int64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetGroupID(v)
+ return nil
+ case subscriptionplan.FieldWalletQuotaUsd:
+ v, ok := value.(float64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetWalletQuotaUsd(v)
+ return nil
+ case subscriptionplan.FieldPlanType:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetPlanType(v)
+ return nil
+ case subscriptionplan.FieldName:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetName(v)
+ return nil
+ case subscriptionplan.FieldDescription:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetDescription(v)
+ return nil
+ case subscriptionplan.FieldPrice:
+ v, ok := value.(float64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetPrice(v)
+ return nil
+ case subscriptionplan.FieldOriginalPrice:
+ v, ok := value.(float64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetOriginalPrice(v)
+ return nil
+ case subscriptionplan.FieldValidityDays:
+ v, ok := value.(int)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetValidityDays(v)
+ return nil
+ case subscriptionplan.FieldValidityUnit:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetValidityUnit(v)
+ return nil
+ case subscriptionplan.FieldFeatures:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetFeatures(v)
+ return nil
+ case subscriptionplan.FieldProductName:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetProductName(v)
+ return nil
+ case subscriptionplan.FieldForSale:
+ v, ok := value.(bool)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetForSale(v)
+ return nil
+ case subscriptionplan.FieldSortOrder:
+ v, ok := value.(int)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetSortOrder(v)
+ return nil
+ case subscriptionplan.FieldCreatedAt:
+ v, ok := value.(time.Time)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetCreatedAt(v)
+ return nil
+ case subscriptionplan.FieldUpdatedAt:
+ v, ok := value.(time.Time)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetUpdatedAt(v)
+ return nil
+ }
+ return fmt.Errorf("unknown SubscriptionPlan field %s", name)
+}
+
+// AddedFields returns all numeric fields that were incremented/decremented during
+// this mutation.
+func (m *SubscriptionPlanMutation) AddedFields() []string {
+ var fields []string
+ if m.addgroup_id != nil {
+ fields = append(fields, subscriptionplan.FieldGroupID)
+ }
+ if m.addwallet_quota_usd != nil {
+ fields = append(fields, subscriptionplan.FieldWalletQuotaUsd)
+ }
+ if m.addprice != nil {
+ fields = append(fields, subscriptionplan.FieldPrice)
+ }
+ if m.addoriginal_price != nil {
+ fields = append(fields, subscriptionplan.FieldOriginalPrice)
+ }
+ if m.addvalidity_days != nil {
+ fields = append(fields, subscriptionplan.FieldValidityDays)
+ }
+ if m.addsort_order != nil {
+ fields = append(fields, subscriptionplan.FieldSortOrder)
+ }
+ return fields
+}
+
+// AddedField returns the numeric value that was incremented/decremented on a field
+// with the given name. The second boolean return value indicates that this field
+// was not set, or was not defined in the schema.
+func (m *SubscriptionPlanMutation) AddedField(name string) (ent.Value, bool) {
+ switch name {
+ case subscriptionplan.FieldGroupID:
+ return m.AddedGroupID()
+ case subscriptionplan.FieldWalletQuotaUsd:
+ return m.AddedWalletQuotaUsd()
+ case subscriptionplan.FieldPrice:
+ return m.AddedPrice()
+ case subscriptionplan.FieldOriginalPrice:
+ return m.AddedOriginalPrice()
+ case subscriptionplan.FieldValidityDays:
+ return m.AddedValidityDays()
+ case subscriptionplan.FieldSortOrder:
+ return m.AddedSortOrder()
+ }
+ return nil, false
+}
+
+// AddField adds the value to the field with the given name. It returns an error if
+// the field is not defined in the schema, or if the type mismatched the field
+// type.
+func (m *SubscriptionPlanMutation) AddField(name string, value ent.Value) error {
+ switch name {
+ case subscriptionplan.FieldGroupID:
+ v, ok := value.(int64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.AddGroupID(v)
+ return nil
+ case subscriptionplan.FieldWalletQuotaUsd:
+ v, ok := value.(float64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.AddWalletQuotaUsd(v)
+ return nil
+ case subscriptionplan.FieldPrice:
+ v, ok := value.(float64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.AddPrice(v)
+ return nil
+ case subscriptionplan.FieldOriginalPrice:
+ v, ok := value.(float64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.AddOriginalPrice(v)
+ return nil
+ case subscriptionplan.FieldValidityDays:
+ v, ok := value.(int)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.AddValidityDays(v)
+ return nil
+ case subscriptionplan.FieldSortOrder:
+ v, ok := value.(int)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.AddSortOrder(v)
+ return nil
+ }
+ return fmt.Errorf("unknown SubscriptionPlan numeric field %s", name)
+}
+
+// ClearedFields returns all nullable fields that were cleared during this
+// mutation.
+func (m *SubscriptionPlanMutation) ClearedFields() []string {
+ var fields []string
+ if m.FieldCleared(subscriptionplan.FieldGroupID) {
+ fields = append(fields, subscriptionplan.FieldGroupID)
+ }
+ if m.FieldCleared(subscriptionplan.FieldWalletQuotaUsd) {
+ fields = append(fields, subscriptionplan.FieldWalletQuotaUsd)
+ }
+ if m.FieldCleared(subscriptionplan.FieldOriginalPrice) {
+ fields = append(fields, subscriptionplan.FieldOriginalPrice)
+ }
+ return fields
+}
+
+// FieldCleared returns a boolean indicating if a field with the given name was
+// cleared in this mutation.
+func (m *SubscriptionPlanMutation) FieldCleared(name string) bool {
+ _, ok := m.clearedFields[name]
+ return ok
+}
+
+// ClearField clears the value of the field with the given name. It returns an
+// error if the field is not defined in the schema.
+func (m *SubscriptionPlanMutation) ClearField(name string) error {
+ switch name {
+ case subscriptionplan.FieldGroupID:
+ m.ClearGroupID()
+ return nil
+ case subscriptionplan.FieldWalletQuotaUsd:
+ m.ClearWalletQuotaUsd()
+ return nil
+ case subscriptionplan.FieldOriginalPrice:
+ m.ClearOriginalPrice()
+ return nil
+ }
+ return fmt.Errorf("unknown SubscriptionPlan nullable field %s", name)
+}
+
+// ResetField resets all changes in the mutation for the field with the given name.
+// It returns an error if the field is not defined in the schema.
+func (m *SubscriptionPlanMutation) ResetField(name string) error {
+ switch name {
+ case subscriptionplan.FieldGroupID:
+ m.ResetGroupID()
+ return nil
+ case subscriptionplan.FieldWalletQuotaUsd:
+ m.ResetWalletQuotaUsd()
+ return nil
+ case subscriptionplan.FieldPlanType:
+ m.ResetPlanType()
+ return nil
+ case subscriptionplan.FieldName:
+ m.ResetName()
+ return nil
+ case subscriptionplan.FieldDescription:
+ m.ResetDescription()
+ return nil
+ case subscriptionplan.FieldPrice:
+ m.ResetPrice()
+ return nil
+ case subscriptionplan.FieldOriginalPrice:
+ m.ResetOriginalPrice()
+ return nil
+ case subscriptionplan.FieldValidityDays:
+ m.ResetValidityDays()
+ return nil
+ case subscriptionplan.FieldValidityUnit:
+ m.ResetValidityUnit()
+ return nil
+ case subscriptionplan.FieldFeatures:
+ m.ResetFeatures()
+ return nil
+ case subscriptionplan.FieldProductName:
+ m.ResetProductName()
+ return nil
+ case subscriptionplan.FieldForSale:
+ m.ResetForSale()
+ return nil
+ case subscriptionplan.FieldSortOrder:
+ m.ResetSortOrder()
+ return nil
+ case subscriptionplan.FieldCreatedAt:
+ m.ResetCreatedAt()
+ return nil
+ case subscriptionplan.FieldUpdatedAt:
+ m.ResetUpdatedAt()
+ return nil
+ }
+ return fmt.Errorf("unknown SubscriptionPlan field %s", name)
+}
+
+// AddedEdges returns all edge names that were set/added in this mutation.
+func (m *SubscriptionPlanMutation) AddedEdges() []string {
+ edges := make([]string, 0, 1)
+ if m.plan_groups != nil {
+ edges = append(edges, subscriptionplan.EdgePlanGroups)
+ }
+ return edges
+}
+
+// AddedIDs returns all IDs (to other nodes) that were added for the given edge
+// name in this mutation.
+func (m *SubscriptionPlanMutation) AddedIDs(name string) []ent.Value {
+ switch name {
+ case subscriptionplan.EdgePlanGroups:
+ ids := make([]ent.Value, 0, len(m.plan_groups))
+ for id := range m.plan_groups {
+ ids = append(ids, id)
+ }
+ return ids
+ }
+ return nil
+}
+
+// RemovedEdges returns all edge names that were removed in this mutation.
+func (m *SubscriptionPlanMutation) RemovedEdges() []string {
+ edges := make([]string, 0, 1)
+ if m.removedplan_groups != nil {
+ edges = append(edges, subscriptionplan.EdgePlanGroups)
+ }
+ return edges
+}
+
+// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with
+// the given name in this mutation.
+func (m *SubscriptionPlanMutation) RemovedIDs(name string) []ent.Value {
+ switch name {
+ case subscriptionplan.EdgePlanGroups:
+ ids := make([]ent.Value, 0, len(m.removedplan_groups))
+ for id := range m.removedplan_groups {
+ ids = append(ids, id)
+ }
+ return ids
+ }
+ return nil
+}
+
+// ClearedEdges returns all edge names that were cleared in this mutation.
+func (m *SubscriptionPlanMutation) ClearedEdges() []string {
+ edges := make([]string, 0, 1)
+ if m.clearedplan_groups {
+ edges = append(edges, subscriptionplan.EdgePlanGroups)
+ }
+ return edges
+}
+
+// EdgeCleared returns a boolean which indicates if the edge with the given name
+// was cleared in this mutation.
+func (m *SubscriptionPlanMutation) EdgeCleared(name string) bool {
+ switch name {
+ case subscriptionplan.EdgePlanGroups:
+ return m.clearedplan_groups
+ }
+ return false
+}
+
+// ClearEdge clears the value of the edge with the given name. It returns an error
+// if that edge is not defined in the schema.
+func (m *SubscriptionPlanMutation) ClearEdge(name string) error {
+ switch name {
+ }
+ return fmt.Errorf("unknown SubscriptionPlan unique edge %s", name)
+}
+
+// ResetEdge resets all changes to the edge with the given name in this mutation.
+// It returns an error if the edge is not defined in the schema.
+func (m *SubscriptionPlanMutation) ResetEdge(name string) error {
+ switch name {
+ case subscriptionplan.EdgePlanGroups:
+ m.ResetPlanGroups()
+ return nil
+ }
+ return fmt.Errorf("unknown SubscriptionPlan edge %s", name)
+}
+
+// SubscriptionPlanGroupMutation represents an operation that mutates the SubscriptionPlanGroup nodes in the graph.
+type SubscriptionPlanGroupMutation struct {
+ config
+ op Op
+ typ string
+ id *int64
+ created_at *time.Time
+ clearedFields map[string]struct{}
+ plan *int64
+ clearedplan bool
+ group *int64
+ clearedgroup bool
+ done bool
+ oldValue func(context.Context) (*SubscriptionPlanGroup, error)
+ predicates []predicate.SubscriptionPlanGroup
+}
+
+var _ ent.Mutation = (*SubscriptionPlanGroupMutation)(nil)
+
+// subscriptionplangroupOption allows management of the mutation configuration using functional options.
+type subscriptionplangroupOption func(*SubscriptionPlanGroupMutation)
+
+// newSubscriptionPlanGroupMutation creates new mutation for the SubscriptionPlanGroup entity.
+func newSubscriptionPlanGroupMutation(c config, op Op, opts ...subscriptionplangroupOption) *SubscriptionPlanGroupMutation {
+ m := &SubscriptionPlanGroupMutation{
+ config: c,
+ op: op,
+ typ: TypeSubscriptionPlanGroup,
+ clearedFields: make(map[string]struct{}),
+ }
+ for _, opt := range opts {
+ opt(m)
+ }
+ return m
+}
+
+// withSubscriptionPlanGroupID sets the ID field of the mutation.
+func withSubscriptionPlanGroupID(id int64) subscriptionplangroupOption {
+ return func(m *SubscriptionPlanGroupMutation) {
+ var (
+ err error
+ once sync.Once
+ value *SubscriptionPlanGroup
+ )
+ m.oldValue = func(ctx context.Context) (*SubscriptionPlanGroup, error) {
+ once.Do(func() {
+ if m.done {
+ err = errors.New("querying old values post mutation is not allowed")
+ } else {
+ value, err = m.Client().SubscriptionPlanGroup.Get(ctx, id)
+ }
+ })
+ return value, err
+ }
+ m.id = &id
+ }
+}
+
+// withSubscriptionPlanGroup sets the old SubscriptionPlanGroup of the mutation.
+func withSubscriptionPlanGroup(node *SubscriptionPlanGroup) subscriptionplangroupOption {
+ return func(m *SubscriptionPlanGroupMutation) {
+ m.oldValue = func(context.Context) (*SubscriptionPlanGroup, error) {
+ return node, nil
+ }
+ m.id = &node.ID
+ }
+}
+
+// Client returns a new `ent.Client` from the mutation. If the mutation was
+// executed in a transaction (ent.Tx), a transactional client is returned.
+func (m SubscriptionPlanGroupMutation) Client() *Client {
+ client := &Client{config: m.config}
+ client.init()
+ return client
+}
+
+// Tx returns an `ent.Tx` for mutations that were executed in transactions;
+// it returns an error otherwise.
+func (m SubscriptionPlanGroupMutation) Tx() (*Tx, error) {
+ if _, ok := m.driver.(*txDriver); !ok {
+ return nil, errors.New("ent: mutation is not running in a transaction")
+ }
+ tx := &Tx{config: m.config}
+ tx.init()
+ return tx, nil
+}
+
+// ID returns the ID value in the mutation. Note that the ID is only available
+// if it was provided to the builder or after it was returned from the database.
+func (m *SubscriptionPlanGroupMutation) ID() (id int64, exists bool) {
+ if m.id == nil {
+ return
+ }
+ return *m.id, true
+}
+
+// IDs queries the database and returns the entity ids that match the mutation's predicate.
+// That means, if the mutation is applied within a transaction with an isolation level such
+// as sql.LevelSerializable, the returned ids match the ids of the rows that will be updated
+// or updated by the mutation.
+func (m *SubscriptionPlanGroupMutation) IDs(ctx context.Context) ([]int64, error) {
+ switch {
+ case m.op.Is(OpUpdateOne | OpDeleteOne):
+ id, exists := m.ID()
+ if exists {
+ return []int64{id}, nil
+ }
+ fallthrough
+ case m.op.Is(OpUpdate | OpDelete):
+ return m.Client().SubscriptionPlanGroup.Query().Where(m.predicates...).IDs(ctx)
+ default:
+ return nil, fmt.Errorf("IDs is not allowed on %s operations", m.op)
+ }
+}
+
+// SetPlanID sets the "plan_id" field.
+func (m *SubscriptionPlanGroupMutation) SetPlanID(i int64) {
+ m.plan = &i
+}
+
+// PlanID returns the value of the "plan_id" field in the mutation.
+func (m *SubscriptionPlanGroupMutation) PlanID() (r int64, exists bool) {
+ v := m.plan
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldPlanID returns the old "plan_id" field's value of the SubscriptionPlanGroup entity.
+// If the SubscriptionPlanGroup object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *SubscriptionPlanGroupMutation) OldPlanID(ctx context.Context) (v int64, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldPlanID is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldPlanID requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldPlanID: %w", err)
+ }
+ return oldValue.PlanID, nil
+}
+
+// ResetPlanID resets all changes to the "plan_id" field.
+func (m *SubscriptionPlanGroupMutation) ResetPlanID() {
+ m.plan = nil
+}
+
+// SetGroupID sets the "group_id" field.
+func (m *SubscriptionPlanGroupMutation) SetGroupID(i int64) {
+ m.group = &i
+}
+
+// GroupID returns the value of the "group_id" field in the mutation.
+func (m *SubscriptionPlanGroupMutation) GroupID() (r int64, exists bool) {
+ v := m.group
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldGroupID returns the old "group_id" field's value of the SubscriptionPlanGroup entity.
+// If the SubscriptionPlanGroup object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *SubscriptionPlanGroupMutation) OldGroupID(ctx context.Context) (v int64, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldGroupID is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldGroupID requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldGroupID: %w", err)
+ }
+ return oldValue.GroupID, nil
+}
+
+// ResetGroupID resets all changes to the "group_id" field.
+func (m *SubscriptionPlanGroupMutation) ResetGroupID() {
+ m.group = nil
+}
+
+// SetCreatedAt sets the "created_at" field.
+func (m *SubscriptionPlanGroupMutation) SetCreatedAt(t time.Time) {
+ m.created_at = &t
+}
+
+// CreatedAt returns the value of the "created_at" field in the mutation.
+func (m *SubscriptionPlanGroupMutation) CreatedAt() (r time.Time, exists bool) {
+ v := m.created_at
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldCreatedAt returns the old "created_at" field's value of the SubscriptionPlanGroup entity.
+// If the SubscriptionPlanGroup object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *SubscriptionPlanGroupMutation) OldCreatedAt(ctx context.Context) (v time.Time, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldCreatedAt is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldCreatedAt requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldCreatedAt: %w", err)
+ }
+ return oldValue.CreatedAt, nil
+}
+
+// ResetCreatedAt resets all changes to the "created_at" field.
+func (m *SubscriptionPlanGroupMutation) ResetCreatedAt() {
+ m.created_at = nil
+}
+
+// ClearPlan clears the "plan" edge to the SubscriptionPlan entity.
+func (m *SubscriptionPlanGroupMutation) ClearPlan() {
+ m.clearedplan = true
+ m.clearedFields[subscriptionplangroup.FieldPlanID] = struct{}{}
+}
+
+// PlanCleared reports if the "plan" edge to the SubscriptionPlan entity was cleared.
+func (m *SubscriptionPlanGroupMutation) PlanCleared() bool {
+ return m.clearedplan
+}
+
+// PlanIDs returns the "plan" edge IDs in the mutation.
+// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use
+// PlanID instead. It exists only for internal usage by the builders.
+func (m *SubscriptionPlanGroupMutation) PlanIDs() (ids []int64) {
+ if id := m.plan; id != nil {
+ ids = append(ids, *id)
+ }
+ return
+}
+
+// ResetPlan resets all changes to the "plan" edge.
+func (m *SubscriptionPlanGroupMutation) ResetPlan() {
+ m.plan = nil
+ m.clearedplan = false
+}
+
+// ClearGroup clears the "group" edge to the Group entity.
+func (m *SubscriptionPlanGroupMutation) ClearGroup() {
+ m.clearedgroup = true
+ m.clearedFields[subscriptionplangroup.FieldGroupID] = struct{}{}
+}
+
+// GroupCleared reports if the "group" edge to the Group entity was cleared.
+func (m *SubscriptionPlanGroupMutation) GroupCleared() bool {
+ return m.clearedgroup
+}
+
+// GroupIDs returns the "group" edge IDs in the mutation.
+// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use
+// GroupID instead. It exists only for internal usage by the builders.
+func (m *SubscriptionPlanGroupMutation) GroupIDs() (ids []int64) {
+ if id := m.group; id != nil {
+ ids = append(ids, *id)
+ }
+ return
+}
+
+// ResetGroup resets all changes to the "group" edge.
+func (m *SubscriptionPlanGroupMutation) ResetGroup() {
+ m.group = nil
+ m.clearedgroup = false
+}
+
+// Where appends a list predicates to the SubscriptionPlanGroupMutation builder.
+func (m *SubscriptionPlanGroupMutation) Where(ps ...predicate.SubscriptionPlanGroup) {
+ m.predicates = append(m.predicates, ps...)
+}
+
+// WhereP appends storage-level predicates to the SubscriptionPlanGroupMutation builder. Using this method,
+// users can use type-assertion to append predicates that do not depend on any generated package.
+func (m *SubscriptionPlanGroupMutation) WhereP(ps ...func(*sql.Selector)) {
+ p := make([]predicate.SubscriptionPlanGroup, len(ps))
+ for i := range ps {
+ p[i] = ps[i]
+ }
+ m.Where(p...)
+}
+
+// Op returns the operation name.
+func (m *SubscriptionPlanGroupMutation) Op() Op {
+ return m.op
+}
+
+// SetOp allows setting the mutation operation.
+func (m *SubscriptionPlanGroupMutation) SetOp(op Op) {
+ m.op = op
+}
+
+// Type returns the node type of this mutation (SubscriptionPlanGroup).
+func (m *SubscriptionPlanGroupMutation) Type() string {
+ return m.typ
+}
+
+// Fields returns all fields that were changed during this mutation. Note that in
+// order to get all numeric fields that were incremented/decremented, call
+// AddedFields().
+func (m *SubscriptionPlanGroupMutation) Fields() []string {
+ fields := make([]string, 0, 3)
+ if m.plan != nil {
+ fields = append(fields, subscriptionplangroup.FieldPlanID)
+ }
+ if m.group != nil {
+ fields = append(fields, subscriptionplangroup.FieldGroupID)
+ }
+ if m.created_at != nil {
+ fields = append(fields, subscriptionplangroup.FieldCreatedAt)
+ }
+ return fields
+}
+
+// Field returns the value of a field with the given name. The second boolean
+// return value indicates that this field was not set, or was not defined in the
+// schema.
+func (m *SubscriptionPlanGroupMutation) Field(name string) (ent.Value, bool) {
+ switch name {
+ case subscriptionplangroup.FieldPlanID:
+ return m.PlanID()
+ case subscriptionplangroup.FieldGroupID:
+ return m.GroupID()
+ case subscriptionplangroup.FieldCreatedAt:
+ return m.CreatedAt()
+ }
+ return nil, false
+}
+
+// OldField returns the old value of the field from the database. An error is
+// returned if the mutation operation is not UpdateOne, or the query to the
+// database failed.
+func (m *SubscriptionPlanGroupMutation) OldField(ctx context.Context, name string) (ent.Value, error) {
+ switch name {
+ case subscriptionplangroup.FieldPlanID:
+ return m.OldPlanID(ctx)
+ case subscriptionplangroup.FieldGroupID:
+ return m.OldGroupID(ctx)
+ case subscriptionplangroup.FieldCreatedAt:
+ return m.OldCreatedAt(ctx)
+ }
+ return nil, fmt.Errorf("unknown SubscriptionPlanGroup field %s", name)
+}
+
+// SetField sets the value of a field with the given name. It returns an error if
+// the field is not defined in the schema, or if the type mismatched the field
+// type.
+func (m *SubscriptionPlanGroupMutation) SetField(name string, value ent.Value) error {
+ switch name {
+ case subscriptionplangroup.FieldPlanID:
+ v, ok := value.(int64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetPlanID(v)
+ return nil
+ case subscriptionplangroup.FieldGroupID:
+ v, ok := value.(int64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetGroupID(v)
+ return nil
+ case subscriptionplangroup.FieldCreatedAt:
+ v, ok := value.(time.Time)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetCreatedAt(v)
+ return nil
+ }
+ return fmt.Errorf("unknown SubscriptionPlanGroup field %s", name)
+}
+
+// AddedFields returns all numeric fields that were incremented/decremented during
+// this mutation.
+func (m *SubscriptionPlanGroupMutation) AddedFields() []string {
+ var fields []string
+ return fields
+}
+
+// AddedField returns the numeric value that was incremented/decremented on a field
+// with the given name. The second boolean return value indicates that this field
+// was not set, or was not defined in the schema.
+func (m *SubscriptionPlanGroupMutation) AddedField(name string) (ent.Value, bool) {
+ switch name {
+ }
+ return nil, false
+}
+
+// AddField adds the value to the field with the given name. It returns an error if
+// the field is not defined in the schema, or if the type mismatched the field
+// type.
+func (m *SubscriptionPlanGroupMutation) AddField(name string, value ent.Value) error {
+ switch name {
+ }
+ return fmt.Errorf("unknown SubscriptionPlanGroup numeric field %s", name)
+}
+
+// ClearedFields returns all nullable fields that were cleared during this
+// mutation.
+func (m *SubscriptionPlanGroupMutation) ClearedFields() []string {
+ return nil
+}
+
+// FieldCleared returns a boolean indicating if a field with the given name was
+// cleared in this mutation.
+func (m *SubscriptionPlanGroupMutation) FieldCleared(name string) bool {
+ _, ok := m.clearedFields[name]
+ return ok
+}
+
+// ClearField clears the value of the field with the given name. It returns an
+// error if the field is not defined in the schema.
+func (m *SubscriptionPlanGroupMutation) ClearField(name string) error {
+ return fmt.Errorf("unknown SubscriptionPlanGroup nullable field %s", name)
+}
+
+// ResetField resets all changes in the mutation for the field with the given name.
+// It returns an error if the field is not defined in the schema.
+func (m *SubscriptionPlanGroupMutation) ResetField(name string) error {
+ switch name {
+ case subscriptionplangroup.FieldPlanID:
+ m.ResetPlanID()
+ return nil
+ case subscriptionplangroup.FieldGroupID:
+ m.ResetGroupID()
+ return nil
+ case subscriptionplangroup.FieldCreatedAt:
+ m.ResetCreatedAt()
+ return nil
+ }
+ return fmt.Errorf("unknown SubscriptionPlanGroup field %s", name)
+}
+
+// AddedEdges returns all edge names that were set/added in this mutation.
+func (m *SubscriptionPlanGroupMutation) AddedEdges() []string {
+ edges := make([]string, 0, 2)
+ if m.plan != nil {
+ edges = append(edges, subscriptionplangroup.EdgePlan)
+ }
+ if m.group != nil {
+ edges = append(edges, subscriptionplangroup.EdgeGroup)
+ }
+ return edges
+}
+
+// AddedIDs returns all IDs (to other nodes) that were added for the given edge
+// name in this mutation.
+func (m *SubscriptionPlanGroupMutation) AddedIDs(name string) []ent.Value {
+ switch name {
+ case subscriptionplangroup.EdgePlan:
+ if id := m.plan; id != nil {
+ return []ent.Value{*id}
+ }
+ case subscriptionplangroup.EdgeGroup:
+ if id := m.group; id != nil {
+ return []ent.Value{*id}
+ }
+ }
+ return nil
+}
+
+// RemovedEdges returns all edge names that were removed in this mutation.
+func (m *SubscriptionPlanGroupMutation) RemovedEdges() []string {
+ edges := make([]string, 0, 2)
+ return edges
+}
+
+// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with
+// the given name in this mutation.
+func (m *SubscriptionPlanGroupMutation) RemovedIDs(name string) []ent.Value {
+ return nil
+}
+
+// ClearedEdges returns all edge names that were cleared in this mutation.
+func (m *SubscriptionPlanGroupMutation) ClearedEdges() []string {
+ edges := make([]string, 0, 2)
+ if m.clearedplan {
+ edges = append(edges, subscriptionplangroup.EdgePlan)
+ }
+ if m.clearedgroup {
+ edges = append(edges, subscriptionplangroup.EdgeGroup)
+ }
+ return edges
+}
+
+// EdgeCleared returns a boolean which indicates if the edge with the given name
+// was cleared in this mutation.
+func (m *SubscriptionPlanGroupMutation) EdgeCleared(name string) bool {
+ switch name {
+ case subscriptionplangroup.EdgePlan:
+ return m.clearedplan
+ case subscriptionplangroup.EdgeGroup:
+ return m.clearedgroup
+ }
+ return false
+}
+
+// ClearEdge clears the value of the edge with the given name. It returns an error
+// if that edge is not defined in the schema.
+func (m *SubscriptionPlanGroupMutation) ClearEdge(name string) error {
+ switch name {
+ case subscriptionplangroup.EdgePlan:
+ m.ClearPlan()
+ return nil
+ case subscriptionplangroup.EdgeGroup:
+ m.ClearGroup()
+ return nil
+ }
+ return fmt.Errorf("unknown SubscriptionPlanGroup unique edge %s", name)
+}
+
+// ResetEdge resets all changes to the edge with the given name in this mutation.
+// It returns an error if the edge is not defined in the schema.
+func (m *SubscriptionPlanGroupMutation) ResetEdge(name string) error {
+ switch name {
+ case subscriptionplangroup.EdgePlan:
+ m.ResetPlan()
+ return nil
+ case subscriptionplangroup.EdgeGroup:
+ m.ResetGroup()
+ return nil
+ }
+ return fmt.Errorf("unknown SubscriptionPlanGroup edge %s", name)
+}
+
+// SubscriptionWalletLedgerMutation represents an operation that mutates the SubscriptionWalletLedger nodes in the graph.
+type SubscriptionWalletLedgerMutation struct {
+ config
+ op Op
+ typ string
+ id *int64
+ delta_usd *float64
+ adddelta_usd *float64
+ balance_after *float64
+ addbalance_after *float64
+ reason *string
+ payment_order_id *int64
+ addpayment_order_id *int64
+ notes *string
+ created_at *time.Time
+ clearedFields map[string]struct{}
+ subscription *int64
+ clearedsubscription bool
+ usage_log *int64
+ clearedusage_log bool
+ operator *int64
+ clearedoperator bool
+ done bool
+ oldValue func(context.Context) (*SubscriptionWalletLedger, error)
+ predicates []predicate.SubscriptionWalletLedger
+}
+
+var _ ent.Mutation = (*SubscriptionWalletLedgerMutation)(nil)
+
+// subscriptionwalletledgerOption allows management of the mutation configuration using functional options.
+type subscriptionwalletledgerOption func(*SubscriptionWalletLedgerMutation)
+
+// newSubscriptionWalletLedgerMutation creates new mutation for the SubscriptionWalletLedger entity.
+func newSubscriptionWalletLedgerMutation(c config, op Op, opts ...subscriptionwalletledgerOption) *SubscriptionWalletLedgerMutation {
+ m := &SubscriptionWalletLedgerMutation{
+ config: c,
+ op: op,
+ typ: TypeSubscriptionWalletLedger,
+ clearedFields: make(map[string]struct{}),
+ }
+ for _, opt := range opts {
+ opt(m)
+ }
+ return m
+}
+
+// withSubscriptionWalletLedgerID sets the ID field of the mutation.
+func withSubscriptionWalletLedgerID(id int64) subscriptionwalletledgerOption {
+ return func(m *SubscriptionWalletLedgerMutation) {
+ var (
+ err error
+ once sync.Once
+ value *SubscriptionWalletLedger
+ )
+ m.oldValue = func(ctx context.Context) (*SubscriptionWalletLedger, error) {
+ once.Do(func() {
+ if m.done {
+ err = errors.New("querying old values post mutation is not allowed")
+ } else {
+ value, err = m.Client().SubscriptionWalletLedger.Get(ctx, id)
+ }
+ })
+ return value, err
+ }
+ m.id = &id
+ }
+}
+
+// withSubscriptionWalletLedger sets the old SubscriptionWalletLedger of the mutation.
+func withSubscriptionWalletLedger(node *SubscriptionWalletLedger) subscriptionwalletledgerOption {
+ return func(m *SubscriptionWalletLedgerMutation) {
+ m.oldValue = func(context.Context) (*SubscriptionWalletLedger, error) {
+ return node, nil
+ }
+ m.id = &node.ID
+ }
+}
+
+// Client returns a new `ent.Client` from the mutation. If the mutation was
+// executed in a transaction (ent.Tx), a transactional client is returned.
+func (m SubscriptionWalletLedgerMutation) Client() *Client {
+ client := &Client{config: m.config}
+ client.init()
+ return client
+}
+
+// Tx returns an `ent.Tx` for mutations that were executed in transactions;
+// it returns an error otherwise.
+func (m SubscriptionWalletLedgerMutation) Tx() (*Tx, error) {
+ if _, ok := m.driver.(*txDriver); !ok {
+ return nil, errors.New("ent: mutation is not running in a transaction")
+ }
+ tx := &Tx{config: m.config}
+ tx.init()
+ return tx, nil
+}
+
+// ID returns the ID value in the mutation. Note that the ID is only available
+// if it was provided to the builder or after it was returned from the database.
+func (m *SubscriptionWalletLedgerMutation) ID() (id int64, exists bool) {
+ if m.id == nil {
+ return
+ }
+ return *m.id, true
+}
+
+// IDs queries the database and returns the entity ids that match the mutation's predicate.
+// That means, if the mutation is applied within a transaction with an isolation level such
+// as sql.LevelSerializable, the returned ids match the ids of the rows that will be updated
+// or updated by the mutation.
+func (m *SubscriptionWalletLedgerMutation) IDs(ctx context.Context) ([]int64, error) {
+ switch {
+ case m.op.Is(OpUpdateOne | OpDeleteOne):
+ id, exists := m.ID()
+ if exists {
+ return []int64{id}, nil
+ }
+ fallthrough
+ case m.op.Is(OpUpdate | OpDelete):
+ return m.Client().SubscriptionWalletLedger.Query().Where(m.predicates...).IDs(ctx)
+ default:
+ return nil, fmt.Errorf("IDs is not allowed on %s operations", m.op)
+ }
+}
+
+// SetSubscriptionID sets the "subscription_id" field.
+func (m *SubscriptionWalletLedgerMutation) SetSubscriptionID(i int64) {
+ m.subscription = &i
+}
+
+// SubscriptionID returns the value of the "subscription_id" field in the mutation.
+func (m *SubscriptionWalletLedgerMutation) SubscriptionID() (r int64, exists bool) {
+ v := m.subscription
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldSubscriptionID returns the old "subscription_id" field's value of the SubscriptionWalletLedger entity.
+// If the SubscriptionWalletLedger object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *SubscriptionWalletLedgerMutation) OldSubscriptionID(ctx context.Context) (v int64, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldSubscriptionID is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldSubscriptionID requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldSubscriptionID: %w", err)
+ }
+ return oldValue.SubscriptionID, nil
+}
+
+// ResetSubscriptionID resets all changes to the "subscription_id" field.
+func (m *SubscriptionWalletLedgerMutation) ResetSubscriptionID() {
+ m.subscription = nil
+}
+
+// SetDeltaUsd sets the "delta_usd" field.
+func (m *SubscriptionWalletLedgerMutation) SetDeltaUsd(f float64) {
+ m.delta_usd = &f
+ m.adddelta_usd = nil
+}
+
+// DeltaUsd returns the value of the "delta_usd" field in the mutation.
+func (m *SubscriptionWalletLedgerMutation) DeltaUsd() (r float64, exists bool) {
+ v := m.delta_usd
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldDeltaUsd returns the old "delta_usd" field's value of the SubscriptionWalletLedger entity.
+// If the SubscriptionWalletLedger object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *SubscriptionWalletLedgerMutation) OldDeltaUsd(ctx context.Context) (v float64, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldDeltaUsd is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldDeltaUsd requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldDeltaUsd: %w", err)
+ }
+ return oldValue.DeltaUsd, nil
+}
+
+// AddDeltaUsd adds f to the "delta_usd" field.
+func (m *SubscriptionWalletLedgerMutation) AddDeltaUsd(f float64) {
+ if m.adddelta_usd != nil {
+ *m.adddelta_usd += f
+ } else {
+ m.adddelta_usd = &f
+ }
+}
+
+// AddedDeltaUsd returns the value that was added to the "delta_usd" field in this mutation.
+func (m *SubscriptionWalletLedgerMutation) AddedDeltaUsd() (r float64, exists bool) {
+ v := m.adddelta_usd
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// ResetDeltaUsd resets all changes to the "delta_usd" field.
+func (m *SubscriptionWalletLedgerMutation) ResetDeltaUsd() {
+ m.delta_usd = nil
+ m.adddelta_usd = nil
+}
+
+// SetBalanceAfter sets the "balance_after" field.
+func (m *SubscriptionWalletLedgerMutation) SetBalanceAfter(f float64) {
+ m.balance_after = &f
+ m.addbalance_after = nil
+}
+
+// BalanceAfter returns the value of the "balance_after" field in the mutation.
+func (m *SubscriptionWalletLedgerMutation) BalanceAfter() (r float64, exists bool) {
+ v := m.balance_after
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldBalanceAfter returns the old "balance_after" field's value of the SubscriptionWalletLedger entity.
+// If the SubscriptionWalletLedger object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *SubscriptionWalletLedgerMutation) OldBalanceAfter(ctx context.Context) (v float64, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldBalanceAfter is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldBalanceAfter requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldBalanceAfter: %w", err)
+ }
+ return oldValue.BalanceAfter, nil
+}
+
+// AddBalanceAfter adds f to the "balance_after" field.
+func (m *SubscriptionWalletLedgerMutation) AddBalanceAfter(f float64) {
+ if m.addbalance_after != nil {
+ *m.addbalance_after += f
+ } else {
+ m.addbalance_after = &f
+ }
+}
+
+// AddedBalanceAfter returns the value that was added to the "balance_after" field in this mutation.
+func (m *SubscriptionWalletLedgerMutation) AddedBalanceAfter() (r float64, exists bool) {
+ v := m.addbalance_after
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// ResetBalanceAfter resets all changes to the "balance_after" field.
+func (m *SubscriptionWalletLedgerMutation) ResetBalanceAfter() {
+ m.balance_after = nil
+ m.addbalance_after = nil
+}
+
+// SetReason sets the "reason" field.
+func (m *SubscriptionWalletLedgerMutation) SetReason(s string) {
+ m.reason = &s
+}
+
+// Reason returns the value of the "reason" field in the mutation.
+func (m *SubscriptionWalletLedgerMutation) Reason() (r string, exists bool) {
+ v := m.reason
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldReason returns the old "reason" field's value of the SubscriptionWalletLedger entity.
+// If the SubscriptionWalletLedger object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *SubscriptionWalletLedgerMutation) OldReason(ctx context.Context) (v string, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldReason is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldReason requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldReason: %w", err)
+ }
+ return oldValue.Reason, nil
+}
+
+// ResetReason resets all changes to the "reason" field.
+func (m *SubscriptionWalletLedgerMutation) ResetReason() {
+ m.reason = nil
+}
+
+// SetPaymentOrderID sets the "payment_order_id" field.
+func (m *SubscriptionWalletLedgerMutation) SetPaymentOrderID(i int64) {
+ m.payment_order_id = &i
+ m.addpayment_order_id = nil
+}
+
+// PaymentOrderID returns the value of the "payment_order_id" field in the mutation.
+func (m *SubscriptionWalletLedgerMutation) PaymentOrderID() (r int64, exists bool) {
+ v := m.payment_order_id
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldPaymentOrderID returns the old "payment_order_id" field's value of the SubscriptionWalletLedger entity.
+// If the SubscriptionWalletLedger object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *SubscriptionWalletLedgerMutation) OldPaymentOrderID(ctx context.Context) (v *int64, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldPaymentOrderID is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldPaymentOrderID requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldPaymentOrderID: %w", err)
+ }
+ return oldValue.PaymentOrderID, nil
+}
+
+// AddPaymentOrderID adds i to the "payment_order_id" field.
+func (m *SubscriptionWalletLedgerMutation) AddPaymentOrderID(i int64) {
+ if m.addpayment_order_id != nil {
+ *m.addpayment_order_id += i
+ } else {
+ m.addpayment_order_id = &i
+ }
+}
+
+// AddedPaymentOrderID returns the value that was added to the "payment_order_id" field in this mutation.
+func (m *SubscriptionWalletLedgerMutation) AddedPaymentOrderID() (r int64, exists bool) {
+ v := m.addpayment_order_id
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// ClearPaymentOrderID clears the value of the "payment_order_id" field.
+func (m *SubscriptionWalletLedgerMutation) ClearPaymentOrderID() {
+ m.payment_order_id = nil
+ m.addpayment_order_id = nil
+ m.clearedFields[subscriptionwalletledger.FieldPaymentOrderID] = struct{}{}
+}
+
+// PaymentOrderIDCleared returns if the "payment_order_id" field was cleared in this mutation.
+func (m *SubscriptionWalletLedgerMutation) PaymentOrderIDCleared() bool {
+ _, ok := m.clearedFields[subscriptionwalletledger.FieldPaymentOrderID]
+ return ok
+}
+
+// ResetPaymentOrderID resets all changes to the "payment_order_id" field.
+func (m *SubscriptionWalletLedgerMutation) ResetPaymentOrderID() {
+ m.payment_order_id = nil
+ m.addpayment_order_id = nil
+ delete(m.clearedFields, subscriptionwalletledger.FieldPaymentOrderID)
+}
+
+// SetUsageLogID sets the "usage_log_id" field.
+func (m *SubscriptionWalletLedgerMutation) SetUsageLogID(i int64) {
+ m.usage_log = &i
+}
+
+// UsageLogID returns the value of the "usage_log_id" field in the mutation.
+func (m *SubscriptionWalletLedgerMutation) UsageLogID() (r int64, exists bool) {
+ v := m.usage_log
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldUsageLogID returns the old "usage_log_id" field's value of the SubscriptionWalletLedger entity.
+// If the SubscriptionWalletLedger object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *SubscriptionWalletLedgerMutation) OldUsageLogID(ctx context.Context) (v *int64, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldUsageLogID is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldUsageLogID requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldUsageLogID: %w", err)
+ }
+ return oldValue.UsageLogID, nil
+}
+
+// ClearUsageLogID clears the value of the "usage_log_id" field.
+func (m *SubscriptionWalletLedgerMutation) ClearUsageLogID() {
+ m.usage_log = nil
+ m.clearedFields[subscriptionwalletledger.FieldUsageLogID] = struct{}{}
+}
+
+// UsageLogIDCleared returns if the "usage_log_id" field was cleared in this mutation.
+func (m *SubscriptionWalletLedgerMutation) UsageLogIDCleared() bool {
+ _, ok := m.clearedFields[subscriptionwalletledger.FieldUsageLogID]
+ return ok
+}
+
+// ResetUsageLogID resets all changes to the "usage_log_id" field.
+func (m *SubscriptionWalletLedgerMutation) ResetUsageLogID() {
+ m.usage_log = nil
+ delete(m.clearedFields, subscriptionwalletledger.FieldUsageLogID)
+}
+
+// SetOperatorID sets the "operator_id" field.
+func (m *SubscriptionWalletLedgerMutation) SetOperatorID(i int64) {
+ m.operator = &i
+}
+
+// OperatorID returns the value of the "operator_id" field in the mutation.
+func (m *SubscriptionWalletLedgerMutation) OperatorID() (r int64, exists bool) {
+ v := m.operator
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldOperatorID returns the old "operator_id" field's value of the SubscriptionWalletLedger entity.
+// If the SubscriptionWalletLedger object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *SubscriptionWalletLedgerMutation) OldOperatorID(ctx context.Context) (v *int64, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldOperatorID is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldOperatorID requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldOperatorID: %w", err)
+ }
+ return oldValue.OperatorID, nil
+}
+
+// ClearOperatorID clears the value of the "operator_id" field.
+func (m *SubscriptionWalletLedgerMutation) ClearOperatorID() {
+ m.operator = nil
+ m.clearedFields[subscriptionwalletledger.FieldOperatorID] = struct{}{}
+}
+
+// OperatorIDCleared returns if the "operator_id" field was cleared in this mutation.
+func (m *SubscriptionWalletLedgerMutation) OperatorIDCleared() bool {
+ _, ok := m.clearedFields[subscriptionwalletledger.FieldOperatorID]
+ return ok
+}
+
+// ResetOperatorID resets all changes to the "operator_id" field.
+func (m *SubscriptionWalletLedgerMutation) ResetOperatorID() {
+ m.operator = nil
+ delete(m.clearedFields, subscriptionwalletledger.FieldOperatorID)
+}
+
+// SetNotes sets the "notes" field.
+func (m *SubscriptionWalletLedgerMutation) SetNotes(s string) {
+ m.notes = &s
+}
+
+// Notes returns the value of the "notes" field in the mutation.
+func (m *SubscriptionWalletLedgerMutation) Notes() (r string, exists bool) {
+ v := m.notes
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldNotes returns the old "notes" field's value of the SubscriptionWalletLedger entity.
+// If the SubscriptionWalletLedger object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *SubscriptionWalletLedgerMutation) OldNotes(ctx context.Context) (v *string, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldNotes is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldNotes requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldNotes: %w", err)
+ }
+ return oldValue.Notes, nil
+}
+
+// ClearNotes clears the value of the "notes" field.
+func (m *SubscriptionWalletLedgerMutation) ClearNotes() {
+ m.notes = nil
+ m.clearedFields[subscriptionwalletledger.FieldNotes] = struct{}{}
+}
+
+// NotesCleared returns if the "notes" field was cleared in this mutation.
+func (m *SubscriptionWalletLedgerMutation) NotesCleared() bool {
+ _, ok := m.clearedFields[subscriptionwalletledger.FieldNotes]
+ return ok
+}
+
+// ResetNotes resets all changes to the "notes" field.
+func (m *SubscriptionWalletLedgerMutation) ResetNotes() {
+ m.notes = nil
+ delete(m.clearedFields, subscriptionwalletledger.FieldNotes)
+}
+
+// SetCreatedAt sets the "created_at" field.
+func (m *SubscriptionWalletLedgerMutation) SetCreatedAt(t time.Time) {
m.created_at = &t
}
// CreatedAt returns the value of the "created_at" field in the mutation.
-func (m *SubscriptionPlanMutation) CreatedAt() (r time.Time, exists bool) {
+func (m *SubscriptionWalletLedgerMutation) CreatedAt() (r time.Time, exists bool) {
v := m.created_at
if v == nil {
return
@@ -31040,10 +33474,10 @@ func (m *SubscriptionPlanMutation) CreatedAt() (r time.Time, exists bool) {
return *v, true
}
-// OldCreatedAt returns the old "created_at" field's value of the SubscriptionPlan entity.
-// If the SubscriptionPlan object wasn't provided to the builder, the object is fetched from the database.
+// OldCreatedAt returns the old "created_at" field's value of the SubscriptionWalletLedger entity.
+// If the SubscriptionWalletLedger object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
-func (m *SubscriptionPlanMutation) OldCreatedAt(ctx context.Context) (v time.Time, err error) {
+func (m *SubscriptionWalletLedgerMutation) OldCreatedAt(ctx context.Context) (v time.Time, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldCreatedAt is only allowed on UpdateOne operations")
}
@@ -31058,55 +33492,100 @@ func (m *SubscriptionPlanMutation) OldCreatedAt(ctx context.Context) (v time.Tim
}
// ResetCreatedAt resets all changes to the "created_at" field.
-func (m *SubscriptionPlanMutation) ResetCreatedAt() {
+func (m *SubscriptionWalletLedgerMutation) ResetCreatedAt() {
m.created_at = nil
}
-// SetUpdatedAt sets the "updated_at" field.
-func (m *SubscriptionPlanMutation) SetUpdatedAt(t time.Time) {
- m.updated_at = &t
+// ClearSubscription clears the "subscription" edge to the UserSubscription entity.
+func (m *SubscriptionWalletLedgerMutation) ClearSubscription() {
+ m.clearedsubscription = true
+ m.clearedFields[subscriptionwalletledger.FieldSubscriptionID] = struct{}{}
}
-// UpdatedAt returns the value of the "updated_at" field in the mutation.
-func (m *SubscriptionPlanMutation) UpdatedAt() (r time.Time, exists bool) {
- v := m.updated_at
- if v == nil {
- return
- }
- return *v, true
+// SubscriptionCleared reports if the "subscription" edge to the UserSubscription entity was cleared.
+func (m *SubscriptionWalletLedgerMutation) SubscriptionCleared() bool {
+ return m.clearedsubscription
}
-// OldUpdatedAt returns the old "updated_at" field's value of the SubscriptionPlan entity.
-// If the SubscriptionPlan object wasn't provided to the builder, the object is fetched from the database.
-// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
-func (m *SubscriptionPlanMutation) OldUpdatedAt(ctx context.Context) (v time.Time, err error) {
- if !m.op.Is(OpUpdateOne) {
- return v, errors.New("OldUpdatedAt is only allowed on UpdateOne operations")
+// SubscriptionIDs returns the "subscription" edge IDs in the mutation.
+// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use
+// SubscriptionID instead. It exists only for internal usage by the builders.
+func (m *SubscriptionWalletLedgerMutation) SubscriptionIDs() (ids []int64) {
+ if id := m.subscription; id != nil {
+ ids = append(ids, *id)
}
- if m.id == nil || m.oldValue == nil {
- return v, errors.New("OldUpdatedAt requires an ID field in the mutation")
+ return
+}
+
+// ResetSubscription resets all changes to the "subscription" edge.
+func (m *SubscriptionWalletLedgerMutation) ResetSubscription() {
+ m.subscription = nil
+ m.clearedsubscription = false
+}
+
+// ClearUsageLog clears the "usage_log" edge to the UsageLog entity.
+func (m *SubscriptionWalletLedgerMutation) ClearUsageLog() {
+ m.clearedusage_log = true
+ m.clearedFields[subscriptionwalletledger.FieldUsageLogID] = struct{}{}
+}
+
+// UsageLogCleared reports if the "usage_log" edge to the UsageLog entity was cleared.
+func (m *SubscriptionWalletLedgerMutation) UsageLogCleared() bool {
+ return m.UsageLogIDCleared() || m.clearedusage_log
+}
+
+// UsageLogIDs returns the "usage_log" edge IDs in the mutation.
+// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use
+// UsageLogID instead. It exists only for internal usage by the builders.
+func (m *SubscriptionWalletLedgerMutation) UsageLogIDs() (ids []int64) {
+ if id := m.usage_log; id != nil {
+ ids = append(ids, *id)
}
- oldValue, err := m.oldValue(ctx)
- if err != nil {
- return v, fmt.Errorf("querying old value for OldUpdatedAt: %w", err)
+ return
+}
+
+// ResetUsageLog resets all changes to the "usage_log" edge.
+func (m *SubscriptionWalletLedgerMutation) ResetUsageLog() {
+ m.usage_log = nil
+ m.clearedusage_log = false
+}
+
+// ClearOperator clears the "operator" edge to the User entity.
+func (m *SubscriptionWalletLedgerMutation) ClearOperator() {
+ m.clearedoperator = true
+ m.clearedFields[subscriptionwalletledger.FieldOperatorID] = struct{}{}
+}
+
+// OperatorCleared reports if the "operator" edge to the User entity was cleared.
+func (m *SubscriptionWalletLedgerMutation) OperatorCleared() bool {
+ return m.OperatorIDCleared() || m.clearedoperator
+}
+
+// OperatorIDs returns the "operator" edge IDs in the mutation.
+// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use
+// OperatorID instead. It exists only for internal usage by the builders.
+func (m *SubscriptionWalletLedgerMutation) OperatorIDs() (ids []int64) {
+ if id := m.operator; id != nil {
+ ids = append(ids, *id)
}
- return oldValue.UpdatedAt, nil
+ return
}
-// ResetUpdatedAt resets all changes to the "updated_at" field.
-func (m *SubscriptionPlanMutation) ResetUpdatedAt() {
- m.updated_at = nil
+// ResetOperator resets all changes to the "operator" edge.
+func (m *SubscriptionWalletLedgerMutation) ResetOperator() {
+ m.operator = nil
+ m.clearedoperator = false
}
-// Where appends a list predicates to the SubscriptionPlanMutation builder.
-func (m *SubscriptionPlanMutation) Where(ps ...predicate.SubscriptionPlan) {
+// Where appends a list predicates to the SubscriptionWalletLedgerMutation builder.
+func (m *SubscriptionWalletLedgerMutation) Where(ps ...predicate.SubscriptionWalletLedger) {
m.predicates = append(m.predicates, ps...)
}
-// WhereP appends storage-level predicates to the SubscriptionPlanMutation builder. Using this method,
+// WhereP appends storage-level predicates to the SubscriptionWalletLedgerMutation builder. Using this method,
// users can use type-assertion to append predicates that do not depend on any generated package.
-func (m *SubscriptionPlanMutation) WhereP(ps ...func(*sql.Selector)) {
- p := make([]predicate.SubscriptionPlan, len(ps))
+func (m *SubscriptionWalletLedgerMutation) WhereP(ps ...func(*sql.Selector)) {
+ p := make([]predicate.SubscriptionWalletLedger, len(ps))
for i := range ps {
p[i] = ps[i]
}
@@ -31114,63 +33593,51 @@ func (m *SubscriptionPlanMutation) WhereP(ps ...func(*sql.Selector)) {
}
// Op returns the operation name.
-func (m *SubscriptionPlanMutation) Op() Op {
+func (m *SubscriptionWalletLedgerMutation) Op() Op {
return m.op
}
// SetOp allows setting the mutation operation.
-func (m *SubscriptionPlanMutation) SetOp(op Op) {
+func (m *SubscriptionWalletLedgerMutation) SetOp(op Op) {
m.op = op
}
-// Type returns the node type of this mutation (SubscriptionPlan).
-func (m *SubscriptionPlanMutation) Type() string {
+// Type returns the node type of this mutation (SubscriptionWalletLedger).
+func (m *SubscriptionWalletLedgerMutation) Type() string {
return m.typ
}
// Fields returns all fields that were changed during this mutation. Note that in
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
-func (m *SubscriptionPlanMutation) Fields() []string {
- fields := make([]string, 0, 13)
- if m.group_id != nil {
- fields = append(fields, subscriptionplan.FieldGroupID)
- }
- if m.name != nil {
- fields = append(fields, subscriptionplan.FieldName)
- }
- if m.description != nil {
- fields = append(fields, subscriptionplan.FieldDescription)
- }
- if m.price != nil {
- fields = append(fields, subscriptionplan.FieldPrice)
+func (m *SubscriptionWalletLedgerMutation) Fields() []string {
+ fields := make([]string, 0, 9)
+ if m.subscription != nil {
+ fields = append(fields, subscriptionwalletledger.FieldSubscriptionID)
}
- if m.original_price != nil {
- fields = append(fields, subscriptionplan.FieldOriginalPrice)
+ if m.delta_usd != nil {
+ fields = append(fields, subscriptionwalletledger.FieldDeltaUsd)
}
- if m.validity_days != nil {
- fields = append(fields, subscriptionplan.FieldValidityDays)
+ if m.balance_after != nil {
+ fields = append(fields, subscriptionwalletledger.FieldBalanceAfter)
}
- if m.validity_unit != nil {
- fields = append(fields, subscriptionplan.FieldValidityUnit)
+ if m.reason != nil {
+ fields = append(fields, subscriptionwalletledger.FieldReason)
}
- if m.features != nil {
- fields = append(fields, subscriptionplan.FieldFeatures)
+ if m.payment_order_id != nil {
+ fields = append(fields, subscriptionwalletledger.FieldPaymentOrderID)
}
- if m.product_name != nil {
- fields = append(fields, subscriptionplan.FieldProductName)
+ if m.usage_log != nil {
+ fields = append(fields, subscriptionwalletledger.FieldUsageLogID)
}
- if m.for_sale != nil {
- fields = append(fields, subscriptionplan.FieldForSale)
+ if m.operator != nil {
+ fields = append(fields, subscriptionwalletledger.FieldOperatorID)
}
- if m.sort_order != nil {
- fields = append(fields, subscriptionplan.FieldSortOrder)
+ if m.notes != nil {
+ fields = append(fields, subscriptionwalletledger.FieldNotes)
}
if m.created_at != nil {
- fields = append(fields, subscriptionplan.FieldCreatedAt)
- }
- if m.updated_at != nil {
- fields = append(fields, subscriptionplan.FieldUpdatedAt)
+ fields = append(fields, subscriptionwalletledger.FieldCreatedAt)
}
return fields
}
@@ -31178,34 +33645,26 @@ func (m *SubscriptionPlanMutation) Fields() []string {
// Field returns the value of a field with the given name. The second boolean
// return value indicates that this field was not set, or was not defined in the
// schema.
-func (m *SubscriptionPlanMutation) Field(name string) (ent.Value, bool) {
+func (m *SubscriptionWalletLedgerMutation) Field(name string) (ent.Value, bool) {
switch name {
- case subscriptionplan.FieldGroupID:
- return m.GroupID()
- case subscriptionplan.FieldName:
- return m.Name()
- case subscriptionplan.FieldDescription:
- return m.Description()
- case subscriptionplan.FieldPrice:
- return m.Price()
- case subscriptionplan.FieldOriginalPrice:
- return m.OriginalPrice()
- case subscriptionplan.FieldValidityDays:
- return m.ValidityDays()
- case subscriptionplan.FieldValidityUnit:
- return m.ValidityUnit()
- case subscriptionplan.FieldFeatures:
- return m.Features()
- case subscriptionplan.FieldProductName:
- return m.ProductName()
- case subscriptionplan.FieldForSale:
- return m.ForSale()
- case subscriptionplan.FieldSortOrder:
- return m.SortOrder()
- case subscriptionplan.FieldCreatedAt:
+ case subscriptionwalletledger.FieldSubscriptionID:
+ return m.SubscriptionID()
+ case subscriptionwalletledger.FieldDeltaUsd:
+ return m.DeltaUsd()
+ case subscriptionwalletledger.FieldBalanceAfter:
+ return m.BalanceAfter()
+ case subscriptionwalletledger.FieldReason:
+ return m.Reason()
+ case subscriptionwalletledger.FieldPaymentOrderID:
+ return m.PaymentOrderID()
+ case subscriptionwalletledger.FieldUsageLogID:
+ return m.UsageLogID()
+ case subscriptionwalletledger.FieldOperatorID:
+ return m.OperatorID()
+ case subscriptionwalletledger.FieldNotes:
+ return m.Notes()
+ case subscriptionwalletledger.FieldCreatedAt:
return m.CreatedAt()
- case subscriptionplan.FieldUpdatedAt:
- return m.UpdatedAt()
}
return nil, false
}
@@ -31213,156 +33672,114 @@ func (m *SubscriptionPlanMutation) Field(name string) (ent.Value, bool) {
// OldField returns the old value of the field from the database. An error is
// returned if the mutation operation is not UpdateOne, or the query to the
// database failed.
-func (m *SubscriptionPlanMutation) OldField(ctx context.Context, name string) (ent.Value, error) {
+func (m *SubscriptionWalletLedgerMutation) OldField(ctx context.Context, name string) (ent.Value, error) {
switch name {
- case subscriptionplan.FieldGroupID:
- return m.OldGroupID(ctx)
- case subscriptionplan.FieldName:
- return m.OldName(ctx)
- case subscriptionplan.FieldDescription:
- return m.OldDescription(ctx)
- case subscriptionplan.FieldPrice:
- return m.OldPrice(ctx)
- case subscriptionplan.FieldOriginalPrice:
- return m.OldOriginalPrice(ctx)
- case subscriptionplan.FieldValidityDays:
- return m.OldValidityDays(ctx)
- case subscriptionplan.FieldValidityUnit:
- return m.OldValidityUnit(ctx)
- case subscriptionplan.FieldFeatures:
- return m.OldFeatures(ctx)
- case subscriptionplan.FieldProductName:
- return m.OldProductName(ctx)
- case subscriptionplan.FieldForSale:
- return m.OldForSale(ctx)
- case subscriptionplan.FieldSortOrder:
- return m.OldSortOrder(ctx)
- case subscriptionplan.FieldCreatedAt:
+ case subscriptionwalletledger.FieldSubscriptionID:
+ return m.OldSubscriptionID(ctx)
+ case subscriptionwalletledger.FieldDeltaUsd:
+ return m.OldDeltaUsd(ctx)
+ case subscriptionwalletledger.FieldBalanceAfter:
+ return m.OldBalanceAfter(ctx)
+ case subscriptionwalletledger.FieldReason:
+ return m.OldReason(ctx)
+ case subscriptionwalletledger.FieldPaymentOrderID:
+ return m.OldPaymentOrderID(ctx)
+ case subscriptionwalletledger.FieldUsageLogID:
+ return m.OldUsageLogID(ctx)
+ case subscriptionwalletledger.FieldOperatorID:
+ return m.OldOperatorID(ctx)
+ case subscriptionwalletledger.FieldNotes:
+ return m.OldNotes(ctx)
+ case subscriptionwalletledger.FieldCreatedAt:
return m.OldCreatedAt(ctx)
- case subscriptionplan.FieldUpdatedAt:
- return m.OldUpdatedAt(ctx)
}
- return nil, fmt.Errorf("unknown SubscriptionPlan field %s", name)
+ return nil, fmt.Errorf("unknown SubscriptionWalletLedger field %s", name)
}
// SetField sets the value of a field with the given name. It returns an error if
// the field is not defined in the schema, or if the type mismatched the field
// type.
-func (m *SubscriptionPlanMutation) SetField(name string, value ent.Value) error {
+func (m *SubscriptionWalletLedgerMutation) SetField(name string, value ent.Value) error {
switch name {
- case subscriptionplan.FieldGroupID:
+ case subscriptionwalletledger.FieldSubscriptionID:
v, ok := value.(int64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
- m.SetGroupID(v)
- return nil
- case subscriptionplan.FieldName:
- v, ok := value.(string)
- if !ok {
- return fmt.Errorf("unexpected type %T for field %s", value, name)
- }
- m.SetName(v)
- return nil
- case subscriptionplan.FieldDescription:
- v, ok := value.(string)
- if !ok {
- return fmt.Errorf("unexpected type %T for field %s", value, name)
- }
- m.SetDescription(v)
+ m.SetSubscriptionID(v)
return nil
- case subscriptionplan.FieldPrice:
+ case subscriptionwalletledger.FieldDeltaUsd:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
- m.SetPrice(v)
+ m.SetDeltaUsd(v)
return nil
- case subscriptionplan.FieldOriginalPrice:
+ case subscriptionwalletledger.FieldBalanceAfter:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
- m.SetOriginalPrice(v)
- return nil
- case subscriptionplan.FieldValidityDays:
- v, ok := value.(int)
- if !ok {
- return fmt.Errorf("unexpected type %T for field %s", value, name)
- }
- m.SetValidityDays(v)
+ m.SetBalanceAfter(v)
return nil
- case subscriptionplan.FieldValidityUnit:
+ case subscriptionwalletledger.FieldReason:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
- m.SetValidityUnit(v)
+ m.SetReason(v)
return nil
- case subscriptionplan.FieldFeatures:
- v, ok := value.(string)
+ case subscriptionwalletledger.FieldPaymentOrderID:
+ v, ok := value.(int64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
- m.SetFeatures(v)
+ m.SetPaymentOrderID(v)
return nil
- case subscriptionplan.FieldProductName:
- v, ok := value.(string)
+ case subscriptionwalletledger.FieldUsageLogID:
+ v, ok := value.(int64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
- m.SetProductName(v)
+ m.SetUsageLogID(v)
return nil
- case subscriptionplan.FieldForSale:
- v, ok := value.(bool)
+ case subscriptionwalletledger.FieldOperatorID:
+ v, ok := value.(int64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
- m.SetForSale(v)
+ m.SetOperatorID(v)
return nil
- case subscriptionplan.FieldSortOrder:
- v, ok := value.(int)
+ case subscriptionwalletledger.FieldNotes:
+ v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
- m.SetSortOrder(v)
+ m.SetNotes(v)
return nil
- case subscriptionplan.FieldCreatedAt:
+ case subscriptionwalletledger.FieldCreatedAt:
v, ok := value.(time.Time)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetCreatedAt(v)
return nil
- case subscriptionplan.FieldUpdatedAt:
- v, ok := value.(time.Time)
- if !ok {
- return fmt.Errorf("unexpected type %T for field %s", value, name)
- }
- m.SetUpdatedAt(v)
- return nil
}
- return fmt.Errorf("unknown SubscriptionPlan field %s", name)
+ return fmt.Errorf("unknown SubscriptionWalletLedger field %s", name)
}
// AddedFields returns all numeric fields that were incremented/decremented during
// this mutation.
-func (m *SubscriptionPlanMutation) AddedFields() []string {
+func (m *SubscriptionWalletLedgerMutation) AddedFields() []string {
var fields []string
- if m.addgroup_id != nil {
- fields = append(fields, subscriptionplan.FieldGroupID)
- }
- if m.addprice != nil {
- fields = append(fields, subscriptionplan.FieldPrice)
+ if m.adddelta_usd != nil {
+ fields = append(fields, subscriptionwalletledger.FieldDeltaUsd)
}
- if m.addoriginal_price != nil {
- fields = append(fields, subscriptionplan.FieldOriginalPrice)
- }
- if m.addvalidity_days != nil {
- fields = append(fields, subscriptionplan.FieldValidityDays)
+ if m.addbalance_after != nil {
+ fields = append(fields, subscriptionwalletledger.FieldBalanceAfter)
}
- if m.addsort_order != nil {
- fields = append(fields, subscriptionplan.FieldSortOrder)
+ if m.addpayment_order_id != nil {
+ fields = append(fields, subscriptionwalletledger.FieldPaymentOrderID)
}
return fields
}
@@ -31370,18 +33787,14 @@ func (m *SubscriptionPlanMutation) AddedFields() []string {
// AddedField returns the numeric value that was incremented/decremented on a field
// with the given name. The second boolean return value indicates that this field
// was not set, or was not defined in the schema.
-func (m *SubscriptionPlanMutation) AddedField(name string) (ent.Value, bool) {
+func (m *SubscriptionWalletLedgerMutation) AddedField(name string) (ent.Value, bool) {
switch name {
- case subscriptionplan.FieldGroupID:
- return m.AddedGroupID()
- case subscriptionplan.FieldPrice:
- return m.AddedPrice()
- case subscriptionplan.FieldOriginalPrice:
- return m.AddedOriginalPrice()
- case subscriptionplan.FieldValidityDays:
- return m.AddedValidityDays()
- case subscriptionplan.FieldSortOrder:
- return m.AddedSortOrder()
+ case subscriptionwalletledger.FieldDeltaUsd:
+ return m.AddedDeltaUsd()
+ case subscriptionwalletledger.FieldBalanceAfter:
+ return m.AddedBalanceAfter()
+ case subscriptionwalletledger.FieldPaymentOrderID:
+ return m.AddedPaymentOrderID()
}
return nil, false
}
@@ -31389,168 +33802,222 @@ func (m *SubscriptionPlanMutation) AddedField(name string) (ent.Value, bool) {
// AddField adds the value to the field with the given name. It returns an error if
// the field is not defined in the schema, or if the type mismatched the field
// type.
-func (m *SubscriptionPlanMutation) AddField(name string, value ent.Value) error {
+func (m *SubscriptionWalletLedgerMutation) AddField(name string, value ent.Value) error {
switch name {
- case subscriptionplan.FieldGroupID:
- v, ok := value.(int64)
- if !ok {
- return fmt.Errorf("unexpected type %T for field %s", value, name)
- }
- m.AddGroupID(v)
- return nil
- case subscriptionplan.FieldPrice:
+ case subscriptionwalletledger.FieldDeltaUsd:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
- m.AddPrice(v)
+ m.AddDeltaUsd(v)
return nil
- case subscriptionplan.FieldOriginalPrice:
+ case subscriptionwalletledger.FieldBalanceAfter:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
- m.AddOriginalPrice(v)
- return nil
- case subscriptionplan.FieldValidityDays:
- v, ok := value.(int)
- if !ok {
- return fmt.Errorf("unexpected type %T for field %s", value, name)
- }
- m.AddValidityDays(v)
+ m.AddBalanceAfter(v)
return nil
- case subscriptionplan.FieldSortOrder:
- v, ok := value.(int)
+ case subscriptionwalletledger.FieldPaymentOrderID:
+ v, ok := value.(int64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
- m.AddSortOrder(v)
+ m.AddPaymentOrderID(v)
return nil
}
- return fmt.Errorf("unknown SubscriptionPlan numeric field %s", name)
+ return fmt.Errorf("unknown SubscriptionWalletLedger numeric field %s", name)
}
// ClearedFields returns all nullable fields that were cleared during this
// mutation.
-func (m *SubscriptionPlanMutation) ClearedFields() []string {
+func (m *SubscriptionWalletLedgerMutation) ClearedFields() []string {
var fields []string
- if m.FieldCleared(subscriptionplan.FieldOriginalPrice) {
- fields = append(fields, subscriptionplan.FieldOriginalPrice)
+ if m.FieldCleared(subscriptionwalletledger.FieldPaymentOrderID) {
+ fields = append(fields, subscriptionwalletledger.FieldPaymentOrderID)
+ }
+ if m.FieldCleared(subscriptionwalletledger.FieldUsageLogID) {
+ fields = append(fields, subscriptionwalletledger.FieldUsageLogID)
+ }
+ if m.FieldCleared(subscriptionwalletledger.FieldOperatorID) {
+ fields = append(fields, subscriptionwalletledger.FieldOperatorID)
+ }
+ if m.FieldCleared(subscriptionwalletledger.FieldNotes) {
+ fields = append(fields, subscriptionwalletledger.FieldNotes)
}
return fields
}
// FieldCleared returns a boolean indicating if a field with the given name was
// cleared in this mutation.
-func (m *SubscriptionPlanMutation) FieldCleared(name string) bool {
+func (m *SubscriptionWalletLedgerMutation) FieldCleared(name string) bool {
_, ok := m.clearedFields[name]
return ok
}
// ClearField clears the value of the field with the given name. It returns an
// error if the field is not defined in the schema.
-func (m *SubscriptionPlanMutation) ClearField(name string) error {
+func (m *SubscriptionWalletLedgerMutation) ClearField(name string) error {
switch name {
- case subscriptionplan.FieldOriginalPrice:
- m.ClearOriginalPrice()
+ case subscriptionwalletledger.FieldPaymentOrderID:
+ m.ClearPaymentOrderID()
+ return nil
+ case subscriptionwalletledger.FieldUsageLogID:
+ m.ClearUsageLogID()
+ return nil
+ case subscriptionwalletledger.FieldOperatorID:
+ m.ClearOperatorID()
+ return nil
+ case subscriptionwalletledger.FieldNotes:
+ m.ClearNotes()
return nil
}
- return fmt.Errorf("unknown SubscriptionPlan nullable field %s", name)
+ return fmt.Errorf("unknown SubscriptionWalletLedger nullable field %s", name)
}
// ResetField resets all changes in the mutation for the field with the given name.
// It returns an error if the field is not defined in the schema.
-func (m *SubscriptionPlanMutation) ResetField(name string) error {
+func (m *SubscriptionWalletLedgerMutation) ResetField(name string) error {
switch name {
- case subscriptionplan.FieldGroupID:
- m.ResetGroupID()
- return nil
- case subscriptionplan.FieldName:
- m.ResetName()
- return nil
- case subscriptionplan.FieldDescription:
- m.ResetDescription()
- return nil
- case subscriptionplan.FieldPrice:
- m.ResetPrice()
+ case subscriptionwalletledger.FieldSubscriptionID:
+ m.ResetSubscriptionID()
return nil
- case subscriptionplan.FieldOriginalPrice:
- m.ResetOriginalPrice()
+ case subscriptionwalletledger.FieldDeltaUsd:
+ m.ResetDeltaUsd()
return nil
- case subscriptionplan.FieldValidityDays:
- m.ResetValidityDays()
+ case subscriptionwalletledger.FieldBalanceAfter:
+ m.ResetBalanceAfter()
return nil
- case subscriptionplan.FieldValidityUnit:
- m.ResetValidityUnit()
+ case subscriptionwalletledger.FieldReason:
+ m.ResetReason()
return nil
- case subscriptionplan.FieldFeatures:
- m.ResetFeatures()
+ case subscriptionwalletledger.FieldPaymentOrderID:
+ m.ResetPaymentOrderID()
return nil
- case subscriptionplan.FieldProductName:
- m.ResetProductName()
+ case subscriptionwalletledger.FieldUsageLogID:
+ m.ResetUsageLogID()
return nil
- case subscriptionplan.FieldForSale:
- m.ResetForSale()
+ case subscriptionwalletledger.FieldOperatorID:
+ m.ResetOperatorID()
return nil
- case subscriptionplan.FieldSortOrder:
- m.ResetSortOrder()
+ case subscriptionwalletledger.FieldNotes:
+ m.ResetNotes()
return nil
- case subscriptionplan.FieldCreatedAt:
+ case subscriptionwalletledger.FieldCreatedAt:
m.ResetCreatedAt()
return nil
- case subscriptionplan.FieldUpdatedAt:
- m.ResetUpdatedAt()
- return nil
}
- return fmt.Errorf("unknown SubscriptionPlan field %s", name)
+ return fmt.Errorf("unknown SubscriptionWalletLedger field %s", name)
}
// AddedEdges returns all edge names that were set/added in this mutation.
-func (m *SubscriptionPlanMutation) AddedEdges() []string {
- edges := make([]string, 0, 0)
+func (m *SubscriptionWalletLedgerMutation) AddedEdges() []string {
+ edges := make([]string, 0, 3)
+ if m.subscription != nil {
+ edges = append(edges, subscriptionwalletledger.EdgeSubscription)
+ }
+ if m.usage_log != nil {
+ edges = append(edges, subscriptionwalletledger.EdgeUsageLog)
+ }
+ if m.operator != nil {
+ edges = append(edges, subscriptionwalletledger.EdgeOperator)
+ }
return edges
}
// AddedIDs returns all IDs (to other nodes) that were added for the given edge
// name in this mutation.
-func (m *SubscriptionPlanMutation) AddedIDs(name string) []ent.Value {
+func (m *SubscriptionWalletLedgerMutation) AddedIDs(name string) []ent.Value {
+ switch name {
+ case subscriptionwalletledger.EdgeSubscription:
+ if id := m.subscription; id != nil {
+ return []ent.Value{*id}
+ }
+ case subscriptionwalletledger.EdgeUsageLog:
+ if id := m.usage_log; id != nil {
+ return []ent.Value{*id}
+ }
+ case subscriptionwalletledger.EdgeOperator:
+ if id := m.operator; id != nil {
+ return []ent.Value{*id}
+ }
+ }
return nil
}
// RemovedEdges returns all edge names that were removed in this mutation.
-func (m *SubscriptionPlanMutation) RemovedEdges() []string {
- edges := make([]string, 0, 0)
+func (m *SubscriptionWalletLedgerMutation) RemovedEdges() []string {
+ edges := make([]string, 0, 3)
return edges
}
// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with
// the given name in this mutation.
-func (m *SubscriptionPlanMutation) RemovedIDs(name string) []ent.Value {
+func (m *SubscriptionWalletLedgerMutation) RemovedIDs(name string) []ent.Value {
return nil
}
// ClearedEdges returns all edge names that were cleared in this mutation.
-func (m *SubscriptionPlanMutation) ClearedEdges() []string {
- edges := make([]string, 0, 0)
+func (m *SubscriptionWalletLedgerMutation) ClearedEdges() []string {
+ edges := make([]string, 0, 3)
+ if m.clearedsubscription {
+ edges = append(edges, subscriptionwalletledger.EdgeSubscription)
+ }
+ if m.clearedusage_log {
+ edges = append(edges, subscriptionwalletledger.EdgeUsageLog)
+ }
+ if m.clearedoperator {
+ edges = append(edges, subscriptionwalletledger.EdgeOperator)
+ }
return edges
}
// EdgeCleared returns a boolean which indicates if the edge with the given name
// was cleared in this mutation.
-func (m *SubscriptionPlanMutation) EdgeCleared(name string) bool {
+func (m *SubscriptionWalletLedgerMutation) EdgeCleared(name string) bool {
+ switch name {
+ case subscriptionwalletledger.EdgeSubscription:
+ return m.clearedsubscription
+ case subscriptionwalletledger.EdgeUsageLog:
+ return m.clearedusage_log
+ case subscriptionwalletledger.EdgeOperator:
+ return m.clearedoperator
+ }
return false
}
// ClearEdge clears the value of the edge with the given name. It returns an error
// if that edge is not defined in the schema.
-func (m *SubscriptionPlanMutation) ClearEdge(name string) error {
- return fmt.Errorf("unknown SubscriptionPlan unique edge %s", name)
+func (m *SubscriptionWalletLedgerMutation) ClearEdge(name string) error {
+ switch name {
+ case subscriptionwalletledger.EdgeSubscription:
+ m.ClearSubscription()
+ return nil
+ case subscriptionwalletledger.EdgeUsageLog:
+ m.ClearUsageLog()
+ return nil
+ case subscriptionwalletledger.EdgeOperator:
+ m.ClearOperator()
+ return nil
+ }
+ return fmt.Errorf("unknown SubscriptionWalletLedger unique edge %s", name)
}
// ResetEdge resets all changes to the edge with the given name in this mutation.
// It returns an error if the edge is not defined in the schema.
-func (m *SubscriptionPlanMutation) ResetEdge(name string) error {
- return fmt.Errorf("unknown SubscriptionPlan edge %s", name)
+func (m *SubscriptionWalletLedgerMutation) ResetEdge(name string) error {
+ switch name {
+ case subscriptionwalletledger.EdgeSubscription:
+ m.ResetSubscription()
+ return nil
+ case subscriptionwalletledger.EdgeUsageLog:
+ m.ResetUsageLog()
+ return nil
+ case subscriptionwalletledger.EdgeOperator:
+ m.ResetOperator()
+ return nil
+ }
+ return fmt.Errorf("unknown SubscriptionWalletLedger edge %s", name)
}
// TLSFingerprintProfileMutation represents an operation that mutates the TLSFingerprintProfile nodes in the graph.
@@ -34013,74 +36480,81 @@ func (m *UsageCleanupTaskMutation) ResetEdge(name string) error {
// UsageLogMutation represents an operation that mutates the UsageLog nodes in the graph.
type UsageLogMutation struct {
config
- op Op
- typ string
- id *int64
- request_id *string
- model *string
- requested_model *string
- upstream_model *string
- channel_id *int64
- addchannel_id *int64
- model_mapping_chain *string
- billing_tier *string
- billing_mode *string
- input_tokens *int
- addinput_tokens *int
- output_tokens *int
- addoutput_tokens *int
- cache_creation_tokens *int
- addcache_creation_tokens *int
- cache_read_tokens *int
- addcache_read_tokens *int
- cache_creation_5m_tokens *int
- addcache_creation_5m_tokens *int
- cache_creation_1h_tokens *int
- addcache_creation_1h_tokens *int
- input_cost *float64
- addinput_cost *float64
- output_cost *float64
- addoutput_cost *float64
- cache_creation_cost *float64
- addcache_creation_cost *float64
- cache_read_cost *float64
- addcache_read_cost *float64
- total_cost *float64
- addtotal_cost *float64
- actual_cost *float64
- addactual_cost *float64
- rate_multiplier *float64
- addrate_multiplier *float64
- account_rate_multiplier *float64
- addaccount_rate_multiplier *float64
- billing_type *int8
- addbilling_type *int8
- stream *bool
- duration_ms *int
- addduration_ms *int
- first_token_ms *int
- addfirst_token_ms *int
- user_agent *string
- ip_address *string
- image_count *int
- addimage_count *int
- image_size *string
- cache_ttl_overridden *bool
- created_at *time.Time
- clearedFields map[string]struct{}
- user *int64
- cleareduser bool
- api_key *int64
- clearedapi_key bool
- account *int64
- clearedaccount bool
- group *int64
- clearedgroup bool
- subscription *int64
- clearedsubscription bool
- done bool
- oldValue func(context.Context) (*UsageLog, error)
- predicates []predicate.UsageLog
+ op Op
+ typ string
+ id *int64
+ request_id *string
+ model *string
+ requested_model *string
+ upstream_model *string
+ billing_model *string
+ pricing_source *string
+ pricing_revision *string
+ pricing_hash *string
+ channel_id *int64
+ addchannel_id *int64
+ model_mapping_chain *string
+ billing_tier *string
+ billing_mode *string
+ input_tokens *int
+ addinput_tokens *int
+ output_tokens *int
+ addoutput_tokens *int
+ cache_creation_tokens *int
+ addcache_creation_tokens *int
+ cache_read_tokens *int
+ addcache_read_tokens *int
+ cache_creation_5m_tokens *int
+ addcache_creation_5m_tokens *int
+ cache_creation_1h_tokens *int
+ addcache_creation_1h_tokens *int
+ input_cost *float64
+ addinput_cost *float64
+ output_cost *float64
+ addoutput_cost *float64
+ cache_creation_cost *float64
+ addcache_creation_cost *float64
+ cache_read_cost *float64
+ addcache_read_cost *float64
+ total_cost *float64
+ addtotal_cost *float64
+ actual_cost *float64
+ addactual_cost *float64
+ rate_multiplier *float64
+ addrate_multiplier *float64
+ account_rate_multiplier *float64
+ addaccount_rate_multiplier *float64
+ billing_type *int8
+ addbilling_type *int8
+ stream *bool
+ duration_ms *int
+ addduration_ms *int
+ first_token_ms *int
+ addfirst_token_ms *int
+ user_agent *string
+ ip_address *string
+ image_count *int
+ addimage_count *int
+ image_size *string
+ cache_ttl_overridden *bool
+ created_at *time.Time
+ clearedFields map[string]struct{}
+ user *int64
+ cleareduser bool
+ api_key *int64
+ clearedapi_key bool
+ account *int64
+ clearedaccount bool
+ group *int64
+ clearedgroup bool
+ subscription *int64
+ clearedsubscription bool
+ wallet_ledger_entries map[int64]struct{}
+ removedwallet_ledger_entries map[int64]struct{}
+ clearedwallet_ledger_entries bool
+ done bool
+ oldValue func(context.Context) (*UsageLog, error)
+ predicates []predicate.UsageLog
}
var _ ent.Mutation = (*UsageLogMutation)(nil)
@@ -34459,6 +36933,202 @@ func (m *UsageLogMutation) ResetUpstreamModel() {
delete(m.clearedFields, usagelog.FieldUpstreamModel)
}
+// SetBillingModel sets the "billing_model" field.
+func (m *UsageLogMutation) SetBillingModel(s string) {
+ m.billing_model = &s
+}
+
+// BillingModel returns the value of the "billing_model" field in the mutation.
+func (m *UsageLogMutation) BillingModel() (r string, exists bool) {
+ v := m.billing_model
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldBillingModel returns the old "billing_model" field's value of the UsageLog entity.
+// If the UsageLog object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *UsageLogMutation) OldBillingModel(ctx context.Context) (v *string, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldBillingModel is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldBillingModel requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldBillingModel: %w", err)
+ }
+ return oldValue.BillingModel, nil
+}
+
+// ClearBillingModel clears the value of the "billing_model" field.
+func (m *UsageLogMutation) ClearBillingModel() {
+ m.billing_model = nil
+ m.clearedFields[usagelog.FieldBillingModel] = struct{}{}
+}
+
+// BillingModelCleared returns if the "billing_model" field was cleared in this mutation.
+func (m *UsageLogMutation) BillingModelCleared() bool {
+ _, ok := m.clearedFields[usagelog.FieldBillingModel]
+ return ok
+}
+
+// ResetBillingModel resets all changes to the "billing_model" field.
+func (m *UsageLogMutation) ResetBillingModel() {
+ m.billing_model = nil
+ delete(m.clearedFields, usagelog.FieldBillingModel)
+}
+
+// SetPricingSource sets the "pricing_source" field.
+func (m *UsageLogMutation) SetPricingSource(s string) {
+ m.pricing_source = &s
+}
+
+// PricingSource returns the value of the "pricing_source" field in the mutation.
+func (m *UsageLogMutation) PricingSource() (r string, exists bool) {
+ v := m.pricing_source
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldPricingSource returns the old "pricing_source" field's value of the UsageLog entity.
+// If the UsageLog object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *UsageLogMutation) OldPricingSource(ctx context.Context) (v *string, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldPricingSource is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldPricingSource requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldPricingSource: %w", err)
+ }
+ return oldValue.PricingSource, nil
+}
+
+// ClearPricingSource clears the value of the "pricing_source" field.
+func (m *UsageLogMutation) ClearPricingSource() {
+ m.pricing_source = nil
+ m.clearedFields[usagelog.FieldPricingSource] = struct{}{}
+}
+
+// PricingSourceCleared returns if the "pricing_source" field was cleared in this mutation.
+func (m *UsageLogMutation) PricingSourceCleared() bool {
+ _, ok := m.clearedFields[usagelog.FieldPricingSource]
+ return ok
+}
+
+// ResetPricingSource resets all changes to the "pricing_source" field.
+func (m *UsageLogMutation) ResetPricingSource() {
+ m.pricing_source = nil
+ delete(m.clearedFields, usagelog.FieldPricingSource)
+}
+
+// SetPricingRevision sets the "pricing_revision" field.
+func (m *UsageLogMutation) SetPricingRevision(s string) {
+ m.pricing_revision = &s
+}
+
+// PricingRevision returns the value of the "pricing_revision" field in the mutation.
+func (m *UsageLogMutation) PricingRevision() (r string, exists bool) {
+ v := m.pricing_revision
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldPricingRevision returns the old "pricing_revision" field's value of the UsageLog entity.
+// If the UsageLog object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *UsageLogMutation) OldPricingRevision(ctx context.Context) (v *string, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldPricingRevision is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldPricingRevision requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldPricingRevision: %w", err)
+ }
+ return oldValue.PricingRevision, nil
+}
+
+// ClearPricingRevision clears the value of the "pricing_revision" field.
+func (m *UsageLogMutation) ClearPricingRevision() {
+ m.pricing_revision = nil
+ m.clearedFields[usagelog.FieldPricingRevision] = struct{}{}
+}
+
+// PricingRevisionCleared returns if the "pricing_revision" field was cleared in this mutation.
+func (m *UsageLogMutation) PricingRevisionCleared() bool {
+ _, ok := m.clearedFields[usagelog.FieldPricingRevision]
+ return ok
+}
+
+// ResetPricingRevision resets all changes to the "pricing_revision" field.
+func (m *UsageLogMutation) ResetPricingRevision() {
+ m.pricing_revision = nil
+ delete(m.clearedFields, usagelog.FieldPricingRevision)
+}
+
+// SetPricingHash sets the "pricing_hash" field.
+func (m *UsageLogMutation) SetPricingHash(s string) {
+ m.pricing_hash = &s
+}
+
+// PricingHash returns the value of the "pricing_hash" field in the mutation.
+func (m *UsageLogMutation) PricingHash() (r string, exists bool) {
+ v := m.pricing_hash
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldPricingHash returns the old "pricing_hash" field's value of the UsageLog entity.
+// If the UsageLog object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *UsageLogMutation) OldPricingHash(ctx context.Context) (v *string, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldPricingHash is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldPricingHash requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldPricingHash: %w", err)
+ }
+ return oldValue.PricingHash, nil
+}
+
+// ClearPricingHash clears the value of the "pricing_hash" field.
+func (m *UsageLogMutation) ClearPricingHash() {
+ m.pricing_hash = nil
+ m.clearedFields[usagelog.FieldPricingHash] = struct{}{}
+}
+
+// PricingHashCleared returns if the "pricing_hash" field was cleared in this mutation.
+func (m *UsageLogMutation) PricingHashCleared() bool {
+ _, ok := m.clearedFields[usagelog.FieldPricingHash]
+ return ok
+}
+
+// ResetPricingHash resets all changes to the "pricing_hash" field.
+func (m *UsageLogMutation) ResetPricingHash() {
+ m.pricing_hash = nil
+ delete(m.clearedFields, usagelog.FieldPricingHash)
+}
+
// SetChannelID sets the "channel_id" field.
func (m *UsageLogMutation) SetChannelID(i int64) {
m.channel_id = &i
@@ -36214,6 +38884,60 @@ func (m *UsageLogMutation) ResetSubscription() {
m.clearedsubscription = false
}
+// AddWalletLedgerEntryIDs adds the "wallet_ledger_entries" edge to the SubscriptionWalletLedger entity by ids.
+func (m *UsageLogMutation) AddWalletLedgerEntryIDs(ids ...int64) {
+ if m.wallet_ledger_entries == nil {
+ m.wallet_ledger_entries = make(map[int64]struct{})
+ }
+ for i := range ids {
+ m.wallet_ledger_entries[ids[i]] = struct{}{}
+ }
+}
+
+// ClearWalletLedgerEntries clears the "wallet_ledger_entries" edge to the SubscriptionWalletLedger entity.
+func (m *UsageLogMutation) ClearWalletLedgerEntries() {
+ m.clearedwallet_ledger_entries = true
+}
+
+// WalletLedgerEntriesCleared reports if the "wallet_ledger_entries" edge to the SubscriptionWalletLedger entity was cleared.
+func (m *UsageLogMutation) WalletLedgerEntriesCleared() bool {
+ return m.clearedwallet_ledger_entries
+}
+
+// RemoveWalletLedgerEntryIDs removes the "wallet_ledger_entries" edge to the SubscriptionWalletLedger entity by IDs.
+func (m *UsageLogMutation) RemoveWalletLedgerEntryIDs(ids ...int64) {
+ if m.removedwallet_ledger_entries == nil {
+ m.removedwallet_ledger_entries = make(map[int64]struct{})
+ }
+ for i := range ids {
+ delete(m.wallet_ledger_entries, ids[i])
+ m.removedwallet_ledger_entries[ids[i]] = struct{}{}
+ }
+}
+
+// RemovedWalletLedgerEntries returns the removed IDs of the "wallet_ledger_entries" edge to the SubscriptionWalletLedger entity.
+func (m *UsageLogMutation) RemovedWalletLedgerEntriesIDs() (ids []int64) {
+ for id := range m.removedwallet_ledger_entries {
+ ids = append(ids, id)
+ }
+ return
+}
+
+// WalletLedgerEntriesIDs returns the "wallet_ledger_entries" edge IDs in the mutation.
+func (m *UsageLogMutation) WalletLedgerEntriesIDs() (ids []int64) {
+ for id := range m.wallet_ledger_entries {
+ ids = append(ids, id)
+ }
+ return
+}
+
+// ResetWalletLedgerEntries resets all changes to the "wallet_ledger_entries" edge.
+func (m *UsageLogMutation) ResetWalletLedgerEntries() {
+ m.wallet_ledger_entries = nil
+ m.clearedwallet_ledger_entries = false
+ m.removedwallet_ledger_entries = nil
+}
+
// Where appends a list predicates to the UsageLogMutation builder.
func (m *UsageLogMutation) Where(ps ...predicate.UsageLog) {
m.predicates = append(m.predicates, ps...)
@@ -36248,7 +38972,7 @@ func (m *UsageLogMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *UsageLogMutation) Fields() []string {
- fields := make([]string, 0, 37)
+ fields := make([]string, 0, 41)
if m.user != nil {
fields = append(fields, usagelog.FieldUserID)
}
@@ -36270,6 +38994,18 @@ func (m *UsageLogMutation) Fields() []string {
if m.upstream_model != nil {
fields = append(fields, usagelog.FieldUpstreamModel)
}
+ if m.billing_model != nil {
+ fields = append(fields, usagelog.FieldBillingModel)
+ }
+ if m.pricing_source != nil {
+ fields = append(fields, usagelog.FieldPricingSource)
+ }
+ if m.pricing_revision != nil {
+ fields = append(fields, usagelog.FieldPricingRevision)
+ }
+ if m.pricing_hash != nil {
+ fields = append(fields, usagelog.FieldPricingHash)
+ }
if m.channel_id != nil {
fields = append(fields, usagelog.FieldChannelID)
}
@@ -36382,6 +39118,14 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) {
return m.RequestedModel()
case usagelog.FieldUpstreamModel:
return m.UpstreamModel()
+ case usagelog.FieldBillingModel:
+ return m.BillingModel()
+ case usagelog.FieldPricingSource:
+ return m.PricingSource()
+ case usagelog.FieldPricingRevision:
+ return m.PricingRevision()
+ case usagelog.FieldPricingHash:
+ return m.PricingHash()
case usagelog.FieldChannelID:
return m.ChannelID()
case usagelog.FieldModelMappingChain:
@@ -36465,6 +39209,14 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value
return m.OldRequestedModel(ctx)
case usagelog.FieldUpstreamModel:
return m.OldUpstreamModel(ctx)
+ case usagelog.FieldBillingModel:
+ return m.OldBillingModel(ctx)
+ case usagelog.FieldPricingSource:
+ return m.OldPricingSource(ctx)
+ case usagelog.FieldPricingRevision:
+ return m.OldPricingRevision(ctx)
+ case usagelog.FieldPricingHash:
+ return m.OldPricingHash(ctx)
case usagelog.FieldChannelID:
return m.OldChannelID(ctx)
case usagelog.FieldModelMappingChain:
@@ -36583,6 +39335,34 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error {
}
m.SetUpstreamModel(v)
return nil
+ case usagelog.FieldBillingModel:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetBillingModel(v)
+ return nil
+ case usagelog.FieldPricingSource:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetPricingSource(v)
+ return nil
+ case usagelog.FieldPricingRevision:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetPricingRevision(v)
+ return nil
+ case usagelog.FieldPricingHash:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetPricingHash(v)
+ return nil
case usagelog.FieldChannelID:
v, ok := value.(int64)
if !ok {
@@ -37060,6 +39840,18 @@ func (m *UsageLogMutation) ClearedFields() []string {
if m.FieldCleared(usagelog.FieldUpstreamModel) {
fields = append(fields, usagelog.FieldUpstreamModel)
}
+ if m.FieldCleared(usagelog.FieldBillingModel) {
+ fields = append(fields, usagelog.FieldBillingModel)
+ }
+ if m.FieldCleared(usagelog.FieldPricingSource) {
+ fields = append(fields, usagelog.FieldPricingSource)
+ }
+ if m.FieldCleared(usagelog.FieldPricingRevision) {
+ fields = append(fields, usagelog.FieldPricingRevision)
+ }
+ if m.FieldCleared(usagelog.FieldPricingHash) {
+ fields = append(fields, usagelog.FieldPricingHash)
+ }
if m.FieldCleared(usagelog.FieldChannelID) {
fields = append(fields, usagelog.FieldChannelID)
}
@@ -37116,6 +39908,18 @@ func (m *UsageLogMutation) ClearField(name string) error {
case usagelog.FieldUpstreamModel:
m.ClearUpstreamModel()
return nil
+ case usagelog.FieldBillingModel:
+ m.ClearBillingModel()
+ return nil
+ case usagelog.FieldPricingSource:
+ m.ClearPricingSource()
+ return nil
+ case usagelog.FieldPricingRevision:
+ m.ClearPricingRevision()
+ return nil
+ case usagelog.FieldPricingHash:
+ m.ClearPricingHash()
+ return nil
case usagelog.FieldChannelID:
m.ClearChannelID()
return nil
@@ -37181,6 +39985,18 @@ func (m *UsageLogMutation) ResetField(name string) error {
case usagelog.FieldUpstreamModel:
m.ResetUpstreamModel()
return nil
+ case usagelog.FieldBillingModel:
+ m.ResetBillingModel()
+ return nil
+ case usagelog.FieldPricingSource:
+ m.ResetPricingSource()
+ return nil
+ case usagelog.FieldPricingRevision:
+ m.ResetPricingRevision()
+ return nil
+ case usagelog.FieldPricingHash:
+ m.ResetPricingHash()
+ return nil
case usagelog.FieldChannelID:
m.ResetChannelID()
return nil
@@ -37277,7 +40093,7 @@ func (m *UsageLogMutation) ResetField(name string) error {
// AddedEdges returns all edge names that were set/added in this mutation.
func (m *UsageLogMutation) AddedEdges() []string {
- edges := make([]string, 0, 5)
+ edges := make([]string, 0, 6)
if m.user != nil {
edges = append(edges, usagelog.EdgeUser)
}
@@ -37293,6 +40109,9 @@ func (m *UsageLogMutation) AddedEdges() []string {
if m.subscription != nil {
edges = append(edges, usagelog.EdgeSubscription)
}
+ if m.wallet_ledger_entries != nil {
+ edges = append(edges, usagelog.EdgeWalletLedgerEntries)
+ }
return edges
}
@@ -37320,25 +40139,42 @@ func (m *UsageLogMutation) AddedIDs(name string) []ent.Value {
if id := m.subscription; id != nil {
return []ent.Value{*id}
}
+ case usagelog.EdgeWalletLedgerEntries:
+ ids := make([]ent.Value, 0, len(m.wallet_ledger_entries))
+ for id := range m.wallet_ledger_entries {
+ ids = append(ids, id)
+ }
+ return ids
}
return nil
}
// RemovedEdges returns all edge names that were removed in this mutation.
func (m *UsageLogMutation) RemovedEdges() []string {
- edges := make([]string, 0, 5)
+ edges := make([]string, 0, 6)
+ if m.removedwallet_ledger_entries != nil {
+ edges = append(edges, usagelog.EdgeWalletLedgerEntries)
+ }
return edges
}
// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with
// the given name in this mutation.
func (m *UsageLogMutation) RemovedIDs(name string) []ent.Value {
+ switch name {
+ case usagelog.EdgeWalletLedgerEntries:
+ ids := make([]ent.Value, 0, len(m.removedwallet_ledger_entries))
+ for id := range m.removedwallet_ledger_entries {
+ ids = append(ids, id)
+ }
+ return ids
+ }
return nil
}
// ClearedEdges returns all edge names that were cleared in this mutation.
func (m *UsageLogMutation) ClearedEdges() []string {
- edges := make([]string, 0, 5)
+ edges := make([]string, 0, 6)
if m.cleareduser {
edges = append(edges, usagelog.EdgeUser)
}
@@ -37354,6 +40190,9 @@ func (m *UsageLogMutation) ClearedEdges() []string {
if m.clearedsubscription {
edges = append(edges, usagelog.EdgeSubscription)
}
+ if m.clearedwallet_ledger_entries {
+ edges = append(edges, usagelog.EdgeWalletLedgerEntries)
+ }
return edges
}
@@ -37371,6 +40210,8 @@ func (m *UsageLogMutation) EdgeCleared(name string) bool {
return m.clearedgroup
case usagelog.EdgeSubscription:
return m.clearedsubscription
+ case usagelog.EdgeWalletLedgerEntries:
+ return m.clearedwallet_ledger_entries
}
return false
}
@@ -37417,6 +40258,9 @@ func (m *UsageLogMutation) ResetEdge(name string) error {
case usagelog.EdgeSubscription:
m.ResetSubscription()
return nil
+ case usagelog.EdgeWalletLedgerEntries:
+ m.ResetWalletLedgerEntries()
+ return nil
}
return fmt.Errorf("unknown UsageLog edge %s", name)
}
@@ -37424,77 +40268,90 @@ func (m *UsageLogMutation) ResetEdge(name string) error {
// UserMutation represents an operation that mutates the User nodes in the graph.
type UserMutation struct {
config
- op Op
- typ string
- id *int64
- created_at *time.Time
- updated_at *time.Time
- deleted_at *time.Time
- email *string
- password_hash *string
- role *string
- balance *float64
- addbalance *float64
- concurrency *int
- addconcurrency *int
- status *string
- username *string
- notes *string
- totp_secret_encrypted *string
- totp_enabled *bool
- totp_enabled_at *time.Time
- signup_source *string
- last_login_at *time.Time
- last_active_at *time.Time
- balance_notify_enabled *bool
- balance_notify_threshold_type *string
- balance_notify_threshold *float64
- addbalance_notify_threshold *float64
- balance_notify_extra_emails *string
- total_recharged *float64
- addtotal_recharged *float64
- rpm_limit *int
- addrpm_limit *int
- clearedFields map[string]struct{}
- api_keys map[int64]struct{}
- removedapi_keys map[int64]struct{}
- clearedapi_keys bool
- redeem_codes map[int64]struct{}
- removedredeem_codes map[int64]struct{}
- clearedredeem_codes bool
- subscriptions map[int64]struct{}
- removedsubscriptions map[int64]struct{}
- clearedsubscriptions bool
- assigned_subscriptions map[int64]struct{}
- removedassigned_subscriptions map[int64]struct{}
- clearedassigned_subscriptions bool
- announcement_reads map[int64]struct{}
- removedannouncement_reads map[int64]struct{}
- clearedannouncement_reads bool
- allowed_groups map[int64]struct{}
- removedallowed_groups map[int64]struct{}
- clearedallowed_groups bool
- usage_logs map[int64]struct{}
- removedusage_logs map[int64]struct{}
- clearedusage_logs bool
- attribute_values map[int64]struct{}
- removedattribute_values map[int64]struct{}
- clearedattribute_values bool
- promo_code_usages map[int64]struct{}
- removedpromo_code_usages map[int64]struct{}
- clearedpromo_code_usages bool
- payment_orders map[int64]struct{}
- removedpayment_orders map[int64]struct{}
- clearedpayment_orders bool
- auth_identities map[int64]struct{}
- removedauth_identities map[int64]struct{}
- clearedauth_identities bool
- pending_auth_sessions map[int64]struct{}
- removedpending_auth_sessions map[int64]struct{}
- clearedpending_auth_sessions bool
- done bool
- oldValue func(context.Context) (*User, error)
- predicates []predicate.User
+ op Op
+ typ string
+ id *int64
+ created_at *time.Time
+ updated_at *time.Time
+ deleted_at *time.Time
+ email *string
+ password_hash *string
+ role *string
+ balance *float64
+ addbalance *float64
+ concurrency *int
+ addconcurrency *int
+ status *string
+ username *string
+ notes *string
+ totp_secret_encrypted *string
+ totp_enabled *bool
+ totp_enabled_at *time.Time
+ signup_source *string
+ last_login_at *time.Time
+ last_active_at *time.Time
+ balance_notify_enabled *bool
+ balance_notify_threshold_type *string
+ balance_notify_threshold *float64
+ addbalance_notify_threshold *float64
+ balance_notify_extra_emails *string
+ total_recharged *float64
+ addtotal_recharged *float64
+ rpm_limit *int
+ addrpm_limit *int
+ signup_ip *string
+ signup_ip_prefix *string
+ signup_user_agent_hash *string
+ signup_device_fingerprint_hash *string
+ trial_bonus_eligible *bool
+ trial_bonus_hold_reason *string
+ trial_bonus_risk_score *int
+ addtrial_bonus_risk_score *int
+ token_version *int64
+ addtoken_version *int64
+ clearedFields map[string]struct{}
+ api_keys map[int64]struct{}
+ removedapi_keys map[int64]struct{}
+ clearedapi_keys bool
+ redeem_codes map[int64]struct{}
+ removedredeem_codes map[int64]struct{}
+ clearedredeem_codes bool
+ subscriptions map[int64]struct{}
+ removedsubscriptions map[int64]struct{}
+ clearedsubscriptions bool
+ assigned_subscriptions map[int64]struct{}
+ removedassigned_subscriptions map[int64]struct{}
+ clearedassigned_subscriptions bool
+ wallet_ledger_operations map[int64]struct{}
+ removedwallet_ledger_operations map[int64]struct{}
+ clearedwallet_ledger_operations bool
+ announcement_reads map[int64]struct{}
+ removedannouncement_reads map[int64]struct{}
+ clearedannouncement_reads bool
+ allowed_groups map[int64]struct{}
+ removedallowed_groups map[int64]struct{}
+ clearedallowed_groups bool
+ usage_logs map[int64]struct{}
+ removedusage_logs map[int64]struct{}
+ clearedusage_logs bool
+ attribute_values map[int64]struct{}
+ removedattribute_values map[int64]struct{}
+ clearedattribute_values bool
+ promo_code_usages map[int64]struct{}
+ removedpromo_code_usages map[int64]struct{}
+ clearedpromo_code_usages bool
+ payment_orders map[int64]struct{}
+ removedpayment_orders map[int64]struct{}
+ clearedpayment_orders bool
+ auth_identities map[int64]struct{}
+ removedauth_identities map[int64]struct{}
+ clearedauth_identities bool
+ pending_auth_sessions map[int64]struct{}
+ removedpending_auth_sessions map[int64]struct{}
+ clearedpending_auth_sessions bool
+ done bool
+ oldValue func(context.Context) (*User, error)
+ predicates []predicate.User
}
var _ ent.Mutation = (*UserMutation)(nil)
@@ -38602,6 +41459,334 @@ func (m *UserMutation) ResetRpmLimit() {
m.addrpm_limit = nil
}
+// SetSignupIP sets the "signup_ip" field.
+func (m *UserMutation) SetSignupIP(s string) {
+ m.signup_ip = &s
+}
+
+// SignupIP returns the value of the "signup_ip" field in the mutation.
+func (m *UserMutation) SignupIP() (r string, exists bool) {
+ v := m.signup_ip
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldSignupIP returns the old "signup_ip" field's value of the User entity.
+// If the User object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *UserMutation) OldSignupIP(ctx context.Context) (v string, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldSignupIP is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldSignupIP requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldSignupIP: %w", err)
+ }
+ return oldValue.SignupIP, nil
+}
+
+// ResetSignupIP resets all changes to the "signup_ip" field.
+func (m *UserMutation) ResetSignupIP() {
+ m.signup_ip = nil
+}
+
+// SetSignupIPPrefix sets the "signup_ip_prefix" field.
+func (m *UserMutation) SetSignupIPPrefix(s string) {
+ m.signup_ip_prefix = &s
+}
+
+// SignupIPPrefix returns the value of the "signup_ip_prefix" field in the mutation.
+func (m *UserMutation) SignupIPPrefix() (r string, exists bool) {
+ v := m.signup_ip_prefix
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldSignupIPPrefix returns the old "signup_ip_prefix" field's value of the User entity.
+// If the User object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *UserMutation) OldSignupIPPrefix(ctx context.Context) (v string, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldSignupIPPrefix is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldSignupIPPrefix requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldSignupIPPrefix: %w", err)
+ }
+ return oldValue.SignupIPPrefix, nil
+}
+
+// ResetSignupIPPrefix resets all changes to the "signup_ip_prefix" field.
+func (m *UserMutation) ResetSignupIPPrefix() {
+ m.signup_ip_prefix = nil
+}
+
+// SetSignupUserAgentHash sets the "signup_user_agent_hash" field.
+func (m *UserMutation) SetSignupUserAgentHash(s string) {
+ m.signup_user_agent_hash = &s
+}
+
+// SignupUserAgentHash returns the value of the "signup_user_agent_hash" field in the mutation.
+func (m *UserMutation) SignupUserAgentHash() (r string, exists bool) {
+ v := m.signup_user_agent_hash
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldSignupUserAgentHash returns the old "signup_user_agent_hash" field's value of the User entity.
+// If the User object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *UserMutation) OldSignupUserAgentHash(ctx context.Context) (v string, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldSignupUserAgentHash is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldSignupUserAgentHash requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldSignupUserAgentHash: %w", err)
+ }
+ return oldValue.SignupUserAgentHash, nil
+}
+
+// ResetSignupUserAgentHash resets all changes to the "signup_user_agent_hash" field.
+func (m *UserMutation) ResetSignupUserAgentHash() {
+ m.signup_user_agent_hash = nil
+}
+
+// SetSignupDeviceFingerprintHash sets the "signup_device_fingerprint_hash" field.
+func (m *UserMutation) SetSignupDeviceFingerprintHash(s string) {
+ m.signup_device_fingerprint_hash = &s
+}
+
+// SignupDeviceFingerprintHash returns the value of the "signup_device_fingerprint_hash" field in the mutation.
+func (m *UserMutation) SignupDeviceFingerprintHash() (r string, exists bool) {
+ v := m.signup_device_fingerprint_hash
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldSignupDeviceFingerprintHash returns the old "signup_device_fingerprint_hash" field's value of the User entity.
+// If the User object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *UserMutation) OldSignupDeviceFingerprintHash(ctx context.Context) (v string, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldSignupDeviceFingerprintHash is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldSignupDeviceFingerprintHash requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldSignupDeviceFingerprintHash: %w", err)
+ }
+ return oldValue.SignupDeviceFingerprintHash, nil
+}
+
+// ResetSignupDeviceFingerprintHash resets all changes to the "signup_device_fingerprint_hash" field.
+func (m *UserMutation) ResetSignupDeviceFingerprintHash() {
+ m.signup_device_fingerprint_hash = nil
+}
+
+// SetTrialBonusEligible sets the "trial_bonus_eligible" field.
+func (m *UserMutation) SetTrialBonusEligible(b bool) {
+ m.trial_bonus_eligible = &b
+}
+
+// TrialBonusEligible returns the value of the "trial_bonus_eligible" field in the mutation.
+func (m *UserMutation) TrialBonusEligible() (r bool, exists bool) {
+ v := m.trial_bonus_eligible
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldTrialBonusEligible returns the old "trial_bonus_eligible" field's value of the User entity.
+// If the User object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *UserMutation) OldTrialBonusEligible(ctx context.Context) (v bool, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldTrialBonusEligible is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldTrialBonusEligible requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldTrialBonusEligible: %w", err)
+ }
+ return oldValue.TrialBonusEligible, nil
+}
+
+// ResetTrialBonusEligible resets all changes to the "trial_bonus_eligible" field.
+func (m *UserMutation) ResetTrialBonusEligible() {
+ m.trial_bonus_eligible = nil
+}
+
+// SetTrialBonusHoldReason sets the "trial_bonus_hold_reason" field.
+func (m *UserMutation) SetTrialBonusHoldReason(s string) {
+ m.trial_bonus_hold_reason = &s
+}
+
+// TrialBonusHoldReason returns the value of the "trial_bonus_hold_reason" field in the mutation.
+func (m *UserMutation) TrialBonusHoldReason() (r string, exists bool) {
+ v := m.trial_bonus_hold_reason
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldTrialBonusHoldReason returns the old "trial_bonus_hold_reason" field's value of the User entity.
+// If the User object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *UserMutation) OldTrialBonusHoldReason(ctx context.Context) (v string, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldTrialBonusHoldReason is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldTrialBonusHoldReason requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldTrialBonusHoldReason: %w", err)
+ }
+ return oldValue.TrialBonusHoldReason, nil
+}
+
+// ResetTrialBonusHoldReason resets all changes to the "trial_bonus_hold_reason" field.
+func (m *UserMutation) ResetTrialBonusHoldReason() {
+ m.trial_bonus_hold_reason = nil
+}
+
+// SetTrialBonusRiskScore sets the "trial_bonus_risk_score" field.
+func (m *UserMutation) SetTrialBonusRiskScore(i int) {
+ m.trial_bonus_risk_score = &i
+ m.addtrial_bonus_risk_score = nil
+}
+
+// TrialBonusRiskScore returns the value of the "trial_bonus_risk_score" field in the mutation.
+func (m *UserMutation) TrialBonusRiskScore() (r int, exists bool) {
+ v := m.trial_bonus_risk_score
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldTrialBonusRiskScore returns the old "trial_bonus_risk_score" field's value of the User entity.
+// If the User object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *UserMutation) OldTrialBonusRiskScore(ctx context.Context) (v int, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldTrialBonusRiskScore is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldTrialBonusRiskScore requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldTrialBonusRiskScore: %w", err)
+ }
+ return oldValue.TrialBonusRiskScore, nil
+}
+
+// AddTrialBonusRiskScore adds i to the "trial_bonus_risk_score" field.
+func (m *UserMutation) AddTrialBonusRiskScore(i int) {
+ if m.addtrial_bonus_risk_score != nil {
+ *m.addtrial_bonus_risk_score += i
+ } else {
+ m.addtrial_bonus_risk_score = &i
+ }
+}
+
+// AddedTrialBonusRiskScore returns the value that was added to the "trial_bonus_risk_score" field in this mutation.
+func (m *UserMutation) AddedTrialBonusRiskScore() (r int, exists bool) {
+ v := m.addtrial_bonus_risk_score
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// ResetTrialBonusRiskScore resets all changes to the "trial_bonus_risk_score" field.
+func (m *UserMutation) ResetTrialBonusRiskScore() {
+ m.trial_bonus_risk_score = nil
+ m.addtrial_bonus_risk_score = nil
+}
+
+// SetTokenVersion sets the "token_version" field.
+func (m *UserMutation) SetTokenVersion(i int64) {
+ m.token_version = &i
+ m.addtoken_version = nil
+}
+
+// TokenVersion returns the value of the "token_version" field in the mutation.
+func (m *UserMutation) TokenVersion() (r int64, exists bool) {
+ v := m.token_version
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldTokenVersion returns the old "token_version" field's value of the User entity.
+// If the User object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *UserMutation) OldTokenVersion(ctx context.Context) (v int64, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldTokenVersion is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldTokenVersion requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldTokenVersion: %w", err)
+ }
+ return oldValue.TokenVersion, nil
+}
+
+// AddTokenVersion adds i to the "token_version" field.
+func (m *UserMutation) AddTokenVersion(i int64) {
+ if m.addtoken_version != nil {
+ *m.addtoken_version += i
+ } else {
+ m.addtoken_version = &i
+ }
+}
+
+// AddedTokenVersion returns the value that was added to the "token_version" field in this mutation.
+func (m *UserMutation) AddedTokenVersion() (r int64, exists bool) {
+ v := m.addtoken_version
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// ResetTokenVersion resets all changes to the "token_version" field.
+func (m *UserMutation) ResetTokenVersion() {
+ m.token_version = nil
+ m.addtoken_version = nil
+}
+
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids.
func (m *UserMutation) AddAPIKeyIDs(ids ...int64) {
if m.api_keys == nil {
@@ -38818,6 +42003,60 @@ func (m *UserMutation) ResetAssignedSubscriptions() {
m.removedassigned_subscriptions = nil
}
+// AddWalletLedgerOperationIDs adds the "wallet_ledger_operations" edge to the SubscriptionWalletLedger entity by ids.
+func (m *UserMutation) AddWalletLedgerOperationIDs(ids ...int64) {
+ if m.wallet_ledger_operations == nil {
+ m.wallet_ledger_operations = make(map[int64]struct{})
+ }
+ for i := range ids {
+ m.wallet_ledger_operations[ids[i]] = struct{}{}
+ }
+}
+
+// ClearWalletLedgerOperations clears the "wallet_ledger_operations" edge to the SubscriptionWalletLedger entity.
+func (m *UserMutation) ClearWalletLedgerOperations() {
+ m.clearedwallet_ledger_operations = true
+}
+
+// WalletLedgerOperationsCleared reports if the "wallet_ledger_operations" edge to the SubscriptionWalletLedger entity was cleared.
+func (m *UserMutation) WalletLedgerOperationsCleared() bool {
+ return m.clearedwallet_ledger_operations
+}
+
+// RemoveWalletLedgerOperationIDs removes the "wallet_ledger_operations" edge to the SubscriptionWalletLedger entity by IDs.
+func (m *UserMutation) RemoveWalletLedgerOperationIDs(ids ...int64) {
+ if m.removedwallet_ledger_operations == nil {
+ m.removedwallet_ledger_operations = make(map[int64]struct{})
+ }
+ for i := range ids {
+ delete(m.wallet_ledger_operations, ids[i])
+ m.removedwallet_ledger_operations[ids[i]] = struct{}{}
+ }
+}
+
+// RemovedWalletLedgerOperations returns the removed IDs of the "wallet_ledger_operations" edge to the SubscriptionWalletLedger entity.
+func (m *UserMutation) RemovedWalletLedgerOperationsIDs() (ids []int64) {
+ for id := range m.removedwallet_ledger_operations {
+ ids = append(ids, id)
+ }
+ return
+}
+
+// WalletLedgerOperationsIDs returns the "wallet_ledger_operations" edge IDs in the mutation.
+func (m *UserMutation) WalletLedgerOperationsIDs() (ids []int64) {
+ for id := range m.wallet_ledger_operations {
+ ids = append(ids, id)
+ }
+ return
+}
+
+// ResetWalletLedgerOperations resets all changes to the "wallet_ledger_operations" edge.
+func (m *UserMutation) ResetWalletLedgerOperations() {
+ m.wallet_ledger_operations = nil
+ m.clearedwallet_ledger_operations = false
+ m.removedwallet_ledger_operations = nil
+}
+
// AddAnnouncementReadIDs adds the "announcement_reads" edge to the AnnouncementRead entity by ids.
func (m *UserMutation) AddAnnouncementReadIDs(ids ...int64) {
if m.announcement_reads == nil {
@@ -39284,7 +42523,7 @@ func (m *UserMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *UserMutation) Fields() []string {
- fields := make([]string, 0, 23)
+ fields := make([]string, 0, 31)
if m.created_at != nil {
fields = append(fields, user.FieldCreatedAt)
}
@@ -39354,6 +42593,30 @@ func (m *UserMutation) Fields() []string {
if m.rpm_limit != nil {
fields = append(fields, user.FieldRpmLimit)
}
+ if m.signup_ip != nil {
+ fields = append(fields, user.FieldSignupIP)
+ }
+ if m.signup_ip_prefix != nil {
+ fields = append(fields, user.FieldSignupIPPrefix)
+ }
+ if m.signup_user_agent_hash != nil {
+ fields = append(fields, user.FieldSignupUserAgentHash)
+ }
+ if m.signup_device_fingerprint_hash != nil {
+ fields = append(fields, user.FieldSignupDeviceFingerprintHash)
+ }
+ if m.trial_bonus_eligible != nil {
+ fields = append(fields, user.FieldTrialBonusEligible)
+ }
+ if m.trial_bonus_hold_reason != nil {
+ fields = append(fields, user.FieldTrialBonusHoldReason)
+ }
+ if m.trial_bonus_risk_score != nil {
+ fields = append(fields, user.FieldTrialBonusRiskScore)
+ }
+ if m.token_version != nil {
+ fields = append(fields, user.FieldTokenVersion)
+ }
return fields
}
@@ -39408,6 +42671,22 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) {
return m.TotalRecharged()
case user.FieldRpmLimit:
return m.RpmLimit()
+ case user.FieldSignupIP:
+ return m.SignupIP()
+ case user.FieldSignupIPPrefix:
+ return m.SignupIPPrefix()
+ case user.FieldSignupUserAgentHash:
+ return m.SignupUserAgentHash()
+ case user.FieldSignupDeviceFingerprintHash:
+ return m.SignupDeviceFingerprintHash()
+ case user.FieldTrialBonusEligible:
+ return m.TrialBonusEligible()
+ case user.FieldTrialBonusHoldReason:
+ return m.TrialBonusHoldReason()
+ case user.FieldTrialBonusRiskScore:
+ return m.TrialBonusRiskScore()
+ case user.FieldTokenVersion:
+ return m.TokenVersion()
}
return nil, false
}
@@ -39463,6 +42742,22 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er
return m.OldTotalRecharged(ctx)
case user.FieldRpmLimit:
return m.OldRpmLimit(ctx)
+ case user.FieldSignupIP:
+ return m.OldSignupIP(ctx)
+ case user.FieldSignupIPPrefix:
+ return m.OldSignupIPPrefix(ctx)
+ case user.FieldSignupUserAgentHash:
+ return m.OldSignupUserAgentHash(ctx)
+ case user.FieldSignupDeviceFingerprintHash:
+ return m.OldSignupDeviceFingerprintHash(ctx)
+ case user.FieldTrialBonusEligible:
+ return m.OldTrialBonusEligible(ctx)
+ case user.FieldTrialBonusHoldReason:
+ return m.OldTrialBonusHoldReason(ctx)
+ case user.FieldTrialBonusRiskScore:
+ return m.OldTrialBonusRiskScore(ctx)
+ case user.FieldTokenVersion:
+ return m.OldTokenVersion(ctx)
}
return nil, fmt.Errorf("unknown User field %s", name)
}
@@ -39633,6 +42928,62 @@ func (m *UserMutation) SetField(name string, value ent.Value) error {
}
m.SetRpmLimit(v)
return nil
+ case user.FieldSignupIP:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetSignupIP(v)
+ return nil
+ case user.FieldSignupIPPrefix:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetSignupIPPrefix(v)
+ return nil
+ case user.FieldSignupUserAgentHash:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetSignupUserAgentHash(v)
+ return nil
+ case user.FieldSignupDeviceFingerprintHash:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetSignupDeviceFingerprintHash(v)
+ return nil
+ case user.FieldTrialBonusEligible:
+ v, ok := value.(bool)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetTrialBonusEligible(v)
+ return nil
+ case user.FieldTrialBonusHoldReason:
+ v, ok := value.(string)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetTrialBonusHoldReason(v)
+ return nil
+ case user.FieldTrialBonusRiskScore:
+ v, ok := value.(int)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetTrialBonusRiskScore(v)
+ return nil
+ case user.FieldTokenVersion:
+ v, ok := value.(int64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetTokenVersion(v)
+ return nil
}
return fmt.Errorf("unknown User field %s", name)
}
@@ -39656,6 +43007,12 @@ func (m *UserMutation) AddedFields() []string {
if m.addrpm_limit != nil {
fields = append(fields, user.FieldRpmLimit)
}
+ if m.addtrial_bonus_risk_score != nil {
+ fields = append(fields, user.FieldTrialBonusRiskScore)
+ }
+ if m.addtoken_version != nil {
+ fields = append(fields, user.FieldTokenVersion)
+ }
return fields
}
@@ -39674,6 +43031,10 @@ func (m *UserMutation) AddedField(name string) (ent.Value, bool) {
return m.AddedTotalRecharged()
case user.FieldRpmLimit:
return m.AddedRpmLimit()
+ case user.FieldTrialBonusRiskScore:
+ return m.AddedTrialBonusRiskScore()
+ case user.FieldTokenVersion:
+ return m.AddedTokenVersion()
}
return nil, false
}
@@ -39718,6 +43079,20 @@ func (m *UserMutation) AddField(name string, value ent.Value) error {
}
m.AddRpmLimit(v)
return nil
+ case user.FieldTrialBonusRiskScore:
+ v, ok := value.(int)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.AddTrialBonusRiskScore(v)
+ return nil
+ case user.FieldTokenVersion:
+ v, ok := value.(int64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.AddTokenVersion(v)
+ return nil
}
return fmt.Errorf("unknown User numeric field %s", name)
}
@@ -39853,13 +43228,37 @@ func (m *UserMutation) ResetField(name string) error {
case user.FieldRpmLimit:
m.ResetRpmLimit()
return nil
+ case user.FieldSignupIP:
+ m.ResetSignupIP()
+ return nil
+ case user.FieldSignupIPPrefix:
+ m.ResetSignupIPPrefix()
+ return nil
+ case user.FieldSignupUserAgentHash:
+ m.ResetSignupUserAgentHash()
+ return nil
+ case user.FieldSignupDeviceFingerprintHash:
+ m.ResetSignupDeviceFingerprintHash()
+ return nil
+ case user.FieldTrialBonusEligible:
+ m.ResetTrialBonusEligible()
+ return nil
+ case user.FieldTrialBonusHoldReason:
+ m.ResetTrialBonusHoldReason()
+ return nil
+ case user.FieldTrialBonusRiskScore:
+ m.ResetTrialBonusRiskScore()
+ return nil
+ case user.FieldTokenVersion:
+ m.ResetTokenVersion()
+ return nil
}
return fmt.Errorf("unknown User field %s", name)
}
// AddedEdges returns all edge names that were set/added in this mutation.
func (m *UserMutation) AddedEdges() []string {
- edges := make([]string, 0, 12)
+ edges := make([]string, 0, 13)
if m.api_keys != nil {
edges = append(edges, user.EdgeAPIKeys)
}
@@ -39872,6 +43271,9 @@ func (m *UserMutation) AddedEdges() []string {
if m.assigned_subscriptions != nil {
edges = append(edges, user.EdgeAssignedSubscriptions)
}
+ if m.wallet_ledger_operations != nil {
+ edges = append(edges, user.EdgeWalletLedgerOperations)
+ }
if m.announcement_reads != nil {
edges = append(edges, user.EdgeAnnouncementReads)
}
@@ -39927,6 +43329,12 @@ func (m *UserMutation) AddedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
+ case user.EdgeWalletLedgerOperations:
+ ids := make([]ent.Value, 0, len(m.wallet_ledger_operations))
+ for id := range m.wallet_ledger_operations {
+ ids = append(ids, id)
+ }
+ return ids
case user.EdgeAnnouncementReads:
ids := make([]ent.Value, 0, len(m.announcement_reads))
for id := range m.announcement_reads {
@@ -39981,7 +43389,7 @@ func (m *UserMutation) AddedIDs(name string) []ent.Value {
// RemovedEdges returns all edge names that were removed in this mutation.
func (m *UserMutation) RemovedEdges() []string {
- edges := make([]string, 0, 12)
+ edges := make([]string, 0, 13)
if m.removedapi_keys != nil {
edges = append(edges, user.EdgeAPIKeys)
}
@@ -39994,6 +43402,9 @@ func (m *UserMutation) RemovedEdges() []string {
if m.removedassigned_subscriptions != nil {
edges = append(edges, user.EdgeAssignedSubscriptions)
}
+ if m.removedwallet_ledger_operations != nil {
+ edges = append(edges, user.EdgeWalletLedgerOperations)
+ }
if m.removedannouncement_reads != nil {
edges = append(edges, user.EdgeAnnouncementReads)
}
@@ -40049,6 +43460,12 @@ func (m *UserMutation) RemovedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
+ case user.EdgeWalletLedgerOperations:
+ ids := make([]ent.Value, 0, len(m.removedwallet_ledger_operations))
+ for id := range m.removedwallet_ledger_operations {
+ ids = append(ids, id)
+ }
+ return ids
case user.EdgeAnnouncementReads:
ids := make([]ent.Value, 0, len(m.removedannouncement_reads))
for id := range m.removedannouncement_reads {
@@ -40103,7 +43520,7 @@ func (m *UserMutation) RemovedIDs(name string) []ent.Value {
// ClearedEdges returns all edge names that were cleared in this mutation.
func (m *UserMutation) ClearedEdges() []string {
- edges := make([]string, 0, 12)
+ edges := make([]string, 0, 13)
if m.clearedapi_keys {
edges = append(edges, user.EdgeAPIKeys)
}
@@ -40116,6 +43533,9 @@ func (m *UserMutation) ClearedEdges() []string {
if m.clearedassigned_subscriptions {
edges = append(edges, user.EdgeAssignedSubscriptions)
}
+ if m.clearedwallet_ledger_operations {
+ edges = append(edges, user.EdgeWalletLedgerOperations)
+ }
if m.clearedannouncement_reads {
edges = append(edges, user.EdgeAnnouncementReads)
}
@@ -40155,6 +43575,8 @@ func (m *UserMutation) EdgeCleared(name string) bool {
return m.clearedsubscriptions
case user.EdgeAssignedSubscriptions:
return m.clearedassigned_subscriptions
+ case user.EdgeWalletLedgerOperations:
+ return m.clearedwallet_ledger_operations
case user.EdgeAnnouncementReads:
return m.clearedannouncement_reads
case user.EdgeAllowedGroups:
@@ -40199,6 +43621,9 @@ func (m *UserMutation) ResetEdge(name string) error {
case user.EdgeAssignedSubscriptions:
m.ResetAssignedSubscriptions()
return nil
+ case user.EdgeWalletLedgerOperations:
+ m.ResetWalletLedgerOperations()
+ return nil
case user.EdgeAnnouncementReads:
m.ResetAnnouncementReads()
return nil
@@ -42446,39 +45871,47 @@ func (m *UserAttributeValueMutation) ResetEdge(name string) error {
// UserSubscriptionMutation represents an operation that mutates the UserSubscription nodes in the graph.
type UserSubscriptionMutation struct {
config
- op Op
- typ string
- id *int64
- created_at *time.Time
- updated_at *time.Time
- deleted_at *time.Time
- starts_at *time.Time
- expires_at *time.Time
- status *string
- daily_window_start *time.Time
- weekly_window_start *time.Time
- monthly_window_start *time.Time
- daily_usage_usd *float64
- adddaily_usage_usd *float64
- weekly_usage_usd *float64
- addweekly_usage_usd *float64
- monthly_usage_usd *float64
- addmonthly_usage_usd *float64
- assigned_at *time.Time
- notes *string
- clearedFields map[string]struct{}
- user *int64
- cleareduser bool
- group *int64
- clearedgroup bool
- assigned_by_user *int64
- clearedassigned_by_user bool
- usage_logs map[int64]struct{}
- removedusage_logs map[int64]struct{}
- clearedusage_logs bool
- done bool
- oldValue func(context.Context) (*UserSubscription, error)
- predicates []predicate.UserSubscription
+ op Op
+ typ string
+ id *int64
+ created_at *time.Time
+ updated_at *time.Time
+ deleted_at *time.Time
+ starts_at *time.Time
+ expires_at *time.Time
+ status *string
+ daily_window_start *time.Time
+ weekly_window_start *time.Time
+ monthly_window_start *time.Time
+ daily_usage_usd *float64
+ adddaily_usage_usd *float64
+ weekly_usage_usd *float64
+ addweekly_usage_usd *float64
+ monthly_usage_usd *float64
+ addmonthly_usage_usd *float64
+ wallet_balance_usd *float64
+ addwallet_balance_usd *float64
+ wallet_initial_usd *float64
+ addwallet_initial_usd *float64
+ locked_rates *map[string]float64
+ assigned_at *time.Time
+ notes *string
+ clearedFields map[string]struct{}
+ user *int64
+ cleareduser bool
+ group *int64
+ clearedgroup bool
+ assigned_by_user *int64
+ clearedassigned_by_user bool
+ usage_logs map[int64]struct{}
+ removedusage_logs map[int64]struct{}
+ clearedusage_logs bool
+ wallet_ledger_entries map[int64]struct{}
+ removedwallet_ledger_entries map[int64]struct{}
+ clearedwallet_ledger_entries bool
+ done bool
+ oldValue func(context.Context) (*UserSubscription, error)
+ predicates []predicate.UserSubscription
}
var _ ent.Mutation = (*UserSubscriptionMutation)(nil)
@@ -42753,7 +46186,7 @@ func (m *UserSubscriptionMutation) GroupID() (r int64, exists bool) {
// OldGroupID returns the old "group_id" field's value of the UserSubscription entity.
// If the UserSubscription object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
-func (m *UserSubscriptionMutation) OldGroupID(ctx context.Context) (v int64, err error) {
+func (m *UserSubscriptionMutation) OldGroupID(ctx context.Context) (v *int64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldGroupID is only allowed on UpdateOne operations")
}
@@ -42767,9 +46200,22 @@ func (m *UserSubscriptionMutation) OldGroupID(ctx context.Context) (v int64, err
return oldValue.GroupID, nil
}
+// ClearGroupID clears the value of the "group_id" field.
+func (m *UserSubscriptionMutation) ClearGroupID() {
+ m.group = nil
+ m.clearedFields[usersubscription.FieldGroupID] = struct{}{}
+}
+
+// GroupIDCleared returns if the "group_id" field was cleared in this mutation.
+func (m *UserSubscriptionMutation) GroupIDCleared() bool {
+ _, ok := m.clearedFields[usersubscription.FieldGroupID]
+ return ok
+}
+
// ResetGroupID resets all changes to the "group_id" field.
func (m *UserSubscriptionMutation) ResetGroupID() {
m.group = nil
+ delete(m.clearedFields, usersubscription.FieldGroupID)
}
// SetStartsAt sets the "starts_at" field.
@@ -43195,6 +46641,195 @@ func (m *UserSubscriptionMutation) ResetMonthlyUsageUsd() {
m.addmonthly_usage_usd = nil
}
+// SetWalletBalanceUsd sets the "wallet_balance_usd" field.
+func (m *UserSubscriptionMutation) SetWalletBalanceUsd(f float64) {
+ m.wallet_balance_usd = &f
+ m.addwallet_balance_usd = nil
+}
+
+// WalletBalanceUsd returns the value of the "wallet_balance_usd" field in the mutation.
+func (m *UserSubscriptionMutation) WalletBalanceUsd() (r float64, exists bool) {
+ v := m.wallet_balance_usd
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldWalletBalanceUsd returns the old "wallet_balance_usd" field's value of the UserSubscription entity.
+// If the UserSubscription object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *UserSubscriptionMutation) OldWalletBalanceUsd(ctx context.Context) (v *float64, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldWalletBalanceUsd is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldWalletBalanceUsd requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldWalletBalanceUsd: %w", err)
+ }
+ return oldValue.WalletBalanceUsd, nil
+}
+
+// AddWalletBalanceUsd adds f to the "wallet_balance_usd" field.
+func (m *UserSubscriptionMutation) AddWalletBalanceUsd(f float64) {
+ if m.addwallet_balance_usd != nil {
+ *m.addwallet_balance_usd += f
+ } else {
+ m.addwallet_balance_usd = &f
+ }
+}
+
+// AddedWalletBalanceUsd returns the value that was added to the "wallet_balance_usd" field in this mutation.
+func (m *UserSubscriptionMutation) AddedWalletBalanceUsd() (r float64, exists bool) {
+ v := m.addwallet_balance_usd
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// ClearWalletBalanceUsd clears the value of the "wallet_balance_usd" field.
+func (m *UserSubscriptionMutation) ClearWalletBalanceUsd() {
+ m.wallet_balance_usd = nil
+ m.addwallet_balance_usd = nil
+ m.clearedFields[usersubscription.FieldWalletBalanceUsd] = struct{}{}
+}
+
+// WalletBalanceUsdCleared returns if the "wallet_balance_usd" field was cleared in this mutation.
+func (m *UserSubscriptionMutation) WalletBalanceUsdCleared() bool {
+ _, ok := m.clearedFields[usersubscription.FieldWalletBalanceUsd]
+ return ok
+}
+
+// ResetWalletBalanceUsd resets all changes to the "wallet_balance_usd" field.
+func (m *UserSubscriptionMutation) ResetWalletBalanceUsd() {
+ m.wallet_balance_usd = nil
+ m.addwallet_balance_usd = nil
+ delete(m.clearedFields, usersubscription.FieldWalletBalanceUsd)
+}
+
+// SetWalletInitialUsd sets the "wallet_initial_usd" field.
+func (m *UserSubscriptionMutation) SetWalletInitialUsd(f float64) {
+ m.wallet_initial_usd = &f
+ m.addwallet_initial_usd = nil
+}
+
+// WalletInitialUsd returns the value of the "wallet_initial_usd" field in the mutation.
+func (m *UserSubscriptionMutation) WalletInitialUsd() (r float64, exists bool) {
+ v := m.wallet_initial_usd
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldWalletInitialUsd returns the old "wallet_initial_usd" field's value of the UserSubscription entity.
+// If the UserSubscription object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *UserSubscriptionMutation) OldWalletInitialUsd(ctx context.Context) (v *float64, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldWalletInitialUsd is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldWalletInitialUsd requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldWalletInitialUsd: %w", err)
+ }
+ return oldValue.WalletInitialUsd, nil
+}
+
+// AddWalletInitialUsd adds f to the "wallet_initial_usd" field.
+func (m *UserSubscriptionMutation) AddWalletInitialUsd(f float64) {
+ if m.addwallet_initial_usd != nil {
+ *m.addwallet_initial_usd += f
+ } else {
+ m.addwallet_initial_usd = &f
+ }
+}
+
+// AddedWalletInitialUsd returns the value that was added to the "wallet_initial_usd" field in this mutation.
+func (m *UserSubscriptionMutation) AddedWalletInitialUsd() (r float64, exists bool) {
+ v := m.addwallet_initial_usd
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// ClearWalletInitialUsd clears the value of the "wallet_initial_usd" field.
+func (m *UserSubscriptionMutation) ClearWalletInitialUsd() {
+ m.wallet_initial_usd = nil
+ m.addwallet_initial_usd = nil
+ m.clearedFields[usersubscription.FieldWalletInitialUsd] = struct{}{}
+}
+
+// WalletInitialUsdCleared returns if the "wallet_initial_usd" field was cleared in this mutation.
+func (m *UserSubscriptionMutation) WalletInitialUsdCleared() bool {
+ _, ok := m.clearedFields[usersubscription.FieldWalletInitialUsd]
+ return ok
+}
+
+// ResetWalletInitialUsd resets all changes to the "wallet_initial_usd" field.
+func (m *UserSubscriptionMutation) ResetWalletInitialUsd() {
+ m.wallet_initial_usd = nil
+ m.addwallet_initial_usd = nil
+ delete(m.clearedFields, usersubscription.FieldWalletInitialUsd)
+}
+
+// SetLockedRates sets the "locked_rates" field.
+func (m *UserSubscriptionMutation) SetLockedRates(value map[string]float64) {
+ m.locked_rates = &value
+}
+
+// LockedRates returns the value of the "locked_rates" field in the mutation.
+func (m *UserSubscriptionMutation) LockedRates() (r map[string]float64, exists bool) {
+ v := m.locked_rates
+ if v == nil {
+ return
+ }
+ return *v, true
+}
+
+// OldLockedRates returns the old "locked_rates" field's value of the UserSubscription entity.
+// If the UserSubscription object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *UserSubscriptionMutation) OldLockedRates(ctx context.Context) (v map[string]float64, err error) {
+ if !m.op.Is(OpUpdateOne) {
+ return v, errors.New("OldLockedRates is only allowed on UpdateOne operations")
+ }
+ if m.id == nil || m.oldValue == nil {
+ return v, errors.New("OldLockedRates requires an ID field in the mutation")
+ }
+ oldValue, err := m.oldValue(ctx)
+ if err != nil {
+ return v, fmt.Errorf("querying old value for OldLockedRates: %w", err)
+ }
+ return oldValue.LockedRates, nil
+}
+
+// ClearLockedRates clears the value of the "locked_rates" field.
+func (m *UserSubscriptionMutation) ClearLockedRates() {
+ m.locked_rates = nil
+ m.clearedFields[usersubscription.FieldLockedRates] = struct{}{}
+}
+
+// LockedRatesCleared returns if the "locked_rates" field was cleared in this mutation.
+func (m *UserSubscriptionMutation) LockedRatesCleared() bool {
+ _, ok := m.clearedFields[usersubscription.FieldLockedRates]
+ return ok
+}
+
+// ResetLockedRates resets all changes to the "locked_rates" field.
+func (m *UserSubscriptionMutation) ResetLockedRates() {
+ m.locked_rates = nil
+ delete(m.clearedFields, usersubscription.FieldLockedRates)
+}
+
// SetAssignedBy sets the "assigned_by" field.
func (m *UserSubscriptionMutation) SetAssignedBy(i int64) {
m.assigned_by_user = &i
@@ -43364,7 +46999,7 @@ func (m *UserSubscriptionMutation) ClearGroup() {
// GroupCleared reports if the "group" edge to the Group entity was cleared.
func (m *UserSubscriptionMutation) GroupCleared() bool {
- return m.clearedgroup
+ return m.GroupIDCleared() || m.clearedgroup
}
// GroupIDs returns the "group" edge IDs in the mutation.
@@ -43477,6 +47112,60 @@ func (m *UserSubscriptionMutation) ResetUsageLogs() {
m.removedusage_logs = nil
}
+// AddWalletLedgerEntryIDs adds the "wallet_ledger_entries" edge to the SubscriptionWalletLedger entity by ids.
+func (m *UserSubscriptionMutation) AddWalletLedgerEntryIDs(ids ...int64) {
+ if m.wallet_ledger_entries == nil {
+ m.wallet_ledger_entries = make(map[int64]struct{})
+ }
+ for i := range ids {
+ m.wallet_ledger_entries[ids[i]] = struct{}{}
+ }
+}
+
+// ClearWalletLedgerEntries clears the "wallet_ledger_entries" edge to the SubscriptionWalletLedger entity.
+func (m *UserSubscriptionMutation) ClearWalletLedgerEntries() {
+ m.clearedwallet_ledger_entries = true
+}
+
+// WalletLedgerEntriesCleared reports if the "wallet_ledger_entries" edge to the SubscriptionWalletLedger entity was cleared.
+func (m *UserSubscriptionMutation) WalletLedgerEntriesCleared() bool {
+ return m.clearedwallet_ledger_entries
+}
+
+// RemoveWalletLedgerEntryIDs removes the "wallet_ledger_entries" edge to the SubscriptionWalletLedger entity by IDs.
+func (m *UserSubscriptionMutation) RemoveWalletLedgerEntryIDs(ids ...int64) {
+ if m.removedwallet_ledger_entries == nil {
+ m.removedwallet_ledger_entries = make(map[int64]struct{})
+ }
+ for i := range ids {
+ delete(m.wallet_ledger_entries, ids[i])
+ m.removedwallet_ledger_entries[ids[i]] = struct{}{}
+ }
+}
+
+// RemovedWalletLedgerEntries returns the removed IDs of the "wallet_ledger_entries" edge to the SubscriptionWalletLedger entity.
+func (m *UserSubscriptionMutation) RemovedWalletLedgerEntriesIDs() (ids []int64) {
+ for id := range m.removedwallet_ledger_entries {
+ ids = append(ids, id)
+ }
+ return
+}
+
+// WalletLedgerEntriesIDs returns the "wallet_ledger_entries" edge IDs in the mutation.
+func (m *UserSubscriptionMutation) WalletLedgerEntriesIDs() (ids []int64) {
+ for id := range m.wallet_ledger_entries {
+ ids = append(ids, id)
+ }
+ return
+}
+
+// ResetWalletLedgerEntries resets all changes to the "wallet_ledger_entries" edge.
+func (m *UserSubscriptionMutation) ResetWalletLedgerEntries() {
+ m.wallet_ledger_entries = nil
+ m.clearedwallet_ledger_entries = false
+ m.removedwallet_ledger_entries = nil
+}
+
// Where appends a list predicates to the UserSubscriptionMutation builder.
func (m *UserSubscriptionMutation) Where(ps ...predicate.UserSubscription) {
m.predicates = append(m.predicates, ps...)
@@ -43511,7 +47200,7 @@ func (m *UserSubscriptionMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *UserSubscriptionMutation) Fields() []string {
- fields := make([]string, 0, 17)
+ fields := make([]string, 0, 20)
if m.created_at != nil {
fields = append(fields, usersubscription.FieldCreatedAt)
}
@@ -43554,6 +47243,15 @@ func (m *UserSubscriptionMutation) Fields() []string {
if m.monthly_usage_usd != nil {
fields = append(fields, usersubscription.FieldMonthlyUsageUsd)
}
+ if m.wallet_balance_usd != nil {
+ fields = append(fields, usersubscription.FieldWalletBalanceUsd)
+ }
+ if m.wallet_initial_usd != nil {
+ fields = append(fields, usersubscription.FieldWalletInitialUsd)
+ }
+ if m.locked_rates != nil {
+ fields = append(fields, usersubscription.FieldLockedRates)
+ }
if m.assigned_by_user != nil {
fields = append(fields, usersubscription.FieldAssignedBy)
}
@@ -43599,6 +47297,12 @@ func (m *UserSubscriptionMutation) Field(name string) (ent.Value, bool) {
return m.WeeklyUsageUsd()
case usersubscription.FieldMonthlyUsageUsd:
return m.MonthlyUsageUsd()
+ case usersubscription.FieldWalletBalanceUsd:
+ return m.WalletBalanceUsd()
+ case usersubscription.FieldWalletInitialUsd:
+ return m.WalletInitialUsd()
+ case usersubscription.FieldLockedRates:
+ return m.LockedRates()
case usersubscription.FieldAssignedBy:
return m.AssignedBy()
case usersubscription.FieldAssignedAt:
@@ -43642,6 +47346,12 @@ func (m *UserSubscriptionMutation) OldField(ctx context.Context, name string) (e
return m.OldWeeklyUsageUsd(ctx)
case usersubscription.FieldMonthlyUsageUsd:
return m.OldMonthlyUsageUsd(ctx)
+ case usersubscription.FieldWalletBalanceUsd:
+ return m.OldWalletBalanceUsd(ctx)
+ case usersubscription.FieldWalletInitialUsd:
+ return m.OldWalletInitialUsd(ctx)
+ case usersubscription.FieldLockedRates:
+ return m.OldLockedRates(ctx)
case usersubscription.FieldAssignedBy:
return m.OldAssignedBy(ctx)
case usersubscription.FieldAssignedAt:
@@ -43755,6 +47465,27 @@ func (m *UserSubscriptionMutation) SetField(name string, value ent.Value) error
}
m.SetMonthlyUsageUsd(v)
return nil
+ case usersubscription.FieldWalletBalanceUsd:
+ v, ok := value.(float64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetWalletBalanceUsd(v)
+ return nil
+ case usersubscription.FieldWalletInitialUsd:
+ v, ok := value.(float64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetWalletInitialUsd(v)
+ return nil
+ case usersubscription.FieldLockedRates:
+ v, ok := value.(map[string]float64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.SetLockedRates(v)
+ return nil
case usersubscription.FieldAssignedBy:
v, ok := value.(int64)
if !ok {
@@ -43793,6 +47524,12 @@ func (m *UserSubscriptionMutation) AddedFields() []string {
if m.addmonthly_usage_usd != nil {
fields = append(fields, usersubscription.FieldMonthlyUsageUsd)
}
+ if m.addwallet_balance_usd != nil {
+ fields = append(fields, usersubscription.FieldWalletBalanceUsd)
+ }
+ if m.addwallet_initial_usd != nil {
+ fields = append(fields, usersubscription.FieldWalletInitialUsd)
+ }
return fields
}
@@ -43807,6 +47544,10 @@ func (m *UserSubscriptionMutation) AddedField(name string) (ent.Value, bool) {
return m.AddedWeeklyUsageUsd()
case usersubscription.FieldMonthlyUsageUsd:
return m.AddedMonthlyUsageUsd()
+ case usersubscription.FieldWalletBalanceUsd:
+ return m.AddedWalletBalanceUsd()
+ case usersubscription.FieldWalletInitialUsd:
+ return m.AddedWalletInitialUsd()
}
return nil, false
}
@@ -43837,6 +47578,20 @@ func (m *UserSubscriptionMutation) AddField(name string, value ent.Value) error
}
m.AddMonthlyUsageUsd(v)
return nil
+ case usersubscription.FieldWalletBalanceUsd:
+ v, ok := value.(float64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.AddWalletBalanceUsd(v)
+ return nil
+ case usersubscription.FieldWalletInitialUsd:
+ v, ok := value.(float64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field %s", value, name)
+ }
+ m.AddWalletInitialUsd(v)
+ return nil
}
return fmt.Errorf("unknown UserSubscription numeric field %s", name)
}
@@ -43848,6 +47603,9 @@ func (m *UserSubscriptionMutation) ClearedFields() []string {
if m.FieldCleared(usersubscription.FieldDeletedAt) {
fields = append(fields, usersubscription.FieldDeletedAt)
}
+ if m.FieldCleared(usersubscription.FieldGroupID) {
+ fields = append(fields, usersubscription.FieldGroupID)
+ }
if m.FieldCleared(usersubscription.FieldDailyWindowStart) {
fields = append(fields, usersubscription.FieldDailyWindowStart)
}
@@ -43857,6 +47615,15 @@ func (m *UserSubscriptionMutation) ClearedFields() []string {
if m.FieldCleared(usersubscription.FieldMonthlyWindowStart) {
fields = append(fields, usersubscription.FieldMonthlyWindowStart)
}
+ if m.FieldCleared(usersubscription.FieldWalletBalanceUsd) {
+ fields = append(fields, usersubscription.FieldWalletBalanceUsd)
+ }
+ if m.FieldCleared(usersubscription.FieldWalletInitialUsd) {
+ fields = append(fields, usersubscription.FieldWalletInitialUsd)
+ }
+ if m.FieldCleared(usersubscription.FieldLockedRates) {
+ fields = append(fields, usersubscription.FieldLockedRates)
+ }
if m.FieldCleared(usersubscription.FieldAssignedBy) {
fields = append(fields, usersubscription.FieldAssignedBy)
}
@@ -43880,6 +47647,9 @@ func (m *UserSubscriptionMutation) ClearField(name string) error {
case usersubscription.FieldDeletedAt:
m.ClearDeletedAt()
return nil
+ case usersubscription.FieldGroupID:
+ m.ClearGroupID()
+ return nil
case usersubscription.FieldDailyWindowStart:
m.ClearDailyWindowStart()
return nil
@@ -43889,6 +47659,15 @@ func (m *UserSubscriptionMutation) ClearField(name string) error {
case usersubscription.FieldMonthlyWindowStart:
m.ClearMonthlyWindowStart()
return nil
+ case usersubscription.FieldWalletBalanceUsd:
+ m.ClearWalletBalanceUsd()
+ return nil
+ case usersubscription.FieldWalletInitialUsd:
+ m.ClearWalletInitialUsd()
+ return nil
+ case usersubscription.FieldLockedRates:
+ m.ClearLockedRates()
+ return nil
case usersubscription.FieldAssignedBy:
m.ClearAssignedBy()
return nil
@@ -43945,6 +47724,15 @@ func (m *UserSubscriptionMutation) ResetField(name string) error {
case usersubscription.FieldMonthlyUsageUsd:
m.ResetMonthlyUsageUsd()
return nil
+ case usersubscription.FieldWalletBalanceUsd:
+ m.ResetWalletBalanceUsd()
+ return nil
+ case usersubscription.FieldWalletInitialUsd:
+ m.ResetWalletInitialUsd()
+ return nil
+ case usersubscription.FieldLockedRates:
+ m.ResetLockedRates()
+ return nil
case usersubscription.FieldAssignedBy:
m.ResetAssignedBy()
return nil
@@ -43960,7 +47748,7 @@ func (m *UserSubscriptionMutation) ResetField(name string) error {
// AddedEdges returns all edge names that were set/added in this mutation.
func (m *UserSubscriptionMutation) AddedEdges() []string {
- edges := make([]string, 0, 4)
+ edges := make([]string, 0, 5)
if m.user != nil {
edges = append(edges, usersubscription.EdgeUser)
}
@@ -43973,6 +47761,9 @@ func (m *UserSubscriptionMutation) AddedEdges() []string {
if m.usage_logs != nil {
edges = append(edges, usersubscription.EdgeUsageLogs)
}
+ if m.wallet_ledger_entries != nil {
+ edges = append(edges, usersubscription.EdgeWalletLedgerEntries)
+ }
return edges
}
@@ -43998,16 +47789,25 @@ func (m *UserSubscriptionMutation) AddedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
+ case usersubscription.EdgeWalletLedgerEntries:
+ ids := make([]ent.Value, 0, len(m.wallet_ledger_entries))
+ for id := range m.wallet_ledger_entries {
+ ids = append(ids, id)
+ }
+ return ids
}
return nil
}
// RemovedEdges returns all edge names that were removed in this mutation.
func (m *UserSubscriptionMutation) RemovedEdges() []string {
- edges := make([]string, 0, 4)
+ edges := make([]string, 0, 5)
if m.removedusage_logs != nil {
edges = append(edges, usersubscription.EdgeUsageLogs)
}
+ if m.removedwallet_ledger_entries != nil {
+ edges = append(edges, usersubscription.EdgeWalletLedgerEntries)
+ }
return edges
}
@@ -44021,13 +47821,19 @@ func (m *UserSubscriptionMutation) RemovedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
+ case usersubscription.EdgeWalletLedgerEntries:
+ ids := make([]ent.Value, 0, len(m.removedwallet_ledger_entries))
+ for id := range m.removedwallet_ledger_entries {
+ ids = append(ids, id)
+ }
+ return ids
}
return nil
}
// ClearedEdges returns all edge names that were cleared in this mutation.
func (m *UserSubscriptionMutation) ClearedEdges() []string {
- edges := make([]string, 0, 4)
+ edges := make([]string, 0, 5)
if m.cleareduser {
edges = append(edges, usersubscription.EdgeUser)
}
@@ -44040,6 +47846,9 @@ func (m *UserSubscriptionMutation) ClearedEdges() []string {
if m.clearedusage_logs {
edges = append(edges, usersubscription.EdgeUsageLogs)
}
+ if m.clearedwallet_ledger_entries {
+ edges = append(edges, usersubscription.EdgeWalletLedgerEntries)
+ }
return edges
}
@@ -44055,6 +47864,8 @@ func (m *UserSubscriptionMutation) EdgeCleared(name string) bool {
return m.clearedassigned_by_user
case usersubscription.EdgeUsageLogs:
return m.clearedusage_logs
+ case usersubscription.EdgeWalletLedgerEntries:
+ return m.clearedwallet_ledger_entries
}
return false
}
@@ -44092,6 +47903,9 @@ func (m *UserSubscriptionMutation) ResetEdge(name string) error {
case usersubscription.EdgeUsageLogs:
m.ResetUsageLogs()
return nil
+ case usersubscription.EdgeWalletLedgerEntries:
+ m.ResetWalletLedgerEntries()
+ return nil
}
return fmt.Errorf("unknown UserSubscription edge %s", name)
}
diff --git a/backend/ent/predicate/predicate.go b/backend/ent/predicate/predicate.go
index dc86471e793..9f50d84cf88 100644
--- a/backend/ent/predicate/predicate.go
+++ b/backend/ent/predicate/predicate.go
@@ -84,6 +84,12 @@ type Setting func(*sql.Selector)
// SubscriptionPlan is the predicate function for subscriptionplan builders.
type SubscriptionPlan func(*sql.Selector)
+// SubscriptionPlanGroup is the predicate function for subscriptionplangroup builders.
+type SubscriptionPlanGroup func(*sql.Selector)
+
+// SubscriptionWalletLedger is the predicate function for subscriptionwalletledger builders.
+type SubscriptionWalletLedger func(*sql.Selector)
+
// TLSFingerprintProfile is the predicate function for tlsfingerprintprofile builders.
type TLSFingerprintProfile func(*sql.Selector)
diff --git a/backend/ent/proxy/proxy.go b/backend/ent/proxy/proxy.go
index db7abcda32d..6a0f96791a3 100644
--- a/backend/ent/proxy/proxy.go
+++ b/backend/ent/proxy/proxy.go
@@ -95,8 +95,6 @@ var (
HostValidator func(string) error
// UsernameValidator is a validator for the "username" field. It is called by the builders before save.
UsernameValidator func(string) error
- // PasswordValidator is a validator for the "password" field. It is called by the builders before save.
- PasswordValidator func(string) error
// DefaultStatus holds the default value on creation for the "status" field.
DefaultStatus string
// StatusValidator is a validator for the "status" field. It is called by the builders before save.
diff --git a/backend/ent/proxy_create.go b/backend/ent/proxy_create.go
index 9687aaa2603..87c8949a3ae 100644
--- a/backend/ent/proxy_create.go
+++ b/backend/ent/proxy_create.go
@@ -244,11 +244,6 @@ func (_c *ProxyCreate) check() error {
return &ValidationError{Name: "username", err: fmt.Errorf(`ent: validator failed for field "Proxy.username": %w`, err)}
}
}
- if v, ok := _c.mutation.Password(); ok {
- if err := proxy.PasswordValidator(v); err != nil {
- return &ValidationError{Name: "password", err: fmt.Errorf(`ent: validator failed for field "Proxy.password": %w`, err)}
- }
- }
if _, ok := _c.mutation.Status(); !ok {
return &ValidationError{Name: "status", err: errors.New(`ent: missing required field "Proxy.status"`)}
}
diff --git a/backend/ent/proxy_update.go b/backend/ent/proxy_update.go
index d487857f825..33e78798a49 100644
--- a/backend/ent/proxy_update.go
+++ b/backend/ent/proxy_update.go
@@ -277,11 +277,6 @@ func (_u *ProxyUpdate) check() error {
return &ValidationError{Name: "username", err: fmt.Errorf(`ent: validator failed for field "Proxy.username": %w`, err)}
}
}
- if v, ok := _u.mutation.Password(); ok {
- if err := proxy.PasswordValidator(v); err != nil {
- return &ValidationError{Name: "password", err: fmt.Errorf(`ent: validator failed for field "Proxy.password": %w`, err)}
- }
- }
if v, ok := _u.mutation.Status(); ok {
if err := proxy.StatusValidator(v); err != nil {
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Proxy.status": %w`, err)}
@@ -667,11 +662,6 @@ func (_u *ProxyUpdateOne) check() error {
return &ValidationError{Name: "username", err: fmt.Errorf(`ent: validator failed for field "Proxy.username": %w`, err)}
}
}
- if v, ok := _u.mutation.Password(); ok {
- if err := proxy.PasswordValidator(v); err != nil {
- return &ValidationError{Name: "password", err: fmt.Errorf(`ent: validator failed for field "Proxy.password": %w`, err)}
- }
- }
if v, ok := _u.mutation.Status(); ok {
if err := proxy.StatusValidator(v); err != nil {
return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "Proxy.status": %w`, err)}
diff --git a/backend/ent/redeemcode.go b/backend/ent/redeemcode.go
index 24cd423164a..14d4ed30b42 100644
--- a/backend/ent/redeemcode.go
+++ b/backend/ent/redeemcode.go
@@ -39,6 +39,8 @@ type RedeemCode struct {
GroupID *int64 `json:"group_id,omitempty"`
// ValidityDays holds the value of the "validity_days" field.
ValidityDays int `json:"validity_days,omitempty"`
+ // PlanID holds the value of the "plan_id" field.
+ PlanID *int64 `json:"plan_id,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the RedeemCodeQuery when eager-loading is set.
Edges RedeemCodeEdges `json:"edges"`
@@ -85,7 +87,7 @@ func (*RedeemCode) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case redeemcode.FieldValue:
values[i] = new(sql.NullFloat64)
- case redeemcode.FieldID, redeemcode.FieldUsedBy, redeemcode.FieldGroupID, redeemcode.FieldValidityDays:
+ case redeemcode.FieldID, redeemcode.FieldUsedBy, redeemcode.FieldGroupID, redeemcode.FieldValidityDays, redeemcode.FieldPlanID:
values[i] = new(sql.NullInt64)
case redeemcode.FieldCode, redeemcode.FieldType, redeemcode.FieldStatus, redeemcode.FieldNotes:
values[i] = new(sql.NullString)
@@ -176,6 +178,13 @@ func (_m *RedeemCode) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.ValidityDays = int(value.Int64)
}
+ case redeemcode.FieldPlanID:
+ if value, ok := values[i].(*sql.NullInt64); !ok {
+ return fmt.Errorf("unexpected type %T for field plan_id", values[i])
+ } else if value.Valid {
+ _m.PlanID = new(int64)
+ *_m.PlanID = value.Int64
+ }
default:
_m.selectValues.Set(columns[i], values[i])
}
@@ -259,6 +268,11 @@ func (_m *RedeemCode) String() string {
builder.WriteString(", ")
builder.WriteString("validity_days=")
builder.WriteString(fmt.Sprintf("%v", _m.ValidityDays))
+ builder.WriteString(", ")
+ if v := _m.PlanID; v != nil {
+ builder.WriteString("plan_id=")
+ builder.WriteString(fmt.Sprintf("%v", *v))
+ }
builder.WriteByte(')')
return builder.String()
}
diff --git a/backend/ent/redeemcode/redeemcode.go b/backend/ent/redeemcode/redeemcode.go
index b010476c76c..0f6f3785c46 100644
--- a/backend/ent/redeemcode/redeemcode.go
+++ b/backend/ent/redeemcode/redeemcode.go
@@ -34,6 +34,8 @@ const (
FieldGroupID = "group_id"
// FieldValidityDays holds the string denoting the validity_days field in the database.
FieldValidityDays = "validity_days"
+ // FieldPlanID holds the string denoting the plan_id field in the database.
+ FieldPlanID = "plan_id"
// EdgeUser holds the string denoting the user edge name in mutations.
EdgeUser = "user"
// EdgeGroup holds the string denoting the group edge name in mutations.
@@ -69,6 +71,7 @@ var Columns = []string{
FieldCreatedAt,
FieldGroupID,
FieldValidityDays,
+ FieldPlanID,
}
// ValidColumn reports if the column name is valid (part of the table columns).
@@ -158,6 +161,11 @@ func ByValidityDays(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldValidityDays, opts...).ToFunc()
}
+// ByPlanID orders the results by the plan_id field.
+func ByPlanID(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldPlanID, opts...).ToFunc()
+}
+
// ByUserField orders the results by user field.
func ByUserField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
diff --git a/backend/ent/redeemcode/where.go b/backend/ent/redeemcode/where.go
index 1fdedba572b..bc4afc73bc0 100644
--- a/backend/ent/redeemcode/where.go
+++ b/backend/ent/redeemcode/where.go
@@ -105,6 +105,11 @@ func ValidityDays(v int) predicate.RedeemCode {
return predicate.RedeemCode(sql.FieldEQ(FieldValidityDays, v))
}
+// PlanID applies equality check predicate on the "plan_id" field. It's identical to PlanIDEQ.
+func PlanID(v int64) predicate.RedeemCode {
+ return predicate.RedeemCode(sql.FieldEQ(FieldPlanID, v))
+}
+
// CodeEQ applies the EQ predicate on the "code" field.
func CodeEQ(v string) predicate.RedeemCode {
return predicate.RedeemCode(sql.FieldEQ(FieldCode, v))
@@ -605,6 +610,56 @@ func ValidityDaysLTE(v int) predicate.RedeemCode {
return predicate.RedeemCode(sql.FieldLTE(FieldValidityDays, v))
}
+// PlanIDEQ applies the EQ predicate on the "plan_id" field.
+func PlanIDEQ(v int64) predicate.RedeemCode {
+ return predicate.RedeemCode(sql.FieldEQ(FieldPlanID, v))
+}
+
+// PlanIDNEQ applies the NEQ predicate on the "plan_id" field.
+func PlanIDNEQ(v int64) predicate.RedeemCode {
+ return predicate.RedeemCode(sql.FieldNEQ(FieldPlanID, v))
+}
+
+// PlanIDIn applies the In predicate on the "plan_id" field.
+func PlanIDIn(vs ...int64) predicate.RedeemCode {
+ return predicate.RedeemCode(sql.FieldIn(FieldPlanID, vs...))
+}
+
+// PlanIDNotIn applies the NotIn predicate on the "plan_id" field.
+func PlanIDNotIn(vs ...int64) predicate.RedeemCode {
+ return predicate.RedeemCode(sql.FieldNotIn(FieldPlanID, vs...))
+}
+
+// PlanIDGT applies the GT predicate on the "plan_id" field.
+func PlanIDGT(v int64) predicate.RedeemCode {
+ return predicate.RedeemCode(sql.FieldGT(FieldPlanID, v))
+}
+
+// PlanIDGTE applies the GTE predicate on the "plan_id" field.
+func PlanIDGTE(v int64) predicate.RedeemCode {
+ return predicate.RedeemCode(sql.FieldGTE(FieldPlanID, v))
+}
+
+// PlanIDLT applies the LT predicate on the "plan_id" field.
+func PlanIDLT(v int64) predicate.RedeemCode {
+ return predicate.RedeemCode(sql.FieldLT(FieldPlanID, v))
+}
+
+// PlanIDLTE applies the LTE predicate on the "plan_id" field.
+func PlanIDLTE(v int64) predicate.RedeemCode {
+ return predicate.RedeemCode(sql.FieldLTE(FieldPlanID, v))
+}
+
+// PlanIDIsNil applies the IsNil predicate on the "plan_id" field.
+func PlanIDIsNil() predicate.RedeemCode {
+ return predicate.RedeemCode(sql.FieldIsNull(FieldPlanID))
+}
+
+// PlanIDNotNil applies the NotNil predicate on the "plan_id" field.
+func PlanIDNotNil() predicate.RedeemCode {
+ return predicate.RedeemCode(sql.FieldNotNull(FieldPlanID))
+}
+
// HasUser applies the HasEdge predicate on the "user" edge.
func HasUser() predicate.RedeemCode {
return predicate.RedeemCode(func(s *sql.Selector) {
diff --git a/backend/ent/redeemcode_create.go b/backend/ent/redeemcode_create.go
index efdcee40b25..d621aa61fc4 100644
--- a/backend/ent/redeemcode_create.go
+++ b/backend/ent/redeemcode_create.go
@@ -156,6 +156,20 @@ func (_c *RedeemCodeCreate) SetNillableValidityDays(v *int) *RedeemCodeCreate {
return _c
}
+// SetPlanID sets the "plan_id" field.
+func (_c *RedeemCodeCreate) SetPlanID(v int64) *RedeemCodeCreate {
+ _c.mutation.SetPlanID(v)
+ return _c
+}
+
+// SetNillablePlanID sets the "plan_id" field if the given value is not nil.
+func (_c *RedeemCodeCreate) SetNillablePlanID(v *int64) *RedeemCodeCreate {
+ if v != nil {
+ _c.SetPlanID(*v)
+ }
+ return _c
+}
+
// SetUserID sets the "user" edge to the User entity by ID.
func (_c *RedeemCodeCreate) SetUserID(id int64) *RedeemCodeCreate {
_c.mutation.SetUserID(id)
@@ -331,6 +345,10 @@ func (_c *RedeemCodeCreate) createSpec() (*RedeemCode, *sqlgraph.CreateSpec) {
_spec.SetField(redeemcode.FieldValidityDays, field.TypeInt, value)
_node.ValidityDays = value
}
+ if value, ok := _c.mutation.PlanID(); ok {
+ _spec.SetField(redeemcode.FieldPlanID, field.TypeInt64, value)
+ _node.PlanID = &value
+ }
if nodes := _c.mutation.UserIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
@@ -561,6 +579,30 @@ func (u *RedeemCodeUpsert) AddValidityDays(v int) *RedeemCodeUpsert {
return u
}
+// SetPlanID sets the "plan_id" field.
+func (u *RedeemCodeUpsert) SetPlanID(v int64) *RedeemCodeUpsert {
+ u.Set(redeemcode.FieldPlanID, v)
+ return u
+}
+
+// UpdatePlanID sets the "plan_id" field to the value that was provided on create.
+func (u *RedeemCodeUpsert) UpdatePlanID() *RedeemCodeUpsert {
+ u.SetExcluded(redeemcode.FieldPlanID)
+ return u
+}
+
+// AddPlanID adds v to the "plan_id" field.
+func (u *RedeemCodeUpsert) AddPlanID(v int64) *RedeemCodeUpsert {
+ u.Add(redeemcode.FieldPlanID, v)
+ return u
+}
+
+// ClearPlanID clears the value of the "plan_id" field.
+func (u *RedeemCodeUpsert) ClearPlanID() *RedeemCodeUpsert {
+ u.SetNull(redeemcode.FieldPlanID)
+ return u
+}
+
// UpdateNewValues updates the mutable fields using the new values that were set on create.
// Using this option is equivalent to using:
//
@@ -774,6 +816,34 @@ func (u *RedeemCodeUpsertOne) UpdateValidityDays() *RedeemCodeUpsertOne {
})
}
+// SetPlanID sets the "plan_id" field.
+func (u *RedeemCodeUpsertOne) SetPlanID(v int64) *RedeemCodeUpsertOne {
+ return u.Update(func(s *RedeemCodeUpsert) {
+ s.SetPlanID(v)
+ })
+}
+
+// AddPlanID adds v to the "plan_id" field.
+func (u *RedeemCodeUpsertOne) AddPlanID(v int64) *RedeemCodeUpsertOne {
+ return u.Update(func(s *RedeemCodeUpsert) {
+ s.AddPlanID(v)
+ })
+}
+
+// UpdatePlanID sets the "plan_id" field to the value that was provided on create.
+func (u *RedeemCodeUpsertOne) UpdatePlanID() *RedeemCodeUpsertOne {
+ return u.Update(func(s *RedeemCodeUpsert) {
+ s.UpdatePlanID()
+ })
+}
+
+// ClearPlanID clears the value of the "plan_id" field.
+func (u *RedeemCodeUpsertOne) ClearPlanID() *RedeemCodeUpsertOne {
+ return u.Update(func(s *RedeemCodeUpsert) {
+ s.ClearPlanID()
+ })
+}
+
// Exec executes the query.
func (u *RedeemCodeUpsertOne) Exec(ctx context.Context) error {
if len(u.create.conflict) == 0 {
@@ -1153,6 +1223,34 @@ func (u *RedeemCodeUpsertBulk) UpdateValidityDays() *RedeemCodeUpsertBulk {
})
}
+// SetPlanID sets the "plan_id" field.
+func (u *RedeemCodeUpsertBulk) SetPlanID(v int64) *RedeemCodeUpsertBulk {
+ return u.Update(func(s *RedeemCodeUpsert) {
+ s.SetPlanID(v)
+ })
+}
+
+// AddPlanID adds v to the "plan_id" field.
+func (u *RedeemCodeUpsertBulk) AddPlanID(v int64) *RedeemCodeUpsertBulk {
+ return u.Update(func(s *RedeemCodeUpsert) {
+ s.AddPlanID(v)
+ })
+}
+
+// UpdatePlanID sets the "plan_id" field to the value that was provided on create.
+func (u *RedeemCodeUpsertBulk) UpdatePlanID() *RedeemCodeUpsertBulk {
+ return u.Update(func(s *RedeemCodeUpsert) {
+ s.UpdatePlanID()
+ })
+}
+
+// ClearPlanID clears the value of the "plan_id" field.
+func (u *RedeemCodeUpsertBulk) ClearPlanID() *RedeemCodeUpsertBulk {
+ return u.Update(func(s *RedeemCodeUpsert) {
+ s.ClearPlanID()
+ })
+}
+
// Exec executes the query.
func (u *RedeemCodeUpsertBulk) Exec(ctx context.Context) error {
if u.create.err != nil {
diff --git a/backend/ent/redeemcode_update.go b/backend/ent/redeemcode_update.go
index 0f05e06dc23..c23c1d478da 100644
--- a/backend/ent/redeemcode_update.go
+++ b/backend/ent/redeemcode_update.go
@@ -194,6 +194,33 @@ func (_u *RedeemCodeUpdate) AddValidityDays(v int) *RedeemCodeUpdate {
return _u
}
+// SetPlanID sets the "plan_id" field.
+func (_u *RedeemCodeUpdate) SetPlanID(v int64) *RedeemCodeUpdate {
+ _u.mutation.ResetPlanID()
+ _u.mutation.SetPlanID(v)
+ return _u
+}
+
+// SetNillablePlanID sets the "plan_id" field if the given value is not nil.
+func (_u *RedeemCodeUpdate) SetNillablePlanID(v *int64) *RedeemCodeUpdate {
+ if v != nil {
+ _u.SetPlanID(*v)
+ }
+ return _u
+}
+
+// AddPlanID adds value to the "plan_id" field.
+func (_u *RedeemCodeUpdate) AddPlanID(v int64) *RedeemCodeUpdate {
+ _u.mutation.AddPlanID(v)
+ return _u
+}
+
+// ClearPlanID clears the value of the "plan_id" field.
+func (_u *RedeemCodeUpdate) ClearPlanID() *RedeemCodeUpdate {
+ _u.mutation.ClearPlanID()
+ return _u
+}
+
// SetUserID sets the "user" edge to the User entity by ID.
func (_u *RedeemCodeUpdate) SetUserID(id int64) *RedeemCodeUpdate {
_u.mutation.SetUserID(id)
@@ -327,6 +354,15 @@ func (_u *RedeemCodeUpdate) sqlSave(ctx context.Context) (_node int, err error)
if value, ok := _u.mutation.AddedValidityDays(); ok {
_spec.AddField(redeemcode.FieldValidityDays, field.TypeInt, value)
}
+ if value, ok := _u.mutation.PlanID(); ok {
+ _spec.SetField(redeemcode.FieldPlanID, field.TypeInt64, value)
+ }
+ if value, ok := _u.mutation.AddedPlanID(); ok {
+ _spec.AddField(redeemcode.FieldPlanID, field.TypeInt64, value)
+ }
+ if _u.mutation.PlanIDCleared() {
+ _spec.ClearField(redeemcode.FieldPlanID, field.TypeInt64)
+ }
if _u.mutation.UserCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
@@ -569,6 +605,33 @@ func (_u *RedeemCodeUpdateOne) AddValidityDays(v int) *RedeemCodeUpdateOne {
return _u
}
+// SetPlanID sets the "plan_id" field.
+func (_u *RedeemCodeUpdateOne) SetPlanID(v int64) *RedeemCodeUpdateOne {
+ _u.mutation.ResetPlanID()
+ _u.mutation.SetPlanID(v)
+ return _u
+}
+
+// SetNillablePlanID sets the "plan_id" field if the given value is not nil.
+func (_u *RedeemCodeUpdateOne) SetNillablePlanID(v *int64) *RedeemCodeUpdateOne {
+ if v != nil {
+ _u.SetPlanID(*v)
+ }
+ return _u
+}
+
+// AddPlanID adds value to the "plan_id" field.
+func (_u *RedeemCodeUpdateOne) AddPlanID(v int64) *RedeemCodeUpdateOne {
+ _u.mutation.AddPlanID(v)
+ return _u
+}
+
+// ClearPlanID clears the value of the "plan_id" field.
+func (_u *RedeemCodeUpdateOne) ClearPlanID() *RedeemCodeUpdateOne {
+ _u.mutation.ClearPlanID()
+ return _u
+}
+
// SetUserID sets the "user" edge to the User entity by ID.
func (_u *RedeemCodeUpdateOne) SetUserID(id int64) *RedeemCodeUpdateOne {
_u.mutation.SetUserID(id)
@@ -732,6 +795,15 @@ func (_u *RedeemCodeUpdateOne) sqlSave(ctx context.Context) (_node *RedeemCode,
if value, ok := _u.mutation.AddedValidityDays(); ok {
_spec.AddField(redeemcode.FieldValidityDays, field.TypeInt, value)
}
+ if value, ok := _u.mutation.PlanID(); ok {
+ _spec.SetField(redeemcode.FieldPlanID, field.TypeInt64, value)
+ }
+ if value, ok := _u.mutation.AddedPlanID(); ok {
+ _spec.AddField(redeemcode.FieldPlanID, field.TypeInt64, value)
+ }
+ if _u.mutation.PlanIDCleared() {
+ _spec.ClearField(redeemcode.FieldPlanID, field.TypeInt64)
+ }
if _u.mutation.UserCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go
index 6b344a5582c..32a559b1382 100644
--- a/backend/ent/runtime/runtime.go
+++ b/backend/ent/runtime/runtime.go
@@ -32,6 +32,8 @@ import (
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
"github.com/Wei-Shaw/sub2api/ent/setting"
"github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
"github.com/Wei-Shaw/sub2api/ent/tlsfingerprintprofile"
"github.com/Wei-Shaw/sub2api/ent/usagecleanuptask"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
@@ -84,8 +86,18 @@ func init() {
return nil
}
}()
+ // apikeyDescKeyHash is the schema descriptor for key_hash field.
+ apikeyDescKeyHash := apikeyFields[2].Descriptor()
+ // apikey.KeyHashValidator is a validator for the "key_hash" field. It is called by the builders before save.
+ apikey.KeyHashValidator = apikeyDescKeyHash.Validators[0].(func(string) error)
+ // apikeyDescKeyPrefix is the schema descriptor for key_prefix field.
+ apikeyDescKeyPrefix := apikeyFields[3].Descriptor()
+ // apikey.DefaultKeyPrefix holds the default value on creation for the key_prefix field.
+ apikey.DefaultKeyPrefix = apikeyDescKeyPrefix.Default.(string)
+ // apikey.KeyPrefixValidator is a validator for the "key_prefix" field. It is called by the builders before save.
+ apikey.KeyPrefixValidator = apikeyDescKeyPrefix.Validators[0].(func(string) error)
// apikeyDescName is the schema descriptor for name field.
- apikeyDescName := apikeyFields[2].Descriptor()
+ apikeyDescName := apikeyFields[4].Descriptor()
// apikey.NameValidator is a validator for the "name" field. It is called by the builders before save.
apikey.NameValidator = func() func(string) error {
validators := apikeyDescName.Validators
@@ -102,42 +114,48 @@ func init() {
return nil
}
}()
+ // apikeyDescPurpose is the schema descriptor for purpose field.
+ apikeyDescPurpose := apikeyFields[5].Descriptor()
+ // apikey.DefaultPurpose holds the default value on creation for the purpose field.
+ apikey.DefaultPurpose = apikeyDescPurpose.Default.(string)
+ // apikey.PurposeValidator is a validator for the "purpose" field. It is called by the builders before save.
+ apikey.PurposeValidator = apikeyDescPurpose.Validators[0].(func(string) error)
// apikeyDescStatus is the schema descriptor for status field.
- apikeyDescStatus := apikeyFields[4].Descriptor()
+ apikeyDescStatus := apikeyFields[7].Descriptor()
// apikey.DefaultStatus holds the default value on creation for the status field.
apikey.DefaultStatus = apikeyDescStatus.Default.(string)
// apikey.StatusValidator is a validator for the "status" field. It is called by the builders before save.
apikey.StatusValidator = apikeyDescStatus.Validators[0].(func(string) error)
// apikeyDescQuota is the schema descriptor for quota field.
- apikeyDescQuota := apikeyFields[8].Descriptor()
+ apikeyDescQuota := apikeyFields[11].Descriptor()
// apikey.DefaultQuota holds the default value on creation for the quota field.
apikey.DefaultQuota = apikeyDescQuota.Default.(float64)
// apikeyDescQuotaUsed is the schema descriptor for quota_used field.
- apikeyDescQuotaUsed := apikeyFields[9].Descriptor()
+ apikeyDescQuotaUsed := apikeyFields[12].Descriptor()
// apikey.DefaultQuotaUsed holds the default value on creation for the quota_used field.
apikey.DefaultQuotaUsed = apikeyDescQuotaUsed.Default.(float64)
// apikeyDescRateLimit5h is the schema descriptor for rate_limit_5h field.
- apikeyDescRateLimit5h := apikeyFields[11].Descriptor()
+ apikeyDescRateLimit5h := apikeyFields[14].Descriptor()
// apikey.DefaultRateLimit5h holds the default value on creation for the rate_limit_5h field.
apikey.DefaultRateLimit5h = apikeyDescRateLimit5h.Default.(float64)
// apikeyDescRateLimit1d is the schema descriptor for rate_limit_1d field.
- apikeyDescRateLimit1d := apikeyFields[12].Descriptor()
+ apikeyDescRateLimit1d := apikeyFields[15].Descriptor()
// apikey.DefaultRateLimit1d holds the default value on creation for the rate_limit_1d field.
apikey.DefaultRateLimit1d = apikeyDescRateLimit1d.Default.(float64)
// apikeyDescRateLimit7d is the schema descriptor for rate_limit_7d field.
- apikeyDescRateLimit7d := apikeyFields[13].Descriptor()
+ apikeyDescRateLimit7d := apikeyFields[16].Descriptor()
// apikey.DefaultRateLimit7d holds the default value on creation for the rate_limit_7d field.
apikey.DefaultRateLimit7d = apikeyDescRateLimit7d.Default.(float64)
// apikeyDescUsage5h is the schema descriptor for usage_5h field.
- apikeyDescUsage5h := apikeyFields[14].Descriptor()
+ apikeyDescUsage5h := apikeyFields[17].Descriptor()
// apikey.DefaultUsage5h holds the default value on creation for the usage_5h field.
apikey.DefaultUsage5h = apikeyDescUsage5h.Default.(float64)
// apikeyDescUsage1d is the schema descriptor for usage_1d field.
- apikeyDescUsage1d := apikeyFields[15].Descriptor()
+ apikeyDescUsage1d := apikeyFields[18].Descriptor()
// apikey.DefaultUsage1d holds the default value on creation for the usage_1d field.
apikey.DefaultUsage1d = apikeyDescUsage1d.Default.(float64)
// apikeyDescUsage7d is the schema descriptor for usage_7d field.
- apikeyDescUsage7d := apikeyFields[16].Descriptor()
+ apikeyDescUsage7d := apikeyFields[19].Descriptor()
// apikey.DefaultUsage7d holds the default value on creation for the usage_7d field.
apikey.DefaultUsage7d = apikeyDescUsage7d.Default.(float64)
accountMixin := schema.Account{}.Mixin()
@@ -803,50 +821,62 @@ func init() {
groupDescDefaultValidityDays := groupFields[10].Descriptor()
// group.DefaultDefaultValidityDays holds the default value on creation for the default_validity_days field.
group.DefaultDefaultValidityDays = groupDescDefaultValidityDays.Default.(int)
+ // groupDescAllowImageGeneration is the schema descriptor for allow_image_generation field.
+ groupDescAllowImageGeneration := groupFields[11].Descriptor()
+ // group.DefaultAllowImageGeneration holds the default value on creation for the allow_image_generation field.
+ group.DefaultAllowImageGeneration = groupDescAllowImageGeneration.Default.(bool)
+ // groupDescImageRateIndependent is the schema descriptor for image_rate_independent field.
+ groupDescImageRateIndependent := groupFields[12].Descriptor()
+ // group.DefaultImageRateIndependent holds the default value on creation for the image_rate_independent field.
+ group.DefaultImageRateIndependent = groupDescImageRateIndependent.Default.(bool)
+ // groupDescImageRateMultiplier is the schema descriptor for image_rate_multiplier field.
+ groupDescImageRateMultiplier := groupFields[13].Descriptor()
+ // group.DefaultImageRateMultiplier holds the default value on creation for the image_rate_multiplier field.
+ group.DefaultImageRateMultiplier = groupDescImageRateMultiplier.Default.(float64)
// groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field.
- groupDescClaudeCodeOnly := groupFields[14].Descriptor()
+ groupDescClaudeCodeOnly := groupFields[17].Descriptor()
// group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field.
group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool)
// groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field.
- groupDescModelRoutingEnabled := groupFields[18].Descriptor()
+ groupDescModelRoutingEnabled := groupFields[21].Descriptor()
// group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field.
group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool)
// groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field.
- groupDescMcpXMLInject := groupFields[19].Descriptor()
+ groupDescMcpXMLInject := groupFields[22].Descriptor()
// group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field.
group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool)
// groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field.
- groupDescSupportedModelScopes := groupFields[20].Descriptor()
+ groupDescSupportedModelScopes := groupFields[23].Descriptor()
// group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field.
group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string)
// groupDescSortOrder is the schema descriptor for sort_order field.
- groupDescSortOrder := groupFields[21].Descriptor()
+ groupDescSortOrder := groupFields[24].Descriptor()
// group.DefaultSortOrder holds the default value on creation for the sort_order field.
group.DefaultSortOrder = groupDescSortOrder.Default.(int)
// groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field.
- groupDescAllowMessagesDispatch := groupFields[22].Descriptor()
+ groupDescAllowMessagesDispatch := groupFields[25].Descriptor()
// group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field.
group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool)
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
- groupDescRequireOauthOnly := groupFields[23].Descriptor()
+ groupDescRequireOauthOnly := groupFields[26].Descriptor()
// group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field.
group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool)
// groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field.
- groupDescRequirePrivacySet := groupFields[24].Descriptor()
+ groupDescRequirePrivacySet := groupFields[27].Descriptor()
// group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field.
group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool)
// groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field.
- groupDescDefaultMappedModel := groupFields[25].Descriptor()
+ groupDescDefaultMappedModel := groupFields[28].Descriptor()
// group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field.
group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string)
// group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error)
// groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field.
- groupDescMessagesDispatchModelConfig := groupFields[26].Descriptor()
+ groupDescMessagesDispatchModelConfig := groupFields[29].Descriptor()
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
- groupDescRpmLimit := groupFields[27].Descriptor()
+ groupDescRpmLimit := groupFields[30].Descriptor()
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
@@ -1323,10 +1353,6 @@ func init() {
proxyDescUsername := proxyFields[4].Descriptor()
// proxy.UsernameValidator is a validator for the "username" field. It is called by the builders before save.
proxy.UsernameValidator = proxyDescUsername.Validators[0].(func(string) error)
- // proxyDescPassword is the schema descriptor for password field.
- proxyDescPassword := proxyFields[5].Descriptor()
- // proxy.PasswordValidator is a validator for the "password" field. It is called by the builders before save.
- proxy.PasswordValidator = proxyDescPassword.Validators[0].(func(string) error)
// proxyDescStatus is the schema descriptor for status field.
proxyDescStatus := proxyFields[6].Descriptor()
// proxy.DefaultStatus holds the default value on creation for the status field.
@@ -1442,8 +1468,14 @@ func init() {
setting.UpdateDefaultUpdatedAt = settingDescUpdatedAt.UpdateDefault.(func() time.Time)
subscriptionplanFields := schema.SubscriptionPlan{}.Fields()
_ = subscriptionplanFields
+ // subscriptionplanDescPlanType is the schema descriptor for plan_type field.
+ subscriptionplanDescPlanType := subscriptionplanFields[2].Descriptor()
+ // subscriptionplan.DefaultPlanType holds the default value on creation for the plan_type field.
+ subscriptionplan.DefaultPlanType = subscriptionplanDescPlanType.Default.(string)
+ // subscriptionplan.PlanTypeValidator is a validator for the "plan_type" field. It is called by the builders before save.
+ subscriptionplan.PlanTypeValidator = subscriptionplanDescPlanType.Validators[0].(func(string) error)
// subscriptionplanDescName is the schema descriptor for name field.
- subscriptionplanDescName := subscriptionplanFields[1].Descriptor()
+ subscriptionplanDescName := subscriptionplanFields[3].Descriptor()
// subscriptionplan.NameValidator is a validator for the "name" field. It is called by the builders before save.
subscriptionplan.NameValidator = func() func(string) error {
validators := subscriptionplanDescName.Validators
@@ -1461,47 +1493,63 @@ func init() {
}
}()
// subscriptionplanDescDescription is the schema descriptor for description field.
- subscriptionplanDescDescription := subscriptionplanFields[2].Descriptor()
+ subscriptionplanDescDescription := subscriptionplanFields[4].Descriptor()
// subscriptionplan.DefaultDescription holds the default value on creation for the description field.
subscriptionplan.DefaultDescription = subscriptionplanDescDescription.Default.(string)
// subscriptionplanDescValidityDays is the schema descriptor for validity_days field.
- subscriptionplanDescValidityDays := subscriptionplanFields[5].Descriptor()
+ subscriptionplanDescValidityDays := subscriptionplanFields[7].Descriptor()
// subscriptionplan.DefaultValidityDays holds the default value on creation for the validity_days field.
subscriptionplan.DefaultValidityDays = subscriptionplanDescValidityDays.Default.(int)
// subscriptionplanDescValidityUnit is the schema descriptor for validity_unit field.
- subscriptionplanDescValidityUnit := subscriptionplanFields[6].Descriptor()
+ subscriptionplanDescValidityUnit := subscriptionplanFields[8].Descriptor()
// subscriptionplan.DefaultValidityUnit holds the default value on creation for the validity_unit field.
subscriptionplan.DefaultValidityUnit = subscriptionplanDescValidityUnit.Default.(string)
// subscriptionplan.ValidityUnitValidator is a validator for the "validity_unit" field. It is called by the builders before save.
subscriptionplan.ValidityUnitValidator = subscriptionplanDescValidityUnit.Validators[0].(func(string) error)
// subscriptionplanDescFeatures is the schema descriptor for features field.
- subscriptionplanDescFeatures := subscriptionplanFields[7].Descriptor()
+ subscriptionplanDescFeatures := subscriptionplanFields[9].Descriptor()
// subscriptionplan.DefaultFeatures holds the default value on creation for the features field.
subscriptionplan.DefaultFeatures = subscriptionplanDescFeatures.Default.(string)
// subscriptionplanDescProductName is the schema descriptor for product_name field.
- subscriptionplanDescProductName := subscriptionplanFields[8].Descriptor()
+ subscriptionplanDescProductName := subscriptionplanFields[10].Descriptor()
// subscriptionplan.DefaultProductName holds the default value on creation for the product_name field.
subscriptionplan.DefaultProductName = subscriptionplanDescProductName.Default.(string)
// subscriptionplan.ProductNameValidator is a validator for the "product_name" field. It is called by the builders before save.
subscriptionplan.ProductNameValidator = subscriptionplanDescProductName.Validators[0].(func(string) error)
// subscriptionplanDescForSale is the schema descriptor for for_sale field.
- subscriptionplanDescForSale := subscriptionplanFields[9].Descriptor()
+ subscriptionplanDescForSale := subscriptionplanFields[11].Descriptor()
// subscriptionplan.DefaultForSale holds the default value on creation for the for_sale field.
subscriptionplan.DefaultForSale = subscriptionplanDescForSale.Default.(bool)
// subscriptionplanDescSortOrder is the schema descriptor for sort_order field.
- subscriptionplanDescSortOrder := subscriptionplanFields[10].Descriptor()
+ subscriptionplanDescSortOrder := subscriptionplanFields[12].Descriptor()
// subscriptionplan.DefaultSortOrder holds the default value on creation for the sort_order field.
subscriptionplan.DefaultSortOrder = subscriptionplanDescSortOrder.Default.(int)
// subscriptionplanDescCreatedAt is the schema descriptor for created_at field.
- subscriptionplanDescCreatedAt := subscriptionplanFields[11].Descriptor()
+ subscriptionplanDescCreatedAt := subscriptionplanFields[13].Descriptor()
// subscriptionplan.DefaultCreatedAt holds the default value on creation for the created_at field.
subscriptionplan.DefaultCreatedAt = subscriptionplanDescCreatedAt.Default.(func() time.Time)
// subscriptionplanDescUpdatedAt is the schema descriptor for updated_at field.
- subscriptionplanDescUpdatedAt := subscriptionplanFields[12].Descriptor()
+ subscriptionplanDescUpdatedAt := subscriptionplanFields[14].Descriptor()
// subscriptionplan.DefaultUpdatedAt holds the default value on creation for the updated_at field.
subscriptionplan.DefaultUpdatedAt = subscriptionplanDescUpdatedAt.Default.(func() time.Time)
// subscriptionplan.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
subscriptionplan.UpdateDefaultUpdatedAt = subscriptionplanDescUpdatedAt.UpdateDefault.(func() time.Time)
+ subscriptionplangroupFields := schema.SubscriptionPlanGroup{}.Fields()
+ _ = subscriptionplangroupFields
+ // subscriptionplangroupDescCreatedAt is the schema descriptor for created_at field.
+ subscriptionplangroupDescCreatedAt := subscriptionplangroupFields[2].Descriptor()
+ // subscriptionplangroup.DefaultCreatedAt holds the default value on creation for the created_at field.
+ subscriptionplangroup.DefaultCreatedAt = subscriptionplangroupDescCreatedAt.Default.(func() time.Time)
+ subscriptionwalletledgerFields := schema.SubscriptionWalletLedger{}.Fields()
+ _ = subscriptionwalletledgerFields
+ // subscriptionwalletledgerDescReason is the schema descriptor for reason field.
+ subscriptionwalletledgerDescReason := subscriptionwalletledgerFields[3].Descriptor()
+ // subscriptionwalletledger.ReasonValidator is a validator for the "reason" field. It is called by the builders before save.
+ subscriptionwalletledger.ReasonValidator = subscriptionwalletledgerDescReason.Validators[0].(func(string) error)
+ // subscriptionwalletledgerDescCreatedAt is the schema descriptor for created_at field.
+ subscriptionwalletledgerDescCreatedAt := subscriptionwalletledgerFields[8].Descriptor()
+ // subscriptionwalletledger.DefaultCreatedAt holds the default value on creation for the created_at field.
+ subscriptionwalletledger.DefaultCreatedAt = subscriptionwalletledgerDescCreatedAt.Default.(func() time.Time)
tlsfingerprintprofileMixin := schema.TLSFingerprintProfile{}.Mixin()
tlsfingerprintprofileMixinFields0 := tlsfingerprintprofileMixin[0].Fields()
_ = tlsfingerprintprofileMixinFields0
@@ -1622,100 +1670,116 @@ func init() {
usagelogDescUpstreamModel := usagelogFields[6].Descriptor()
// usagelog.UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save.
usagelog.UpstreamModelValidator = usagelogDescUpstreamModel.Validators[0].(func(string) error)
+ // usagelogDescBillingModel is the schema descriptor for billing_model field.
+ usagelogDescBillingModel := usagelogFields[7].Descriptor()
+ // usagelog.BillingModelValidator is a validator for the "billing_model" field. It is called by the builders before save.
+ usagelog.BillingModelValidator = usagelogDescBillingModel.Validators[0].(func(string) error)
+ // usagelogDescPricingSource is the schema descriptor for pricing_source field.
+ usagelogDescPricingSource := usagelogFields[8].Descriptor()
+ // usagelog.PricingSourceValidator is a validator for the "pricing_source" field. It is called by the builders before save.
+ usagelog.PricingSourceValidator = usagelogDescPricingSource.Validators[0].(func(string) error)
+ // usagelogDescPricingRevision is the schema descriptor for pricing_revision field.
+ usagelogDescPricingRevision := usagelogFields[9].Descriptor()
+ // usagelog.PricingRevisionValidator is a validator for the "pricing_revision" field. It is called by the builders before save.
+ usagelog.PricingRevisionValidator = usagelogDescPricingRevision.Validators[0].(func(string) error)
+ // usagelogDescPricingHash is the schema descriptor for pricing_hash field.
+ usagelogDescPricingHash := usagelogFields[10].Descriptor()
+ // usagelog.PricingHashValidator is a validator for the "pricing_hash" field. It is called by the builders before save.
+ usagelog.PricingHashValidator = usagelogDescPricingHash.Validators[0].(func(string) error)
// usagelogDescModelMappingChain is the schema descriptor for model_mapping_chain field.
- usagelogDescModelMappingChain := usagelogFields[8].Descriptor()
+ usagelogDescModelMappingChain := usagelogFields[12].Descriptor()
// usagelog.ModelMappingChainValidator is a validator for the "model_mapping_chain" field. It is called by the builders before save.
usagelog.ModelMappingChainValidator = usagelogDescModelMappingChain.Validators[0].(func(string) error)
// usagelogDescBillingTier is the schema descriptor for billing_tier field.
- usagelogDescBillingTier := usagelogFields[9].Descriptor()
+ usagelogDescBillingTier := usagelogFields[13].Descriptor()
// usagelog.BillingTierValidator is a validator for the "billing_tier" field. It is called by the builders before save.
usagelog.BillingTierValidator = usagelogDescBillingTier.Validators[0].(func(string) error)
// usagelogDescBillingMode is the schema descriptor for billing_mode field.
- usagelogDescBillingMode := usagelogFields[10].Descriptor()
+ usagelogDescBillingMode := usagelogFields[14].Descriptor()
// usagelog.BillingModeValidator is a validator for the "billing_mode" field. It is called by the builders before save.
usagelog.BillingModeValidator = usagelogDescBillingMode.Validators[0].(func(string) error)
// usagelogDescInputTokens is the schema descriptor for input_tokens field.
- usagelogDescInputTokens := usagelogFields[13].Descriptor()
+ usagelogDescInputTokens := usagelogFields[17].Descriptor()
// usagelog.DefaultInputTokens holds the default value on creation for the input_tokens field.
usagelog.DefaultInputTokens = usagelogDescInputTokens.Default.(int)
// usagelogDescOutputTokens is the schema descriptor for output_tokens field.
- usagelogDescOutputTokens := usagelogFields[14].Descriptor()
+ usagelogDescOutputTokens := usagelogFields[18].Descriptor()
// usagelog.DefaultOutputTokens holds the default value on creation for the output_tokens field.
usagelog.DefaultOutputTokens = usagelogDescOutputTokens.Default.(int)
// usagelogDescCacheCreationTokens is the schema descriptor for cache_creation_tokens field.
- usagelogDescCacheCreationTokens := usagelogFields[15].Descriptor()
+ usagelogDescCacheCreationTokens := usagelogFields[19].Descriptor()
// usagelog.DefaultCacheCreationTokens holds the default value on creation for the cache_creation_tokens field.
usagelog.DefaultCacheCreationTokens = usagelogDescCacheCreationTokens.Default.(int)
// usagelogDescCacheReadTokens is the schema descriptor for cache_read_tokens field.
- usagelogDescCacheReadTokens := usagelogFields[16].Descriptor()
+ usagelogDescCacheReadTokens := usagelogFields[20].Descriptor()
// usagelog.DefaultCacheReadTokens holds the default value on creation for the cache_read_tokens field.
usagelog.DefaultCacheReadTokens = usagelogDescCacheReadTokens.Default.(int)
// usagelogDescCacheCreation5mTokens is the schema descriptor for cache_creation_5m_tokens field.
- usagelogDescCacheCreation5mTokens := usagelogFields[17].Descriptor()
+ usagelogDescCacheCreation5mTokens := usagelogFields[21].Descriptor()
// usagelog.DefaultCacheCreation5mTokens holds the default value on creation for the cache_creation_5m_tokens field.
usagelog.DefaultCacheCreation5mTokens = usagelogDescCacheCreation5mTokens.Default.(int)
// usagelogDescCacheCreation1hTokens is the schema descriptor for cache_creation_1h_tokens field.
- usagelogDescCacheCreation1hTokens := usagelogFields[18].Descriptor()
+ usagelogDescCacheCreation1hTokens := usagelogFields[22].Descriptor()
// usagelog.DefaultCacheCreation1hTokens holds the default value on creation for the cache_creation_1h_tokens field.
usagelog.DefaultCacheCreation1hTokens = usagelogDescCacheCreation1hTokens.Default.(int)
// usagelogDescInputCost is the schema descriptor for input_cost field.
- usagelogDescInputCost := usagelogFields[19].Descriptor()
+ usagelogDescInputCost := usagelogFields[23].Descriptor()
// usagelog.DefaultInputCost holds the default value on creation for the input_cost field.
usagelog.DefaultInputCost = usagelogDescInputCost.Default.(float64)
// usagelogDescOutputCost is the schema descriptor for output_cost field.
- usagelogDescOutputCost := usagelogFields[20].Descriptor()
+ usagelogDescOutputCost := usagelogFields[24].Descriptor()
// usagelog.DefaultOutputCost holds the default value on creation for the output_cost field.
usagelog.DefaultOutputCost = usagelogDescOutputCost.Default.(float64)
// usagelogDescCacheCreationCost is the schema descriptor for cache_creation_cost field.
- usagelogDescCacheCreationCost := usagelogFields[21].Descriptor()
+ usagelogDescCacheCreationCost := usagelogFields[25].Descriptor()
// usagelog.DefaultCacheCreationCost holds the default value on creation for the cache_creation_cost field.
usagelog.DefaultCacheCreationCost = usagelogDescCacheCreationCost.Default.(float64)
// usagelogDescCacheReadCost is the schema descriptor for cache_read_cost field.
- usagelogDescCacheReadCost := usagelogFields[22].Descriptor()
+ usagelogDescCacheReadCost := usagelogFields[26].Descriptor()
// usagelog.DefaultCacheReadCost holds the default value on creation for the cache_read_cost field.
usagelog.DefaultCacheReadCost = usagelogDescCacheReadCost.Default.(float64)
// usagelogDescTotalCost is the schema descriptor for total_cost field.
- usagelogDescTotalCost := usagelogFields[23].Descriptor()
+ usagelogDescTotalCost := usagelogFields[27].Descriptor()
// usagelog.DefaultTotalCost holds the default value on creation for the total_cost field.
usagelog.DefaultTotalCost = usagelogDescTotalCost.Default.(float64)
// usagelogDescActualCost is the schema descriptor for actual_cost field.
- usagelogDescActualCost := usagelogFields[24].Descriptor()
+ usagelogDescActualCost := usagelogFields[28].Descriptor()
// usagelog.DefaultActualCost holds the default value on creation for the actual_cost field.
usagelog.DefaultActualCost = usagelogDescActualCost.Default.(float64)
// usagelogDescRateMultiplier is the schema descriptor for rate_multiplier field.
- usagelogDescRateMultiplier := usagelogFields[25].Descriptor()
+ usagelogDescRateMultiplier := usagelogFields[29].Descriptor()
// usagelog.DefaultRateMultiplier holds the default value on creation for the rate_multiplier field.
usagelog.DefaultRateMultiplier = usagelogDescRateMultiplier.Default.(float64)
// usagelogDescBillingType is the schema descriptor for billing_type field.
- usagelogDescBillingType := usagelogFields[27].Descriptor()
+ usagelogDescBillingType := usagelogFields[31].Descriptor()
// usagelog.DefaultBillingType holds the default value on creation for the billing_type field.
usagelog.DefaultBillingType = usagelogDescBillingType.Default.(int8)
// usagelogDescStream is the schema descriptor for stream field.
- usagelogDescStream := usagelogFields[28].Descriptor()
+ usagelogDescStream := usagelogFields[32].Descriptor()
// usagelog.DefaultStream holds the default value on creation for the stream field.
usagelog.DefaultStream = usagelogDescStream.Default.(bool)
// usagelogDescUserAgent is the schema descriptor for user_agent field.
- usagelogDescUserAgent := usagelogFields[31].Descriptor()
+ usagelogDescUserAgent := usagelogFields[35].Descriptor()
// usagelog.UserAgentValidator is a validator for the "user_agent" field. It is called by the builders before save.
usagelog.UserAgentValidator = usagelogDescUserAgent.Validators[0].(func(string) error)
// usagelogDescIPAddress is the schema descriptor for ip_address field.
- usagelogDescIPAddress := usagelogFields[32].Descriptor()
+ usagelogDescIPAddress := usagelogFields[36].Descriptor()
// usagelog.IPAddressValidator is a validator for the "ip_address" field. It is called by the builders before save.
usagelog.IPAddressValidator = usagelogDescIPAddress.Validators[0].(func(string) error)
// usagelogDescImageCount is the schema descriptor for image_count field.
- usagelogDescImageCount := usagelogFields[33].Descriptor()
+ usagelogDescImageCount := usagelogFields[37].Descriptor()
// usagelog.DefaultImageCount holds the default value on creation for the image_count field.
usagelog.DefaultImageCount = usagelogDescImageCount.Default.(int)
// usagelogDescImageSize is the schema descriptor for image_size field.
- usagelogDescImageSize := usagelogFields[34].Descriptor()
+ usagelogDescImageSize := usagelogFields[38].Descriptor()
// usagelog.ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save.
usagelog.ImageSizeValidator = usagelogDescImageSize.Validators[0].(func(string) error)
// usagelogDescCacheTTLOverridden is the schema descriptor for cache_ttl_overridden field.
- usagelogDescCacheTTLOverridden := usagelogFields[35].Descriptor()
+ usagelogDescCacheTTLOverridden := usagelogFields[39].Descriptor()
// usagelog.DefaultCacheTTLOverridden holds the default value on creation for the cache_ttl_overridden field.
usagelog.DefaultCacheTTLOverridden = usagelogDescCacheTTLOverridden.Default.(bool)
// usagelogDescCreatedAt is the schema descriptor for created_at field.
- usagelogDescCreatedAt := usagelogFields[36].Descriptor()
+ usagelogDescCreatedAt := usagelogFields[40].Descriptor()
// usagelog.DefaultCreatedAt holds the default value on creation for the created_at field.
usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time)
userMixin := schema.User{}.Mixin()
@@ -1833,6 +1897,50 @@ func init() {
userDescRpmLimit := userFields[19].Descriptor()
// user.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
user.DefaultRpmLimit = userDescRpmLimit.Default.(int)
+ // userDescSignupIP is the schema descriptor for signup_ip field.
+ userDescSignupIP := userFields[20].Descriptor()
+ // user.DefaultSignupIP holds the default value on creation for the signup_ip field.
+ user.DefaultSignupIP = userDescSignupIP.Default.(string)
+ // user.SignupIPValidator is a validator for the "signup_ip" field. It is called by the builders before save.
+ user.SignupIPValidator = userDescSignupIP.Validators[0].(func(string) error)
+ // userDescSignupIPPrefix is the schema descriptor for signup_ip_prefix field.
+ userDescSignupIPPrefix := userFields[21].Descriptor()
+ // user.DefaultSignupIPPrefix holds the default value on creation for the signup_ip_prefix field.
+ user.DefaultSignupIPPrefix = userDescSignupIPPrefix.Default.(string)
+ // user.SignupIPPrefixValidator is a validator for the "signup_ip_prefix" field. It is called by the builders before save.
+ user.SignupIPPrefixValidator = userDescSignupIPPrefix.Validators[0].(func(string) error)
+ // userDescSignupUserAgentHash is the schema descriptor for signup_user_agent_hash field.
+ userDescSignupUserAgentHash := userFields[22].Descriptor()
+ // user.DefaultSignupUserAgentHash holds the default value on creation for the signup_user_agent_hash field.
+ user.DefaultSignupUserAgentHash = userDescSignupUserAgentHash.Default.(string)
+ // user.SignupUserAgentHashValidator is a validator for the "signup_user_agent_hash" field. It is called by the builders before save.
+ user.SignupUserAgentHashValidator = userDescSignupUserAgentHash.Validators[0].(func(string) error)
+ // userDescSignupDeviceFingerprintHash is the schema descriptor for signup_device_fingerprint_hash field.
+ userDescSignupDeviceFingerprintHash := userFields[23].Descriptor()
+ // user.DefaultSignupDeviceFingerprintHash holds the default value on creation for the signup_device_fingerprint_hash field.
+ user.DefaultSignupDeviceFingerprintHash = userDescSignupDeviceFingerprintHash.Default.(string)
+ // user.SignupDeviceFingerprintHashValidator is a validator for the "signup_device_fingerprint_hash" field. It is called by the builders before save.
+ user.SignupDeviceFingerprintHashValidator = userDescSignupDeviceFingerprintHash.Validators[0].(func(string) error)
+ // userDescTrialBonusEligible is the schema descriptor for trial_bonus_eligible field.
+ userDescTrialBonusEligible := userFields[24].Descriptor()
+ // user.DefaultTrialBonusEligible holds the default value on creation for the trial_bonus_eligible field.
+ user.DefaultTrialBonusEligible = userDescTrialBonusEligible.Default.(bool)
+ // userDescTrialBonusHoldReason is the schema descriptor for trial_bonus_hold_reason field.
+ userDescTrialBonusHoldReason := userFields[25].Descriptor()
+ // user.DefaultTrialBonusHoldReason holds the default value on creation for the trial_bonus_hold_reason field.
+ user.DefaultTrialBonusHoldReason = userDescTrialBonusHoldReason.Default.(string)
+ // user.TrialBonusHoldReasonValidator is a validator for the "trial_bonus_hold_reason" field. It is called by the builders before save.
+ user.TrialBonusHoldReasonValidator = userDescTrialBonusHoldReason.Validators[0].(func(string) error)
+ // userDescTrialBonusRiskScore is the schema descriptor for trial_bonus_risk_score field.
+ userDescTrialBonusRiskScore := userFields[26].Descriptor()
+ // user.DefaultTrialBonusRiskScore holds the default value on creation for the trial_bonus_risk_score field.
+ user.DefaultTrialBonusRiskScore = userDescTrialBonusRiskScore.Default.(int)
+ // userDescTokenVersion is the schema descriptor for token_version field.
+ userDescTokenVersion := userFields[27].Descriptor()
+ // user.DefaultTokenVersion holds the default value on creation for the token_version field.
+ user.DefaultTokenVersion = userDescTokenVersion.Default.(int64)
+ // user.TokenVersionValidator is a validator for the "token_version" field. It is called by the builders before save.
+ user.TokenVersionValidator = userDescTokenVersion.Validators[0].(func(int64) error)
userallowedgroupFields := schema.UserAllowedGroup{}.Fields()
_ = userallowedgroupFields
// userallowedgroupDescCreatedAt is the schema descriptor for created_at field.
@@ -1999,7 +2107,7 @@ func init() {
// usersubscription.DefaultMonthlyUsageUsd holds the default value on creation for the monthly_usage_usd field.
usersubscription.DefaultMonthlyUsageUsd = usersubscriptionDescMonthlyUsageUsd.Default.(float64)
// usersubscriptionDescAssignedAt is the schema descriptor for assigned_at field.
- usersubscriptionDescAssignedAt := usersubscriptionFields[12].Descriptor()
+ usersubscriptionDescAssignedAt := usersubscriptionFields[15].Descriptor()
// usersubscription.DefaultAssignedAt holds the default value on creation for the assigned_at field.
usersubscription.DefaultAssignedAt = usersubscriptionDescAssignedAt.Default.(func() time.Time)
}
diff --git a/backend/ent/schema/api_key.go b/backend/ent/schema/api_key.go
index 5db51270b1b..68da4fde33c 100644
--- a/backend/ent/schema/api_key.go
+++ b/backend/ent/schema/api_key.go
@@ -35,12 +35,29 @@ func (APIKey) Fields() []ent.Field {
return []ent.Field{
field.Int64("user_id"),
field.String("key").
- MaxLen(128).
+ MaxLen(512).
NotEmpty().
- Unique(),
+ Unique().
+ Sensitive().
+ Comment("Versioned AES-GCM ciphertext for the customer API key; never plaintext"),
+ field.String("key_hash").
+ MaxLen(64).
+ Optional().
+ Nillable().
+ Sensitive().
+ Comment("HMAC-SHA-256 lookup locator derived from the API-key protection key"),
+ field.String("key_prefix").
+ MaxLen(16).
+ Default("").
+ Comment("Non-secret API key prefix for search/display"),
field.String("name").
MaxLen(100).
NotEmpty(),
+ field.String("purpose").
+ MaxLen(32).
+ Default("standard").
+ Immutable().
+ Comment("Immutable security identity: standard or wallet_universal"),
field.Int64("group_id").
Optional().
Nillable(),
@@ -136,7 +153,13 @@ func (APIKey) Edges() []ent.Edge {
func (APIKey) Indexes() []ent.Index {
return []ent.Index{
// key 字段已在 Fields() 中声明 Unique(),无需重复索引
+ index.Fields("key_hash").
+ Unique().
+ Annotations(entsql.IndexWhere("deleted_at IS NULL AND key_hash IS NOT NULL")),
index.Fields("user_id"),
+ index.Fields("user_id", "purpose").
+ Unique().
+ Annotations(entsql.IndexWhere("deleted_at IS NULL AND purpose = 'wallet_universal'")),
index.Fields("group_id"),
index.Fields("status"),
index.Fields("deleted_at"),
diff --git a/backend/ent/schema/auth_identity.go b/backend/ent/schema/auth_identity.go
index 0b1b56ab0fd..5f864080086 100644
--- a/backend/ent/schema/auth_identity.go
+++ b/backend/ent/schema/auth_identity.go
@@ -16,6 +16,8 @@ import (
var authProviderTypes = map[string]struct{}{
"email": {},
+ "github": {},
+ "google": {},
"linuxdo": {},
"oidc": {},
"wechat": {},
diff --git a/backend/ent/schema/auth_identity_schema_test.go b/backend/ent/schema/auth_identity_schema_test.go
index fbb932368a5..d3e2405069a 100644
--- a/backend/ent/schema/auth_identity_schema_test.go
+++ b/backend/ent/schema/auth_identity_schema_test.go
@@ -83,10 +83,10 @@ func TestAuthIdentityFoundationSchemas(t *testing.T) {
require.Equal(t, 1, signupSource.Validators)
validator := requireStringFieldValidator(t, User{}.Fields(), "signup_source")
- for _, value := range []string{"email", "linuxdo", "wechat", "oidc"} {
+ for _, value := range []string{"email", "linuxdo", "wechat", "oidc", "github", "google"} {
require.NoError(t, validator(value))
}
- require.Error(t, validator("github"))
+ require.Error(t, validator("unknown"))
}
func requireSchema(t *testing.T, schemas map[string]*load.Schema, name string) *load.Schema {
diff --git a/backend/ent/schema/channel_monitor.go b/backend/ent/schema/channel_monitor.go
index 355ade4b686..688c14a660d 100644
--- a/backend/ent/schema/channel_monitor.go
+++ b/backend/ent/schema/channel_monitor.go
@@ -72,14 +72,16 @@ func (ChannelMonitor) Fields() []ent.Field {
Optional().
Nillable(),
// extra_headers: 自定义 HTTP 头快照(来自模板 or 用户手填)。
- // 运行时 merge 进 adapter 默认 headers。
+ // 数据库只保存 marker-only 的 domain-bound 加密 envelope;service 解密后
+ // 才会在运行时 merge 进 adapter 默认 headers。
field.JSON("extra_headers", map[string]string{}).
Default(map[string]string{}),
// body_override_mode: 同 ChannelMonitorRequestTemplate.body_override_mode
field.String("body_override_mode").
Default("off").
MaxLen(10),
- // body_override: 同 ChannelMonitorRequestTemplate.body_override
+ // body_override: 同 ChannelMonitorRequestTemplate.body_override;非 NULL 值
+ // 在数据库中同样只保存 marker-only 的加密 envelope。
field.JSON("body_override", map[string]any{}).
Optional(),
}
diff --git a/backend/ent/schema/channel_monitor_request_template.go b/backend/ent/schema/channel_monitor_request_template.go
index 59df2f29d0e..086cad5777d 100644
--- a/backend/ent/schema/channel_monitor_request_template.go
+++ b/backend/ent/schema/channel_monitor_request_template.go
@@ -45,8 +45,8 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field {
Default("").
MaxLen(500),
// extra_headers: 用户自定义 HTTP 头(如 User-Agent 伪装)。
- // 运行时 merge 进 adapter 默认 headers,用户值优先;
- // hop-by-hop 黑名单(Host/Content-Length/...)由 checker 过滤。
+ // 数据库只保存 marker-only 的 domain-bound 加密 envelope;service 解密后
+ // 才会用于运行时 merge。鉴权、hop-by-hop 和客户端自管 header 均禁止覆盖。
field.JSON("extra_headers", map[string]string{}).
Default(map[string]string{}),
// body_override_mode: 'off' | 'merge' | 'replace'
@@ -59,7 +59,8 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field {
Default("off").
MaxLen(10),
// body_override: JSON 对象,根据 body_override_mode 使用。
- // 用 map[string]any 以便前端传任意结构(含嵌套)。
+ // 用 map[string]any 以便前端传任意结构(含嵌套);非 NULL 数据在库内
+ // 保存为 marker-only 的 domain-bound 加密 envelope。
field.JSON("body_override", map[string]any{}).
Optional(),
}
diff --git a/backend/ent/schema/error_passthrough_rule.go b/backend/ent/schema/error_passthrough_rule.go
index 63a81230c2c..ea73b687389 100644
--- a/backend/ent/schema/error_passthrough_rule.go
+++ b/backend/ent/schema/error_passthrough_rule.go
@@ -16,7 +16,7 @@ import (
//
// 错误透传规则用于控制上游错误如何返回给客户端:
// - 匹配条件:错误码 + 关键词组合
-// - 响应行为:透传原始信息 或 自定义错误信息
+// - 响应行为:返回自定义且有界的错误信息
// - 响应状态码:可指定返回给客户端的状态码
// - 平台范围:规则适用的平台(Anthropic、OpenAI、Gemini、Antigravity)
type ErrorPassthroughRule struct {
@@ -93,14 +93,12 @@ func (ErrorPassthroughRule) Fields() []ent.Field {
Optional().
Nillable(),
- // passthrough_body: 是否透传上游原始错误信息
- // true: 使用上游返回的错误信息
- // false: 使用 custom_message 指定的错误信息
+ // passthrough_body: 旧版兼容字段;安全策略禁止透传上游原始错误信息
field.Bool("passthrough_body").
- Default(true),
+ Default(false),
// custom_message: 自定义错误信息
- // 当 passthrough_body=false 时使用此错误信息
+ // 返回给客户端的安全错误信息
field.Text("custom_message").
Optional().
Nillable(),
diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go
index 11f38d66f04..394098c8a53 100644
--- a/backend/ent/schema/group.go
+++ b/backend/ent/schema/group.go
@@ -74,6 +74,16 @@ func (Group) Fields() []ent.Field {
Default(30),
// 图片生成计费配置(antigravity 和 gemini 平台使用)
+ field.Bool("allow_image_generation").
+ Default(true).
+ Comment("是否允许该分组使用图片生成能力"),
+ field.Bool("image_rate_independent").
+ Default(false).
+ Comment("图片生成是否使用独立倍率;false 表示共享分组有效倍率"),
+ field.Float("image_rate_multiplier").
+ SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
+ Default(1.0).
+ Comment("图片生成独立倍率,仅 image_rate_independent=true 时生效"),
field.Float("image_price_1k").
Optional().
Nillable().
@@ -159,6 +169,7 @@ func (Group) Edges() []ent.Edge {
edge.To("redeem_codes", RedeemCode.Type),
edge.To("subscriptions", UserSubscription.Type),
edge.To("usage_logs", UsageLog.Type),
+ edge.To("plan_groups", SubscriptionPlanGroup.Type),
edge.From("accounts", Account.Type).
Ref("groups").
Through("account_groups", AccountGroup.Type),
diff --git a/backend/ent/schema/payment_order.go b/backend/ent/schema/payment_order.go
index d25d1e5e17c..2feb765f47c 100644
--- a/backend/ent/schema/payment_order.go
+++ b/backend/ent/schema/payment_order.go
@@ -195,5 +195,7 @@ func (PaymentOrder) Indexes() []ent.Index {
index.Fields("paid_at"),
index.Fields("payment_type", "paid_at"),
index.Fields("order_type"),
+ index.Fields("provider_instance_id", "created_at").
+ Annotations(entsql.IndexWhere("provider_instance_id IS NOT NULL")),
}
}
diff --git a/backend/ent/schema/proxy.go b/backend/ent/schema/proxy.go
index 46d657d36d0..384812d8d1e 100644
--- a/backend/ent/schema/proxy.go
+++ b/backend/ent/schema/proxy.go
@@ -4,6 +4,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
"entgo.io/ent"
+ "entgo.io/ent/dialect"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema"
"entgo.io/ent/schema/edge"
@@ -46,9 +47,9 @@ func (Proxy) Fields() []ent.Field {
Optional().
Nillable(),
field.String("password").
- MaxLen(100).
Optional().
- Nillable(),
+ Nillable().
+ SchemaType(map[string]string{dialect.Postgres: "text"}),
field.String("status").
MaxLen(20).
Default("active"),
diff --git a/backend/ent/schema/redeem_code.go b/backend/ent/schema/redeem_code.go
index 6fb8614847a..7811a2f23c9 100644
--- a/backend/ent/schema/redeem_code.go
+++ b/backend/ent/schema/redeem_code.go
@@ -68,6 +68,12 @@ func (RedeemCode) Fields() []ent.Field {
Nillable(),
field.Int("validity_days").
Default(30),
+ // plan_id 钱包模式额度卡用:兑换时按 plan.WalletQuotaUsd 创建 wallet 订阅,
+ // plan_type='credits' → 永久 expires_at;group_id 此时应为 NULL。
+ // 链动小铺额度卡 SKU 走此路径(B2.7)。
+ field.Int64("plan_id").
+ Optional().
+ Nillable(),
}
}
diff --git a/backend/ent/schema/subscription_plan.go b/backend/ent/schema/subscription_plan.go
index 3e30490bb24..6976f599c60 100644
--- a/backend/ent/schema/subscription_plan.go
+++ b/backend/ent/schema/subscription_plan.go
@@ -7,6 +7,7 @@ import (
"entgo.io/ent/dialect"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema"
+ "entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/index"
)
@@ -30,7 +31,25 @@ func (SubscriptionPlan) Annotations() []schema.Annotation {
func (SubscriptionPlan) Fields() []ent.Field {
return []ent.Field{
- field.Int64("group_id"),
+ // group_id 老 v3 单 group 订阅模式下必填;钱包模式(v4)下为 NULL,
+ // 关联 group 走 subscription_plan_groups 关联表。
+ // 互斥约束由 SQL CHECK chk_subscription_plans_mode 保证(migration 151)。
+ field.Int64("group_id").
+ Optional().
+ Nillable(),
+ field.Float("wallet_quota_usd").
+ SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
+ Optional().
+ Nillable().
+ Comment("钱包模式月度总额度(USD)。NOT NULL = v4 钱包;NULL = v3 单 group"),
+ // plan_type 区分月卡 / 额度卡:
+ // subscription = 月卡,validity_days 控时长(30 天),到期冻结余额
+ // credits = 额度卡,永久有效(validity_days=36500),烧完为止
+ // 取值与互斥约束(credits 必须钱包模式)由 migration 153 保证。
+ field.String("plan_type").
+ MaxLen(16).
+ Default("subscription").
+ Comment("subscription = 月卡(validity_days 控时长,到期冻结);credits = 额度卡(永久有效)"),
field.String("name").
MaxLen(100).
NotEmpty(),
@@ -75,3 +94,10 @@ func (SubscriptionPlan) Indexes() []ent.Index {
index.Fields("for_sale"),
}
}
+
+func (SubscriptionPlan) Edges() []ent.Edge {
+ return []ent.Edge{
+ // 钱包模式 plan 关联多个 group(M:N),走 subscription_plan_groups 关联表。
+ edge.To("plan_groups", SubscriptionPlanGroup.Type),
+ }
+}
diff --git a/backend/ent/schema/subscription_plan_group.go b/backend/ent/schema/subscription_plan_group.go
new file mode 100644
index 00000000000..788753f20ec
--- /dev/null
+++ b/backend/ent/schema/subscription_plan_group.go
@@ -0,0 +1,63 @@
+package schema
+
+import (
+ "time"
+
+ "entgo.io/ent"
+ "entgo.io/ent/dialect"
+ "entgo.io/ent/dialect/entsql"
+ "entgo.io/ent/schema"
+ "entgo.io/ent/schema/edge"
+ "entgo.io/ent/schema/field"
+ "entgo.io/ent/schema/index"
+)
+
+// SubscriptionPlanGroup 钱包模式 plan ↔ group 多对多关联(v4)。
+//
+// 模式判别:
+// - 老 v3 单 group 订阅:subscription_plans.group_id NOT NULL,本表无关联
+// - 新 v4 钱包模式: subscription_plans.group_id NULL,本表 N 行关联
+//
+// 详细设计:ai-relay-infra/docs/plans/2026-05-10-wallet-mode-design.md §1.1
+type SubscriptionPlanGroup struct {
+ ent.Schema
+}
+
+func (SubscriptionPlanGroup) Annotations() []schema.Annotation {
+ return []schema.Annotation{
+ entsql.Annotation{Table: "subscription_plan_groups"},
+ }
+}
+
+func (SubscriptionPlanGroup) Fields() []ent.Field {
+ return []ent.Field{
+ field.Int64("plan_id"),
+ field.Int64("group_id"),
+ field.Time("created_at").
+ Immutable().
+ Default(time.Now).
+ SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
+ }
+}
+
+func (SubscriptionPlanGroup) Edges() []ent.Edge {
+ return []ent.Edge{
+ edge.From("plan", SubscriptionPlan.Type).
+ Ref("plan_groups").
+ Field("plan_id").
+ Unique().
+ Required(),
+ edge.From("group", Group.Type).
+ Ref("plan_groups").
+ Field("group_id").
+ Unique().
+ Required(),
+ }
+}
+
+func (SubscriptionPlanGroup) Indexes() []ent.Index {
+ return []ent.Index{
+ index.Fields("plan_id", "group_id").Unique(),
+ index.Fields("group_id"),
+ }
+}
diff --git a/backend/ent/schema/subscription_wallet_ledger.go b/backend/ent/schema/subscription_wallet_ledger.go
new file mode 100644
index 00000000000..f1dd8fdb293
--- /dev/null
+++ b/backend/ent/schema/subscription_wallet_ledger.go
@@ -0,0 +1,98 @@
+package schema
+
+import (
+ "time"
+
+ "entgo.io/ent"
+ "entgo.io/ent/dialect"
+ "entgo.io/ent/dialect/entsql"
+ "entgo.io/ent/schema"
+ "entgo.io/ent/schema/edge"
+ "entgo.io/ent/schema/field"
+ "entgo.io/ent/schema/index"
+)
+
+// SubscriptionWalletLedger 钱包流水追加表(v4)。
+//
+// 每次 activation/usage/refund/adjustment/expiration 写一行,永不更新永不删除。
+// user_subscriptions.wallet_balance_usd 是其聚合的缓存字段,对账以本表为准。
+//
+// 数据库提交时和对账任务共同校验:
+//
+// SUM(ledger.delta_usd) = wallet_balance_usd
+// activation 已包含初始入账;偏差会阻断写入或进入对账告警。
+//
+// 详细设计:ai-relay-infra/docs/plans/2026-05-10-wallet-mode-design.md §1.2 §5.3
+type SubscriptionWalletLedger struct {
+ ent.Schema
+}
+
+func (SubscriptionWalletLedger) Annotations() []schema.Annotation {
+ return []schema.Annotation{
+ entsql.Annotation{Table: "subscription_wallet_ledger"},
+ }
+}
+
+func (SubscriptionWalletLedger) Fields() []ent.Field {
+ return []ent.Field{
+ field.Int64("subscription_id"),
+ field.Float("delta_usd").
+ SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}).
+ Comment("正=入账(激活/退款), 负=出账(消费)"),
+ field.Float("balance_after").
+ SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}).
+ Comment("此次操作后余额(冗余,便于排查)"),
+ field.String("reason").
+ MaxLen(32).
+ Comment("activation | usage | refund | adjustment | expiration"),
+ field.Int64("payment_order_id").
+ Optional().
+ Nillable().
+ Comment("immutable payment source for activation/topup and its refund reversal"),
+ field.Int64("usage_log_id").
+ Optional().
+ Nillable().
+ Comment("仅 reason=usage 时填,关联到 usage_logs.id"),
+ field.Int64("operator_id").
+ Optional().
+ Nillable().
+ Comment("仅 refund/adjustment 时填,操作员 user_id"),
+ field.String("notes").
+ Optional().
+ Nillable().
+ SchemaType(map[string]string{dialect.Postgres: "text"}),
+ field.Time("created_at").
+ Immutable().
+ Default(time.Now).
+ SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
+ }
+}
+
+func (SubscriptionWalletLedger) Edges() []ent.Edge {
+ return []ent.Edge{
+ edge.From("subscription", UserSubscription.Type).
+ Ref("wallet_ledger_entries").
+ Field("subscription_id").
+ Unique().
+ Required(),
+ edge.From("usage_log", UsageLog.Type).
+ Ref("wallet_ledger_entries").
+ Field("usage_log_id").
+ Unique(),
+ edge.From("operator", User.Type).
+ Ref("wallet_ledger_operations").
+ Field("operator_id").
+ Unique(),
+ }
+}
+
+func (SubscriptionWalletLedger) Indexes() []ent.Index {
+ return []ent.Index{
+ index.Fields("subscription_id", "created_at"),
+ index.Fields("usage_log_id").
+ Unique().
+ Annotations(entsql.IndexWhere("usage_log_id IS NOT NULL AND reason = 'usage'")),
+ index.Fields("payment_order_id"),
+ index.Fields("reason"),
+ }
+}
diff --git a/backend/ent/schema/usage_log.go b/backend/ent/schema/usage_log.go
index bd3ebfcc3ce..c742164403e 100644
--- a/backend/ent/schema/usage_log.go
+++ b/backend/ent/schema/usage_log.go
@@ -53,6 +53,13 @@ func (UsageLog) Fields() []ent.Field {
MaxLen(100).
Optional().
Nillable(),
+ field.String("billing_model").
+ MaxLen(100).
+ Optional().
+ Nillable(),
+ field.String("pricing_source").MaxLen(50).Optional().Nillable(),
+ field.String("pricing_revision").MaxLen(100).Optional().Nillable(),
+ field.String("pricing_hash").MaxLen(64).Optional().Nillable(),
field.Int64("channel_id").Optional().Nillable().Comment("渠道 ID"),
field.String("model_mapping_chain").MaxLen(500).Optional().Nillable().Comment("模型映射链"),
field.String("billing_tier").MaxLen(50).Optional().Nillable().Comment("计费层级标签"),
@@ -172,6 +179,7 @@ func (UsageLog) Edges() []ent.Edge {
Ref("usage_logs").
Field("subscription_id").
Unique(),
+ edge.To("wallet_ledger_entries", SubscriptionWalletLedger.Type),
}
}
diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go
index 83da5c32ba8..d4586aede0f 100644
--- a/backend/ent/schema/user.go
+++ b/backend/ent/schema/user.go
@@ -77,10 +77,10 @@ func (User) Fields() []ent.Field {
field.String("signup_source").
Validate(func(value string) error {
switch value {
- case "email", "linuxdo", "wechat", "oidc":
+ case "email", "linuxdo", "wechat", "oidc", "github", "google":
return nil
default:
- return fmt.Errorf("must be one of email, linuxdo, wechat, oidc")
+ return fmt.Errorf("must be one of email, linuxdo, wechat, oidc, github, google")
}
}).
Default("email"),
@@ -112,6 +112,35 @@ func (User) Fields() []ent.Field {
// 用户级每分钟请求数上限(0 = 不限制)。仅当所在分组未设置 rpm_limit 时作为兜底生效。
field.Int("rpm_limit").
Default(0),
+
+ // Trial bonus signup risk fields are appended after existing generated
+ // fields to preserve Ent runtime field indexes until code generation is run.
+ field.String("signup_ip").
+ MaxLen(64).
+ Default(""),
+ field.String("signup_ip_prefix").
+ MaxLen(64).
+ Default(""),
+ field.String("signup_user_agent_hash").
+ MaxLen(64).
+ Default(""),
+ field.String("signup_device_fingerprint_hash").
+ MaxLen(64).
+ Default(""),
+ field.Bool("trial_bonus_eligible").
+ Default(true),
+ field.String("trial_bonus_hold_reason").
+ MaxLen(80).
+ Default(""),
+ field.Int("trial_bonus_risk_score").
+ Default(0),
+
+ // Durable revocation generation for stateless access tokens and refresh
+ // sessions. Incrementing this field invalidates every previously issued
+ // token without mutating credential material.
+ field.Int64("token_version").
+ Default(0).
+ NonNegative(),
}
}
@@ -121,6 +150,7 @@ func (User) Edges() []ent.Edge {
edge.To("redeem_codes", RedeemCode.Type),
edge.To("subscriptions", UserSubscription.Type),
edge.To("assigned_subscriptions", UserSubscription.Type),
+ edge.To("wallet_ledger_operations", SubscriptionWalletLedger.Type),
edge.To("announcement_reads", AnnouncementRead.Type),
edge.To("allowed_groups", Group.Type).
Through("user_allowed_groups", UserAllowedGroup.Type),
diff --git a/backend/ent/schema/user_subscription.go b/backend/ent/schema/user_subscription.go
index a81850b120b..44baa62c48c 100644
--- a/backend/ent/schema/user_subscription.go
+++ b/backend/ent/schema/user_subscription.go
@@ -36,7 +36,11 @@ func (UserSubscription) Mixin() []ent.Mixin {
func (UserSubscription) Fields() []ent.Field {
return []ent.Field{
field.Int64("user_id"),
- field.Int64("group_id"),
+ // group_id 钱包模式(v4)下为 NULL;老的单 group 订阅模式(v3)下必填。
+ // 互斥约束由 SQL CHECK chk_user_subscriptions_mode 保证(migration 151)。
+ field.Int64("group_id").
+ Optional().
+ Nillable(),
field.Time("starts_at").
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
@@ -69,6 +73,22 @@ func (UserSubscription) Fields() []ent.Field {
SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}).
Default(0),
+ // 钱包模式(v4)字段;NULL = 走老的单 group 订阅(v3)。详见 migration 151。
+ field.Float("wallet_balance_usd").
+ SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}).
+ Optional().
+ Nillable().
+ Comment("钱包模式当前余额(USD,含倍率扣减)。NULL = 老 group 订阅模式"),
+ field.Float("wallet_initial_usd").
+ SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}).
+ Optional().
+ Nillable().
+ Comment("钱包模式激活时的总额度(用于 UI 进度条)"),
+ field.JSON("locked_rates", map[string]float64{}).
+ Optional().
+ SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
+ Comment("订阅级锁定倍率:group_id 字符串 -> rate_multiplier;存在时优先于用户专属倍率和 group 默认倍率"),
+
field.Int64("assigned_by").
Optional().
Nillable(),
@@ -89,16 +109,17 @@ func (UserSubscription) Edges() []ent.Edge {
Field("user_id").
Unique().
Required(),
+ // group 在钱包模式下可为空(v4),单 group 订阅模式下必填(v3)。
edge.From("group", Group.Type).
Ref("subscriptions").
Field("group_id").
- Unique().
- Required(),
+ Unique(),
edge.From("assigned_by_user", User.Type).
Ref("assigned_subscriptions").
Field("assigned_by").
Unique(),
edge.To("usage_logs", UsageLog.Type),
+ edge.To("wallet_ledger_entries", SubscriptionWalletLedger.Type),
}
}
diff --git a/backend/ent/subscriptionplan.go b/backend/ent/subscriptionplan.go
index fa4d7ae3b57..7c21efef0ab 100644
--- a/backend/ent/subscriptionplan.go
+++ b/backend/ent/subscriptionplan.go
@@ -18,7 +18,11 @@ type SubscriptionPlan struct {
// ID of the ent.
ID int64 `json:"id,omitempty"`
// GroupID holds the value of the "group_id" field.
- GroupID int64 `json:"group_id,omitempty"`
+ GroupID *int64 `json:"group_id,omitempty"`
+ // 钱包模式月度总额度(USD)。NOT NULL = v4 钱包;NULL = v3 单 group
+ WalletQuotaUsd *float64 `json:"wallet_quota_usd,omitempty"`
+ // subscription = 月卡(validity_days 控时长,到期冻结);credits = 额度卡(永久有效)
+ PlanType string `json:"plan_type,omitempty"`
// Name holds the value of the "name" field.
Name string `json:"name,omitempty"`
// Description holds the value of the "description" field.
@@ -42,10 +46,31 @@ type SubscriptionPlan struct {
// CreatedAt holds the value of the "created_at" field.
CreatedAt time.Time `json:"created_at,omitempty"`
// UpdatedAt holds the value of the "updated_at" field.
- UpdatedAt time.Time `json:"updated_at,omitempty"`
+ UpdatedAt time.Time `json:"updated_at,omitempty"`
+ // Edges holds the relations/edges for other nodes in the graph.
+ // The values are being populated by the SubscriptionPlanQuery when eager-loading is set.
+ Edges SubscriptionPlanEdges `json:"edges"`
selectValues sql.SelectValues
}
+// SubscriptionPlanEdges holds the relations/edges for other nodes in the graph.
+type SubscriptionPlanEdges struct {
+ // PlanGroups holds the value of the plan_groups edge.
+ PlanGroups []*SubscriptionPlanGroup `json:"plan_groups,omitempty"`
+ // loadedTypes holds the information for reporting if a
+ // type was loaded (or requested) in eager-loading or not.
+ loadedTypes [1]bool
+}
+
+// PlanGroupsOrErr returns the PlanGroups value or an error if the edge
+// was not loaded in eager-loading.
+func (e SubscriptionPlanEdges) PlanGroupsOrErr() ([]*SubscriptionPlanGroup, error) {
+ if e.loadedTypes[0] {
+ return e.PlanGroups, nil
+ }
+ return nil, &NotLoadedError{edge: "plan_groups"}
+}
+
// scanValues returns the types for scanning values from sql.Rows.
func (*SubscriptionPlan) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
@@ -53,11 +78,11 @@ func (*SubscriptionPlan) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case subscriptionplan.FieldForSale:
values[i] = new(sql.NullBool)
- case subscriptionplan.FieldPrice, subscriptionplan.FieldOriginalPrice:
+ case subscriptionplan.FieldWalletQuotaUsd, subscriptionplan.FieldPrice, subscriptionplan.FieldOriginalPrice:
values[i] = new(sql.NullFloat64)
case subscriptionplan.FieldID, subscriptionplan.FieldGroupID, subscriptionplan.FieldValidityDays, subscriptionplan.FieldSortOrder:
values[i] = new(sql.NullInt64)
- case subscriptionplan.FieldName, subscriptionplan.FieldDescription, subscriptionplan.FieldValidityUnit, subscriptionplan.FieldFeatures, subscriptionplan.FieldProductName:
+ case subscriptionplan.FieldPlanType, subscriptionplan.FieldName, subscriptionplan.FieldDescription, subscriptionplan.FieldValidityUnit, subscriptionplan.FieldFeatures, subscriptionplan.FieldProductName:
values[i] = new(sql.NullString)
case subscriptionplan.FieldCreatedAt, subscriptionplan.FieldUpdatedAt:
values[i] = new(sql.NullTime)
@@ -86,7 +111,21 @@ func (_m *SubscriptionPlan) assignValues(columns []string, values []any) error {
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field group_id", values[i])
} else if value.Valid {
- _m.GroupID = value.Int64
+ _m.GroupID = new(int64)
+ *_m.GroupID = value.Int64
+ }
+ case subscriptionplan.FieldWalletQuotaUsd:
+ if value, ok := values[i].(*sql.NullFloat64); !ok {
+ return fmt.Errorf("unexpected type %T for field wallet_quota_usd", values[i])
+ } else if value.Valid {
+ _m.WalletQuotaUsd = new(float64)
+ *_m.WalletQuotaUsd = value.Float64
+ }
+ case subscriptionplan.FieldPlanType:
+ if value, ok := values[i].(*sql.NullString); !ok {
+ return fmt.Errorf("unexpected type %T for field plan_type", values[i])
+ } else if value.Valid {
+ _m.PlanType = value.String
}
case subscriptionplan.FieldName:
if value, ok := values[i].(*sql.NullString); !ok {
@@ -174,6 +213,11 @@ func (_m *SubscriptionPlan) Value(name string) (ent.Value, error) {
return _m.selectValues.Get(name)
}
+// QueryPlanGroups queries the "plan_groups" edge of the SubscriptionPlan entity.
+func (_m *SubscriptionPlan) QueryPlanGroups() *SubscriptionPlanGroupQuery {
+ return NewSubscriptionPlanClient(_m.config).QueryPlanGroups(_m)
+}
+
// Update returns a builder for updating this SubscriptionPlan.
// Note that you need to call SubscriptionPlan.Unwrap() before calling this method if this SubscriptionPlan
// was returned from a transaction, and the transaction was committed or rolled back.
@@ -197,8 +241,18 @@ func (_m *SubscriptionPlan) String() string {
var builder strings.Builder
builder.WriteString("SubscriptionPlan(")
builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID))
- builder.WriteString("group_id=")
- builder.WriteString(fmt.Sprintf("%v", _m.GroupID))
+ if v := _m.GroupID; v != nil {
+ builder.WriteString("group_id=")
+ builder.WriteString(fmt.Sprintf("%v", *v))
+ }
+ builder.WriteString(", ")
+ if v := _m.WalletQuotaUsd; v != nil {
+ builder.WriteString("wallet_quota_usd=")
+ builder.WriteString(fmt.Sprintf("%v", *v))
+ }
+ builder.WriteString(", ")
+ builder.WriteString("plan_type=")
+ builder.WriteString(_m.PlanType)
builder.WriteString(", ")
builder.WriteString("name=")
builder.WriteString(_m.Name)
diff --git a/backend/ent/subscriptionplan/subscriptionplan.go b/backend/ent/subscriptionplan/subscriptionplan.go
index fa125aa714c..08537f9c1aa 100644
--- a/backend/ent/subscriptionplan/subscriptionplan.go
+++ b/backend/ent/subscriptionplan/subscriptionplan.go
@@ -6,6 +6,7 @@ import (
"time"
"entgo.io/ent/dialect/sql"
+ "entgo.io/ent/dialect/sql/sqlgraph"
)
const (
@@ -15,6 +16,10 @@ const (
FieldID = "id"
// FieldGroupID holds the string denoting the group_id field in the database.
FieldGroupID = "group_id"
+ // FieldWalletQuotaUsd holds the string denoting the wallet_quota_usd field in the database.
+ FieldWalletQuotaUsd = "wallet_quota_usd"
+ // FieldPlanType holds the string denoting the plan_type field in the database.
+ FieldPlanType = "plan_type"
// FieldName holds the string denoting the name field in the database.
FieldName = "name"
// FieldDescription holds the string denoting the description field in the database.
@@ -39,14 +44,25 @@ const (
FieldCreatedAt = "created_at"
// FieldUpdatedAt holds the string denoting the updated_at field in the database.
FieldUpdatedAt = "updated_at"
+ // EdgePlanGroups holds the string denoting the plan_groups edge name in mutations.
+ EdgePlanGroups = "plan_groups"
// Table holds the table name of the subscriptionplan in the database.
Table = "subscription_plans"
+ // PlanGroupsTable is the table that holds the plan_groups relation/edge.
+ PlanGroupsTable = "subscription_plan_groups"
+ // PlanGroupsInverseTable is the table name for the SubscriptionPlanGroup entity.
+ // It exists in this package in order to avoid circular dependency with the "subscriptionplangroup" package.
+ PlanGroupsInverseTable = "subscription_plan_groups"
+ // PlanGroupsColumn is the table column denoting the plan_groups relation/edge.
+ PlanGroupsColumn = "plan_id"
)
// Columns holds all SQL columns for subscriptionplan fields.
var Columns = []string{
FieldID,
FieldGroupID,
+ FieldWalletQuotaUsd,
+ FieldPlanType,
FieldName,
FieldDescription,
FieldPrice,
@@ -72,6 +88,10 @@ func ValidColumn(column string) bool {
}
var (
+ // DefaultPlanType holds the default value on creation for the "plan_type" field.
+ DefaultPlanType string
+ // PlanTypeValidator is a validator for the "plan_type" field. It is called by the builders before save.
+ PlanTypeValidator func(string) error
// NameValidator is a validator for the "name" field. It is called by the builders before save.
NameValidator func(string) error
// DefaultDescription holds the default value on creation for the "description" field.
@@ -113,6 +133,16 @@ func ByGroupID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldGroupID, opts...).ToFunc()
}
+// ByWalletQuotaUsd orders the results by the wallet_quota_usd field.
+func ByWalletQuotaUsd(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldWalletQuotaUsd, opts...).ToFunc()
+}
+
+// ByPlanType orders the results by the plan_type field.
+func ByPlanType(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldPlanType, opts...).ToFunc()
+}
+
// ByName orders the results by the name field.
func ByName(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldName, opts...).ToFunc()
@@ -172,3 +202,24 @@ func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption {
func ByUpdatedAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUpdatedAt, opts...).ToFunc()
}
+
+// ByPlanGroupsCount orders the results by plan_groups count.
+func ByPlanGroupsCount(opts ...sql.OrderTermOption) OrderOption {
+ return func(s *sql.Selector) {
+ sqlgraph.OrderByNeighborsCount(s, newPlanGroupsStep(), opts...)
+ }
+}
+
+// ByPlanGroups orders the results by plan_groups terms.
+func ByPlanGroups(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
+ return func(s *sql.Selector) {
+ sqlgraph.OrderByNeighborTerms(s, newPlanGroupsStep(), append([]sql.OrderTerm{term}, terms...)...)
+ }
+}
+func newPlanGroupsStep() *sqlgraph.Step {
+ return sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.To(PlanGroupsInverseTable, FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, PlanGroupsTable, PlanGroupsColumn),
+ )
+}
diff --git a/backend/ent/subscriptionplan/where.go b/backend/ent/subscriptionplan/where.go
index 319cfdb5095..9619cee30db 100644
--- a/backend/ent/subscriptionplan/where.go
+++ b/backend/ent/subscriptionplan/where.go
@@ -6,6 +6,7 @@ import (
"time"
"entgo.io/ent/dialect/sql"
+ "entgo.io/ent/dialect/sql/sqlgraph"
"github.com/Wei-Shaw/sub2api/ent/predicate"
)
@@ -59,6 +60,16 @@ func GroupID(v int64) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldEQ(FieldGroupID, v))
}
+// WalletQuotaUsd applies equality check predicate on the "wallet_quota_usd" field. It's identical to WalletQuotaUsdEQ.
+func WalletQuotaUsd(v float64) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldEQ(FieldWalletQuotaUsd, v))
+}
+
+// PlanType applies equality check predicate on the "plan_type" field. It's identical to PlanTypeEQ.
+func PlanType(v string) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldEQ(FieldPlanType, v))
+}
+
// Name applies equality check predicate on the "name" field. It's identical to NameEQ.
func Name(v string) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldEQ(FieldName, v))
@@ -159,6 +170,131 @@ func GroupIDLTE(v int64) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldLTE(FieldGroupID, v))
}
+// GroupIDIsNil applies the IsNil predicate on the "group_id" field.
+func GroupIDIsNil() predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldIsNull(FieldGroupID))
+}
+
+// GroupIDNotNil applies the NotNil predicate on the "group_id" field.
+func GroupIDNotNil() predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldNotNull(FieldGroupID))
+}
+
+// WalletQuotaUsdEQ applies the EQ predicate on the "wallet_quota_usd" field.
+func WalletQuotaUsdEQ(v float64) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldEQ(FieldWalletQuotaUsd, v))
+}
+
+// WalletQuotaUsdNEQ applies the NEQ predicate on the "wallet_quota_usd" field.
+func WalletQuotaUsdNEQ(v float64) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldNEQ(FieldWalletQuotaUsd, v))
+}
+
+// WalletQuotaUsdIn applies the In predicate on the "wallet_quota_usd" field.
+func WalletQuotaUsdIn(vs ...float64) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldIn(FieldWalletQuotaUsd, vs...))
+}
+
+// WalletQuotaUsdNotIn applies the NotIn predicate on the "wallet_quota_usd" field.
+func WalletQuotaUsdNotIn(vs ...float64) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldNotIn(FieldWalletQuotaUsd, vs...))
+}
+
+// WalletQuotaUsdGT applies the GT predicate on the "wallet_quota_usd" field.
+func WalletQuotaUsdGT(v float64) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldGT(FieldWalletQuotaUsd, v))
+}
+
+// WalletQuotaUsdGTE applies the GTE predicate on the "wallet_quota_usd" field.
+func WalletQuotaUsdGTE(v float64) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldGTE(FieldWalletQuotaUsd, v))
+}
+
+// WalletQuotaUsdLT applies the LT predicate on the "wallet_quota_usd" field.
+func WalletQuotaUsdLT(v float64) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldLT(FieldWalletQuotaUsd, v))
+}
+
+// WalletQuotaUsdLTE applies the LTE predicate on the "wallet_quota_usd" field.
+func WalletQuotaUsdLTE(v float64) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldLTE(FieldWalletQuotaUsd, v))
+}
+
+// WalletQuotaUsdIsNil applies the IsNil predicate on the "wallet_quota_usd" field.
+func WalletQuotaUsdIsNil() predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldIsNull(FieldWalletQuotaUsd))
+}
+
+// WalletQuotaUsdNotNil applies the NotNil predicate on the "wallet_quota_usd" field.
+func WalletQuotaUsdNotNil() predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldNotNull(FieldWalletQuotaUsd))
+}
+
+// PlanTypeEQ applies the EQ predicate on the "plan_type" field.
+func PlanTypeEQ(v string) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldEQ(FieldPlanType, v))
+}
+
+// PlanTypeNEQ applies the NEQ predicate on the "plan_type" field.
+func PlanTypeNEQ(v string) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldNEQ(FieldPlanType, v))
+}
+
+// PlanTypeIn applies the In predicate on the "plan_type" field.
+func PlanTypeIn(vs ...string) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldIn(FieldPlanType, vs...))
+}
+
+// PlanTypeNotIn applies the NotIn predicate on the "plan_type" field.
+func PlanTypeNotIn(vs ...string) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldNotIn(FieldPlanType, vs...))
+}
+
+// PlanTypeGT applies the GT predicate on the "plan_type" field.
+func PlanTypeGT(v string) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldGT(FieldPlanType, v))
+}
+
+// PlanTypeGTE applies the GTE predicate on the "plan_type" field.
+func PlanTypeGTE(v string) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldGTE(FieldPlanType, v))
+}
+
+// PlanTypeLT applies the LT predicate on the "plan_type" field.
+func PlanTypeLT(v string) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldLT(FieldPlanType, v))
+}
+
+// PlanTypeLTE applies the LTE predicate on the "plan_type" field.
+func PlanTypeLTE(v string) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldLTE(FieldPlanType, v))
+}
+
+// PlanTypeContains applies the Contains predicate on the "plan_type" field.
+func PlanTypeContains(v string) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldContains(FieldPlanType, v))
+}
+
+// PlanTypeHasPrefix applies the HasPrefix predicate on the "plan_type" field.
+func PlanTypeHasPrefix(v string) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldHasPrefix(FieldPlanType, v))
+}
+
+// PlanTypeHasSuffix applies the HasSuffix predicate on the "plan_type" field.
+func PlanTypeHasSuffix(v string) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldHasSuffix(FieldPlanType, v))
+}
+
+// PlanTypeEqualFold applies the EqualFold predicate on the "plan_type" field.
+func PlanTypeEqualFold(v string) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldEqualFold(FieldPlanType, v))
+}
+
+// PlanTypeContainsFold applies the ContainsFold predicate on the "plan_type" field.
+func PlanTypeContainsFold(v string) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(sql.FieldContainsFold(FieldPlanType, v))
+}
+
// NameEQ applies the EQ predicate on the "name" field.
func NameEQ(v string) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldEQ(FieldName, v))
@@ -744,6 +880,29 @@ func UpdatedAtLTE(v time.Time) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.FieldLTE(FieldUpdatedAt, v))
}
+// HasPlanGroups applies the HasEdge predicate on the "plan_groups" edge.
+func HasPlanGroups() predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(func(s *sql.Selector) {
+ step := sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, PlanGroupsTable, PlanGroupsColumn),
+ )
+ sqlgraph.HasNeighbors(s, step)
+ })
+}
+
+// HasPlanGroupsWith applies the HasEdge predicate on the "plan_groups" edge with a given conditions (other predicates).
+func HasPlanGroupsWith(preds ...predicate.SubscriptionPlanGroup) predicate.SubscriptionPlan {
+ return predicate.SubscriptionPlan(func(s *sql.Selector) {
+ step := newPlanGroupsStep()
+ sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
+ for _, p := range preds {
+ p(s)
+ }
+ })
+ })
+}
+
// And groups predicates with the AND operator between them.
func And(predicates ...predicate.SubscriptionPlan) predicate.SubscriptionPlan {
return predicate.SubscriptionPlan(sql.AndPredicates(predicates...))
diff --git a/backend/ent/subscriptionplan_create.go b/backend/ent/subscriptionplan_create.go
index 9109db3a01e..22c8041d617 100644
--- a/backend/ent/subscriptionplan_create.go
+++ b/backend/ent/subscriptionplan_create.go
@@ -12,6 +12,7 @@ import (
"entgo.io/ent/dialect/sql/sqlgraph"
"entgo.io/ent/schema/field"
"github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
)
// SubscriptionPlanCreate is the builder for creating a SubscriptionPlan entity.
@@ -28,6 +29,42 @@ func (_c *SubscriptionPlanCreate) SetGroupID(v int64) *SubscriptionPlanCreate {
return _c
}
+// SetNillableGroupID sets the "group_id" field if the given value is not nil.
+func (_c *SubscriptionPlanCreate) SetNillableGroupID(v *int64) *SubscriptionPlanCreate {
+ if v != nil {
+ _c.SetGroupID(*v)
+ }
+ return _c
+}
+
+// SetWalletQuotaUsd sets the "wallet_quota_usd" field.
+func (_c *SubscriptionPlanCreate) SetWalletQuotaUsd(v float64) *SubscriptionPlanCreate {
+ _c.mutation.SetWalletQuotaUsd(v)
+ return _c
+}
+
+// SetNillableWalletQuotaUsd sets the "wallet_quota_usd" field if the given value is not nil.
+func (_c *SubscriptionPlanCreate) SetNillableWalletQuotaUsd(v *float64) *SubscriptionPlanCreate {
+ if v != nil {
+ _c.SetWalletQuotaUsd(*v)
+ }
+ return _c
+}
+
+// SetPlanType sets the "plan_type" field.
+func (_c *SubscriptionPlanCreate) SetPlanType(v string) *SubscriptionPlanCreate {
+ _c.mutation.SetPlanType(v)
+ return _c
+}
+
+// SetNillablePlanType sets the "plan_type" field if the given value is not nil.
+func (_c *SubscriptionPlanCreate) SetNillablePlanType(v *string) *SubscriptionPlanCreate {
+ if v != nil {
+ _c.SetPlanType(*v)
+ }
+ return _c
+}
+
// SetName sets the "name" field.
func (_c *SubscriptionPlanCreate) SetName(v string) *SubscriptionPlanCreate {
_c.mutation.SetName(v)
@@ -180,6 +217,21 @@ func (_c *SubscriptionPlanCreate) SetNillableUpdatedAt(v *time.Time) *Subscripti
return _c
}
+// AddPlanGroupIDs adds the "plan_groups" edge to the SubscriptionPlanGroup entity by IDs.
+func (_c *SubscriptionPlanCreate) AddPlanGroupIDs(ids ...int64) *SubscriptionPlanCreate {
+ _c.mutation.AddPlanGroupIDs(ids...)
+ return _c
+}
+
+// AddPlanGroups adds the "plan_groups" edges to the SubscriptionPlanGroup entity.
+func (_c *SubscriptionPlanCreate) AddPlanGroups(v ...*SubscriptionPlanGroup) *SubscriptionPlanCreate {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _c.AddPlanGroupIDs(ids...)
+}
+
// Mutation returns the SubscriptionPlanMutation object of the builder.
func (_c *SubscriptionPlanCreate) Mutation() *SubscriptionPlanMutation {
return _c.mutation
@@ -215,6 +267,10 @@ func (_c *SubscriptionPlanCreate) ExecX(ctx context.Context) {
// defaults sets the default values of the builder before save.
func (_c *SubscriptionPlanCreate) defaults() {
+ if _, ok := _c.mutation.PlanType(); !ok {
+ v := subscriptionplan.DefaultPlanType
+ _c.mutation.SetPlanType(v)
+ }
if _, ok := _c.mutation.Description(); !ok {
v := subscriptionplan.DefaultDescription
_c.mutation.SetDescription(v)
@@ -255,8 +311,13 @@ func (_c *SubscriptionPlanCreate) defaults() {
// check runs all checks and user-defined validators on the builder.
func (_c *SubscriptionPlanCreate) check() error {
- if _, ok := _c.mutation.GroupID(); !ok {
- return &ValidationError{Name: "group_id", err: errors.New(`ent: missing required field "SubscriptionPlan.group_id"`)}
+ if _, ok := _c.mutation.PlanType(); !ok {
+ return &ValidationError{Name: "plan_type", err: errors.New(`ent: missing required field "SubscriptionPlan.plan_type"`)}
+ }
+ if v, ok := _c.mutation.PlanType(); ok {
+ if err := subscriptionplan.PlanTypeValidator(v); err != nil {
+ return &ValidationError{Name: "plan_type", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.plan_type": %w`, err)}
+ }
}
if _, ok := _c.mutation.Name(); !ok {
return &ValidationError{Name: "name", err: errors.New(`ent: missing required field "SubscriptionPlan.name"`)}
@@ -335,7 +396,15 @@ func (_c *SubscriptionPlanCreate) createSpec() (*SubscriptionPlan, *sqlgraph.Cre
_spec.OnConflict = _c.conflict
if value, ok := _c.mutation.GroupID(); ok {
_spec.SetField(subscriptionplan.FieldGroupID, field.TypeInt64, value)
- _node.GroupID = value
+ _node.GroupID = &value
+ }
+ if value, ok := _c.mutation.WalletQuotaUsd(); ok {
+ _spec.SetField(subscriptionplan.FieldWalletQuotaUsd, field.TypeFloat64, value)
+ _node.WalletQuotaUsd = &value
+ }
+ if value, ok := _c.mutation.PlanType(); ok {
+ _spec.SetField(subscriptionplan.FieldPlanType, field.TypeString, value)
+ _node.PlanType = value
}
if value, ok := _c.mutation.Name(); ok {
_spec.SetField(subscriptionplan.FieldName, field.TypeString, value)
@@ -385,6 +454,22 @@ func (_c *SubscriptionPlanCreate) createSpec() (*SubscriptionPlan, *sqlgraph.Cre
_spec.SetField(subscriptionplan.FieldUpdatedAt, field.TypeTime, value)
_node.UpdatedAt = value
}
+ if nodes := _c.mutation.PlanGroupsIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: subscriptionplan.PlanGroupsTable,
+ Columns: []string{subscriptionplan.PlanGroupsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges = append(_spec.Edges, edge)
+ }
return _node, _spec
}
@@ -455,6 +540,48 @@ func (u *SubscriptionPlanUpsert) AddGroupID(v int64) *SubscriptionPlanUpsert {
return u
}
+// ClearGroupID clears the value of the "group_id" field.
+func (u *SubscriptionPlanUpsert) ClearGroupID() *SubscriptionPlanUpsert {
+ u.SetNull(subscriptionplan.FieldGroupID)
+ return u
+}
+
+// SetWalletQuotaUsd sets the "wallet_quota_usd" field.
+func (u *SubscriptionPlanUpsert) SetWalletQuotaUsd(v float64) *SubscriptionPlanUpsert {
+ u.Set(subscriptionplan.FieldWalletQuotaUsd, v)
+ return u
+}
+
+// UpdateWalletQuotaUsd sets the "wallet_quota_usd" field to the value that was provided on create.
+func (u *SubscriptionPlanUpsert) UpdateWalletQuotaUsd() *SubscriptionPlanUpsert {
+ u.SetExcluded(subscriptionplan.FieldWalletQuotaUsd)
+ return u
+}
+
+// AddWalletQuotaUsd adds v to the "wallet_quota_usd" field.
+func (u *SubscriptionPlanUpsert) AddWalletQuotaUsd(v float64) *SubscriptionPlanUpsert {
+ u.Add(subscriptionplan.FieldWalletQuotaUsd, v)
+ return u
+}
+
+// ClearWalletQuotaUsd clears the value of the "wallet_quota_usd" field.
+func (u *SubscriptionPlanUpsert) ClearWalletQuotaUsd() *SubscriptionPlanUpsert {
+ u.SetNull(subscriptionplan.FieldWalletQuotaUsd)
+ return u
+}
+
+// SetPlanType sets the "plan_type" field.
+func (u *SubscriptionPlanUpsert) SetPlanType(v string) *SubscriptionPlanUpsert {
+ u.Set(subscriptionplan.FieldPlanType, v)
+ return u
+}
+
+// UpdatePlanType sets the "plan_type" field to the value that was provided on create.
+func (u *SubscriptionPlanUpsert) UpdatePlanType() *SubscriptionPlanUpsert {
+ u.SetExcluded(subscriptionplan.FieldPlanType)
+ return u
+}
+
// SetName sets the "name" field.
func (u *SubscriptionPlanUpsert) SetName(v string) *SubscriptionPlanUpsert {
u.Set(subscriptionplan.FieldName, v)
@@ -683,6 +810,55 @@ func (u *SubscriptionPlanUpsertOne) UpdateGroupID() *SubscriptionPlanUpsertOne {
})
}
+// ClearGroupID clears the value of the "group_id" field.
+func (u *SubscriptionPlanUpsertOne) ClearGroupID() *SubscriptionPlanUpsertOne {
+ return u.Update(func(s *SubscriptionPlanUpsert) {
+ s.ClearGroupID()
+ })
+}
+
+// SetWalletQuotaUsd sets the "wallet_quota_usd" field.
+func (u *SubscriptionPlanUpsertOne) SetWalletQuotaUsd(v float64) *SubscriptionPlanUpsertOne {
+ return u.Update(func(s *SubscriptionPlanUpsert) {
+ s.SetWalletQuotaUsd(v)
+ })
+}
+
+// AddWalletQuotaUsd adds v to the "wallet_quota_usd" field.
+func (u *SubscriptionPlanUpsertOne) AddWalletQuotaUsd(v float64) *SubscriptionPlanUpsertOne {
+ return u.Update(func(s *SubscriptionPlanUpsert) {
+ s.AddWalletQuotaUsd(v)
+ })
+}
+
+// UpdateWalletQuotaUsd sets the "wallet_quota_usd" field to the value that was provided on create.
+func (u *SubscriptionPlanUpsertOne) UpdateWalletQuotaUsd() *SubscriptionPlanUpsertOne {
+ return u.Update(func(s *SubscriptionPlanUpsert) {
+ s.UpdateWalletQuotaUsd()
+ })
+}
+
+// ClearWalletQuotaUsd clears the value of the "wallet_quota_usd" field.
+func (u *SubscriptionPlanUpsertOne) ClearWalletQuotaUsd() *SubscriptionPlanUpsertOne {
+ return u.Update(func(s *SubscriptionPlanUpsert) {
+ s.ClearWalletQuotaUsd()
+ })
+}
+
+// SetPlanType sets the "plan_type" field.
+func (u *SubscriptionPlanUpsertOne) SetPlanType(v string) *SubscriptionPlanUpsertOne {
+ return u.Update(func(s *SubscriptionPlanUpsert) {
+ s.SetPlanType(v)
+ })
+}
+
+// UpdatePlanType sets the "plan_type" field to the value that was provided on create.
+func (u *SubscriptionPlanUpsertOne) UpdatePlanType() *SubscriptionPlanUpsertOne {
+ return u.Update(func(s *SubscriptionPlanUpsert) {
+ s.UpdatePlanType()
+ })
+}
+
// SetName sets the "name" field.
func (u *SubscriptionPlanUpsertOne) SetName(v string) *SubscriptionPlanUpsertOne {
return u.Update(func(s *SubscriptionPlanUpsert) {
@@ -1104,6 +1280,55 @@ func (u *SubscriptionPlanUpsertBulk) UpdateGroupID() *SubscriptionPlanUpsertBulk
})
}
+// ClearGroupID clears the value of the "group_id" field.
+func (u *SubscriptionPlanUpsertBulk) ClearGroupID() *SubscriptionPlanUpsertBulk {
+ return u.Update(func(s *SubscriptionPlanUpsert) {
+ s.ClearGroupID()
+ })
+}
+
+// SetWalletQuotaUsd sets the "wallet_quota_usd" field.
+func (u *SubscriptionPlanUpsertBulk) SetWalletQuotaUsd(v float64) *SubscriptionPlanUpsertBulk {
+ return u.Update(func(s *SubscriptionPlanUpsert) {
+ s.SetWalletQuotaUsd(v)
+ })
+}
+
+// AddWalletQuotaUsd adds v to the "wallet_quota_usd" field.
+func (u *SubscriptionPlanUpsertBulk) AddWalletQuotaUsd(v float64) *SubscriptionPlanUpsertBulk {
+ return u.Update(func(s *SubscriptionPlanUpsert) {
+ s.AddWalletQuotaUsd(v)
+ })
+}
+
+// UpdateWalletQuotaUsd sets the "wallet_quota_usd" field to the value that was provided on create.
+func (u *SubscriptionPlanUpsertBulk) UpdateWalletQuotaUsd() *SubscriptionPlanUpsertBulk {
+ return u.Update(func(s *SubscriptionPlanUpsert) {
+ s.UpdateWalletQuotaUsd()
+ })
+}
+
+// ClearWalletQuotaUsd clears the value of the "wallet_quota_usd" field.
+func (u *SubscriptionPlanUpsertBulk) ClearWalletQuotaUsd() *SubscriptionPlanUpsertBulk {
+ return u.Update(func(s *SubscriptionPlanUpsert) {
+ s.ClearWalletQuotaUsd()
+ })
+}
+
+// SetPlanType sets the "plan_type" field.
+func (u *SubscriptionPlanUpsertBulk) SetPlanType(v string) *SubscriptionPlanUpsertBulk {
+ return u.Update(func(s *SubscriptionPlanUpsert) {
+ s.SetPlanType(v)
+ })
+}
+
+// UpdatePlanType sets the "plan_type" field to the value that was provided on create.
+func (u *SubscriptionPlanUpsertBulk) UpdatePlanType() *SubscriptionPlanUpsertBulk {
+ return u.Update(func(s *SubscriptionPlanUpsert) {
+ s.UpdatePlanType()
+ })
+}
+
// SetName sets the "name" field.
func (u *SubscriptionPlanUpsertBulk) SetName(v string) *SubscriptionPlanUpsertBulk {
return u.Update(func(s *SubscriptionPlanUpsert) {
diff --git a/backend/ent/subscriptionplan_query.go b/backend/ent/subscriptionplan_query.go
index 6c301dcd294..cc4e3ffddd2 100644
--- a/backend/ent/subscriptionplan_query.go
+++ b/backend/ent/subscriptionplan_query.go
@@ -4,6 +4,7 @@ package ent
import (
"context"
+ "database/sql/driver"
"fmt"
"math"
@@ -14,16 +15,18 @@ import (
"entgo.io/ent/schema/field"
"github.com/Wei-Shaw/sub2api/ent/predicate"
"github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
)
// SubscriptionPlanQuery is the builder for querying SubscriptionPlan entities.
type SubscriptionPlanQuery struct {
config
- ctx *QueryContext
- order []subscriptionplan.OrderOption
- inters []Interceptor
- predicates []predicate.SubscriptionPlan
- modifiers []func(*sql.Selector)
+ ctx *QueryContext
+ order []subscriptionplan.OrderOption
+ inters []Interceptor
+ predicates []predicate.SubscriptionPlan
+ withPlanGroups *SubscriptionPlanGroupQuery
+ modifiers []func(*sql.Selector)
// intermediate query (i.e. traversal path).
sql *sql.Selector
path func(context.Context) (*sql.Selector, error)
@@ -60,6 +63,28 @@ func (_q *SubscriptionPlanQuery) Order(o ...subscriptionplan.OrderOption) *Subsc
return _q
}
+// QueryPlanGroups chains the current query on the "plan_groups" edge.
+func (_q *SubscriptionPlanQuery) QueryPlanGroups() *SubscriptionPlanGroupQuery {
+ query := (&SubscriptionPlanGroupClient{config: _q.config}).Query()
+ query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
+ if err := _q.prepareQuery(ctx); err != nil {
+ return nil, err
+ }
+ selector := _q.sqlQuery(ctx)
+ if err := selector.Err(); err != nil {
+ return nil, err
+ }
+ step := sqlgraph.NewStep(
+ sqlgraph.From(subscriptionplan.Table, subscriptionplan.FieldID, selector),
+ sqlgraph.To(subscriptionplangroup.Table, subscriptionplangroup.FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, subscriptionplan.PlanGroupsTable, subscriptionplan.PlanGroupsColumn),
+ )
+ fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
+ return fromU, nil
+ }
+ return query
+}
+
// First returns the first SubscriptionPlan entity from the query.
// Returns a *NotFoundError when no SubscriptionPlan was found.
func (_q *SubscriptionPlanQuery) First(ctx context.Context) (*SubscriptionPlan, error) {
@@ -247,17 +272,29 @@ func (_q *SubscriptionPlanQuery) Clone() *SubscriptionPlanQuery {
return nil
}
return &SubscriptionPlanQuery{
- config: _q.config,
- ctx: _q.ctx.Clone(),
- order: append([]subscriptionplan.OrderOption{}, _q.order...),
- inters: append([]Interceptor{}, _q.inters...),
- predicates: append([]predicate.SubscriptionPlan{}, _q.predicates...),
+ config: _q.config,
+ ctx: _q.ctx.Clone(),
+ order: append([]subscriptionplan.OrderOption{}, _q.order...),
+ inters: append([]Interceptor{}, _q.inters...),
+ predicates: append([]predicate.SubscriptionPlan{}, _q.predicates...),
+ withPlanGroups: _q.withPlanGroups.Clone(),
// clone intermediate query.
sql: _q.sql.Clone(),
path: _q.path,
}
}
+// WithPlanGroups tells the query-builder to eager-load the nodes that are connected to
+// the "plan_groups" edge. The optional arguments are used to configure the query builder of the edge.
+func (_q *SubscriptionPlanQuery) WithPlanGroups(opts ...func(*SubscriptionPlanGroupQuery)) *SubscriptionPlanQuery {
+ query := (&SubscriptionPlanGroupClient{config: _q.config}).Query()
+ for _, opt := range opts {
+ opt(query)
+ }
+ _q.withPlanGroups = query
+ return _q
+}
+
// GroupBy is used to group vertices by one or more fields/columns.
// It is often used with aggregate functions, like: count, max, mean, min, sum.
//
@@ -334,8 +371,11 @@ func (_q *SubscriptionPlanQuery) prepareQuery(ctx context.Context) error {
func (_q *SubscriptionPlanQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*SubscriptionPlan, error) {
var (
- nodes = []*SubscriptionPlan{}
- _spec = _q.querySpec()
+ nodes = []*SubscriptionPlan{}
+ _spec = _q.querySpec()
+ loadedTypes = [1]bool{
+ _q.withPlanGroups != nil,
+ }
)
_spec.ScanValues = func(columns []string) ([]any, error) {
return (*SubscriptionPlan).scanValues(nil, columns)
@@ -343,6 +383,7 @@ func (_q *SubscriptionPlanQuery) sqlAll(ctx context.Context, hooks ...queryHook)
_spec.Assign = func(columns []string, values []any) error {
node := &SubscriptionPlan{config: _q.config}
nodes = append(nodes, node)
+ node.Edges.loadedTypes = loadedTypes
return node.assignValues(columns, values)
}
if len(_q.modifiers) > 0 {
@@ -357,9 +398,49 @@ func (_q *SubscriptionPlanQuery) sqlAll(ctx context.Context, hooks ...queryHook)
if len(nodes) == 0 {
return nodes, nil
}
+ if query := _q.withPlanGroups; query != nil {
+ if err := _q.loadPlanGroups(ctx, query, nodes,
+ func(n *SubscriptionPlan) { n.Edges.PlanGroups = []*SubscriptionPlanGroup{} },
+ func(n *SubscriptionPlan, e *SubscriptionPlanGroup) {
+ n.Edges.PlanGroups = append(n.Edges.PlanGroups, e)
+ }); err != nil {
+ return nil, err
+ }
+ }
return nodes, nil
}
+func (_q *SubscriptionPlanQuery) loadPlanGroups(ctx context.Context, query *SubscriptionPlanGroupQuery, nodes []*SubscriptionPlan, init func(*SubscriptionPlan), assign func(*SubscriptionPlan, *SubscriptionPlanGroup)) error {
+ fks := make([]driver.Value, 0, len(nodes))
+ nodeids := make(map[int64]*SubscriptionPlan)
+ for i := range nodes {
+ fks = append(fks, nodes[i].ID)
+ nodeids[nodes[i].ID] = nodes[i]
+ if init != nil {
+ init(nodes[i])
+ }
+ }
+ if len(query.ctx.Fields) > 0 {
+ query.ctx.AppendFieldOnce(subscriptionplangroup.FieldPlanID)
+ }
+ query.Where(predicate.SubscriptionPlanGroup(func(s *sql.Selector) {
+ s.Where(sql.InValues(s.C(subscriptionplan.PlanGroupsColumn), fks...))
+ }))
+ neighbors, err := query.All(ctx)
+ if err != nil {
+ return err
+ }
+ for _, n := range neighbors {
+ fk := n.PlanID
+ node, ok := nodeids[fk]
+ if !ok {
+ return fmt.Errorf(`unexpected referenced foreign-key "plan_id" returned %v for node %v`, fk, n.ID)
+ }
+ assign(node, n)
+ }
+ return nil
+}
+
func (_q *SubscriptionPlanQuery) sqlCount(ctx context.Context) (int, error) {
_spec := _q.querySpec()
if len(_q.modifiers) > 0 {
diff --git a/backend/ent/subscriptionplan_update.go b/backend/ent/subscriptionplan_update.go
index c9225d0f7ea..8e42345b5a2 100644
--- a/backend/ent/subscriptionplan_update.go
+++ b/backend/ent/subscriptionplan_update.go
@@ -13,6 +13,7 @@ import (
"entgo.io/ent/schema/field"
"github.com/Wei-Shaw/sub2api/ent/predicate"
"github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
)
// SubscriptionPlanUpdate is the builder for updating SubscriptionPlan entities.
@@ -49,6 +50,53 @@ func (_u *SubscriptionPlanUpdate) AddGroupID(v int64) *SubscriptionPlanUpdate {
return _u
}
+// ClearGroupID clears the value of the "group_id" field.
+func (_u *SubscriptionPlanUpdate) ClearGroupID() *SubscriptionPlanUpdate {
+ _u.mutation.ClearGroupID()
+ return _u
+}
+
+// SetWalletQuotaUsd sets the "wallet_quota_usd" field.
+func (_u *SubscriptionPlanUpdate) SetWalletQuotaUsd(v float64) *SubscriptionPlanUpdate {
+ _u.mutation.ResetWalletQuotaUsd()
+ _u.mutation.SetWalletQuotaUsd(v)
+ return _u
+}
+
+// SetNillableWalletQuotaUsd sets the "wallet_quota_usd" field if the given value is not nil.
+func (_u *SubscriptionPlanUpdate) SetNillableWalletQuotaUsd(v *float64) *SubscriptionPlanUpdate {
+ if v != nil {
+ _u.SetWalletQuotaUsd(*v)
+ }
+ return _u
+}
+
+// AddWalletQuotaUsd adds value to the "wallet_quota_usd" field.
+func (_u *SubscriptionPlanUpdate) AddWalletQuotaUsd(v float64) *SubscriptionPlanUpdate {
+ _u.mutation.AddWalletQuotaUsd(v)
+ return _u
+}
+
+// ClearWalletQuotaUsd clears the value of the "wallet_quota_usd" field.
+func (_u *SubscriptionPlanUpdate) ClearWalletQuotaUsd() *SubscriptionPlanUpdate {
+ _u.mutation.ClearWalletQuotaUsd()
+ return _u
+}
+
+// SetPlanType sets the "plan_type" field.
+func (_u *SubscriptionPlanUpdate) SetPlanType(v string) *SubscriptionPlanUpdate {
+ _u.mutation.SetPlanType(v)
+ return _u
+}
+
+// SetNillablePlanType sets the "plan_type" field if the given value is not nil.
+func (_u *SubscriptionPlanUpdate) SetNillablePlanType(v *string) *SubscriptionPlanUpdate {
+ if v != nil {
+ _u.SetPlanType(*v)
+ }
+ return _u
+}
+
// SetName sets the "name" field.
func (_u *SubscriptionPlanUpdate) SetName(v string) *SubscriptionPlanUpdate {
_u.mutation.SetName(v)
@@ -229,11 +277,47 @@ func (_u *SubscriptionPlanUpdate) SetUpdatedAt(v time.Time) *SubscriptionPlanUpd
return _u
}
+// AddPlanGroupIDs adds the "plan_groups" edge to the SubscriptionPlanGroup entity by IDs.
+func (_u *SubscriptionPlanUpdate) AddPlanGroupIDs(ids ...int64) *SubscriptionPlanUpdate {
+ _u.mutation.AddPlanGroupIDs(ids...)
+ return _u
+}
+
+// AddPlanGroups adds the "plan_groups" edges to the SubscriptionPlanGroup entity.
+func (_u *SubscriptionPlanUpdate) AddPlanGroups(v ...*SubscriptionPlanGroup) *SubscriptionPlanUpdate {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.AddPlanGroupIDs(ids...)
+}
+
// Mutation returns the SubscriptionPlanMutation object of the builder.
func (_u *SubscriptionPlanUpdate) Mutation() *SubscriptionPlanMutation {
return _u.mutation
}
+// ClearPlanGroups clears all "plan_groups" edges to the SubscriptionPlanGroup entity.
+func (_u *SubscriptionPlanUpdate) ClearPlanGroups() *SubscriptionPlanUpdate {
+ _u.mutation.ClearPlanGroups()
+ return _u
+}
+
+// RemovePlanGroupIDs removes the "plan_groups" edge to SubscriptionPlanGroup entities by IDs.
+func (_u *SubscriptionPlanUpdate) RemovePlanGroupIDs(ids ...int64) *SubscriptionPlanUpdate {
+ _u.mutation.RemovePlanGroupIDs(ids...)
+ return _u
+}
+
+// RemovePlanGroups removes "plan_groups" edges to SubscriptionPlanGroup entities.
+func (_u *SubscriptionPlanUpdate) RemovePlanGroups(v ...*SubscriptionPlanGroup) *SubscriptionPlanUpdate {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.RemovePlanGroupIDs(ids...)
+}
+
// Save executes the query and returns the number of nodes affected by the update operation.
func (_u *SubscriptionPlanUpdate) Save(ctx context.Context) (int, error) {
_u.defaults()
@@ -272,6 +356,11 @@ func (_u *SubscriptionPlanUpdate) defaults() {
// check runs all checks and user-defined validators on the builder.
func (_u *SubscriptionPlanUpdate) check() error {
+ if v, ok := _u.mutation.PlanType(); ok {
+ if err := subscriptionplan.PlanTypeValidator(v); err != nil {
+ return &ValidationError{Name: "plan_type", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.plan_type": %w`, err)}
+ }
+ }
if v, ok := _u.mutation.Name(); ok {
if err := subscriptionplan.NameValidator(v); err != nil {
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.name": %w`, err)}
@@ -308,6 +397,21 @@ func (_u *SubscriptionPlanUpdate) sqlSave(ctx context.Context) (_node int, err e
if value, ok := _u.mutation.AddedGroupID(); ok {
_spec.AddField(subscriptionplan.FieldGroupID, field.TypeInt64, value)
}
+ if _u.mutation.GroupIDCleared() {
+ _spec.ClearField(subscriptionplan.FieldGroupID, field.TypeInt64)
+ }
+ if value, ok := _u.mutation.WalletQuotaUsd(); ok {
+ _spec.SetField(subscriptionplan.FieldWalletQuotaUsd, field.TypeFloat64, value)
+ }
+ if value, ok := _u.mutation.AddedWalletQuotaUsd(); ok {
+ _spec.AddField(subscriptionplan.FieldWalletQuotaUsd, field.TypeFloat64, value)
+ }
+ if _u.mutation.WalletQuotaUsdCleared() {
+ _spec.ClearField(subscriptionplan.FieldWalletQuotaUsd, field.TypeFloat64)
+ }
+ if value, ok := _u.mutation.PlanType(); ok {
+ _spec.SetField(subscriptionplan.FieldPlanType, field.TypeString, value)
+ }
if value, ok := _u.mutation.Name(); ok {
_spec.SetField(subscriptionplan.FieldName, field.TypeString, value)
}
@@ -356,6 +460,51 @@ func (_u *SubscriptionPlanUpdate) sqlSave(ctx context.Context) (_node int, err e
if value, ok := _u.mutation.UpdatedAt(); ok {
_spec.SetField(subscriptionplan.FieldUpdatedAt, field.TypeTime, value)
}
+ if _u.mutation.PlanGroupsCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: subscriptionplan.PlanGroupsTable,
+ Columns: []string{subscriptionplan.PlanGroupsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.RemovedPlanGroupsIDs(); len(nodes) > 0 && !_u.mutation.PlanGroupsCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: subscriptionplan.PlanGroupsTable,
+ Columns: []string{subscriptionplan.PlanGroupsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.PlanGroupsIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: subscriptionplan.PlanGroupsTable,
+ Columns: []string{subscriptionplan.PlanGroupsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil {
if _, ok := err.(*sqlgraph.NotFoundError); ok {
err = &NotFoundError{subscriptionplan.Label}
@@ -397,6 +546,53 @@ func (_u *SubscriptionPlanUpdateOne) AddGroupID(v int64) *SubscriptionPlanUpdate
return _u
}
+// ClearGroupID clears the value of the "group_id" field.
+func (_u *SubscriptionPlanUpdateOne) ClearGroupID() *SubscriptionPlanUpdateOne {
+ _u.mutation.ClearGroupID()
+ return _u
+}
+
+// SetWalletQuotaUsd sets the "wallet_quota_usd" field.
+func (_u *SubscriptionPlanUpdateOne) SetWalletQuotaUsd(v float64) *SubscriptionPlanUpdateOne {
+ _u.mutation.ResetWalletQuotaUsd()
+ _u.mutation.SetWalletQuotaUsd(v)
+ return _u
+}
+
+// SetNillableWalletQuotaUsd sets the "wallet_quota_usd" field if the given value is not nil.
+func (_u *SubscriptionPlanUpdateOne) SetNillableWalletQuotaUsd(v *float64) *SubscriptionPlanUpdateOne {
+ if v != nil {
+ _u.SetWalletQuotaUsd(*v)
+ }
+ return _u
+}
+
+// AddWalletQuotaUsd adds value to the "wallet_quota_usd" field.
+func (_u *SubscriptionPlanUpdateOne) AddWalletQuotaUsd(v float64) *SubscriptionPlanUpdateOne {
+ _u.mutation.AddWalletQuotaUsd(v)
+ return _u
+}
+
+// ClearWalletQuotaUsd clears the value of the "wallet_quota_usd" field.
+func (_u *SubscriptionPlanUpdateOne) ClearWalletQuotaUsd() *SubscriptionPlanUpdateOne {
+ _u.mutation.ClearWalletQuotaUsd()
+ return _u
+}
+
+// SetPlanType sets the "plan_type" field.
+func (_u *SubscriptionPlanUpdateOne) SetPlanType(v string) *SubscriptionPlanUpdateOne {
+ _u.mutation.SetPlanType(v)
+ return _u
+}
+
+// SetNillablePlanType sets the "plan_type" field if the given value is not nil.
+func (_u *SubscriptionPlanUpdateOne) SetNillablePlanType(v *string) *SubscriptionPlanUpdateOne {
+ if v != nil {
+ _u.SetPlanType(*v)
+ }
+ return _u
+}
+
// SetName sets the "name" field.
func (_u *SubscriptionPlanUpdateOne) SetName(v string) *SubscriptionPlanUpdateOne {
_u.mutation.SetName(v)
@@ -577,11 +773,47 @@ func (_u *SubscriptionPlanUpdateOne) SetUpdatedAt(v time.Time) *SubscriptionPlan
return _u
}
+// AddPlanGroupIDs adds the "plan_groups" edge to the SubscriptionPlanGroup entity by IDs.
+func (_u *SubscriptionPlanUpdateOne) AddPlanGroupIDs(ids ...int64) *SubscriptionPlanUpdateOne {
+ _u.mutation.AddPlanGroupIDs(ids...)
+ return _u
+}
+
+// AddPlanGroups adds the "plan_groups" edges to the SubscriptionPlanGroup entity.
+func (_u *SubscriptionPlanUpdateOne) AddPlanGroups(v ...*SubscriptionPlanGroup) *SubscriptionPlanUpdateOne {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.AddPlanGroupIDs(ids...)
+}
+
// Mutation returns the SubscriptionPlanMutation object of the builder.
func (_u *SubscriptionPlanUpdateOne) Mutation() *SubscriptionPlanMutation {
return _u.mutation
}
+// ClearPlanGroups clears all "plan_groups" edges to the SubscriptionPlanGroup entity.
+func (_u *SubscriptionPlanUpdateOne) ClearPlanGroups() *SubscriptionPlanUpdateOne {
+ _u.mutation.ClearPlanGroups()
+ return _u
+}
+
+// RemovePlanGroupIDs removes the "plan_groups" edge to SubscriptionPlanGroup entities by IDs.
+func (_u *SubscriptionPlanUpdateOne) RemovePlanGroupIDs(ids ...int64) *SubscriptionPlanUpdateOne {
+ _u.mutation.RemovePlanGroupIDs(ids...)
+ return _u
+}
+
+// RemovePlanGroups removes "plan_groups" edges to SubscriptionPlanGroup entities.
+func (_u *SubscriptionPlanUpdateOne) RemovePlanGroups(v ...*SubscriptionPlanGroup) *SubscriptionPlanUpdateOne {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.RemovePlanGroupIDs(ids...)
+}
+
// Where appends a list predicates to the SubscriptionPlanUpdate builder.
func (_u *SubscriptionPlanUpdateOne) Where(ps ...predicate.SubscriptionPlan) *SubscriptionPlanUpdateOne {
_u.mutation.Where(ps...)
@@ -633,6 +865,11 @@ func (_u *SubscriptionPlanUpdateOne) defaults() {
// check runs all checks and user-defined validators on the builder.
func (_u *SubscriptionPlanUpdateOne) check() error {
+ if v, ok := _u.mutation.PlanType(); ok {
+ if err := subscriptionplan.PlanTypeValidator(v); err != nil {
+ return &ValidationError{Name: "plan_type", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.plan_type": %w`, err)}
+ }
+ }
if v, ok := _u.mutation.Name(); ok {
if err := subscriptionplan.NameValidator(v); err != nil {
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "SubscriptionPlan.name": %w`, err)}
@@ -686,6 +923,21 @@ func (_u *SubscriptionPlanUpdateOne) sqlSave(ctx context.Context) (_node *Subscr
if value, ok := _u.mutation.AddedGroupID(); ok {
_spec.AddField(subscriptionplan.FieldGroupID, field.TypeInt64, value)
}
+ if _u.mutation.GroupIDCleared() {
+ _spec.ClearField(subscriptionplan.FieldGroupID, field.TypeInt64)
+ }
+ if value, ok := _u.mutation.WalletQuotaUsd(); ok {
+ _spec.SetField(subscriptionplan.FieldWalletQuotaUsd, field.TypeFloat64, value)
+ }
+ if value, ok := _u.mutation.AddedWalletQuotaUsd(); ok {
+ _spec.AddField(subscriptionplan.FieldWalletQuotaUsd, field.TypeFloat64, value)
+ }
+ if _u.mutation.WalletQuotaUsdCleared() {
+ _spec.ClearField(subscriptionplan.FieldWalletQuotaUsd, field.TypeFloat64)
+ }
+ if value, ok := _u.mutation.PlanType(); ok {
+ _spec.SetField(subscriptionplan.FieldPlanType, field.TypeString, value)
+ }
if value, ok := _u.mutation.Name(); ok {
_spec.SetField(subscriptionplan.FieldName, field.TypeString, value)
}
@@ -734,6 +986,51 @@ func (_u *SubscriptionPlanUpdateOne) sqlSave(ctx context.Context) (_node *Subscr
if value, ok := _u.mutation.UpdatedAt(); ok {
_spec.SetField(subscriptionplan.FieldUpdatedAt, field.TypeTime, value)
}
+ if _u.mutation.PlanGroupsCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: subscriptionplan.PlanGroupsTable,
+ Columns: []string{subscriptionplan.PlanGroupsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.RemovedPlanGroupsIDs(); len(nodes) > 0 && !_u.mutation.PlanGroupsCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: subscriptionplan.PlanGroupsTable,
+ Columns: []string{subscriptionplan.PlanGroupsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.PlanGroupsIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: subscriptionplan.PlanGroupsTable,
+ Columns: []string{subscriptionplan.PlanGroupsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
_node = &SubscriptionPlan{config: _u.config}
_spec.Assign = _node.assignValues
_spec.ScanValues = _node.scanValues
diff --git a/backend/ent/subscriptionplangroup.go b/backend/ent/subscriptionplangroup.go
new file mode 100644
index 00000000000..e6f91a32795
--- /dev/null
+++ b/backend/ent/subscriptionplangroup.go
@@ -0,0 +1,174 @@
+// Code generated by ent, DO NOT EDIT.
+
+package ent
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "entgo.io/ent"
+ "entgo.io/ent/dialect/sql"
+ "github.com/Wei-Shaw/sub2api/ent/group"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
+)
+
+// SubscriptionPlanGroup is the model entity for the SubscriptionPlanGroup schema.
+type SubscriptionPlanGroup struct {
+ config `json:"-"`
+ // ID of the ent.
+ ID int64 `json:"id,omitempty"`
+ // PlanID holds the value of the "plan_id" field.
+ PlanID int64 `json:"plan_id,omitempty"`
+ // GroupID holds the value of the "group_id" field.
+ GroupID int64 `json:"group_id,omitempty"`
+ // CreatedAt holds the value of the "created_at" field.
+ CreatedAt time.Time `json:"created_at,omitempty"`
+ // Edges holds the relations/edges for other nodes in the graph.
+ // The values are being populated by the SubscriptionPlanGroupQuery when eager-loading is set.
+ Edges SubscriptionPlanGroupEdges `json:"edges"`
+ selectValues sql.SelectValues
+}
+
+// SubscriptionPlanGroupEdges holds the relations/edges for other nodes in the graph.
+type SubscriptionPlanGroupEdges struct {
+ // Plan holds the value of the plan edge.
+ Plan *SubscriptionPlan `json:"plan,omitempty"`
+ // Group holds the value of the group edge.
+ Group *Group `json:"group,omitempty"`
+ // loadedTypes holds the information for reporting if a
+ // type was loaded (or requested) in eager-loading or not.
+ loadedTypes [2]bool
+}
+
+// PlanOrErr returns the Plan value or an error if the edge
+// was not loaded in eager-loading, or loaded but was not found.
+func (e SubscriptionPlanGroupEdges) PlanOrErr() (*SubscriptionPlan, error) {
+ if e.Plan != nil {
+ return e.Plan, nil
+ } else if e.loadedTypes[0] {
+ return nil, &NotFoundError{label: subscriptionplan.Label}
+ }
+ return nil, &NotLoadedError{edge: "plan"}
+}
+
+// GroupOrErr returns the Group value or an error if the edge
+// was not loaded in eager-loading, or loaded but was not found.
+func (e SubscriptionPlanGroupEdges) GroupOrErr() (*Group, error) {
+ if e.Group != nil {
+ return e.Group, nil
+ } else if e.loadedTypes[1] {
+ return nil, &NotFoundError{label: group.Label}
+ }
+ return nil, &NotLoadedError{edge: "group"}
+}
+
+// scanValues returns the types for scanning values from sql.Rows.
+func (*SubscriptionPlanGroup) scanValues(columns []string) ([]any, error) {
+ values := make([]any, len(columns))
+ for i := range columns {
+ switch columns[i] {
+ case subscriptionplangroup.FieldID, subscriptionplangroup.FieldPlanID, subscriptionplangroup.FieldGroupID:
+ values[i] = new(sql.NullInt64)
+ case subscriptionplangroup.FieldCreatedAt:
+ values[i] = new(sql.NullTime)
+ default:
+ values[i] = new(sql.UnknownType)
+ }
+ }
+ return values, nil
+}
+
+// assignValues assigns the values that were returned from sql.Rows (after scanning)
+// to the SubscriptionPlanGroup fields.
+func (_m *SubscriptionPlanGroup) assignValues(columns []string, values []any) error {
+ if m, n := len(values), len(columns); m < n {
+ return fmt.Errorf("mismatch number of scan values: %d != %d", m, n)
+ }
+ for i := range columns {
+ switch columns[i] {
+ case subscriptionplangroup.FieldID:
+ value, ok := values[i].(*sql.NullInt64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field id", value)
+ }
+ _m.ID = int64(value.Int64)
+ case subscriptionplangroup.FieldPlanID:
+ if value, ok := values[i].(*sql.NullInt64); !ok {
+ return fmt.Errorf("unexpected type %T for field plan_id", values[i])
+ } else if value.Valid {
+ _m.PlanID = value.Int64
+ }
+ case subscriptionplangroup.FieldGroupID:
+ if value, ok := values[i].(*sql.NullInt64); !ok {
+ return fmt.Errorf("unexpected type %T for field group_id", values[i])
+ } else if value.Valid {
+ _m.GroupID = value.Int64
+ }
+ case subscriptionplangroup.FieldCreatedAt:
+ if value, ok := values[i].(*sql.NullTime); !ok {
+ return fmt.Errorf("unexpected type %T for field created_at", values[i])
+ } else if value.Valid {
+ _m.CreatedAt = value.Time
+ }
+ default:
+ _m.selectValues.Set(columns[i], values[i])
+ }
+ }
+ return nil
+}
+
+// Value returns the ent.Value that was dynamically selected and assigned to the SubscriptionPlanGroup.
+// This includes values selected through modifiers, order, etc.
+func (_m *SubscriptionPlanGroup) Value(name string) (ent.Value, error) {
+ return _m.selectValues.Get(name)
+}
+
+// QueryPlan queries the "plan" edge of the SubscriptionPlanGroup entity.
+func (_m *SubscriptionPlanGroup) QueryPlan() *SubscriptionPlanQuery {
+ return NewSubscriptionPlanGroupClient(_m.config).QueryPlan(_m)
+}
+
+// QueryGroup queries the "group" edge of the SubscriptionPlanGroup entity.
+func (_m *SubscriptionPlanGroup) QueryGroup() *GroupQuery {
+ return NewSubscriptionPlanGroupClient(_m.config).QueryGroup(_m)
+}
+
+// Update returns a builder for updating this SubscriptionPlanGroup.
+// Note that you need to call SubscriptionPlanGroup.Unwrap() before calling this method if this SubscriptionPlanGroup
+// was returned from a transaction, and the transaction was committed or rolled back.
+func (_m *SubscriptionPlanGroup) Update() *SubscriptionPlanGroupUpdateOne {
+ return NewSubscriptionPlanGroupClient(_m.config).UpdateOne(_m)
+}
+
+// Unwrap unwraps the SubscriptionPlanGroup entity that was returned from a transaction after it was closed,
+// so that all future queries will be executed through the driver which created the transaction.
+func (_m *SubscriptionPlanGroup) Unwrap() *SubscriptionPlanGroup {
+ _tx, ok := _m.config.driver.(*txDriver)
+ if !ok {
+ panic("ent: SubscriptionPlanGroup is not a transactional entity")
+ }
+ _m.config.driver = _tx.drv
+ return _m
+}
+
+// String implements the fmt.Stringer.
+func (_m *SubscriptionPlanGroup) String() string {
+ var builder strings.Builder
+ builder.WriteString("SubscriptionPlanGroup(")
+ builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID))
+ builder.WriteString("plan_id=")
+ builder.WriteString(fmt.Sprintf("%v", _m.PlanID))
+ builder.WriteString(", ")
+ builder.WriteString("group_id=")
+ builder.WriteString(fmt.Sprintf("%v", _m.GroupID))
+ builder.WriteString(", ")
+ builder.WriteString("created_at=")
+ builder.WriteString(_m.CreatedAt.Format(time.ANSIC))
+ builder.WriteByte(')')
+ return builder.String()
+}
+
+// SubscriptionPlanGroups is a parsable slice of SubscriptionPlanGroup.
+type SubscriptionPlanGroups []*SubscriptionPlanGroup
diff --git a/backend/ent/subscriptionplangroup/subscriptionplangroup.go b/backend/ent/subscriptionplangroup/subscriptionplangroup.go
new file mode 100644
index 00000000000..0675468495e
--- /dev/null
+++ b/backend/ent/subscriptionplangroup/subscriptionplangroup.go
@@ -0,0 +1,117 @@
+// Code generated by ent, DO NOT EDIT.
+
+package subscriptionplangroup
+
+import (
+ "time"
+
+ "entgo.io/ent/dialect/sql"
+ "entgo.io/ent/dialect/sql/sqlgraph"
+)
+
+const (
+ // Label holds the string label denoting the subscriptionplangroup type in the database.
+ Label = "subscription_plan_group"
+ // FieldID holds the string denoting the id field in the database.
+ FieldID = "id"
+ // FieldPlanID holds the string denoting the plan_id field in the database.
+ FieldPlanID = "plan_id"
+ // FieldGroupID holds the string denoting the group_id field in the database.
+ FieldGroupID = "group_id"
+ // FieldCreatedAt holds the string denoting the created_at field in the database.
+ FieldCreatedAt = "created_at"
+ // EdgePlan holds the string denoting the plan edge name in mutations.
+ EdgePlan = "plan"
+ // EdgeGroup holds the string denoting the group edge name in mutations.
+ EdgeGroup = "group"
+ // Table holds the table name of the subscriptionplangroup in the database.
+ Table = "subscription_plan_groups"
+ // PlanTable is the table that holds the plan relation/edge.
+ PlanTable = "subscription_plan_groups"
+ // PlanInverseTable is the table name for the SubscriptionPlan entity.
+ // It exists in this package in order to avoid circular dependency with the "subscriptionplan" package.
+ PlanInverseTable = "subscription_plans"
+ // PlanColumn is the table column denoting the plan relation/edge.
+ PlanColumn = "plan_id"
+ // GroupTable is the table that holds the group relation/edge.
+ GroupTable = "subscription_plan_groups"
+ // GroupInverseTable is the table name for the Group entity.
+ // It exists in this package in order to avoid circular dependency with the "group" package.
+ GroupInverseTable = "groups"
+ // GroupColumn is the table column denoting the group relation/edge.
+ GroupColumn = "group_id"
+)
+
+// Columns holds all SQL columns for subscriptionplangroup fields.
+var Columns = []string{
+ FieldID,
+ FieldPlanID,
+ FieldGroupID,
+ FieldCreatedAt,
+}
+
+// ValidColumn reports if the column name is valid (part of the table columns).
+func ValidColumn(column string) bool {
+ for i := range Columns {
+ if column == Columns[i] {
+ return true
+ }
+ }
+ return false
+}
+
+var (
+ // DefaultCreatedAt holds the default value on creation for the "created_at" field.
+ DefaultCreatedAt func() time.Time
+)
+
+// OrderOption defines the ordering options for the SubscriptionPlanGroup queries.
+type OrderOption func(*sql.Selector)
+
+// ByID orders the results by the id field.
+func ByID(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldID, opts...).ToFunc()
+}
+
+// ByPlanID orders the results by the plan_id field.
+func ByPlanID(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldPlanID, opts...).ToFunc()
+}
+
+// ByGroupID orders the results by the group_id field.
+func ByGroupID(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldGroupID, opts...).ToFunc()
+}
+
+// ByCreatedAt orders the results by the created_at field.
+func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldCreatedAt, opts...).ToFunc()
+}
+
+// ByPlanField orders the results by plan field.
+func ByPlanField(field string, opts ...sql.OrderTermOption) OrderOption {
+ return func(s *sql.Selector) {
+ sqlgraph.OrderByNeighborTerms(s, newPlanStep(), sql.OrderByField(field, opts...))
+ }
+}
+
+// ByGroupField orders the results by group field.
+func ByGroupField(field string, opts ...sql.OrderTermOption) OrderOption {
+ return func(s *sql.Selector) {
+ sqlgraph.OrderByNeighborTerms(s, newGroupStep(), sql.OrderByField(field, opts...))
+ }
+}
+func newPlanStep() *sqlgraph.Step {
+ return sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.To(PlanInverseTable, FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, PlanTable, PlanColumn),
+ )
+}
+func newGroupStep() *sqlgraph.Step {
+ return sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.To(GroupInverseTable, FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, GroupTable, GroupColumn),
+ )
+}
diff --git a/backend/ent/subscriptionplangroup/where.go b/backend/ent/subscriptionplangroup/where.go
new file mode 100644
index 00000000000..2f09de1128a
--- /dev/null
+++ b/backend/ent/subscriptionplangroup/where.go
@@ -0,0 +1,212 @@
+// Code generated by ent, DO NOT EDIT.
+
+package subscriptionplangroup
+
+import (
+ "time"
+
+ "entgo.io/ent/dialect/sql"
+ "entgo.io/ent/dialect/sql/sqlgraph"
+ "github.com/Wei-Shaw/sub2api/ent/predicate"
+)
+
+// ID filters vertices based on their ID field.
+func ID(id int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldEQ(FieldID, id))
+}
+
+// IDEQ applies the EQ predicate on the ID field.
+func IDEQ(id int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldEQ(FieldID, id))
+}
+
+// IDNEQ applies the NEQ predicate on the ID field.
+func IDNEQ(id int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldNEQ(FieldID, id))
+}
+
+// IDIn applies the In predicate on the ID field.
+func IDIn(ids ...int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldIn(FieldID, ids...))
+}
+
+// IDNotIn applies the NotIn predicate on the ID field.
+func IDNotIn(ids ...int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldNotIn(FieldID, ids...))
+}
+
+// IDGT applies the GT predicate on the ID field.
+func IDGT(id int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldGT(FieldID, id))
+}
+
+// IDGTE applies the GTE predicate on the ID field.
+func IDGTE(id int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldGTE(FieldID, id))
+}
+
+// IDLT applies the LT predicate on the ID field.
+func IDLT(id int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldLT(FieldID, id))
+}
+
+// IDLTE applies the LTE predicate on the ID field.
+func IDLTE(id int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldLTE(FieldID, id))
+}
+
+// PlanID applies equality check predicate on the "plan_id" field. It's identical to PlanIDEQ.
+func PlanID(v int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldEQ(FieldPlanID, v))
+}
+
+// GroupID applies equality check predicate on the "group_id" field. It's identical to GroupIDEQ.
+func GroupID(v int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldEQ(FieldGroupID, v))
+}
+
+// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ.
+func CreatedAt(v time.Time) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldEQ(FieldCreatedAt, v))
+}
+
+// PlanIDEQ applies the EQ predicate on the "plan_id" field.
+func PlanIDEQ(v int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldEQ(FieldPlanID, v))
+}
+
+// PlanIDNEQ applies the NEQ predicate on the "plan_id" field.
+func PlanIDNEQ(v int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldNEQ(FieldPlanID, v))
+}
+
+// PlanIDIn applies the In predicate on the "plan_id" field.
+func PlanIDIn(vs ...int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldIn(FieldPlanID, vs...))
+}
+
+// PlanIDNotIn applies the NotIn predicate on the "plan_id" field.
+func PlanIDNotIn(vs ...int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldNotIn(FieldPlanID, vs...))
+}
+
+// GroupIDEQ applies the EQ predicate on the "group_id" field.
+func GroupIDEQ(v int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldEQ(FieldGroupID, v))
+}
+
+// GroupIDNEQ applies the NEQ predicate on the "group_id" field.
+func GroupIDNEQ(v int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldNEQ(FieldGroupID, v))
+}
+
+// GroupIDIn applies the In predicate on the "group_id" field.
+func GroupIDIn(vs ...int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldIn(FieldGroupID, vs...))
+}
+
+// GroupIDNotIn applies the NotIn predicate on the "group_id" field.
+func GroupIDNotIn(vs ...int64) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldNotIn(FieldGroupID, vs...))
+}
+
+// CreatedAtEQ applies the EQ predicate on the "created_at" field.
+func CreatedAtEQ(v time.Time) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldEQ(FieldCreatedAt, v))
+}
+
+// CreatedAtNEQ applies the NEQ predicate on the "created_at" field.
+func CreatedAtNEQ(v time.Time) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldNEQ(FieldCreatedAt, v))
+}
+
+// CreatedAtIn applies the In predicate on the "created_at" field.
+func CreatedAtIn(vs ...time.Time) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldIn(FieldCreatedAt, vs...))
+}
+
+// CreatedAtNotIn applies the NotIn predicate on the "created_at" field.
+func CreatedAtNotIn(vs ...time.Time) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldNotIn(FieldCreatedAt, vs...))
+}
+
+// CreatedAtGT applies the GT predicate on the "created_at" field.
+func CreatedAtGT(v time.Time) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldGT(FieldCreatedAt, v))
+}
+
+// CreatedAtGTE applies the GTE predicate on the "created_at" field.
+func CreatedAtGTE(v time.Time) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldGTE(FieldCreatedAt, v))
+}
+
+// CreatedAtLT applies the LT predicate on the "created_at" field.
+func CreatedAtLT(v time.Time) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldLT(FieldCreatedAt, v))
+}
+
+// CreatedAtLTE applies the LTE predicate on the "created_at" field.
+func CreatedAtLTE(v time.Time) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.FieldLTE(FieldCreatedAt, v))
+}
+
+// HasPlan applies the HasEdge predicate on the "plan" edge.
+func HasPlan() predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(func(s *sql.Selector) {
+ step := sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, PlanTable, PlanColumn),
+ )
+ sqlgraph.HasNeighbors(s, step)
+ })
+}
+
+// HasPlanWith applies the HasEdge predicate on the "plan" edge with a given conditions (other predicates).
+func HasPlanWith(preds ...predicate.SubscriptionPlan) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(func(s *sql.Selector) {
+ step := newPlanStep()
+ sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
+ for _, p := range preds {
+ p(s)
+ }
+ })
+ })
+}
+
+// HasGroup applies the HasEdge predicate on the "group" edge.
+func HasGroup() predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(func(s *sql.Selector) {
+ step := sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, GroupTable, GroupColumn),
+ )
+ sqlgraph.HasNeighbors(s, step)
+ })
+}
+
+// HasGroupWith applies the HasEdge predicate on the "group" edge with a given conditions (other predicates).
+func HasGroupWith(preds ...predicate.Group) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(func(s *sql.Selector) {
+ step := newGroupStep()
+ sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
+ for _, p := range preds {
+ p(s)
+ }
+ })
+ })
+}
+
+// And groups predicates with the AND operator between them.
+func And(predicates ...predicate.SubscriptionPlanGroup) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.AndPredicates(predicates...))
+}
+
+// Or groups predicates with the OR operator between them.
+func Or(predicates ...predicate.SubscriptionPlanGroup) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.OrPredicates(predicates...))
+}
+
+// Not applies the not operator on the given predicate.
+func Not(p predicate.SubscriptionPlanGroup) predicate.SubscriptionPlanGroup {
+ return predicate.SubscriptionPlanGroup(sql.NotPredicates(p))
+}
diff --git a/backend/ent/subscriptionplangroup_create.go b/backend/ent/subscriptionplangroup_create.go
new file mode 100644
index 00000000000..efc7b0ef97a
--- /dev/null
+++ b/backend/ent/subscriptionplangroup_create.go
@@ -0,0 +1,595 @@
+// Code generated by ent, DO NOT EDIT.
+
+package ent
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "entgo.io/ent/dialect/sql"
+ "entgo.io/ent/dialect/sql/sqlgraph"
+ "entgo.io/ent/schema/field"
+ "github.com/Wei-Shaw/sub2api/ent/group"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
+)
+
+// SubscriptionPlanGroupCreate is the builder for creating a SubscriptionPlanGroup entity.
+type SubscriptionPlanGroupCreate struct {
+ config
+ mutation *SubscriptionPlanGroupMutation
+ hooks []Hook
+ conflict []sql.ConflictOption
+}
+
+// SetPlanID sets the "plan_id" field.
+func (_c *SubscriptionPlanGroupCreate) SetPlanID(v int64) *SubscriptionPlanGroupCreate {
+ _c.mutation.SetPlanID(v)
+ return _c
+}
+
+// SetGroupID sets the "group_id" field.
+func (_c *SubscriptionPlanGroupCreate) SetGroupID(v int64) *SubscriptionPlanGroupCreate {
+ _c.mutation.SetGroupID(v)
+ return _c
+}
+
+// SetCreatedAt sets the "created_at" field.
+func (_c *SubscriptionPlanGroupCreate) SetCreatedAt(v time.Time) *SubscriptionPlanGroupCreate {
+ _c.mutation.SetCreatedAt(v)
+ return _c
+}
+
+// SetNillableCreatedAt sets the "created_at" field if the given value is not nil.
+func (_c *SubscriptionPlanGroupCreate) SetNillableCreatedAt(v *time.Time) *SubscriptionPlanGroupCreate {
+ if v != nil {
+ _c.SetCreatedAt(*v)
+ }
+ return _c
+}
+
+// SetPlan sets the "plan" edge to the SubscriptionPlan entity.
+func (_c *SubscriptionPlanGroupCreate) SetPlan(v *SubscriptionPlan) *SubscriptionPlanGroupCreate {
+ return _c.SetPlanID(v.ID)
+}
+
+// SetGroup sets the "group" edge to the Group entity.
+func (_c *SubscriptionPlanGroupCreate) SetGroup(v *Group) *SubscriptionPlanGroupCreate {
+ return _c.SetGroupID(v.ID)
+}
+
+// Mutation returns the SubscriptionPlanGroupMutation object of the builder.
+func (_c *SubscriptionPlanGroupCreate) Mutation() *SubscriptionPlanGroupMutation {
+ return _c.mutation
+}
+
+// Save creates the SubscriptionPlanGroup in the database.
+func (_c *SubscriptionPlanGroupCreate) Save(ctx context.Context) (*SubscriptionPlanGroup, error) {
+ _c.defaults()
+ return withHooks(ctx, _c.sqlSave, _c.mutation, _c.hooks)
+}
+
+// SaveX calls Save and panics if Save returns an error.
+func (_c *SubscriptionPlanGroupCreate) SaveX(ctx context.Context) *SubscriptionPlanGroup {
+ v, err := _c.Save(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return v
+}
+
+// Exec executes the query.
+func (_c *SubscriptionPlanGroupCreate) Exec(ctx context.Context) error {
+ _, err := _c.Save(ctx)
+ return err
+}
+
+// ExecX is like Exec, but panics if an error occurs.
+func (_c *SubscriptionPlanGroupCreate) ExecX(ctx context.Context) {
+ if err := _c.Exec(ctx); err != nil {
+ panic(err)
+ }
+}
+
+// defaults sets the default values of the builder before save.
+func (_c *SubscriptionPlanGroupCreate) defaults() {
+ if _, ok := _c.mutation.CreatedAt(); !ok {
+ v := subscriptionplangroup.DefaultCreatedAt()
+ _c.mutation.SetCreatedAt(v)
+ }
+}
+
+// check runs all checks and user-defined validators on the builder.
+func (_c *SubscriptionPlanGroupCreate) check() error {
+ if _, ok := _c.mutation.PlanID(); !ok {
+ return &ValidationError{Name: "plan_id", err: errors.New(`ent: missing required field "SubscriptionPlanGroup.plan_id"`)}
+ }
+ if _, ok := _c.mutation.GroupID(); !ok {
+ return &ValidationError{Name: "group_id", err: errors.New(`ent: missing required field "SubscriptionPlanGroup.group_id"`)}
+ }
+ if _, ok := _c.mutation.CreatedAt(); !ok {
+ return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "SubscriptionPlanGroup.created_at"`)}
+ }
+ if len(_c.mutation.PlanIDs()) == 0 {
+ return &ValidationError{Name: "plan", err: errors.New(`ent: missing required edge "SubscriptionPlanGroup.plan"`)}
+ }
+ if len(_c.mutation.GroupIDs()) == 0 {
+ return &ValidationError{Name: "group", err: errors.New(`ent: missing required edge "SubscriptionPlanGroup.group"`)}
+ }
+ return nil
+}
+
+func (_c *SubscriptionPlanGroupCreate) sqlSave(ctx context.Context) (*SubscriptionPlanGroup, error) {
+ if err := _c.check(); err != nil {
+ return nil, err
+ }
+ _node, _spec := _c.createSpec()
+ if err := sqlgraph.CreateNode(ctx, _c.driver, _spec); err != nil {
+ if sqlgraph.IsConstraintError(err) {
+ err = &ConstraintError{msg: err.Error(), wrap: err}
+ }
+ return nil, err
+ }
+ id := _spec.ID.Value.(int64)
+ _node.ID = int64(id)
+ _c.mutation.id = &_node.ID
+ _c.mutation.done = true
+ return _node, nil
+}
+
+func (_c *SubscriptionPlanGroupCreate) createSpec() (*SubscriptionPlanGroup, *sqlgraph.CreateSpec) {
+ var (
+ _node = &SubscriptionPlanGroup{config: _c.config}
+ _spec = sqlgraph.NewCreateSpec(subscriptionplangroup.Table, sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64))
+ )
+ _spec.OnConflict = _c.conflict
+ if value, ok := _c.mutation.CreatedAt(); ok {
+ _spec.SetField(subscriptionplangroup.FieldCreatedAt, field.TypeTime, value)
+ _node.CreatedAt = value
+ }
+ if nodes := _c.mutation.PlanIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionplangroup.PlanTable,
+ Columns: []string{subscriptionplangroup.PlanColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplan.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _node.PlanID = nodes[0]
+ _spec.Edges = append(_spec.Edges, edge)
+ }
+ if nodes := _c.mutation.GroupIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionplangroup.GroupTable,
+ Columns: []string{subscriptionplangroup.GroupColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _node.GroupID = nodes[0]
+ _spec.Edges = append(_spec.Edges, edge)
+ }
+ return _node, _spec
+}
+
+// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause
+// of the `INSERT` statement. For example:
+//
+// client.SubscriptionPlanGroup.Create().
+// SetPlanID(v).
+// OnConflict(
+// // Update the row with the new values
+// // the was proposed for insertion.
+// sql.ResolveWithNewValues(),
+// ).
+// // Override some of the fields with custom
+// // update values.
+// Update(func(u *ent.SubscriptionPlanGroupUpsert) {
+// SetPlanID(v+v).
+// }).
+// Exec(ctx)
+func (_c *SubscriptionPlanGroupCreate) OnConflict(opts ...sql.ConflictOption) *SubscriptionPlanGroupUpsertOne {
+ _c.conflict = opts
+ return &SubscriptionPlanGroupUpsertOne{
+ create: _c,
+ }
+}
+
+// OnConflictColumns calls `OnConflict` and configures the columns
+// as conflict target. Using this option is equivalent to using:
+//
+// client.SubscriptionPlanGroup.Create().
+// OnConflict(sql.ConflictColumns(columns...)).
+// Exec(ctx)
+func (_c *SubscriptionPlanGroupCreate) OnConflictColumns(columns ...string) *SubscriptionPlanGroupUpsertOne {
+ _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...))
+ return &SubscriptionPlanGroupUpsertOne{
+ create: _c,
+ }
+}
+
+type (
+ // SubscriptionPlanGroupUpsertOne is the builder for "upsert"-ing
+ // one SubscriptionPlanGroup node.
+ SubscriptionPlanGroupUpsertOne struct {
+ create *SubscriptionPlanGroupCreate
+ }
+
+ // SubscriptionPlanGroupUpsert is the "OnConflict" setter.
+ SubscriptionPlanGroupUpsert struct {
+ *sql.UpdateSet
+ }
+)
+
+// SetPlanID sets the "plan_id" field.
+func (u *SubscriptionPlanGroupUpsert) SetPlanID(v int64) *SubscriptionPlanGroupUpsert {
+ u.Set(subscriptionplangroup.FieldPlanID, v)
+ return u
+}
+
+// UpdatePlanID sets the "plan_id" field to the value that was provided on create.
+func (u *SubscriptionPlanGroupUpsert) UpdatePlanID() *SubscriptionPlanGroupUpsert {
+ u.SetExcluded(subscriptionplangroup.FieldPlanID)
+ return u
+}
+
+// SetGroupID sets the "group_id" field.
+func (u *SubscriptionPlanGroupUpsert) SetGroupID(v int64) *SubscriptionPlanGroupUpsert {
+ u.Set(subscriptionplangroup.FieldGroupID, v)
+ return u
+}
+
+// UpdateGroupID sets the "group_id" field to the value that was provided on create.
+func (u *SubscriptionPlanGroupUpsert) UpdateGroupID() *SubscriptionPlanGroupUpsert {
+ u.SetExcluded(subscriptionplangroup.FieldGroupID)
+ return u
+}
+
+// UpdateNewValues updates the mutable fields using the new values that were set on create.
+// Using this option is equivalent to using:
+//
+// client.SubscriptionPlanGroup.Create().
+// OnConflict(
+// sql.ResolveWithNewValues(),
+// ).
+// Exec(ctx)
+func (u *SubscriptionPlanGroupUpsertOne) UpdateNewValues() *SubscriptionPlanGroupUpsertOne {
+ u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues())
+ u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) {
+ if _, exists := u.create.mutation.CreatedAt(); exists {
+ s.SetIgnore(subscriptionplangroup.FieldCreatedAt)
+ }
+ }))
+ return u
+}
+
+// Ignore sets each column to itself in case of conflict.
+// Using this option is equivalent to using:
+//
+// client.SubscriptionPlanGroup.Create().
+// OnConflict(sql.ResolveWithIgnore()).
+// Exec(ctx)
+func (u *SubscriptionPlanGroupUpsertOne) Ignore() *SubscriptionPlanGroupUpsertOne {
+ u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore())
+ return u
+}
+
+// DoNothing configures the conflict_action to `DO NOTHING`.
+// Supported only by SQLite and PostgreSQL.
+func (u *SubscriptionPlanGroupUpsertOne) DoNothing() *SubscriptionPlanGroupUpsertOne {
+ u.create.conflict = append(u.create.conflict, sql.DoNothing())
+ return u
+}
+
+// Update allows overriding fields `UPDATE` values. See the SubscriptionPlanGroupCreate.OnConflict
+// documentation for more info.
+func (u *SubscriptionPlanGroupUpsertOne) Update(set func(*SubscriptionPlanGroupUpsert)) *SubscriptionPlanGroupUpsertOne {
+ u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) {
+ set(&SubscriptionPlanGroupUpsert{UpdateSet: update})
+ }))
+ return u
+}
+
+// SetPlanID sets the "plan_id" field.
+func (u *SubscriptionPlanGroupUpsertOne) SetPlanID(v int64) *SubscriptionPlanGroupUpsertOne {
+ return u.Update(func(s *SubscriptionPlanGroupUpsert) {
+ s.SetPlanID(v)
+ })
+}
+
+// UpdatePlanID sets the "plan_id" field to the value that was provided on create.
+func (u *SubscriptionPlanGroupUpsertOne) UpdatePlanID() *SubscriptionPlanGroupUpsertOne {
+ return u.Update(func(s *SubscriptionPlanGroupUpsert) {
+ s.UpdatePlanID()
+ })
+}
+
+// SetGroupID sets the "group_id" field.
+func (u *SubscriptionPlanGroupUpsertOne) SetGroupID(v int64) *SubscriptionPlanGroupUpsertOne {
+ return u.Update(func(s *SubscriptionPlanGroupUpsert) {
+ s.SetGroupID(v)
+ })
+}
+
+// UpdateGroupID sets the "group_id" field to the value that was provided on create.
+func (u *SubscriptionPlanGroupUpsertOne) UpdateGroupID() *SubscriptionPlanGroupUpsertOne {
+ return u.Update(func(s *SubscriptionPlanGroupUpsert) {
+ s.UpdateGroupID()
+ })
+}
+
+// Exec executes the query.
+func (u *SubscriptionPlanGroupUpsertOne) Exec(ctx context.Context) error {
+ if len(u.create.conflict) == 0 {
+ return errors.New("ent: missing options for SubscriptionPlanGroupCreate.OnConflict")
+ }
+ return u.create.Exec(ctx)
+}
+
+// ExecX is like Exec, but panics if an error occurs.
+func (u *SubscriptionPlanGroupUpsertOne) ExecX(ctx context.Context) {
+ if err := u.create.Exec(ctx); err != nil {
+ panic(err)
+ }
+}
+
+// Exec executes the UPSERT query and returns the inserted/updated ID.
+func (u *SubscriptionPlanGroupUpsertOne) ID(ctx context.Context) (id int64, err error) {
+ node, err := u.create.Save(ctx)
+ if err != nil {
+ return id, err
+ }
+ return node.ID, nil
+}
+
+// IDX is like ID, but panics if an error occurs.
+func (u *SubscriptionPlanGroupUpsertOne) IDX(ctx context.Context) int64 {
+ id, err := u.ID(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return id
+}
+
+// SubscriptionPlanGroupCreateBulk is the builder for creating many SubscriptionPlanGroup entities in bulk.
+type SubscriptionPlanGroupCreateBulk struct {
+ config
+ err error
+ builders []*SubscriptionPlanGroupCreate
+ conflict []sql.ConflictOption
+}
+
+// Save creates the SubscriptionPlanGroup entities in the database.
+func (_c *SubscriptionPlanGroupCreateBulk) Save(ctx context.Context) ([]*SubscriptionPlanGroup, error) {
+ if _c.err != nil {
+ return nil, _c.err
+ }
+ specs := make([]*sqlgraph.CreateSpec, len(_c.builders))
+ nodes := make([]*SubscriptionPlanGroup, len(_c.builders))
+ mutators := make([]Mutator, len(_c.builders))
+ for i := range _c.builders {
+ func(i int, root context.Context) {
+ builder := _c.builders[i]
+ builder.defaults()
+ var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) {
+ mutation, ok := m.(*SubscriptionPlanGroupMutation)
+ if !ok {
+ return nil, fmt.Errorf("unexpected mutation type %T", m)
+ }
+ if err := builder.check(); err != nil {
+ return nil, err
+ }
+ builder.mutation = mutation
+ var err error
+ nodes[i], specs[i] = builder.createSpec()
+ if i < len(mutators)-1 {
+ _, err = mutators[i+1].Mutate(root, _c.builders[i+1].mutation)
+ } else {
+ spec := &sqlgraph.BatchCreateSpec{Nodes: specs}
+ spec.OnConflict = _c.conflict
+ // Invoke the actual operation on the latest mutation in the chain.
+ if err = sqlgraph.BatchCreate(ctx, _c.driver, spec); err != nil {
+ if sqlgraph.IsConstraintError(err) {
+ err = &ConstraintError{msg: err.Error(), wrap: err}
+ }
+ }
+ }
+ if err != nil {
+ return nil, err
+ }
+ mutation.id = &nodes[i].ID
+ if specs[i].ID.Value != nil {
+ id := specs[i].ID.Value.(int64)
+ nodes[i].ID = int64(id)
+ }
+ mutation.done = true
+ return nodes[i], nil
+ })
+ for i := len(builder.hooks) - 1; i >= 0; i-- {
+ mut = builder.hooks[i](mut)
+ }
+ mutators[i] = mut
+ }(i, ctx)
+ }
+ if len(mutators) > 0 {
+ if _, err := mutators[0].Mutate(ctx, _c.builders[0].mutation); err != nil {
+ return nil, err
+ }
+ }
+ return nodes, nil
+}
+
+// SaveX is like Save, but panics if an error occurs.
+func (_c *SubscriptionPlanGroupCreateBulk) SaveX(ctx context.Context) []*SubscriptionPlanGroup {
+ v, err := _c.Save(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return v
+}
+
+// Exec executes the query.
+func (_c *SubscriptionPlanGroupCreateBulk) Exec(ctx context.Context) error {
+ _, err := _c.Save(ctx)
+ return err
+}
+
+// ExecX is like Exec, but panics if an error occurs.
+func (_c *SubscriptionPlanGroupCreateBulk) ExecX(ctx context.Context) {
+ if err := _c.Exec(ctx); err != nil {
+ panic(err)
+ }
+}
+
+// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause
+// of the `INSERT` statement. For example:
+//
+// client.SubscriptionPlanGroup.CreateBulk(builders...).
+// OnConflict(
+// // Update the row with the new values
+// // the was proposed for insertion.
+// sql.ResolveWithNewValues(),
+// ).
+// // Override some of the fields with custom
+// // update values.
+// Update(func(u *ent.SubscriptionPlanGroupUpsert) {
+// SetPlanID(v+v).
+// }).
+// Exec(ctx)
+func (_c *SubscriptionPlanGroupCreateBulk) OnConflict(opts ...sql.ConflictOption) *SubscriptionPlanGroupUpsertBulk {
+ _c.conflict = opts
+ return &SubscriptionPlanGroupUpsertBulk{
+ create: _c,
+ }
+}
+
+// OnConflictColumns calls `OnConflict` and configures the columns
+// as conflict target. Using this option is equivalent to using:
+//
+// client.SubscriptionPlanGroup.Create().
+// OnConflict(sql.ConflictColumns(columns...)).
+// Exec(ctx)
+func (_c *SubscriptionPlanGroupCreateBulk) OnConflictColumns(columns ...string) *SubscriptionPlanGroupUpsertBulk {
+ _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...))
+ return &SubscriptionPlanGroupUpsertBulk{
+ create: _c,
+ }
+}
+
+// SubscriptionPlanGroupUpsertBulk is the builder for "upsert"-ing
+// a bulk of SubscriptionPlanGroup nodes.
+type SubscriptionPlanGroupUpsertBulk struct {
+ create *SubscriptionPlanGroupCreateBulk
+}
+
+// UpdateNewValues updates the mutable fields using the new values that
+// were set on create. Using this option is equivalent to using:
+//
+// client.SubscriptionPlanGroup.Create().
+// OnConflict(
+// sql.ResolveWithNewValues(),
+// ).
+// Exec(ctx)
+func (u *SubscriptionPlanGroupUpsertBulk) UpdateNewValues() *SubscriptionPlanGroupUpsertBulk {
+ u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues())
+ u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) {
+ for _, b := range u.create.builders {
+ if _, exists := b.mutation.CreatedAt(); exists {
+ s.SetIgnore(subscriptionplangroup.FieldCreatedAt)
+ }
+ }
+ }))
+ return u
+}
+
+// Ignore sets each column to itself in case of conflict.
+// Using this option is equivalent to using:
+//
+// client.SubscriptionPlanGroup.Create().
+// OnConflict(sql.ResolveWithIgnore()).
+// Exec(ctx)
+func (u *SubscriptionPlanGroupUpsertBulk) Ignore() *SubscriptionPlanGroupUpsertBulk {
+ u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore())
+ return u
+}
+
+// DoNothing configures the conflict_action to `DO NOTHING`.
+// Supported only by SQLite and PostgreSQL.
+func (u *SubscriptionPlanGroupUpsertBulk) DoNothing() *SubscriptionPlanGroupUpsertBulk {
+ u.create.conflict = append(u.create.conflict, sql.DoNothing())
+ return u
+}
+
+// Update allows overriding fields `UPDATE` values. See the SubscriptionPlanGroupCreateBulk.OnConflict
+// documentation for more info.
+func (u *SubscriptionPlanGroupUpsertBulk) Update(set func(*SubscriptionPlanGroupUpsert)) *SubscriptionPlanGroupUpsertBulk {
+ u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) {
+ set(&SubscriptionPlanGroupUpsert{UpdateSet: update})
+ }))
+ return u
+}
+
+// SetPlanID sets the "plan_id" field.
+func (u *SubscriptionPlanGroupUpsertBulk) SetPlanID(v int64) *SubscriptionPlanGroupUpsertBulk {
+ return u.Update(func(s *SubscriptionPlanGroupUpsert) {
+ s.SetPlanID(v)
+ })
+}
+
+// UpdatePlanID sets the "plan_id" field to the value that was provided on create.
+func (u *SubscriptionPlanGroupUpsertBulk) UpdatePlanID() *SubscriptionPlanGroupUpsertBulk {
+ return u.Update(func(s *SubscriptionPlanGroupUpsert) {
+ s.UpdatePlanID()
+ })
+}
+
+// SetGroupID sets the "group_id" field.
+func (u *SubscriptionPlanGroupUpsertBulk) SetGroupID(v int64) *SubscriptionPlanGroupUpsertBulk {
+ return u.Update(func(s *SubscriptionPlanGroupUpsert) {
+ s.SetGroupID(v)
+ })
+}
+
+// UpdateGroupID sets the "group_id" field to the value that was provided on create.
+func (u *SubscriptionPlanGroupUpsertBulk) UpdateGroupID() *SubscriptionPlanGroupUpsertBulk {
+ return u.Update(func(s *SubscriptionPlanGroupUpsert) {
+ s.UpdateGroupID()
+ })
+}
+
+// Exec executes the query.
+func (u *SubscriptionPlanGroupUpsertBulk) Exec(ctx context.Context) error {
+ if u.create.err != nil {
+ return u.create.err
+ }
+ for i, b := range u.create.builders {
+ if len(b.conflict) != 0 {
+ return fmt.Errorf("ent: OnConflict was set for builder %d. Set it on the SubscriptionPlanGroupCreateBulk instead", i)
+ }
+ }
+ if len(u.create.conflict) == 0 {
+ return errors.New("ent: missing options for SubscriptionPlanGroupCreateBulk.OnConflict")
+ }
+ return u.create.Exec(ctx)
+}
+
+// ExecX is like Exec, but panics if an error occurs.
+func (u *SubscriptionPlanGroupUpsertBulk) ExecX(ctx context.Context) {
+ if err := u.create.Exec(ctx); err != nil {
+ panic(err)
+ }
+}
diff --git a/backend/ent/subscriptionplangroup_delete.go b/backend/ent/subscriptionplangroup_delete.go
new file mode 100644
index 00000000000..f535ab41f7f
--- /dev/null
+++ b/backend/ent/subscriptionplangroup_delete.go
@@ -0,0 +1,88 @@
+// Code generated by ent, DO NOT EDIT.
+
+package ent
+
+import (
+ "context"
+
+ "entgo.io/ent/dialect/sql"
+ "entgo.io/ent/dialect/sql/sqlgraph"
+ "entgo.io/ent/schema/field"
+ "github.com/Wei-Shaw/sub2api/ent/predicate"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
+)
+
+// SubscriptionPlanGroupDelete is the builder for deleting a SubscriptionPlanGroup entity.
+type SubscriptionPlanGroupDelete struct {
+ config
+ hooks []Hook
+ mutation *SubscriptionPlanGroupMutation
+}
+
+// Where appends a list predicates to the SubscriptionPlanGroupDelete builder.
+func (_d *SubscriptionPlanGroupDelete) Where(ps ...predicate.SubscriptionPlanGroup) *SubscriptionPlanGroupDelete {
+ _d.mutation.Where(ps...)
+ return _d
+}
+
+// Exec executes the deletion query and returns how many vertices were deleted.
+func (_d *SubscriptionPlanGroupDelete) Exec(ctx context.Context) (int, error) {
+ return withHooks(ctx, _d.sqlExec, _d.mutation, _d.hooks)
+}
+
+// ExecX is like Exec, but panics if an error occurs.
+func (_d *SubscriptionPlanGroupDelete) ExecX(ctx context.Context) int {
+ n, err := _d.Exec(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return n
+}
+
+func (_d *SubscriptionPlanGroupDelete) sqlExec(ctx context.Context) (int, error) {
+ _spec := sqlgraph.NewDeleteSpec(subscriptionplangroup.Table, sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64))
+ if ps := _d.mutation.predicates; len(ps) > 0 {
+ _spec.Predicate = func(selector *sql.Selector) {
+ for i := range ps {
+ ps[i](selector)
+ }
+ }
+ }
+ affected, err := sqlgraph.DeleteNodes(ctx, _d.driver, _spec)
+ if err != nil && sqlgraph.IsConstraintError(err) {
+ err = &ConstraintError{msg: err.Error(), wrap: err}
+ }
+ _d.mutation.done = true
+ return affected, err
+}
+
+// SubscriptionPlanGroupDeleteOne is the builder for deleting a single SubscriptionPlanGroup entity.
+type SubscriptionPlanGroupDeleteOne struct {
+ _d *SubscriptionPlanGroupDelete
+}
+
+// Where appends a list predicates to the SubscriptionPlanGroupDelete builder.
+func (_d *SubscriptionPlanGroupDeleteOne) Where(ps ...predicate.SubscriptionPlanGroup) *SubscriptionPlanGroupDeleteOne {
+ _d._d.mutation.Where(ps...)
+ return _d
+}
+
+// Exec executes the deletion query.
+func (_d *SubscriptionPlanGroupDeleteOne) Exec(ctx context.Context) error {
+ n, err := _d._d.Exec(ctx)
+ switch {
+ case err != nil:
+ return err
+ case n == 0:
+ return &NotFoundError{subscriptionplangroup.Label}
+ default:
+ return nil
+ }
+}
+
+// ExecX is like Exec, but panics if an error occurs.
+func (_d *SubscriptionPlanGroupDeleteOne) ExecX(ctx context.Context) {
+ if err := _d.Exec(ctx); err != nil {
+ panic(err)
+ }
+}
diff --git a/backend/ent/subscriptionplangroup_query.go b/backend/ent/subscriptionplangroup_query.go
new file mode 100644
index 00000000000..158dbbf0604
--- /dev/null
+++ b/backend/ent/subscriptionplangroup_query.go
@@ -0,0 +1,718 @@
+// Code generated by ent, DO NOT EDIT.
+
+package ent
+
+import (
+ "context"
+ "fmt"
+ "math"
+
+ "entgo.io/ent"
+ "entgo.io/ent/dialect"
+ "entgo.io/ent/dialect/sql"
+ "entgo.io/ent/dialect/sql/sqlgraph"
+ "entgo.io/ent/schema/field"
+ "github.com/Wei-Shaw/sub2api/ent/group"
+ "github.com/Wei-Shaw/sub2api/ent/predicate"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
+)
+
+// SubscriptionPlanGroupQuery is the builder for querying SubscriptionPlanGroup entities.
+type SubscriptionPlanGroupQuery struct {
+ config
+ ctx *QueryContext
+ order []subscriptionplangroup.OrderOption
+ inters []Interceptor
+ predicates []predicate.SubscriptionPlanGroup
+ withPlan *SubscriptionPlanQuery
+ withGroup *GroupQuery
+ modifiers []func(*sql.Selector)
+ // intermediate query (i.e. traversal path).
+ sql *sql.Selector
+ path func(context.Context) (*sql.Selector, error)
+}
+
+// Where adds a new predicate for the SubscriptionPlanGroupQuery builder.
+func (_q *SubscriptionPlanGroupQuery) Where(ps ...predicate.SubscriptionPlanGroup) *SubscriptionPlanGroupQuery {
+ _q.predicates = append(_q.predicates, ps...)
+ return _q
+}
+
+// Limit the number of records to be returned by this query.
+func (_q *SubscriptionPlanGroupQuery) Limit(limit int) *SubscriptionPlanGroupQuery {
+ _q.ctx.Limit = &limit
+ return _q
+}
+
+// Offset to start from.
+func (_q *SubscriptionPlanGroupQuery) Offset(offset int) *SubscriptionPlanGroupQuery {
+ _q.ctx.Offset = &offset
+ return _q
+}
+
+// Unique configures the query builder to filter duplicate records on query.
+// By default, unique is set to true, and can be disabled using this method.
+func (_q *SubscriptionPlanGroupQuery) Unique(unique bool) *SubscriptionPlanGroupQuery {
+ _q.ctx.Unique = &unique
+ return _q
+}
+
+// Order specifies how the records should be ordered.
+func (_q *SubscriptionPlanGroupQuery) Order(o ...subscriptionplangroup.OrderOption) *SubscriptionPlanGroupQuery {
+ _q.order = append(_q.order, o...)
+ return _q
+}
+
+// QueryPlan chains the current query on the "plan" edge.
+func (_q *SubscriptionPlanGroupQuery) QueryPlan() *SubscriptionPlanQuery {
+ query := (&SubscriptionPlanClient{config: _q.config}).Query()
+ query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
+ if err := _q.prepareQuery(ctx); err != nil {
+ return nil, err
+ }
+ selector := _q.sqlQuery(ctx)
+ if err := selector.Err(); err != nil {
+ return nil, err
+ }
+ step := sqlgraph.NewStep(
+ sqlgraph.From(subscriptionplangroup.Table, subscriptionplangroup.FieldID, selector),
+ sqlgraph.To(subscriptionplan.Table, subscriptionplan.FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, subscriptionplangroup.PlanTable, subscriptionplangroup.PlanColumn),
+ )
+ fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
+ return fromU, nil
+ }
+ return query
+}
+
+// QueryGroup chains the current query on the "group" edge.
+func (_q *SubscriptionPlanGroupQuery) QueryGroup() *GroupQuery {
+ query := (&GroupClient{config: _q.config}).Query()
+ query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
+ if err := _q.prepareQuery(ctx); err != nil {
+ return nil, err
+ }
+ selector := _q.sqlQuery(ctx)
+ if err := selector.Err(); err != nil {
+ return nil, err
+ }
+ step := sqlgraph.NewStep(
+ sqlgraph.From(subscriptionplangroup.Table, subscriptionplangroup.FieldID, selector),
+ sqlgraph.To(group.Table, group.FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, subscriptionplangroup.GroupTable, subscriptionplangroup.GroupColumn),
+ )
+ fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
+ return fromU, nil
+ }
+ return query
+}
+
+// First returns the first SubscriptionPlanGroup entity from the query.
+// Returns a *NotFoundError when no SubscriptionPlanGroup was found.
+func (_q *SubscriptionPlanGroupQuery) First(ctx context.Context) (*SubscriptionPlanGroup, error) {
+ nodes, err := _q.Limit(1).All(setContextOp(ctx, _q.ctx, ent.OpQueryFirst))
+ if err != nil {
+ return nil, err
+ }
+ if len(nodes) == 0 {
+ return nil, &NotFoundError{subscriptionplangroup.Label}
+ }
+ return nodes[0], nil
+}
+
+// FirstX is like First, but panics if an error occurs.
+func (_q *SubscriptionPlanGroupQuery) FirstX(ctx context.Context) *SubscriptionPlanGroup {
+ node, err := _q.First(ctx)
+ if err != nil && !IsNotFound(err) {
+ panic(err)
+ }
+ return node
+}
+
+// FirstID returns the first SubscriptionPlanGroup ID from the query.
+// Returns a *NotFoundError when no SubscriptionPlanGroup ID was found.
+func (_q *SubscriptionPlanGroupQuery) FirstID(ctx context.Context) (id int64, err error) {
+ var ids []int64
+ if ids, err = _q.Limit(1).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryFirstID)); err != nil {
+ return
+ }
+ if len(ids) == 0 {
+ err = &NotFoundError{subscriptionplangroup.Label}
+ return
+ }
+ return ids[0], nil
+}
+
+// FirstIDX is like FirstID, but panics if an error occurs.
+func (_q *SubscriptionPlanGroupQuery) FirstIDX(ctx context.Context) int64 {
+ id, err := _q.FirstID(ctx)
+ if err != nil && !IsNotFound(err) {
+ panic(err)
+ }
+ return id
+}
+
+// Only returns a single SubscriptionPlanGroup entity found by the query, ensuring it only returns one.
+// Returns a *NotSingularError when more than one SubscriptionPlanGroup entity is found.
+// Returns a *NotFoundError when no SubscriptionPlanGroup entities are found.
+func (_q *SubscriptionPlanGroupQuery) Only(ctx context.Context) (*SubscriptionPlanGroup, error) {
+ nodes, err := _q.Limit(2).All(setContextOp(ctx, _q.ctx, ent.OpQueryOnly))
+ if err != nil {
+ return nil, err
+ }
+ switch len(nodes) {
+ case 1:
+ return nodes[0], nil
+ case 0:
+ return nil, &NotFoundError{subscriptionplangroup.Label}
+ default:
+ return nil, &NotSingularError{subscriptionplangroup.Label}
+ }
+}
+
+// OnlyX is like Only, but panics if an error occurs.
+func (_q *SubscriptionPlanGroupQuery) OnlyX(ctx context.Context) *SubscriptionPlanGroup {
+ node, err := _q.Only(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return node
+}
+
+// OnlyID is like Only, but returns the only SubscriptionPlanGroup ID in the query.
+// Returns a *NotSingularError when more than one SubscriptionPlanGroup ID is found.
+// Returns a *NotFoundError when no entities are found.
+func (_q *SubscriptionPlanGroupQuery) OnlyID(ctx context.Context) (id int64, err error) {
+ var ids []int64
+ if ids, err = _q.Limit(2).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryOnlyID)); err != nil {
+ return
+ }
+ switch len(ids) {
+ case 1:
+ id = ids[0]
+ case 0:
+ err = &NotFoundError{subscriptionplangroup.Label}
+ default:
+ err = &NotSingularError{subscriptionplangroup.Label}
+ }
+ return
+}
+
+// OnlyIDX is like OnlyID, but panics if an error occurs.
+func (_q *SubscriptionPlanGroupQuery) OnlyIDX(ctx context.Context) int64 {
+ id, err := _q.OnlyID(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return id
+}
+
+// All executes the query and returns a list of SubscriptionPlanGroups.
+func (_q *SubscriptionPlanGroupQuery) All(ctx context.Context) ([]*SubscriptionPlanGroup, error) {
+ ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll)
+ if err := _q.prepareQuery(ctx); err != nil {
+ return nil, err
+ }
+ qr := querierAll[[]*SubscriptionPlanGroup, *SubscriptionPlanGroupQuery]()
+ return withInterceptors[[]*SubscriptionPlanGroup](ctx, _q, qr, _q.inters)
+}
+
+// AllX is like All, but panics if an error occurs.
+func (_q *SubscriptionPlanGroupQuery) AllX(ctx context.Context) []*SubscriptionPlanGroup {
+ nodes, err := _q.All(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return nodes
+}
+
+// IDs executes the query and returns a list of SubscriptionPlanGroup IDs.
+func (_q *SubscriptionPlanGroupQuery) IDs(ctx context.Context) (ids []int64, err error) {
+ if _q.ctx.Unique == nil && _q.path != nil {
+ _q.Unique(true)
+ }
+ ctx = setContextOp(ctx, _q.ctx, ent.OpQueryIDs)
+ if err = _q.Select(subscriptionplangroup.FieldID).Scan(ctx, &ids); err != nil {
+ return nil, err
+ }
+ return ids, nil
+}
+
+// IDsX is like IDs, but panics if an error occurs.
+func (_q *SubscriptionPlanGroupQuery) IDsX(ctx context.Context) []int64 {
+ ids, err := _q.IDs(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return ids
+}
+
+// Count returns the count of the given query.
+func (_q *SubscriptionPlanGroupQuery) Count(ctx context.Context) (int, error) {
+ ctx = setContextOp(ctx, _q.ctx, ent.OpQueryCount)
+ if err := _q.prepareQuery(ctx); err != nil {
+ return 0, err
+ }
+ return withInterceptors[int](ctx, _q, querierCount[*SubscriptionPlanGroupQuery](), _q.inters)
+}
+
+// CountX is like Count, but panics if an error occurs.
+func (_q *SubscriptionPlanGroupQuery) CountX(ctx context.Context) int {
+ count, err := _q.Count(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return count
+}
+
+// Exist returns true if the query has elements in the graph.
+func (_q *SubscriptionPlanGroupQuery) Exist(ctx context.Context) (bool, error) {
+ ctx = setContextOp(ctx, _q.ctx, ent.OpQueryExist)
+ switch _, err := _q.FirstID(ctx); {
+ case IsNotFound(err):
+ return false, nil
+ case err != nil:
+ return false, fmt.Errorf("ent: check existence: %w", err)
+ default:
+ return true, nil
+ }
+}
+
+// ExistX is like Exist, but panics if an error occurs.
+func (_q *SubscriptionPlanGroupQuery) ExistX(ctx context.Context) bool {
+ exist, err := _q.Exist(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return exist
+}
+
+// Clone returns a duplicate of the SubscriptionPlanGroupQuery builder, including all associated steps. It can be
+// used to prepare common query builders and use them differently after the clone is made.
+func (_q *SubscriptionPlanGroupQuery) Clone() *SubscriptionPlanGroupQuery {
+ if _q == nil {
+ return nil
+ }
+ return &SubscriptionPlanGroupQuery{
+ config: _q.config,
+ ctx: _q.ctx.Clone(),
+ order: append([]subscriptionplangroup.OrderOption{}, _q.order...),
+ inters: append([]Interceptor{}, _q.inters...),
+ predicates: append([]predicate.SubscriptionPlanGroup{}, _q.predicates...),
+ withPlan: _q.withPlan.Clone(),
+ withGroup: _q.withGroup.Clone(),
+ // clone intermediate query.
+ sql: _q.sql.Clone(),
+ path: _q.path,
+ }
+}
+
+// WithPlan tells the query-builder to eager-load the nodes that are connected to
+// the "plan" edge. The optional arguments are used to configure the query builder of the edge.
+func (_q *SubscriptionPlanGroupQuery) WithPlan(opts ...func(*SubscriptionPlanQuery)) *SubscriptionPlanGroupQuery {
+ query := (&SubscriptionPlanClient{config: _q.config}).Query()
+ for _, opt := range opts {
+ opt(query)
+ }
+ _q.withPlan = query
+ return _q
+}
+
+// WithGroup tells the query-builder to eager-load the nodes that are connected to
+// the "group" edge. The optional arguments are used to configure the query builder of the edge.
+func (_q *SubscriptionPlanGroupQuery) WithGroup(opts ...func(*GroupQuery)) *SubscriptionPlanGroupQuery {
+ query := (&GroupClient{config: _q.config}).Query()
+ for _, opt := range opts {
+ opt(query)
+ }
+ _q.withGroup = query
+ return _q
+}
+
+// GroupBy is used to group vertices by one or more fields/columns.
+// It is often used with aggregate functions, like: count, max, mean, min, sum.
+//
+// Example:
+//
+// var v []struct {
+// PlanID int64 `json:"plan_id,omitempty"`
+// Count int `json:"count,omitempty"`
+// }
+//
+// client.SubscriptionPlanGroup.Query().
+// GroupBy(subscriptionplangroup.FieldPlanID).
+// Aggregate(ent.Count()).
+// Scan(ctx, &v)
+func (_q *SubscriptionPlanGroupQuery) GroupBy(field string, fields ...string) *SubscriptionPlanGroupGroupBy {
+ _q.ctx.Fields = append([]string{field}, fields...)
+ grbuild := &SubscriptionPlanGroupGroupBy{build: _q}
+ grbuild.flds = &_q.ctx.Fields
+ grbuild.label = subscriptionplangroup.Label
+ grbuild.scan = grbuild.Scan
+ return grbuild
+}
+
+// Select allows the selection one or more fields/columns for the given query,
+// instead of selecting all fields in the entity.
+//
+// Example:
+//
+// var v []struct {
+// PlanID int64 `json:"plan_id,omitempty"`
+// }
+//
+// client.SubscriptionPlanGroup.Query().
+// Select(subscriptionplangroup.FieldPlanID).
+// Scan(ctx, &v)
+func (_q *SubscriptionPlanGroupQuery) Select(fields ...string) *SubscriptionPlanGroupSelect {
+ _q.ctx.Fields = append(_q.ctx.Fields, fields...)
+ sbuild := &SubscriptionPlanGroupSelect{SubscriptionPlanGroupQuery: _q}
+ sbuild.label = subscriptionplangroup.Label
+ sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan
+ return sbuild
+}
+
+// Aggregate returns a SubscriptionPlanGroupSelect configured with the given aggregations.
+func (_q *SubscriptionPlanGroupQuery) Aggregate(fns ...AggregateFunc) *SubscriptionPlanGroupSelect {
+ return _q.Select().Aggregate(fns...)
+}
+
+func (_q *SubscriptionPlanGroupQuery) prepareQuery(ctx context.Context) error {
+ for _, inter := range _q.inters {
+ if inter == nil {
+ return fmt.Errorf("ent: uninitialized interceptor (forgotten import ent/runtime?)")
+ }
+ if trv, ok := inter.(Traverser); ok {
+ if err := trv.Traverse(ctx, _q); err != nil {
+ return err
+ }
+ }
+ }
+ for _, f := range _q.ctx.Fields {
+ if !subscriptionplangroup.ValidColumn(f) {
+ return &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)}
+ }
+ }
+ if _q.path != nil {
+ prev, err := _q.path(ctx)
+ if err != nil {
+ return err
+ }
+ _q.sql = prev
+ }
+ return nil
+}
+
+func (_q *SubscriptionPlanGroupQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*SubscriptionPlanGroup, error) {
+ var (
+ nodes = []*SubscriptionPlanGroup{}
+ _spec = _q.querySpec()
+ loadedTypes = [2]bool{
+ _q.withPlan != nil,
+ _q.withGroup != nil,
+ }
+ )
+ _spec.ScanValues = func(columns []string) ([]any, error) {
+ return (*SubscriptionPlanGroup).scanValues(nil, columns)
+ }
+ _spec.Assign = func(columns []string, values []any) error {
+ node := &SubscriptionPlanGroup{config: _q.config}
+ nodes = append(nodes, node)
+ node.Edges.loadedTypes = loadedTypes
+ return node.assignValues(columns, values)
+ }
+ if len(_q.modifiers) > 0 {
+ _spec.Modifiers = _q.modifiers
+ }
+ for i := range hooks {
+ hooks[i](ctx, _spec)
+ }
+ if err := sqlgraph.QueryNodes(ctx, _q.driver, _spec); err != nil {
+ return nil, err
+ }
+ if len(nodes) == 0 {
+ return nodes, nil
+ }
+ if query := _q.withPlan; query != nil {
+ if err := _q.loadPlan(ctx, query, nodes, nil,
+ func(n *SubscriptionPlanGroup, e *SubscriptionPlan) { n.Edges.Plan = e }); err != nil {
+ return nil, err
+ }
+ }
+ if query := _q.withGroup; query != nil {
+ if err := _q.loadGroup(ctx, query, nodes, nil,
+ func(n *SubscriptionPlanGroup, e *Group) { n.Edges.Group = e }); err != nil {
+ return nil, err
+ }
+ }
+ return nodes, nil
+}
+
+func (_q *SubscriptionPlanGroupQuery) loadPlan(ctx context.Context, query *SubscriptionPlanQuery, nodes []*SubscriptionPlanGroup, init func(*SubscriptionPlanGroup), assign func(*SubscriptionPlanGroup, *SubscriptionPlan)) error {
+ ids := make([]int64, 0, len(nodes))
+ nodeids := make(map[int64][]*SubscriptionPlanGroup)
+ for i := range nodes {
+ fk := nodes[i].PlanID
+ if _, ok := nodeids[fk]; !ok {
+ ids = append(ids, fk)
+ }
+ nodeids[fk] = append(nodeids[fk], nodes[i])
+ }
+ if len(ids) == 0 {
+ return nil
+ }
+ query.Where(subscriptionplan.IDIn(ids...))
+ neighbors, err := query.All(ctx)
+ if err != nil {
+ return err
+ }
+ for _, n := range neighbors {
+ nodes, ok := nodeids[n.ID]
+ if !ok {
+ return fmt.Errorf(`unexpected foreign-key "plan_id" returned %v`, n.ID)
+ }
+ for i := range nodes {
+ assign(nodes[i], n)
+ }
+ }
+ return nil
+}
+func (_q *SubscriptionPlanGroupQuery) loadGroup(ctx context.Context, query *GroupQuery, nodes []*SubscriptionPlanGroup, init func(*SubscriptionPlanGroup), assign func(*SubscriptionPlanGroup, *Group)) error {
+ ids := make([]int64, 0, len(nodes))
+ nodeids := make(map[int64][]*SubscriptionPlanGroup)
+ for i := range nodes {
+ fk := nodes[i].GroupID
+ if _, ok := nodeids[fk]; !ok {
+ ids = append(ids, fk)
+ }
+ nodeids[fk] = append(nodeids[fk], nodes[i])
+ }
+ if len(ids) == 0 {
+ return nil
+ }
+ query.Where(group.IDIn(ids...))
+ neighbors, err := query.All(ctx)
+ if err != nil {
+ return err
+ }
+ for _, n := range neighbors {
+ nodes, ok := nodeids[n.ID]
+ if !ok {
+ return fmt.Errorf(`unexpected foreign-key "group_id" returned %v`, n.ID)
+ }
+ for i := range nodes {
+ assign(nodes[i], n)
+ }
+ }
+ return nil
+}
+
+func (_q *SubscriptionPlanGroupQuery) sqlCount(ctx context.Context) (int, error) {
+ _spec := _q.querySpec()
+ if len(_q.modifiers) > 0 {
+ _spec.Modifiers = _q.modifiers
+ }
+ _spec.Node.Columns = _q.ctx.Fields
+ if len(_q.ctx.Fields) > 0 {
+ _spec.Unique = _q.ctx.Unique != nil && *_q.ctx.Unique
+ }
+ return sqlgraph.CountNodes(ctx, _q.driver, _spec)
+}
+
+func (_q *SubscriptionPlanGroupQuery) querySpec() *sqlgraph.QuerySpec {
+ _spec := sqlgraph.NewQuerySpec(subscriptionplangroup.Table, subscriptionplangroup.Columns, sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64))
+ _spec.From = _q.sql
+ if unique := _q.ctx.Unique; unique != nil {
+ _spec.Unique = *unique
+ } else if _q.path != nil {
+ _spec.Unique = true
+ }
+ if fields := _q.ctx.Fields; len(fields) > 0 {
+ _spec.Node.Columns = make([]string, 0, len(fields))
+ _spec.Node.Columns = append(_spec.Node.Columns, subscriptionplangroup.FieldID)
+ for i := range fields {
+ if fields[i] != subscriptionplangroup.FieldID {
+ _spec.Node.Columns = append(_spec.Node.Columns, fields[i])
+ }
+ }
+ if _q.withPlan != nil {
+ _spec.Node.AddColumnOnce(subscriptionplangroup.FieldPlanID)
+ }
+ if _q.withGroup != nil {
+ _spec.Node.AddColumnOnce(subscriptionplangroup.FieldGroupID)
+ }
+ }
+ if ps := _q.predicates; len(ps) > 0 {
+ _spec.Predicate = func(selector *sql.Selector) {
+ for i := range ps {
+ ps[i](selector)
+ }
+ }
+ }
+ if limit := _q.ctx.Limit; limit != nil {
+ _spec.Limit = *limit
+ }
+ if offset := _q.ctx.Offset; offset != nil {
+ _spec.Offset = *offset
+ }
+ if ps := _q.order; len(ps) > 0 {
+ _spec.Order = func(selector *sql.Selector) {
+ for i := range ps {
+ ps[i](selector)
+ }
+ }
+ }
+ return _spec
+}
+
+func (_q *SubscriptionPlanGroupQuery) sqlQuery(ctx context.Context) *sql.Selector {
+ builder := sql.Dialect(_q.driver.Dialect())
+ t1 := builder.Table(subscriptionplangroup.Table)
+ columns := _q.ctx.Fields
+ if len(columns) == 0 {
+ columns = subscriptionplangroup.Columns
+ }
+ selector := builder.Select(t1.Columns(columns...)...).From(t1)
+ if _q.sql != nil {
+ selector = _q.sql
+ selector.Select(selector.Columns(columns...)...)
+ }
+ if _q.ctx.Unique != nil && *_q.ctx.Unique {
+ selector.Distinct()
+ }
+ for _, m := range _q.modifiers {
+ m(selector)
+ }
+ for _, p := range _q.predicates {
+ p(selector)
+ }
+ for _, p := range _q.order {
+ p(selector)
+ }
+ if offset := _q.ctx.Offset; offset != nil {
+ // limit is mandatory for offset clause. We start
+ // with default value, and override it below if needed.
+ selector.Offset(*offset).Limit(math.MaxInt32)
+ }
+ if limit := _q.ctx.Limit; limit != nil {
+ selector.Limit(*limit)
+ }
+ return selector
+}
+
+// ForUpdate locks the selected rows against concurrent updates, and prevent them from being
+// updated, deleted or "selected ... for update" by other sessions, until the transaction is
+// either committed or rolled-back.
+func (_q *SubscriptionPlanGroupQuery) ForUpdate(opts ...sql.LockOption) *SubscriptionPlanGroupQuery {
+ if _q.driver.Dialect() == dialect.Postgres {
+ _q.Unique(false)
+ }
+ _q.modifiers = append(_q.modifiers, func(s *sql.Selector) {
+ s.ForUpdate(opts...)
+ })
+ return _q
+}
+
+// ForShare behaves similarly to ForUpdate, except that it acquires a shared mode lock
+// on any rows that are read. Other sessions can read the rows, but cannot modify them
+// until your transaction commits.
+func (_q *SubscriptionPlanGroupQuery) ForShare(opts ...sql.LockOption) *SubscriptionPlanGroupQuery {
+ if _q.driver.Dialect() == dialect.Postgres {
+ _q.Unique(false)
+ }
+ _q.modifiers = append(_q.modifiers, func(s *sql.Selector) {
+ s.ForShare(opts...)
+ })
+ return _q
+}
+
+// SubscriptionPlanGroupGroupBy is the group-by builder for SubscriptionPlanGroup entities.
+type SubscriptionPlanGroupGroupBy struct {
+ selector
+ build *SubscriptionPlanGroupQuery
+}
+
+// Aggregate adds the given aggregation functions to the group-by query.
+func (_g *SubscriptionPlanGroupGroupBy) Aggregate(fns ...AggregateFunc) *SubscriptionPlanGroupGroupBy {
+ _g.fns = append(_g.fns, fns...)
+ return _g
+}
+
+// Scan applies the selector query and scans the result into the given value.
+func (_g *SubscriptionPlanGroupGroupBy) Scan(ctx context.Context, v any) error {
+ ctx = setContextOp(ctx, _g.build.ctx, ent.OpQueryGroupBy)
+ if err := _g.build.prepareQuery(ctx); err != nil {
+ return err
+ }
+ return scanWithInterceptors[*SubscriptionPlanGroupQuery, *SubscriptionPlanGroupGroupBy](ctx, _g.build, _g, _g.build.inters, v)
+}
+
+func (_g *SubscriptionPlanGroupGroupBy) sqlScan(ctx context.Context, root *SubscriptionPlanGroupQuery, v any) error {
+ selector := root.sqlQuery(ctx).Select()
+ aggregation := make([]string, 0, len(_g.fns))
+ for _, fn := range _g.fns {
+ aggregation = append(aggregation, fn(selector))
+ }
+ if len(selector.SelectedColumns()) == 0 {
+ columns := make([]string, 0, len(*_g.flds)+len(_g.fns))
+ for _, f := range *_g.flds {
+ columns = append(columns, selector.C(f))
+ }
+ columns = append(columns, aggregation...)
+ selector.Select(columns...)
+ }
+ selector.GroupBy(selector.Columns(*_g.flds...)...)
+ if err := selector.Err(); err != nil {
+ return err
+ }
+ rows := &sql.Rows{}
+ query, args := selector.Query()
+ if err := _g.build.driver.Query(ctx, query, args, rows); err != nil {
+ return err
+ }
+ defer rows.Close()
+ return sql.ScanSlice(rows, v)
+}
+
+// SubscriptionPlanGroupSelect is the builder for selecting fields of SubscriptionPlanGroup entities.
+type SubscriptionPlanGroupSelect struct {
+ *SubscriptionPlanGroupQuery
+ selector
+}
+
+// Aggregate adds the given aggregation functions to the selector query.
+func (_s *SubscriptionPlanGroupSelect) Aggregate(fns ...AggregateFunc) *SubscriptionPlanGroupSelect {
+ _s.fns = append(_s.fns, fns...)
+ return _s
+}
+
+// Scan applies the selector query and scans the result into the given value.
+func (_s *SubscriptionPlanGroupSelect) Scan(ctx context.Context, v any) error {
+ ctx = setContextOp(ctx, _s.ctx, ent.OpQuerySelect)
+ if err := _s.prepareQuery(ctx); err != nil {
+ return err
+ }
+ return scanWithInterceptors[*SubscriptionPlanGroupQuery, *SubscriptionPlanGroupSelect](ctx, _s.SubscriptionPlanGroupQuery, _s, _s.inters, v)
+}
+
+func (_s *SubscriptionPlanGroupSelect) sqlScan(ctx context.Context, root *SubscriptionPlanGroupQuery, v any) error {
+ selector := root.sqlQuery(ctx)
+ aggregation := make([]string, 0, len(_s.fns))
+ for _, fn := range _s.fns {
+ aggregation = append(aggregation, fn(selector))
+ }
+ switch n := len(*_s.selector.flds); {
+ case n == 0 && len(aggregation) > 0:
+ selector.Select(aggregation...)
+ case n != 0 && len(aggregation) > 0:
+ selector.AppendSelect(aggregation...)
+ }
+ rows := &sql.Rows{}
+ query, args := selector.Query()
+ if err := _s.driver.Query(ctx, query, args, rows); err != nil {
+ return err
+ }
+ defer rows.Close()
+ return sql.ScanSlice(rows, v)
+}
diff --git a/backend/ent/subscriptionplangroup_update.go b/backend/ent/subscriptionplangroup_update.go
new file mode 100644
index 00000000000..187cfd0a3ed
--- /dev/null
+++ b/backend/ent/subscriptionplangroup_update.go
@@ -0,0 +1,421 @@
+// Code generated by ent, DO NOT EDIT.
+
+package ent
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "entgo.io/ent/dialect/sql"
+ "entgo.io/ent/dialect/sql/sqlgraph"
+ "entgo.io/ent/schema/field"
+ "github.com/Wei-Shaw/sub2api/ent/group"
+ "github.com/Wei-Shaw/sub2api/ent/predicate"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
+)
+
+// SubscriptionPlanGroupUpdate is the builder for updating SubscriptionPlanGroup entities.
+type SubscriptionPlanGroupUpdate struct {
+ config
+ hooks []Hook
+ mutation *SubscriptionPlanGroupMutation
+}
+
+// Where appends a list predicates to the SubscriptionPlanGroupUpdate builder.
+func (_u *SubscriptionPlanGroupUpdate) Where(ps ...predicate.SubscriptionPlanGroup) *SubscriptionPlanGroupUpdate {
+ _u.mutation.Where(ps...)
+ return _u
+}
+
+// SetPlanID sets the "plan_id" field.
+func (_u *SubscriptionPlanGroupUpdate) SetPlanID(v int64) *SubscriptionPlanGroupUpdate {
+ _u.mutation.SetPlanID(v)
+ return _u
+}
+
+// SetNillablePlanID sets the "plan_id" field if the given value is not nil.
+func (_u *SubscriptionPlanGroupUpdate) SetNillablePlanID(v *int64) *SubscriptionPlanGroupUpdate {
+ if v != nil {
+ _u.SetPlanID(*v)
+ }
+ return _u
+}
+
+// SetGroupID sets the "group_id" field.
+func (_u *SubscriptionPlanGroupUpdate) SetGroupID(v int64) *SubscriptionPlanGroupUpdate {
+ _u.mutation.SetGroupID(v)
+ return _u
+}
+
+// SetNillableGroupID sets the "group_id" field if the given value is not nil.
+func (_u *SubscriptionPlanGroupUpdate) SetNillableGroupID(v *int64) *SubscriptionPlanGroupUpdate {
+ if v != nil {
+ _u.SetGroupID(*v)
+ }
+ return _u
+}
+
+// SetPlan sets the "plan" edge to the SubscriptionPlan entity.
+func (_u *SubscriptionPlanGroupUpdate) SetPlan(v *SubscriptionPlan) *SubscriptionPlanGroupUpdate {
+ return _u.SetPlanID(v.ID)
+}
+
+// SetGroup sets the "group" edge to the Group entity.
+func (_u *SubscriptionPlanGroupUpdate) SetGroup(v *Group) *SubscriptionPlanGroupUpdate {
+ return _u.SetGroupID(v.ID)
+}
+
+// Mutation returns the SubscriptionPlanGroupMutation object of the builder.
+func (_u *SubscriptionPlanGroupUpdate) Mutation() *SubscriptionPlanGroupMutation {
+ return _u.mutation
+}
+
+// ClearPlan clears the "plan" edge to the SubscriptionPlan entity.
+func (_u *SubscriptionPlanGroupUpdate) ClearPlan() *SubscriptionPlanGroupUpdate {
+ _u.mutation.ClearPlan()
+ return _u
+}
+
+// ClearGroup clears the "group" edge to the Group entity.
+func (_u *SubscriptionPlanGroupUpdate) ClearGroup() *SubscriptionPlanGroupUpdate {
+ _u.mutation.ClearGroup()
+ return _u
+}
+
+// Save executes the query and returns the number of nodes affected by the update operation.
+func (_u *SubscriptionPlanGroupUpdate) Save(ctx context.Context) (int, error) {
+ return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks)
+}
+
+// SaveX is like Save, but panics if an error occurs.
+func (_u *SubscriptionPlanGroupUpdate) SaveX(ctx context.Context) int {
+ affected, err := _u.Save(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return affected
+}
+
+// Exec executes the query.
+func (_u *SubscriptionPlanGroupUpdate) Exec(ctx context.Context) error {
+ _, err := _u.Save(ctx)
+ return err
+}
+
+// ExecX is like Exec, but panics if an error occurs.
+func (_u *SubscriptionPlanGroupUpdate) ExecX(ctx context.Context) {
+ if err := _u.Exec(ctx); err != nil {
+ panic(err)
+ }
+}
+
+// check runs all checks and user-defined validators on the builder.
+func (_u *SubscriptionPlanGroupUpdate) check() error {
+ if _u.mutation.PlanCleared() && len(_u.mutation.PlanIDs()) > 0 {
+ return errors.New(`ent: clearing a required unique edge "SubscriptionPlanGroup.plan"`)
+ }
+ if _u.mutation.GroupCleared() && len(_u.mutation.GroupIDs()) > 0 {
+ return errors.New(`ent: clearing a required unique edge "SubscriptionPlanGroup.group"`)
+ }
+ return nil
+}
+
+func (_u *SubscriptionPlanGroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
+ if err := _u.check(); err != nil {
+ return _node, err
+ }
+ _spec := sqlgraph.NewUpdateSpec(subscriptionplangroup.Table, subscriptionplangroup.Columns, sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64))
+ if ps := _u.mutation.predicates; len(ps) > 0 {
+ _spec.Predicate = func(selector *sql.Selector) {
+ for i := range ps {
+ ps[i](selector)
+ }
+ }
+ }
+ if _u.mutation.PlanCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionplangroup.PlanTable,
+ Columns: []string{subscriptionplangroup.PlanColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplan.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.PlanIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionplangroup.PlanTable,
+ Columns: []string{subscriptionplangroup.PlanColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplan.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
+ if _u.mutation.GroupCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionplangroup.GroupTable,
+ Columns: []string{subscriptionplangroup.GroupColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.GroupIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionplangroup.GroupTable,
+ Columns: []string{subscriptionplangroup.GroupColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
+ if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil {
+ if _, ok := err.(*sqlgraph.NotFoundError); ok {
+ err = &NotFoundError{subscriptionplangroup.Label}
+ } else if sqlgraph.IsConstraintError(err) {
+ err = &ConstraintError{msg: err.Error(), wrap: err}
+ }
+ return 0, err
+ }
+ _u.mutation.done = true
+ return _node, nil
+}
+
+// SubscriptionPlanGroupUpdateOne is the builder for updating a single SubscriptionPlanGroup entity.
+type SubscriptionPlanGroupUpdateOne struct {
+ config
+ fields []string
+ hooks []Hook
+ mutation *SubscriptionPlanGroupMutation
+}
+
+// SetPlanID sets the "plan_id" field.
+func (_u *SubscriptionPlanGroupUpdateOne) SetPlanID(v int64) *SubscriptionPlanGroupUpdateOne {
+ _u.mutation.SetPlanID(v)
+ return _u
+}
+
+// SetNillablePlanID sets the "plan_id" field if the given value is not nil.
+func (_u *SubscriptionPlanGroupUpdateOne) SetNillablePlanID(v *int64) *SubscriptionPlanGroupUpdateOne {
+ if v != nil {
+ _u.SetPlanID(*v)
+ }
+ return _u
+}
+
+// SetGroupID sets the "group_id" field.
+func (_u *SubscriptionPlanGroupUpdateOne) SetGroupID(v int64) *SubscriptionPlanGroupUpdateOne {
+ _u.mutation.SetGroupID(v)
+ return _u
+}
+
+// SetNillableGroupID sets the "group_id" field if the given value is not nil.
+func (_u *SubscriptionPlanGroupUpdateOne) SetNillableGroupID(v *int64) *SubscriptionPlanGroupUpdateOne {
+ if v != nil {
+ _u.SetGroupID(*v)
+ }
+ return _u
+}
+
+// SetPlan sets the "plan" edge to the SubscriptionPlan entity.
+func (_u *SubscriptionPlanGroupUpdateOne) SetPlan(v *SubscriptionPlan) *SubscriptionPlanGroupUpdateOne {
+ return _u.SetPlanID(v.ID)
+}
+
+// SetGroup sets the "group" edge to the Group entity.
+func (_u *SubscriptionPlanGroupUpdateOne) SetGroup(v *Group) *SubscriptionPlanGroupUpdateOne {
+ return _u.SetGroupID(v.ID)
+}
+
+// Mutation returns the SubscriptionPlanGroupMutation object of the builder.
+func (_u *SubscriptionPlanGroupUpdateOne) Mutation() *SubscriptionPlanGroupMutation {
+ return _u.mutation
+}
+
+// ClearPlan clears the "plan" edge to the SubscriptionPlan entity.
+func (_u *SubscriptionPlanGroupUpdateOne) ClearPlan() *SubscriptionPlanGroupUpdateOne {
+ _u.mutation.ClearPlan()
+ return _u
+}
+
+// ClearGroup clears the "group" edge to the Group entity.
+func (_u *SubscriptionPlanGroupUpdateOne) ClearGroup() *SubscriptionPlanGroupUpdateOne {
+ _u.mutation.ClearGroup()
+ return _u
+}
+
+// Where appends a list predicates to the SubscriptionPlanGroupUpdate builder.
+func (_u *SubscriptionPlanGroupUpdateOne) Where(ps ...predicate.SubscriptionPlanGroup) *SubscriptionPlanGroupUpdateOne {
+ _u.mutation.Where(ps...)
+ return _u
+}
+
+// Select allows selecting one or more fields (columns) of the returned entity.
+// The default is selecting all fields defined in the entity schema.
+func (_u *SubscriptionPlanGroupUpdateOne) Select(field string, fields ...string) *SubscriptionPlanGroupUpdateOne {
+ _u.fields = append([]string{field}, fields...)
+ return _u
+}
+
+// Save executes the query and returns the updated SubscriptionPlanGroup entity.
+func (_u *SubscriptionPlanGroupUpdateOne) Save(ctx context.Context) (*SubscriptionPlanGroup, error) {
+ return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks)
+}
+
+// SaveX is like Save, but panics if an error occurs.
+func (_u *SubscriptionPlanGroupUpdateOne) SaveX(ctx context.Context) *SubscriptionPlanGroup {
+ node, err := _u.Save(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return node
+}
+
+// Exec executes the query on the entity.
+func (_u *SubscriptionPlanGroupUpdateOne) Exec(ctx context.Context) error {
+ _, err := _u.Save(ctx)
+ return err
+}
+
+// ExecX is like Exec, but panics if an error occurs.
+func (_u *SubscriptionPlanGroupUpdateOne) ExecX(ctx context.Context) {
+ if err := _u.Exec(ctx); err != nil {
+ panic(err)
+ }
+}
+
+// check runs all checks and user-defined validators on the builder.
+func (_u *SubscriptionPlanGroupUpdateOne) check() error {
+ if _u.mutation.PlanCleared() && len(_u.mutation.PlanIDs()) > 0 {
+ return errors.New(`ent: clearing a required unique edge "SubscriptionPlanGroup.plan"`)
+ }
+ if _u.mutation.GroupCleared() && len(_u.mutation.GroupIDs()) > 0 {
+ return errors.New(`ent: clearing a required unique edge "SubscriptionPlanGroup.group"`)
+ }
+ return nil
+}
+
+func (_u *SubscriptionPlanGroupUpdateOne) sqlSave(ctx context.Context) (_node *SubscriptionPlanGroup, err error) {
+ if err := _u.check(); err != nil {
+ return _node, err
+ }
+ _spec := sqlgraph.NewUpdateSpec(subscriptionplangroup.Table, subscriptionplangroup.Columns, sqlgraph.NewFieldSpec(subscriptionplangroup.FieldID, field.TypeInt64))
+ id, ok := _u.mutation.ID()
+ if !ok {
+ return nil, &ValidationError{Name: "id", err: errors.New(`ent: missing "SubscriptionPlanGroup.id" for update`)}
+ }
+ _spec.Node.ID.Value = id
+ if fields := _u.fields; len(fields) > 0 {
+ _spec.Node.Columns = make([]string, 0, len(fields))
+ _spec.Node.Columns = append(_spec.Node.Columns, subscriptionplangroup.FieldID)
+ for _, f := range fields {
+ if !subscriptionplangroup.ValidColumn(f) {
+ return nil, &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)}
+ }
+ if f != subscriptionplangroup.FieldID {
+ _spec.Node.Columns = append(_spec.Node.Columns, f)
+ }
+ }
+ }
+ if ps := _u.mutation.predicates; len(ps) > 0 {
+ _spec.Predicate = func(selector *sql.Selector) {
+ for i := range ps {
+ ps[i](selector)
+ }
+ }
+ }
+ if _u.mutation.PlanCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionplangroup.PlanTable,
+ Columns: []string{subscriptionplangroup.PlanColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplan.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.PlanIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionplangroup.PlanTable,
+ Columns: []string{subscriptionplangroup.PlanColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionplan.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
+ if _u.mutation.GroupCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionplangroup.GroupTable,
+ Columns: []string{subscriptionplangroup.GroupColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.GroupIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionplangroup.GroupTable,
+ Columns: []string{subscriptionplangroup.GroupColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
+ _node = &SubscriptionPlanGroup{config: _u.config}
+ _spec.Assign = _node.assignValues
+ _spec.ScanValues = _node.scanValues
+ if err = sqlgraph.UpdateNode(ctx, _u.driver, _spec); err != nil {
+ if _, ok := err.(*sqlgraph.NotFoundError); ok {
+ err = &NotFoundError{subscriptionplangroup.Label}
+ } else if sqlgraph.IsConstraintError(err) {
+ err = &ConstraintError{msg: err.Error(), wrap: err}
+ }
+ return nil, err
+ }
+ _u.mutation.done = true
+ return _node, nil
+}
diff --git a/backend/ent/subscriptionwalletledger.go b/backend/ent/subscriptionwalletledger.go
new file mode 100644
index 00000000000..50f42c21a22
--- /dev/null
+++ b/backend/ent/subscriptionwalletledger.go
@@ -0,0 +1,275 @@
+// Code generated by ent, DO NOT EDIT.
+
+package ent
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "entgo.io/ent"
+ "entgo.io/ent/dialect/sql"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
+ "github.com/Wei-Shaw/sub2api/ent/usagelog"
+ "github.com/Wei-Shaw/sub2api/ent/user"
+ "github.com/Wei-Shaw/sub2api/ent/usersubscription"
+)
+
+// SubscriptionWalletLedger is the model entity for the SubscriptionWalletLedger schema.
+type SubscriptionWalletLedger struct {
+ config `json:"-"`
+ // ID of the ent.
+ ID int64 `json:"id,omitempty"`
+ // SubscriptionID holds the value of the "subscription_id" field.
+ SubscriptionID int64 `json:"subscription_id,omitempty"`
+ // 正=入账(激活/退款), 负=出账(消费)
+ DeltaUsd float64 `json:"delta_usd,omitempty"`
+ // 此次操作后余额(冗余,便于排查)
+ BalanceAfter float64 `json:"balance_after,omitempty"`
+ // activation | usage | refund | adjustment | expiration
+ Reason string `json:"reason,omitempty"`
+ // immutable payment source for activation/topup and its refund reversal
+ PaymentOrderID *int64 `json:"payment_order_id,omitempty"`
+ // 仅 reason=usage 时填,关联到 usage_logs.id
+ UsageLogID *int64 `json:"usage_log_id,omitempty"`
+ // 仅 refund/adjustment 时填,操作员 user_id
+ OperatorID *int64 `json:"operator_id,omitempty"`
+ // Notes holds the value of the "notes" field.
+ Notes *string `json:"notes,omitempty"`
+ // CreatedAt holds the value of the "created_at" field.
+ CreatedAt time.Time `json:"created_at,omitempty"`
+ // Edges holds the relations/edges for other nodes in the graph.
+ // The values are being populated by the SubscriptionWalletLedgerQuery when eager-loading is set.
+ Edges SubscriptionWalletLedgerEdges `json:"edges"`
+ selectValues sql.SelectValues
+}
+
+// SubscriptionWalletLedgerEdges holds the relations/edges for other nodes in the graph.
+type SubscriptionWalletLedgerEdges struct {
+ // Subscription holds the value of the subscription edge.
+ Subscription *UserSubscription `json:"subscription,omitempty"`
+ // UsageLog holds the value of the usage_log edge.
+ UsageLog *UsageLog `json:"usage_log,omitempty"`
+ // Operator holds the value of the operator edge.
+ Operator *User `json:"operator,omitempty"`
+ // loadedTypes holds the information for reporting if a
+ // type was loaded (or requested) in eager-loading or not.
+ loadedTypes [3]bool
+}
+
+// SubscriptionOrErr returns the Subscription value or an error if the edge
+// was not loaded in eager-loading, or loaded but was not found.
+func (e SubscriptionWalletLedgerEdges) SubscriptionOrErr() (*UserSubscription, error) {
+ if e.Subscription != nil {
+ return e.Subscription, nil
+ } else if e.loadedTypes[0] {
+ return nil, &NotFoundError{label: usersubscription.Label}
+ }
+ return nil, &NotLoadedError{edge: "subscription"}
+}
+
+// UsageLogOrErr returns the UsageLog value or an error if the edge
+// was not loaded in eager-loading, or loaded but was not found.
+func (e SubscriptionWalletLedgerEdges) UsageLogOrErr() (*UsageLog, error) {
+ if e.UsageLog != nil {
+ return e.UsageLog, nil
+ } else if e.loadedTypes[1] {
+ return nil, &NotFoundError{label: usagelog.Label}
+ }
+ return nil, &NotLoadedError{edge: "usage_log"}
+}
+
+// OperatorOrErr returns the Operator value or an error if the edge
+// was not loaded in eager-loading, or loaded but was not found.
+func (e SubscriptionWalletLedgerEdges) OperatorOrErr() (*User, error) {
+ if e.Operator != nil {
+ return e.Operator, nil
+ } else if e.loadedTypes[2] {
+ return nil, &NotFoundError{label: user.Label}
+ }
+ return nil, &NotLoadedError{edge: "operator"}
+}
+
+// scanValues returns the types for scanning values from sql.Rows.
+func (*SubscriptionWalletLedger) scanValues(columns []string) ([]any, error) {
+ values := make([]any, len(columns))
+ for i := range columns {
+ switch columns[i] {
+ case subscriptionwalletledger.FieldDeltaUsd, subscriptionwalletledger.FieldBalanceAfter:
+ values[i] = new(sql.NullFloat64)
+ case subscriptionwalletledger.FieldID, subscriptionwalletledger.FieldSubscriptionID, subscriptionwalletledger.FieldPaymentOrderID, subscriptionwalletledger.FieldUsageLogID, subscriptionwalletledger.FieldOperatorID:
+ values[i] = new(sql.NullInt64)
+ case subscriptionwalletledger.FieldReason, subscriptionwalletledger.FieldNotes:
+ values[i] = new(sql.NullString)
+ case subscriptionwalletledger.FieldCreatedAt:
+ values[i] = new(sql.NullTime)
+ default:
+ values[i] = new(sql.UnknownType)
+ }
+ }
+ return values, nil
+}
+
+// assignValues assigns the values that were returned from sql.Rows (after scanning)
+// to the SubscriptionWalletLedger fields.
+func (_m *SubscriptionWalletLedger) assignValues(columns []string, values []any) error {
+ if m, n := len(values), len(columns); m < n {
+ return fmt.Errorf("mismatch number of scan values: %d != %d", m, n)
+ }
+ for i := range columns {
+ switch columns[i] {
+ case subscriptionwalletledger.FieldID:
+ value, ok := values[i].(*sql.NullInt64)
+ if !ok {
+ return fmt.Errorf("unexpected type %T for field id", value)
+ }
+ _m.ID = int64(value.Int64)
+ case subscriptionwalletledger.FieldSubscriptionID:
+ if value, ok := values[i].(*sql.NullInt64); !ok {
+ return fmt.Errorf("unexpected type %T for field subscription_id", values[i])
+ } else if value.Valid {
+ _m.SubscriptionID = value.Int64
+ }
+ case subscriptionwalletledger.FieldDeltaUsd:
+ if value, ok := values[i].(*sql.NullFloat64); !ok {
+ return fmt.Errorf("unexpected type %T for field delta_usd", values[i])
+ } else if value.Valid {
+ _m.DeltaUsd = value.Float64
+ }
+ case subscriptionwalletledger.FieldBalanceAfter:
+ if value, ok := values[i].(*sql.NullFloat64); !ok {
+ return fmt.Errorf("unexpected type %T for field balance_after", values[i])
+ } else if value.Valid {
+ _m.BalanceAfter = value.Float64
+ }
+ case subscriptionwalletledger.FieldReason:
+ if value, ok := values[i].(*sql.NullString); !ok {
+ return fmt.Errorf("unexpected type %T for field reason", values[i])
+ } else if value.Valid {
+ _m.Reason = value.String
+ }
+ case subscriptionwalletledger.FieldPaymentOrderID:
+ if value, ok := values[i].(*sql.NullInt64); !ok {
+ return fmt.Errorf("unexpected type %T for field payment_order_id", values[i])
+ } else if value.Valid {
+ _m.PaymentOrderID = new(int64)
+ *_m.PaymentOrderID = value.Int64
+ }
+ case subscriptionwalletledger.FieldUsageLogID:
+ if value, ok := values[i].(*sql.NullInt64); !ok {
+ return fmt.Errorf("unexpected type %T for field usage_log_id", values[i])
+ } else if value.Valid {
+ _m.UsageLogID = new(int64)
+ *_m.UsageLogID = value.Int64
+ }
+ case subscriptionwalletledger.FieldOperatorID:
+ if value, ok := values[i].(*sql.NullInt64); !ok {
+ return fmt.Errorf("unexpected type %T for field operator_id", values[i])
+ } else if value.Valid {
+ _m.OperatorID = new(int64)
+ *_m.OperatorID = value.Int64
+ }
+ case subscriptionwalletledger.FieldNotes:
+ if value, ok := values[i].(*sql.NullString); !ok {
+ return fmt.Errorf("unexpected type %T for field notes", values[i])
+ } else if value.Valid {
+ _m.Notes = new(string)
+ *_m.Notes = value.String
+ }
+ case subscriptionwalletledger.FieldCreatedAt:
+ if value, ok := values[i].(*sql.NullTime); !ok {
+ return fmt.Errorf("unexpected type %T for field created_at", values[i])
+ } else if value.Valid {
+ _m.CreatedAt = value.Time
+ }
+ default:
+ _m.selectValues.Set(columns[i], values[i])
+ }
+ }
+ return nil
+}
+
+// Value returns the ent.Value that was dynamically selected and assigned to the SubscriptionWalletLedger.
+// This includes values selected through modifiers, order, etc.
+func (_m *SubscriptionWalletLedger) Value(name string) (ent.Value, error) {
+ return _m.selectValues.Get(name)
+}
+
+// QuerySubscription queries the "subscription" edge of the SubscriptionWalletLedger entity.
+func (_m *SubscriptionWalletLedger) QuerySubscription() *UserSubscriptionQuery {
+ return NewSubscriptionWalletLedgerClient(_m.config).QuerySubscription(_m)
+}
+
+// QueryUsageLog queries the "usage_log" edge of the SubscriptionWalletLedger entity.
+func (_m *SubscriptionWalletLedger) QueryUsageLog() *UsageLogQuery {
+ return NewSubscriptionWalletLedgerClient(_m.config).QueryUsageLog(_m)
+}
+
+// QueryOperator queries the "operator" edge of the SubscriptionWalletLedger entity.
+func (_m *SubscriptionWalletLedger) QueryOperator() *UserQuery {
+ return NewSubscriptionWalletLedgerClient(_m.config).QueryOperator(_m)
+}
+
+// Update returns a builder for updating this SubscriptionWalletLedger.
+// Note that you need to call SubscriptionWalletLedger.Unwrap() before calling this method if this SubscriptionWalletLedger
+// was returned from a transaction, and the transaction was committed or rolled back.
+func (_m *SubscriptionWalletLedger) Update() *SubscriptionWalletLedgerUpdateOne {
+ return NewSubscriptionWalletLedgerClient(_m.config).UpdateOne(_m)
+}
+
+// Unwrap unwraps the SubscriptionWalletLedger entity that was returned from a transaction after it was closed,
+// so that all future queries will be executed through the driver which created the transaction.
+func (_m *SubscriptionWalletLedger) Unwrap() *SubscriptionWalletLedger {
+ _tx, ok := _m.config.driver.(*txDriver)
+ if !ok {
+ panic("ent: SubscriptionWalletLedger is not a transactional entity")
+ }
+ _m.config.driver = _tx.drv
+ return _m
+}
+
+// String implements the fmt.Stringer.
+func (_m *SubscriptionWalletLedger) String() string {
+ var builder strings.Builder
+ builder.WriteString("SubscriptionWalletLedger(")
+ builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID))
+ builder.WriteString("subscription_id=")
+ builder.WriteString(fmt.Sprintf("%v", _m.SubscriptionID))
+ builder.WriteString(", ")
+ builder.WriteString("delta_usd=")
+ builder.WriteString(fmt.Sprintf("%v", _m.DeltaUsd))
+ builder.WriteString(", ")
+ builder.WriteString("balance_after=")
+ builder.WriteString(fmt.Sprintf("%v", _m.BalanceAfter))
+ builder.WriteString(", ")
+ builder.WriteString("reason=")
+ builder.WriteString(_m.Reason)
+ builder.WriteString(", ")
+ if v := _m.PaymentOrderID; v != nil {
+ builder.WriteString("payment_order_id=")
+ builder.WriteString(fmt.Sprintf("%v", *v))
+ }
+ builder.WriteString(", ")
+ if v := _m.UsageLogID; v != nil {
+ builder.WriteString("usage_log_id=")
+ builder.WriteString(fmt.Sprintf("%v", *v))
+ }
+ builder.WriteString(", ")
+ if v := _m.OperatorID; v != nil {
+ builder.WriteString("operator_id=")
+ builder.WriteString(fmt.Sprintf("%v", *v))
+ }
+ builder.WriteString(", ")
+ if v := _m.Notes; v != nil {
+ builder.WriteString("notes=")
+ builder.WriteString(*v)
+ }
+ builder.WriteString(", ")
+ builder.WriteString("created_at=")
+ builder.WriteString(_m.CreatedAt.Format(time.ANSIC))
+ builder.WriteByte(')')
+ return builder.String()
+}
+
+// SubscriptionWalletLedgers is a parsable slice of SubscriptionWalletLedger.
+type SubscriptionWalletLedgers []*SubscriptionWalletLedger
diff --git a/backend/ent/subscriptionwalletledger/subscriptionwalletledger.go b/backend/ent/subscriptionwalletledger/subscriptionwalletledger.go
new file mode 100644
index 00000000000..9eeffe1991e
--- /dev/null
+++ b/backend/ent/subscriptionwalletledger/subscriptionwalletledger.go
@@ -0,0 +1,190 @@
+// Code generated by ent, DO NOT EDIT.
+
+package subscriptionwalletledger
+
+import (
+ "time"
+
+ "entgo.io/ent/dialect/sql"
+ "entgo.io/ent/dialect/sql/sqlgraph"
+)
+
+const (
+ // Label holds the string label denoting the subscriptionwalletledger type in the database.
+ Label = "subscription_wallet_ledger"
+ // FieldID holds the string denoting the id field in the database.
+ FieldID = "id"
+ // FieldSubscriptionID holds the string denoting the subscription_id field in the database.
+ FieldSubscriptionID = "subscription_id"
+ // FieldDeltaUsd holds the string denoting the delta_usd field in the database.
+ FieldDeltaUsd = "delta_usd"
+ // FieldBalanceAfter holds the string denoting the balance_after field in the database.
+ FieldBalanceAfter = "balance_after"
+ // FieldReason holds the string denoting the reason field in the database.
+ FieldReason = "reason"
+ // FieldPaymentOrderID holds the string denoting the payment_order_id field in the database.
+ FieldPaymentOrderID = "payment_order_id"
+ // FieldUsageLogID holds the string denoting the usage_log_id field in the database.
+ FieldUsageLogID = "usage_log_id"
+ // FieldOperatorID holds the string denoting the operator_id field in the database.
+ FieldOperatorID = "operator_id"
+ // FieldNotes holds the string denoting the notes field in the database.
+ FieldNotes = "notes"
+ // FieldCreatedAt holds the string denoting the created_at field in the database.
+ FieldCreatedAt = "created_at"
+ // EdgeSubscription holds the string denoting the subscription edge name in mutations.
+ EdgeSubscription = "subscription"
+ // EdgeUsageLog holds the string denoting the usage_log edge name in mutations.
+ EdgeUsageLog = "usage_log"
+ // EdgeOperator holds the string denoting the operator edge name in mutations.
+ EdgeOperator = "operator"
+ // Table holds the table name of the subscriptionwalletledger in the database.
+ Table = "subscription_wallet_ledger"
+ // SubscriptionTable is the table that holds the subscription relation/edge.
+ SubscriptionTable = "subscription_wallet_ledger"
+ // SubscriptionInverseTable is the table name for the UserSubscription entity.
+ // It exists in this package in order to avoid circular dependency with the "usersubscription" package.
+ SubscriptionInverseTable = "user_subscriptions"
+ // SubscriptionColumn is the table column denoting the subscription relation/edge.
+ SubscriptionColumn = "subscription_id"
+ // UsageLogTable is the table that holds the usage_log relation/edge.
+ UsageLogTable = "subscription_wallet_ledger"
+ // UsageLogInverseTable is the table name for the UsageLog entity.
+ // It exists in this package in order to avoid circular dependency with the "usagelog" package.
+ UsageLogInverseTable = "usage_logs"
+ // UsageLogColumn is the table column denoting the usage_log relation/edge.
+ UsageLogColumn = "usage_log_id"
+ // OperatorTable is the table that holds the operator relation/edge.
+ OperatorTable = "subscription_wallet_ledger"
+ // OperatorInverseTable is the table name for the User entity.
+ // It exists in this package in order to avoid circular dependency with the "user" package.
+ OperatorInverseTable = "users"
+ // OperatorColumn is the table column denoting the operator relation/edge.
+ OperatorColumn = "operator_id"
+)
+
+// Columns holds all SQL columns for subscriptionwalletledger fields.
+var Columns = []string{
+ FieldID,
+ FieldSubscriptionID,
+ FieldDeltaUsd,
+ FieldBalanceAfter,
+ FieldReason,
+ FieldPaymentOrderID,
+ FieldUsageLogID,
+ FieldOperatorID,
+ FieldNotes,
+ FieldCreatedAt,
+}
+
+// ValidColumn reports if the column name is valid (part of the table columns).
+func ValidColumn(column string) bool {
+ for i := range Columns {
+ if column == Columns[i] {
+ return true
+ }
+ }
+ return false
+}
+
+var (
+ // ReasonValidator is a validator for the "reason" field. It is called by the builders before save.
+ ReasonValidator func(string) error
+ // DefaultCreatedAt holds the default value on creation for the "created_at" field.
+ DefaultCreatedAt func() time.Time
+)
+
+// OrderOption defines the ordering options for the SubscriptionWalletLedger queries.
+type OrderOption func(*sql.Selector)
+
+// ByID orders the results by the id field.
+func ByID(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldID, opts...).ToFunc()
+}
+
+// BySubscriptionID orders the results by the subscription_id field.
+func BySubscriptionID(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldSubscriptionID, opts...).ToFunc()
+}
+
+// ByDeltaUsd orders the results by the delta_usd field.
+func ByDeltaUsd(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldDeltaUsd, opts...).ToFunc()
+}
+
+// ByBalanceAfter orders the results by the balance_after field.
+func ByBalanceAfter(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldBalanceAfter, opts...).ToFunc()
+}
+
+// ByReason orders the results by the reason field.
+func ByReason(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldReason, opts...).ToFunc()
+}
+
+// ByPaymentOrderID orders the results by the payment_order_id field.
+func ByPaymentOrderID(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldPaymentOrderID, opts...).ToFunc()
+}
+
+// ByUsageLogID orders the results by the usage_log_id field.
+func ByUsageLogID(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldUsageLogID, opts...).ToFunc()
+}
+
+// ByOperatorID orders the results by the operator_id field.
+func ByOperatorID(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldOperatorID, opts...).ToFunc()
+}
+
+// ByNotes orders the results by the notes field.
+func ByNotes(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldNotes, opts...).ToFunc()
+}
+
+// ByCreatedAt orders the results by the created_at field.
+func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldCreatedAt, opts...).ToFunc()
+}
+
+// BySubscriptionField orders the results by subscription field.
+func BySubscriptionField(field string, opts ...sql.OrderTermOption) OrderOption {
+ return func(s *sql.Selector) {
+ sqlgraph.OrderByNeighborTerms(s, newSubscriptionStep(), sql.OrderByField(field, opts...))
+ }
+}
+
+// ByUsageLogField orders the results by usage_log field.
+func ByUsageLogField(field string, opts ...sql.OrderTermOption) OrderOption {
+ return func(s *sql.Selector) {
+ sqlgraph.OrderByNeighborTerms(s, newUsageLogStep(), sql.OrderByField(field, opts...))
+ }
+}
+
+// ByOperatorField orders the results by operator field.
+func ByOperatorField(field string, opts ...sql.OrderTermOption) OrderOption {
+ return func(s *sql.Selector) {
+ sqlgraph.OrderByNeighborTerms(s, newOperatorStep(), sql.OrderByField(field, opts...))
+ }
+}
+func newSubscriptionStep() *sqlgraph.Step {
+ return sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.To(SubscriptionInverseTable, FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, SubscriptionTable, SubscriptionColumn),
+ )
+}
+func newUsageLogStep() *sqlgraph.Step {
+ return sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.To(UsageLogInverseTable, FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, UsageLogTable, UsageLogColumn),
+ )
+}
+func newOperatorStep() *sqlgraph.Step {
+ return sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.To(OperatorInverseTable, FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, OperatorTable, OperatorColumn),
+ )
+}
diff --git a/backend/ent/subscriptionwalletledger/where.go b/backend/ent/subscriptionwalletledger/where.go
new file mode 100644
index 00000000000..1f1d279d281
--- /dev/null
+++ b/backend/ent/subscriptionwalletledger/where.go
@@ -0,0 +1,575 @@
+// Code generated by ent, DO NOT EDIT.
+
+package subscriptionwalletledger
+
+import (
+ "time"
+
+ "entgo.io/ent/dialect/sql"
+ "entgo.io/ent/dialect/sql/sqlgraph"
+ "github.com/Wei-Shaw/sub2api/ent/predicate"
+)
+
+// ID filters vertices based on their ID field.
+func ID(id int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldID, id))
+}
+
+// IDEQ applies the EQ predicate on the ID field.
+func IDEQ(id int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldID, id))
+}
+
+// IDNEQ applies the NEQ predicate on the ID field.
+func IDNEQ(id int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNEQ(FieldID, id))
+}
+
+// IDIn applies the In predicate on the ID field.
+func IDIn(ids ...int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldIn(FieldID, ids...))
+}
+
+// IDNotIn applies the NotIn predicate on the ID field.
+func IDNotIn(ids ...int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNotIn(FieldID, ids...))
+}
+
+// IDGT applies the GT predicate on the ID field.
+func IDGT(id int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldGT(FieldID, id))
+}
+
+// IDGTE applies the GTE predicate on the ID field.
+func IDGTE(id int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldGTE(FieldID, id))
+}
+
+// IDLT applies the LT predicate on the ID field.
+func IDLT(id int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldLT(FieldID, id))
+}
+
+// IDLTE applies the LTE predicate on the ID field.
+func IDLTE(id int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldLTE(FieldID, id))
+}
+
+// SubscriptionID applies equality check predicate on the "subscription_id" field. It's identical to SubscriptionIDEQ.
+func SubscriptionID(v int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldSubscriptionID, v))
+}
+
+// DeltaUsd applies equality check predicate on the "delta_usd" field. It's identical to DeltaUsdEQ.
+func DeltaUsd(v float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldDeltaUsd, v))
+}
+
+// BalanceAfter applies equality check predicate on the "balance_after" field. It's identical to BalanceAfterEQ.
+func BalanceAfter(v float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldBalanceAfter, v))
+}
+
+// Reason applies equality check predicate on the "reason" field. It's identical to ReasonEQ.
+func Reason(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldReason, v))
+}
+
+// PaymentOrderID applies equality check predicate on the "payment_order_id" field. It's identical to PaymentOrderIDEQ.
+func PaymentOrderID(v int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldPaymentOrderID, v))
+}
+
+// UsageLogID applies equality check predicate on the "usage_log_id" field. It's identical to UsageLogIDEQ.
+func UsageLogID(v int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldUsageLogID, v))
+}
+
+// OperatorID applies equality check predicate on the "operator_id" field. It's identical to OperatorIDEQ.
+func OperatorID(v int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldOperatorID, v))
+}
+
+// Notes applies equality check predicate on the "notes" field. It's identical to NotesEQ.
+func Notes(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldNotes, v))
+}
+
+// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ.
+func CreatedAt(v time.Time) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldCreatedAt, v))
+}
+
+// SubscriptionIDEQ applies the EQ predicate on the "subscription_id" field.
+func SubscriptionIDEQ(v int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldSubscriptionID, v))
+}
+
+// SubscriptionIDNEQ applies the NEQ predicate on the "subscription_id" field.
+func SubscriptionIDNEQ(v int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNEQ(FieldSubscriptionID, v))
+}
+
+// SubscriptionIDIn applies the In predicate on the "subscription_id" field.
+func SubscriptionIDIn(vs ...int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldIn(FieldSubscriptionID, vs...))
+}
+
+// SubscriptionIDNotIn applies the NotIn predicate on the "subscription_id" field.
+func SubscriptionIDNotIn(vs ...int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNotIn(FieldSubscriptionID, vs...))
+}
+
+// DeltaUsdEQ applies the EQ predicate on the "delta_usd" field.
+func DeltaUsdEQ(v float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldDeltaUsd, v))
+}
+
+// DeltaUsdNEQ applies the NEQ predicate on the "delta_usd" field.
+func DeltaUsdNEQ(v float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNEQ(FieldDeltaUsd, v))
+}
+
+// DeltaUsdIn applies the In predicate on the "delta_usd" field.
+func DeltaUsdIn(vs ...float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldIn(FieldDeltaUsd, vs...))
+}
+
+// DeltaUsdNotIn applies the NotIn predicate on the "delta_usd" field.
+func DeltaUsdNotIn(vs ...float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNotIn(FieldDeltaUsd, vs...))
+}
+
+// DeltaUsdGT applies the GT predicate on the "delta_usd" field.
+func DeltaUsdGT(v float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldGT(FieldDeltaUsd, v))
+}
+
+// DeltaUsdGTE applies the GTE predicate on the "delta_usd" field.
+func DeltaUsdGTE(v float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldGTE(FieldDeltaUsd, v))
+}
+
+// DeltaUsdLT applies the LT predicate on the "delta_usd" field.
+func DeltaUsdLT(v float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldLT(FieldDeltaUsd, v))
+}
+
+// DeltaUsdLTE applies the LTE predicate on the "delta_usd" field.
+func DeltaUsdLTE(v float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldLTE(FieldDeltaUsd, v))
+}
+
+// BalanceAfterEQ applies the EQ predicate on the "balance_after" field.
+func BalanceAfterEQ(v float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldBalanceAfter, v))
+}
+
+// BalanceAfterNEQ applies the NEQ predicate on the "balance_after" field.
+func BalanceAfterNEQ(v float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNEQ(FieldBalanceAfter, v))
+}
+
+// BalanceAfterIn applies the In predicate on the "balance_after" field.
+func BalanceAfterIn(vs ...float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldIn(FieldBalanceAfter, vs...))
+}
+
+// BalanceAfterNotIn applies the NotIn predicate on the "balance_after" field.
+func BalanceAfterNotIn(vs ...float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNotIn(FieldBalanceAfter, vs...))
+}
+
+// BalanceAfterGT applies the GT predicate on the "balance_after" field.
+func BalanceAfterGT(v float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldGT(FieldBalanceAfter, v))
+}
+
+// BalanceAfterGTE applies the GTE predicate on the "balance_after" field.
+func BalanceAfterGTE(v float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldGTE(FieldBalanceAfter, v))
+}
+
+// BalanceAfterLT applies the LT predicate on the "balance_after" field.
+func BalanceAfterLT(v float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldLT(FieldBalanceAfter, v))
+}
+
+// BalanceAfterLTE applies the LTE predicate on the "balance_after" field.
+func BalanceAfterLTE(v float64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldLTE(FieldBalanceAfter, v))
+}
+
+// ReasonEQ applies the EQ predicate on the "reason" field.
+func ReasonEQ(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldReason, v))
+}
+
+// ReasonNEQ applies the NEQ predicate on the "reason" field.
+func ReasonNEQ(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNEQ(FieldReason, v))
+}
+
+// ReasonIn applies the In predicate on the "reason" field.
+func ReasonIn(vs ...string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldIn(FieldReason, vs...))
+}
+
+// ReasonNotIn applies the NotIn predicate on the "reason" field.
+func ReasonNotIn(vs ...string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNotIn(FieldReason, vs...))
+}
+
+// ReasonGT applies the GT predicate on the "reason" field.
+func ReasonGT(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldGT(FieldReason, v))
+}
+
+// ReasonGTE applies the GTE predicate on the "reason" field.
+func ReasonGTE(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldGTE(FieldReason, v))
+}
+
+// ReasonLT applies the LT predicate on the "reason" field.
+func ReasonLT(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldLT(FieldReason, v))
+}
+
+// ReasonLTE applies the LTE predicate on the "reason" field.
+func ReasonLTE(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldLTE(FieldReason, v))
+}
+
+// ReasonContains applies the Contains predicate on the "reason" field.
+func ReasonContains(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldContains(FieldReason, v))
+}
+
+// ReasonHasPrefix applies the HasPrefix predicate on the "reason" field.
+func ReasonHasPrefix(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldHasPrefix(FieldReason, v))
+}
+
+// ReasonHasSuffix applies the HasSuffix predicate on the "reason" field.
+func ReasonHasSuffix(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldHasSuffix(FieldReason, v))
+}
+
+// ReasonEqualFold applies the EqualFold predicate on the "reason" field.
+func ReasonEqualFold(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEqualFold(FieldReason, v))
+}
+
+// ReasonContainsFold applies the ContainsFold predicate on the "reason" field.
+func ReasonContainsFold(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldContainsFold(FieldReason, v))
+}
+
+// PaymentOrderIDEQ applies the EQ predicate on the "payment_order_id" field.
+func PaymentOrderIDEQ(v int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldPaymentOrderID, v))
+}
+
+// PaymentOrderIDNEQ applies the NEQ predicate on the "payment_order_id" field.
+func PaymentOrderIDNEQ(v int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNEQ(FieldPaymentOrderID, v))
+}
+
+// PaymentOrderIDIn applies the In predicate on the "payment_order_id" field.
+func PaymentOrderIDIn(vs ...int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldIn(FieldPaymentOrderID, vs...))
+}
+
+// PaymentOrderIDNotIn applies the NotIn predicate on the "payment_order_id" field.
+func PaymentOrderIDNotIn(vs ...int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNotIn(FieldPaymentOrderID, vs...))
+}
+
+// PaymentOrderIDGT applies the GT predicate on the "payment_order_id" field.
+func PaymentOrderIDGT(v int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldGT(FieldPaymentOrderID, v))
+}
+
+// PaymentOrderIDGTE applies the GTE predicate on the "payment_order_id" field.
+func PaymentOrderIDGTE(v int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldGTE(FieldPaymentOrderID, v))
+}
+
+// PaymentOrderIDLT applies the LT predicate on the "payment_order_id" field.
+func PaymentOrderIDLT(v int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldLT(FieldPaymentOrderID, v))
+}
+
+// PaymentOrderIDLTE applies the LTE predicate on the "payment_order_id" field.
+func PaymentOrderIDLTE(v int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldLTE(FieldPaymentOrderID, v))
+}
+
+// PaymentOrderIDIsNil applies the IsNil predicate on the "payment_order_id" field.
+func PaymentOrderIDIsNil() predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldIsNull(FieldPaymentOrderID))
+}
+
+// PaymentOrderIDNotNil applies the NotNil predicate on the "payment_order_id" field.
+func PaymentOrderIDNotNil() predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNotNull(FieldPaymentOrderID))
+}
+
+// UsageLogIDEQ applies the EQ predicate on the "usage_log_id" field.
+func UsageLogIDEQ(v int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldUsageLogID, v))
+}
+
+// UsageLogIDNEQ applies the NEQ predicate on the "usage_log_id" field.
+func UsageLogIDNEQ(v int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNEQ(FieldUsageLogID, v))
+}
+
+// UsageLogIDIn applies the In predicate on the "usage_log_id" field.
+func UsageLogIDIn(vs ...int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldIn(FieldUsageLogID, vs...))
+}
+
+// UsageLogIDNotIn applies the NotIn predicate on the "usage_log_id" field.
+func UsageLogIDNotIn(vs ...int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNotIn(FieldUsageLogID, vs...))
+}
+
+// UsageLogIDIsNil applies the IsNil predicate on the "usage_log_id" field.
+func UsageLogIDIsNil() predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldIsNull(FieldUsageLogID))
+}
+
+// UsageLogIDNotNil applies the NotNil predicate on the "usage_log_id" field.
+func UsageLogIDNotNil() predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNotNull(FieldUsageLogID))
+}
+
+// OperatorIDEQ applies the EQ predicate on the "operator_id" field.
+func OperatorIDEQ(v int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldOperatorID, v))
+}
+
+// OperatorIDNEQ applies the NEQ predicate on the "operator_id" field.
+func OperatorIDNEQ(v int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNEQ(FieldOperatorID, v))
+}
+
+// OperatorIDIn applies the In predicate on the "operator_id" field.
+func OperatorIDIn(vs ...int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldIn(FieldOperatorID, vs...))
+}
+
+// OperatorIDNotIn applies the NotIn predicate on the "operator_id" field.
+func OperatorIDNotIn(vs ...int64) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNotIn(FieldOperatorID, vs...))
+}
+
+// OperatorIDIsNil applies the IsNil predicate on the "operator_id" field.
+func OperatorIDIsNil() predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldIsNull(FieldOperatorID))
+}
+
+// OperatorIDNotNil applies the NotNil predicate on the "operator_id" field.
+func OperatorIDNotNil() predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNotNull(FieldOperatorID))
+}
+
+// NotesEQ applies the EQ predicate on the "notes" field.
+func NotesEQ(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldNotes, v))
+}
+
+// NotesNEQ applies the NEQ predicate on the "notes" field.
+func NotesNEQ(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNEQ(FieldNotes, v))
+}
+
+// NotesIn applies the In predicate on the "notes" field.
+func NotesIn(vs ...string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldIn(FieldNotes, vs...))
+}
+
+// NotesNotIn applies the NotIn predicate on the "notes" field.
+func NotesNotIn(vs ...string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNotIn(FieldNotes, vs...))
+}
+
+// NotesGT applies the GT predicate on the "notes" field.
+func NotesGT(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldGT(FieldNotes, v))
+}
+
+// NotesGTE applies the GTE predicate on the "notes" field.
+func NotesGTE(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldGTE(FieldNotes, v))
+}
+
+// NotesLT applies the LT predicate on the "notes" field.
+func NotesLT(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldLT(FieldNotes, v))
+}
+
+// NotesLTE applies the LTE predicate on the "notes" field.
+func NotesLTE(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldLTE(FieldNotes, v))
+}
+
+// NotesContains applies the Contains predicate on the "notes" field.
+func NotesContains(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldContains(FieldNotes, v))
+}
+
+// NotesHasPrefix applies the HasPrefix predicate on the "notes" field.
+func NotesHasPrefix(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldHasPrefix(FieldNotes, v))
+}
+
+// NotesHasSuffix applies the HasSuffix predicate on the "notes" field.
+func NotesHasSuffix(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldHasSuffix(FieldNotes, v))
+}
+
+// NotesIsNil applies the IsNil predicate on the "notes" field.
+func NotesIsNil() predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldIsNull(FieldNotes))
+}
+
+// NotesNotNil applies the NotNil predicate on the "notes" field.
+func NotesNotNil() predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNotNull(FieldNotes))
+}
+
+// NotesEqualFold applies the EqualFold predicate on the "notes" field.
+func NotesEqualFold(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEqualFold(FieldNotes, v))
+}
+
+// NotesContainsFold applies the ContainsFold predicate on the "notes" field.
+func NotesContainsFold(v string) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldContainsFold(FieldNotes, v))
+}
+
+// CreatedAtEQ applies the EQ predicate on the "created_at" field.
+func CreatedAtEQ(v time.Time) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldEQ(FieldCreatedAt, v))
+}
+
+// CreatedAtNEQ applies the NEQ predicate on the "created_at" field.
+func CreatedAtNEQ(v time.Time) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNEQ(FieldCreatedAt, v))
+}
+
+// CreatedAtIn applies the In predicate on the "created_at" field.
+func CreatedAtIn(vs ...time.Time) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldIn(FieldCreatedAt, vs...))
+}
+
+// CreatedAtNotIn applies the NotIn predicate on the "created_at" field.
+func CreatedAtNotIn(vs ...time.Time) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldNotIn(FieldCreatedAt, vs...))
+}
+
+// CreatedAtGT applies the GT predicate on the "created_at" field.
+func CreatedAtGT(v time.Time) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldGT(FieldCreatedAt, v))
+}
+
+// CreatedAtGTE applies the GTE predicate on the "created_at" field.
+func CreatedAtGTE(v time.Time) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldGTE(FieldCreatedAt, v))
+}
+
+// CreatedAtLT applies the LT predicate on the "created_at" field.
+func CreatedAtLT(v time.Time) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldLT(FieldCreatedAt, v))
+}
+
+// CreatedAtLTE applies the LTE predicate on the "created_at" field.
+func CreatedAtLTE(v time.Time) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.FieldLTE(FieldCreatedAt, v))
+}
+
+// HasSubscription applies the HasEdge predicate on the "subscription" edge.
+func HasSubscription() predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(func(s *sql.Selector) {
+ step := sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, SubscriptionTable, SubscriptionColumn),
+ )
+ sqlgraph.HasNeighbors(s, step)
+ })
+}
+
+// HasSubscriptionWith applies the HasEdge predicate on the "subscription" edge with a given conditions (other predicates).
+func HasSubscriptionWith(preds ...predicate.UserSubscription) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(func(s *sql.Selector) {
+ step := newSubscriptionStep()
+ sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
+ for _, p := range preds {
+ p(s)
+ }
+ })
+ })
+}
+
+// HasUsageLog applies the HasEdge predicate on the "usage_log" edge.
+func HasUsageLog() predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(func(s *sql.Selector) {
+ step := sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, UsageLogTable, UsageLogColumn),
+ )
+ sqlgraph.HasNeighbors(s, step)
+ })
+}
+
+// HasUsageLogWith applies the HasEdge predicate on the "usage_log" edge with a given conditions (other predicates).
+func HasUsageLogWith(preds ...predicate.UsageLog) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(func(s *sql.Selector) {
+ step := newUsageLogStep()
+ sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
+ for _, p := range preds {
+ p(s)
+ }
+ })
+ })
+}
+
+// HasOperator applies the HasEdge predicate on the "operator" edge.
+func HasOperator() predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(func(s *sql.Selector) {
+ step := sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, OperatorTable, OperatorColumn),
+ )
+ sqlgraph.HasNeighbors(s, step)
+ })
+}
+
+// HasOperatorWith applies the HasEdge predicate on the "operator" edge with a given conditions (other predicates).
+func HasOperatorWith(preds ...predicate.User) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(func(s *sql.Selector) {
+ step := newOperatorStep()
+ sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
+ for _, p := range preds {
+ p(s)
+ }
+ })
+ })
+}
+
+// And groups predicates with the AND operator between them.
+func And(predicates ...predicate.SubscriptionWalletLedger) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.AndPredicates(predicates...))
+}
+
+// Or groups predicates with the OR operator between them.
+func Or(predicates ...predicate.SubscriptionWalletLedger) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.OrPredicates(predicates...))
+}
+
+// Not applies the not operator on the given predicate.
+func Not(p predicate.SubscriptionWalletLedger) predicate.SubscriptionWalletLedger {
+ return predicate.SubscriptionWalletLedger(sql.NotPredicates(p))
+}
diff --git a/backend/ent/subscriptionwalletledger_create.go b/backend/ent/subscriptionwalletledger_create.go
new file mode 100644
index 00000000000..62e63d831b1
--- /dev/null
+++ b/backend/ent/subscriptionwalletledger_create.go
@@ -0,0 +1,1094 @@
+// Code generated by ent, DO NOT EDIT.
+
+package ent
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "entgo.io/ent/dialect/sql"
+ "entgo.io/ent/dialect/sql/sqlgraph"
+ "entgo.io/ent/schema/field"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
+ "github.com/Wei-Shaw/sub2api/ent/usagelog"
+ "github.com/Wei-Shaw/sub2api/ent/user"
+ "github.com/Wei-Shaw/sub2api/ent/usersubscription"
+)
+
+// SubscriptionWalletLedgerCreate is the builder for creating a SubscriptionWalletLedger entity.
+type SubscriptionWalletLedgerCreate struct {
+ config
+ mutation *SubscriptionWalletLedgerMutation
+ hooks []Hook
+ conflict []sql.ConflictOption
+}
+
+// SetSubscriptionID sets the "subscription_id" field.
+func (_c *SubscriptionWalletLedgerCreate) SetSubscriptionID(v int64) *SubscriptionWalletLedgerCreate {
+ _c.mutation.SetSubscriptionID(v)
+ return _c
+}
+
+// SetDeltaUsd sets the "delta_usd" field.
+func (_c *SubscriptionWalletLedgerCreate) SetDeltaUsd(v float64) *SubscriptionWalletLedgerCreate {
+ _c.mutation.SetDeltaUsd(v)
+ return _c
+}
+
+// SetBalanceAfter sets the "balance_after" field.
+func (_c *SubscriptionWalletLedgerCreate) SetBalanceAfter(v float64) *SubscriptionWalletLedgerCreate {
+ _c.mutation.SetBalanceAfter(v)
+ return _c
+}
+
+// SetReason sets the "reason" field.
+func (_c *SubscriptionWalletLedgerCreate) SetReason(v string) *SubscriptionWalletLedgerCreate {
+ _c.mutation.SetReason(v)
+ return _c
+}
+
+// SetPaymentOrderID sets the "payment_order_id" field.
+func (_c *SubscriptionWalletLedgerCreate) SetPaymentOrderID(v int64) *SubscriptionWalletLedgerCreate {
+ _c.mutation.SetPaymentOrderID(v)
+ return _c
+}
+
+// SetNillablePaymentOrderID sets the "payment_order_id" field if the given value is not nil.
+func (_c *SubscriptionWalletLedgerCreate) SetNillablePaymentOrderID(v *int64) *SubscriptionWalletLedgerCreate {
+ if v != nil {
+ _c.SetPaymentOrderID(*v)
+ }
+ return _c
+}
+
+// SetUsageLogID sets the "usage_log_id" field.
+func (_c *SubscriptionWalletLedgerCreate) SetUsageLogID(v int64) *SubscriptionWalletLedgerCreate {
+ _c.mutation.SetUsageLogID(v)
+ return _c
+}
+
+// SetNillableUsageLogID sets the "usage_log_id" field if the given value is not nil.
+func (_c *SubscriptionWalletLedgerCreate) SetNillableUsageLogID(v *int64) *SubscriptionWalletLedgerCreate {
+ if v != nil {
+ _c.SetUsageLogID(*v)
+ }
+ return _c
+}
+
+// SetOperatorID sets the "operator_id" field.
+func (_c *SubscriptionWalletLedgerCreate) SetOperatorID(v int64) *SubscriptionWalletLedgerCreate {
+ _c.mutation.SetOperatorID(v)
+ return _c
+}
+
+// SetNillableOperatorID sets the "operator_id" field if the given value is not nil.
+func (_c *SubscriptionWalletLedgerCreate) SetNillableOperatorID(v *int64) *SubscriptionWalletLedgerCreate {
+ if v != nil {
+ _c.SetOperatorID(*v)
+ }
+ return _c
+}
+
+// SetNotes sets the "notes" field.
+func (_c *SubscriptionWalletLedgerCreate) SetNotes(v string) *SubscriptionWalletLedgerCreate {
+ _c.mutation.SetNotes(v)
+ return _c
+}
+
+// SetNillableNotes sets the "notes" field if the given value is not nil.
+func (_c *SubscriptionWalletLedgerCreate) SetNillableNotes(v *string) *SubscriptionWalletLedgerCreate {
+ if v != nil {
+ _c.SetNotes(*v)
+ }
+ return _c
+}
+
+// SetCreatedAt sets the "created_at" field.
+func (_c *SubscriptionWalletLedgerCreate) SetCreatedAt(v time.Time) *SubscriptionWalletLedgerCreate {
+ _c.mutation.SetCreatedAt(v)
+ return _c
+}
+
+// SetNillableCreatedAt sets the "created_at" field if the given value is not nil.
+func (_c *SubscriptionWalletLedgerCreate) SetNillableCreatedAt(v *time.Time) *SubscriptionWalletLedgerCreate {
+ if v != nil {
+ _c.SetCreatedAt(*v)
+ }
+ return _c
+}
+
+// SetSubscription sets the "subscription" edge to the UserSubscription entity.
+func (_c *SubscriptionWalletLedgerCreate) SetSubscription(v *UserSubscription) *SubscriptionWalletLedgerCreate {
+ return _c.SetSubscriptionID(v.ID)
+}
+
+// SetUsageLog sets the "usage_log" edge to the UsageLog entity.
+func (_c *SubscriptionWalletLedgerCreate) SetUsageLog(v *UsageLog) *SubscriptionWalletLedgerCreate {
+ return _c.SetUsageLogID(v.ID)
+}
+
+// SetOperator sets the "operator" edge to the User entity.
+func (_c *SubscriptionWalletLedgerCreate) SetOperator(v *User) *SubscriptionWalletLedgerCreate {
+ return _c.SetOperatorID(v.ID)
+}
+
+// Mutation returns the SubscriptionWalletLedgerMutation object of the builder.
+func (_c *SubscriptionWalletLedgerCreate) Mutation() *SubscriptionWalletLedgerMutation {
+ return _c.mutation
+}
+
+// Save creates the SubscriptionWalletLedger in the database.
+func (_c *SubscriptionWalletLedgerCreate) Save(ctx context.Context) (*SubscriptionWalletLedger, error) {
+ _c.defaults()
+ return withHooks(ctx, _c.sqlSave, _c.mutation, _c.hooks)
+}
+
+// SaveX calls Save and panics if Save returns an error.
+func (_c *SubscriptionWalletLedgerCreate) SaveX(ctx context.Context) *SubscriptionWalletLedger {
+ v, err := _c.Save(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return v
+}
+
+// Exec executes the query.
+func (_c *SubscriptionWalletLedgerCreate) Exec(ctx context.Context) error {
+ _, err := _c.Save(ctx)
+ return err
+}
+
+// ExecX is like Exec, but panics if an error occurs.
+func (_c *SubscriptionWalletLedgerCreate) ExecX(ctx context.Context) {
+ if err := _c.Exec(ctx); err != nil {
+ panic(err)
+ }
+}
+
+// defaults sets the default values of the builder before save.
+func (_c *SubscriptionWalletLedgerCreate) defaults() {
+ if _, ok := _c.mutation.CreatedAt(); !ok {
+ v := subscriptionwalletledger.DefaultCreatedAt()
+ _c.mutation.SetCreatedAt(v)
+ }
+}
+
+// check runs all checks and user-defined validators on the builder.
+func (_c *SubscriptionWalletLedgerCreate) check() error {
+ if _, ok := _c.mutation.SubscriptionID(); !ok {
+ return &ValidationError{Name: "subscription_id", err: errors.New(`ent: missing required field "SubscriptionWalletLedger.subscription_id"`)}
+ }
+ if _, ok := _c.mutation.DeltaUsd(); !ok {
+ return &ValidationError{Name: "delta_usd", err: errors.New(`ent: missing required field "SubscriptionWalletLedger.delta_usd"`)}
+ }
+ if _, ok := _c.mutation.BalanceAfter(); !ok {
+ return &ValidationError{Name: "balance_after", err: errors.New(`ent: missing required field "SubscriptionWalletLedger.balance_after"`)}
+ }
+ if _, ok := _c.mutation.Reason(); !ok {
+ return &ValidationError{Name: "reason", err: errors.New(`ent: missing required field "SubscriptionWalletLedger.reason"`)}
+ }
+ if v, ok := _c.mutation.Reason(); ok {
+ if err := subscriptionwalletledger.ReasonValidator(v); err != nil {
+ return &ValidationError{Name: "reason", err: fmt.Errorf(`ent: validator failed for field "SubscriptionWalletLedger.reason": %w`, err)}
+ }
+ }
+ if _, ok := _c.mutation.CreatedAt(); !ok {
+ return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "SubscriptionWalletLedger.created_at"`)}
+ }
+ if len(_c.mutation.SubscriptionIDs()) == 0 {
+ return &ValidationError{Name: "subscription", err: errors.New(`ent: missing required edge "SubscriptionWalletLedger.subscription"`)}
+ }
+ return nil
+}
+
+func (_c *SubscriptionWalletLedgerCreate) sqlSave(ctx context.Context) (*SubscriptionWalletLedger, error) {
+ if err := _c.check(); err != nil {
+ return nil, err
+ }
+ _node, _spec := _c.createSpec()
+ if err := sqlgraph.CreateNode(ctx, _c.driver, _spec); err != nil {
+ if sqlgraph.IsConstraintError(err) {
+ err = &ConstraintError{msg: err.Error(), wrap: err}
+ }
+ return nil, err
+ }
+ id := _spec.ID.Value.(int64)
+ _node.ID = int64(id)
+ _c.mutation.id = &_node.ID
+ _c.mutation.done = true
+ return _node, nil
+}
+
+func (_c *SubscriptionWalletLedgerCreate) createSpec() (*SubscriptionWalletLedger, *sqlgraph.CreateSpec) {
+ var (
+ _node = &SubscriptionWalletLedger{config: _c.config}
+ _spec = sqlgraph.NewCreateSpec(subscriptionwalletledger.Table, sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64))
+ )
+ _spec.OnConflict = _c.conflict
+ if value, ok := _c.mutation.DeltaUsd(); ok {
+ _spec.SetField(subscriptionwalletledger.FieldDeltaUsd, field.TypeFloat64, value)
+ _node.DeltaUsd = value
+ }
+ if value, ok := _c.mutation.BalanceAfter(); ok {
+ _spec.SetField(subscriptionwalletledger.FieldBalanceAfter, field.TypeFloat64, value)
+ _node.BalanceAfter = value
+ }
+ if value, ok := _c.mutation.Reason(); ok {
+ _spec.SetField(subscriptionwalletledger.FieldReason, field.TypeString, value)
+ _node.Reason = value
+ }
+ if value, ok := _c.mutation.PaymentOrderID(); ok {
+ _spec.SetField(subscriptionwalletledger.FieldPaymentOrderID, field.TypeInt64, value)
+ _node.PaymentOrderID = &value
+ }
+ if value, ok := _c.mutation.Notes(); ok {
+ _spec.SetField(subscriptionwalletledger.FieldNotes, field.TypeString, value)
+ _node.Notes = &value
+ }
+ if value, ok := _c.mutation.CreatedAt(); ok {
+ _spec.SetField(subscriptionwalletledger.FieldCreatedAt, field.TypeTime, value)
+ _node.CreatedAt = value
+ }
+ if nodes := _c.mutation.SubscriptionIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionwalletledger.SubscriptionTable,
+ Columns: []string{subscriptionwalletledger.SubscriptionColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(usersubscription.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _node.SubscriptionID = nodes[0]
+ _spec.Edges = append(_spec.Edges, edge)
+ }
+ if nodes := _c.mutation.UsageLogIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionwalletledger.UsageLogTable,
+ Columns: []string{subscriptionwalletledger.UsageLogColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(usagelog.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _node.UsageLogID = &nodes[0]
+ _spec.Edges = append(_spec.Edges, edge)
+ }
+ if nodes := _c.mutation.OperatorIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionwalletledger.OperatorTable,
+ Columns: []string{subscriptionwalletledger.OperatorColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _node.OperatorID = &nodes[0]
+ _spec.Edges = append(_spec.Edges, edge)
+ }
+ return _node, _spec
+}
+
+// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause
+// of the `INSERT` statement. For example:
+//
+// client.SubscriptionWalletLedger.Create().
+// SetSubscriptionID(v).
+// OnConflict(
+// // Update the row with the new values
+// // the was proposed for insertion.
+// sql.ResolveWithNewValues(),
+// ).
+// // Override some of the fields with custom
+// // update values.
+// Update(func(u *ent.SubscriptionWalletLedgerUpsert) {
+// SetSubscriptionID(v+v).
+// }).
+// Exec(ctx)
+func (_c *SubscriptionWalletLedgerCreate) OnConflict(opts ...sql.ConflictOption) *SubscriptionWalletLedgerUpsertOne {
+ _c.conflict = opts
+ return &SubscriptionWalletLedgerUpsertOne{
+ create: _c,
+ }
+}
+
+// OnConflictColumns calls `OnConflict` and configures the columns
+// as conflict target. Using this option is equivalent to using:
+//
+// client.SubscriptionWalletLedger.Create().
+// OnConflict(sql.ConflictColumns(columns...)).
+// Exec(ctx)
+func (_c *SubscriptionWalletLedgerCreate) OnConflictColumns(columns ...string) *SubscriptionWalletLedgerUpsertOne {
+ _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...))
+ return &SubscriptionWalletLedgerUpsertOne{
+ create: _c,
+ }
+}
+
+type (
+ // SubscriptionWalletLedgerUpsertOne is the builder for "upsert"-ing
+ // one SubscriptionWalletLedger node.
+ SubscriptionWalletLedgerUpsertOne struct {
+ create *SubscriptionWalletLedgerCreate
+ }
+
+ // SubscriptionWalletLedgerUpsert is the "OnConflict" setter.
+ SubscriptionWalletLedgerUpsert struct {
+ *sql.UpdateSet
+ }
+)
+
+// SetSubscriptionID sets the "subscription_id" field.
+func (u *SubscriptionWalletLedgerUpsert) SetSubscriptionID(v int64) *SubscriptionWalletLedgerUpsert {
+ u.Set(subscriptionwalletledger.FieldSubscriptionID, v)
+ return u
+}
+
+// UpdateSubscriptionID sets the "subscription_id" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsert) UpdateSubscriptionID() *SubscriptionWalletLedgerUpsert {
+ u.SetExcluded(subscriptionwalletledger.FieldSubscriptionID)
+ return u
+}
+
+// SetDeltaUsd sets the "delta_usd" field.
+func (u *SubscriptionWalletLedgerUpsert) SetDeltaUsd(v float64) *SubscriptionWalletLedgerUpsert {
+ u.Set(subscriptionwalletledger.FieldDeltaUsd, v)
+ return u
+}
+
+// UpdateDeltaUsd sets the "delta_usd" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsert) UpdateDeltaUsd() *SubscriptionWalletLedgerUpsert {
+ u.SetExcluded(subscriptionwalletledger.FieldDeltaUsd)
+ return u
+}
+
+// AddDeltaUsd adds v to the "delta_usd" field.
+func (u *SubscriptionWalletLedgerUpsert) AddDeltaUsd(v float64) *SubscriptionWalletLedgerUpsert {
+ u.Add(subscriptionwalletledger.FieldDeltaUsd, v)
+ return u
+}
+
+// SetBalanceAfter sets the "balance_after" field.
+func (u *SubscriptionWalletLedgerUpsert) SetBalanceAfter(v float64) *SubscriptionWalletLedgerUpsert {
+ u.Set(subscriptionwalletledger.FieldBalanceAfter, v)
+ return u
+}
+
+// UpdateBalanceAfter sets the "balance_after" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsert) UpdateBalanceAfter() *SubscriptionWalletLedgerUpsert {
+ u.SetExcluded(subscriptionwalletledger.FieldBalanceAfter)
+ return u
+}
+
+// AddBalanceAfter adds v to the "balance_after" field.
+func (u *SubscriptionWalletLedgerUpsert) AddBalanceAfter(v float64) *SubscriptionWalletLedgerUpsert {
+ u.Add(subscriptionwalletledger.FieldBalanceAfter, v)
+ return u
+}
+
+// SetReason sets the "reason" field.
+func (u *SubscriptionWalletLedgerUpsert) SetReason(v string) *SubscriptionWalletLedgerUpsert {
+ u.Set(subscriptionwalletledger.FieldReason, v)
+ return u
+}
+
+// UpdateReason sets the "reason" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsert) UpdateReason() *SubscriptionWalletLedgerUpsert {
+ u.SetExcluded(subscriptionwalletledger.FieldReason)
+ return u
+}
+
+// SetPaymentOrderID sets the "payment_order_id" field.
+func (u *SubscriptionWalletLedgerUpsert) SetPaymentOrderID(v int64) *SubscriptionWalletLedgerUpsert {
+ u.Set(subscriptionwalletledger.FieldPaymentOrderID, v)
+ return u
+}
+
+// UpdatePaymentOrderID sets the "payment_order_id" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsert) UpdatePaymentOrderID() *SubscriptionWalletLedgerUpsert {
+ u.SetExcluded(subscriptionwalletledger.FieldPaymentOrderID)
+ return u
+}
+
+// AddPaymentOrderID adds v to the "payment_order_id" field.
+func (u *SubscriptionWalletLedgerUpsert) AddPaymentOrderID(v int64) *SubscriptionWalletLedgerUpsert {
+ u.Add(subscriptionwalletledger.FieldPaymentOrderID, v)
+ return u
+}
+
+// ClearPaymentOrderID clears the value of the "payment_order_id" field.
+func (u *SubscriptionWalletLedgerUpsert) ClearPaymentOrderID() *SubscriptionWalletLedgerUpsert {
+ u.SetNull(subscriptionwalletledger.FieldPaymentOrderID)
+ return u
+}
+
+// SetUsageLogID sets the "usage_log_id" field.
+func (u *SubscriptionWalletLedgerUpsert) SetUsageLogID(v int64) *SubscriptionWalletLedgerUpsert {
+ u.Set(subscriptionwalletledger.FieldUsageLogID, v)
+ return u
+}
+
+// UpdateUsageLogID sets the "usage_log_id" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsert) UpdateUsageLogID() *SubscriptionWalletLedgerUpsert {
+ u.SetExcluded(subscriptionwalletledger.FieldUsageLogID)
+ return u
+}
+
+// ClearUsageLogID clears the value of the "usage_log_id" field.
+func (u *SubscriptionWalletLedgerUpsert) ClearUsageLogID() *SubscriptionWalletLedgerUpsert {
+ u.SetNull(subscriptionwalletledger.FieldUsageLogID)
+ return u
+}
+
+// SetOperatorID sets the "operator_id" field.
+func (u *SubscriptionWalletLedgerUpsert) SetOperatorID(v int64) *SubscriptionWalletLedgerUpsert {
+ u.Set(subscriptionwalletledger.FieldOperatorID, v)
+ return u
+}
+
+// UpdateOperatorID sets the "operator_id" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsert) UpdateOperatorID() *SubscriptionWalletLedgerUpsert {
+ u.SetExcluded(subscriptionwalletledger.FieldOperatorID)
+ return u
+}
+
+// ClearOperatorID clears the value of the "operator_id" field.
+func (u *SubscriptionWalletLedgerUpsert) ClearOperatorID() *SubscriptionWalletLedgerUpsert {
+ u.SetNull(subscriptionwalletledger.FieldOperatorID)
+ return u
+}
+
+// SetNotes sets the "notes" field.
+func (u *SubscriptionWalletLedgerUpsert) SetNotes(v string) *SubscriptionWalletLedgerUpsert {
+ u.Set(subscriptionwalletledger.FieldNotes, v)
+ return u
+}
+
+// UpdateNotes sets the "notes" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsert) UpdateNotes() *SubscriptionWalletLedgerUpsert {
+ u.SetExcluded(subscriptionwalletledger.FieldNotes)
+ return u
+}
+
+// ClearNotes clears the value of the "notes" field.
+func (u *SubscriptionWalletLedgerUpsert) ClearNotes() *SubscriptionWalletLedgerUpsert {
+ u.SetNull(subscriptionwalletledger.FieldNotes)
+ return u
+}
+
+// UpdateNewValues updates the mutable fields using the new values that were set on create.
+// Using this option is equivalent to using:
+//
+// client.SubscriptionWalletLedger.Create().
+// OnConflict(
+// sql.ResolveWithNewValues(),
+// ).
+// Exec(ctx)
+func (u *SubscriptionWalletLedgerUpsertOne) UpdateNewValues() *SubscriptionWalletLedgerUpsertOne {
+ u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues())
+ u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) {
+ if _, exists := u.create.mutation.CreatedAt(); exists {
+ s.SetIgnore(subscriptionwalletledger.FieldCreatedAt)
+ }
+ }))
+ return u
+}
+
+// Ignore sets each column to itself in case of conflict.
+// Using this option is equivalent to using:
+//
+// client.SubscriptionWalletLedger.Create().
+// OnConflict(sql.ResolveWithIgnore()).
+// Exec(ctx)
+func (u *SubscriptionWalletLedgerUpsertOne) Ignore() *SubscriptionWalletLedgerUpsertOne {
+ u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore())
+ return u
+}
+
+// DoNothing configures the conflict_action to `DO NOTHING`.
+// Supported only by SQLite and PostgreSQL.
+func (u *SubscriptionWalletLedgerUpsertOne) DoNothing() *SubscriptionWalletLedgerUpsertOne {
+ u.create.conflict = append(u.create.conflict, sql.DoNothing())
+ return u
+}
+
+// Update allows overriding fields `UPDATE` values. See the SubscriptionWalletLedgerCreate.OnConflict
+// documentation for more info.
+func (u *SubscriptionWalletLedgerUpsertOne) Update(set func(*SubscriptionWalletLedgerUpsert)) *SubscriptionWalletLedgerUpsertOne {
+ u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) {
+ set(&SubscriptionWalletLedgerUpsert{UpdateSet: update})
+ }))
+ return u
+}
+
+// SetSubscriptionID sets the "subscription_id" field.
+func (u *SubscriptionWalletLedgerUpsertOne) SetSubscriptionID(v int64) *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.SetSubscriptionID(v)
+ })
+}
+
+// UpdateSubscriptionID sets the "subscription_id" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsertOne) UpdateSubscriptionID() *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.UpdateSubscriptionID()
+ })
+}
+
+// SetDeltaUsd sets the "delta_usd" field.
+func (u *SubscriptionWalletLedgerUpsertOne) SetDeltaUsd(v float64) *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.SetDeltaUsd(v)
+ })
+}
+
+// AddDeltaUsd adds v to the "delta_usd" field.
+func (u *SubscriptionWalletLedgerUpsertOne) AddDeltaUsd(v float64) *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.AddDeltaUsd(v)
+ })
+}
+
+// UpdateDeltaUsd sets the "delta_usd" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsertOne) UpdateDeltaUsd() *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.UpdateDeltaUsd()
+ })
+}
+
+// SetBalanceAfter sets the "balance_after" field.
+func (u *SubscriptionWalletLedgerUpsertOne) SetBalanceAfter(v float64) *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.SetBalanceAfter(v)
+ })
+}
+
+// AddBalanceAfter adds v to the "balance_after" field.
+func (u *SubscriptionWalletLedgerUpsertOne) AddBalanceAfter(v float64) *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.AddBalanceAfter(v)
+ })
+}
+
+// UpdateBalanceAfter sets the "balance_after" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsertOne) UpdateBalanceAfter() *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.UpdateBalanceAfter()
+ })
+}
+
+// SetReason sets the "reason" field.
+func (u *SubscriptionWalletLedgerUpsertOne) SetReason(v string) *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.SetReason(v)
+ })
+}
+
+// UpdateReason sets the "reason" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsertOne) UpdateReason() *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.UpdateReason()
+ })
+}
+
+// SetPaymentOrderID sets the "payment_order_id" field.
+func (u *SubscriptionWalletLedgerUpsertOne) SetPaymentOrderID(v int64) *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.SetPaymentOrderID(v)
+ })
+}
+
+// AddPaymentOrderID adds v to the "payment_order_id" field.
+func (u *SubscriptionWalletLedgerUpsertOne) AddPaymentOrderID(v int64) *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.AddPaymentOrderID(v)
+ })
+}
+
+// UpdatePaymentOrderID sets the "payment_order_id" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsertOne) UpdatePaymentOrderID() *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.UpdatePaymentOrderID()
+ })
+}
+
+// ClearPaymentOrderID clears the value of the "payment_order_id" field.
+func (u *SubscriptionWalletLedgerUpsertOne) ClearPaymentOrderID() *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.ClearPaymentOrderID()
+ })
+}
+
+// SetUsageLogID sets the "usage_log_id" field.
+func (u *SubscriptionWalletLedgerUpsertOne) SetUsageLogID(v int64) *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.SetUsageLogID(v)
+ })
+}
+
+// UpdateUsageLogID sets the "usage_log_id" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsertOne) UpdateUsageLogID() *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.UpdateUsageLogID()
+ })
+}
+
+// ClearUsageLogID clears the value of the "usage_log_id" field.
+func (u *SubscriptionWalletLedgerUpsertOne) ClearUsageLogID() *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.ClearUsageLogID()
+ })
+}
+
+// SetOperatorID sets the "operator_id" field.
+func (u *SubscriptionWalletLedgerUpsertOne) SetOperatorID(v int64) *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.SetOperatorID(v)
+ })
+}
+
+// UpdateOperatorID sets the "operator_id" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsertOne) UpdateOperatorID() *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.UpdateOperatorID()
+ })
+}
+
+// ClearOperatorID clears the value of the "operator_id" field.
+func (u *SubscriptionWalletLedgerUpsertOne) ClearOperatorID() *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.ClearOperatorID()
+ })
+}
+
+// SetNotes sets the "notes" field.
+func (u *SubscriptionWalletLedgerUpsertOne) SetNotes(v string) *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.SetNotes(v)
+ })
+}
+
+// UpdateNotes sets the "notes" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsertOne) UpdateNotes() *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.UpdateNotes()
+ })
+}
+
+// ClearNotes clears the value of the "notes" field.
+func (u *SubscriptionWalletLedgerUpsertOne) ClearNotes() *SubscriptionWalletLedgerUpsertOne {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.ClearNotes()
+ })
+}
+
+// Exec executes the query.
+func (u *SubscriptionWalletLedgerUpsertOne) Exec(ctx context.Context) error {
+ if len(u.create.conflict) == 0 {
+ return errors.New("ent: missing options for SubscriptionWalletLedgerCreate.OnConflict")
+ }
+ return u.create.Exec(ctx)
+}
+
+// ExecX is like Exec, but panics if an error occurs.
+func (u *SubscriptionWalletLedgerUpsertOne) ExecX(ctx context.Context) {
+ if err := u.create.Exec(ctx); err != nil {
+ panic(err)
+ }
+}
+
+// Exec executes the UPSERT query and returns the inserted/updated ID.
+func (u *SubscriptionWalletLedgerUpsertOne) ID(ctx context.Context) (id int64, err error) {
+ node, err := u.create.Save(ctx)
+ if err != nil {
+ return id, err
+ }
+ return node.ID, nil
+}
+
+// IDX is like ID, but panics if an error occurs.
+func (u *SubscriptionWalletLedgerUpsertOne) IDX(ctx context.Context) int64 {
+ id, err := u.ID(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return id
+}
+
+// SubscriptionWalletLedgerCreateBulk is the builder for creating many SubscriptionWalletLedger entities in bulk.
+type SubscriptionWalletLedgerCreateBulk struct {
+ config
+ err error
+ builders []*SubscriptionWalletLedgerCreate
+ conflict []sql.ConflictOption
+}
+
+// Save creates the SubscriptionWalletLedger entities in the database.
+func (_c *SubscriptionWalletLedgerCreateBulk) Save(ctx context.Context) ([]*SubscriptionWalletLedger, error) {
+ if _c.err != nil {
+ return nil, _c.err
+ }
+ specs := make([]*sqlgraph.CreateSpec, len(_c.builders))
+ nodes := make([]*SubscriptionWalletLedger, len(_c.builders))
+ mutators := make([]Mutator, len(_c.builders))
+ for i := range _c.builders {
+ func(i int, root context.Context) {
+ builder := _c.builders[i]
+ builder.defaults()
+ var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) {
+ mutation, ok := m.(*SubscriptionWalletLedgerMutation)
+ if !ok {
+ return nil, fmt.Errorf("unexpected mutation type %T", m)
+ }
+ if err := builder.check(); err != nil {
+ return nil, err
+ }
+ builder.mutation = mutation
+ var err error
+ nodes[i], specs[i] = builder.createSpec()
+ if i < len(mutators)-1 {
+ _, err = mutators[i+1].Mutate(root, _c.builders[i+1].mutation)
+ } else {
+ spec := &sqlgraph.BatchCreateSpec{Nodes: specs}
+ spec.OnConflict = _c.conflict
+ // Invoke the actual operation on the latest mutation in the chain.
+ if err = sqlgraph.BatchCreate(ctx, _c.driver, spec); err != nil {
+ if sqlgraph.IsConstraintError(err) {
+ err = &ConstraintError{msg: err.Error(), wrap: err}
+ }
+ }
+ }
+ if err != nil {
+ return nil, err
+ }
+ mutation.id = &nodes[i].ID
+ if specs[i].ID.Value != nil {
+ id := specs[i].ID.Value.(int64)
+ nodes[i].ID = int64(id)
+ }
+ mutation.done = true
+ return nodes[i], nil
+ })
+ for i := len(builder.hooks) - 1; i >= 0; i-- {
+ mut = builder.hooks[i](mut)
+ }
+ mutators[i] = mut
+ }(i, ctx)
+ }
+ if len(mutators) > 0 {
+ if _, err := mutators[0].Mutate(ctx, _c.builders[0].mutation); err != nil {
+ return nil, err
+ }
+ }
+ return nodes, nil
+}
+
+// SaveX is like Save, but panics if an error occurs.
+func (_c *SubscriptionWalletLedgerCreateBulk) SaveX(ctx context.Context) []*SubscriptionWalletLedger {
+ v, err := _c.Save(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return v
+}
+
+// Exec executes the query.
+func (_c *SubscriptionWalletLedgerCreateBulk) Exec(ctx context.Context) error {
+ _, err := _c.Save(ctx)
+ return err
+}
+
+// ExecX is like Exec, but panics if an error occurs.
+func (_c *SubscriptionWalletLedgerCreateBulk) ExecX(ctx context.Context) {
+ if err := _c.Exec(ctx); err != nil {
+ panic(err)
+ }
+}
+
+// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause
+// of the `INSERT` statement. For example:
+//
+// client.SubscriptionWalletLedger.CreateBulk(builders...).
+// OnConflict(
+// // Update the row with the new values
+// // the was proposed for insertion.
+// sql.ResolveWithNewValues(),
+// ).
+// // Override some of the fields with custom
+// // update values.
+// Update(func(u *ent.SubscriptionWalletLedgerUpsert) {
+// SetSubscriptionID(v+v).
+// }).
+// Exec(ctx)
+func (_c *SubscriptionWalletLedgerCreateBulk) OnConflict(opts ...sql.ConflictOption) *SubscriptionWalletLedgerUpsertBulk {
+ _c.conflict = opts
+ return &SubscriptionWalletLedgerUpsertBulk{
+ create: _c,
+ }
+}
+
+// OnConflictColumns calls `OnConflict` and configures the columns
+// as conflict target. Using this option is equivalent to using:
+//
+// client.SubscriptionWalletLedger.Create().
+// OnConflict(sql.ConflictColumns(columns...)).
+// Exec(ctx)
+func (_c *SubscriptionWalletLedgerCreateBulk) OnConflictColumns(columns ...string) *SubscriptionWalletLedgerUpsertBulk {
+ _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...))
+ return &SubscriptionWalletLedgerUpsertBulk{
+ create: _c,
+ }
+}
+
+// SubscriptionWalletLedgerUpsertBulk is the builder for "upsert"-ing
+// a bulk of SubscriptionWalletLedger nodes.
+type SubscriptionWalletLedgerUpsertBulk struct {
+ create *SubscriptionWalletLedgerCreateBulk
+}
+
+// UpdateNewValues updates the mutable fields using the new values that
+// were set on create. Using this option is equivalent to using:
+//
+// client.SubscriptionWalletLedger.Create().
+// OnConflict(
+// sql.ResolveWithNewValues(),
+// ).
+// Exec(ctx)
+func (u *SubscriptionWalletLedgerUpsertBulk) UpdateNewValues() *SubscriptionWalletLedgerUpsertBulk {
+ u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues())
+ u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) {
+ for _, b := range u.create.builders {
+ if _, exists := b.mutation.CreatedAt(); exists {
+ s.SetIgnore(subscriptionwalletledger.FieldCreatedAt)
+ }
+ }
+ }))
+ return u
+}
+
+// Ignore sets each column to itself in case of conflict.
+// Using this option is equivalent to using:
+//
+// client.SubscriptionWalletLedger.Create().
+// OnConflict(sql.ResolveWithIgnore()).
+// Exec(ctx)
+func (u *SubscriptionWalletLedgerUpsertBulk) Ignore() *SubscriptionWalletLedgerUpsertBulk {
+ u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore())
+ return u
+}
+
+// DoNothing configures the conflict_action to `DO NOTHING`.
+// Supported only by SQLite and PostgreSQL.
+func (u *SubscriptionWalletLedgerUpsertBulk) DoNothing() *SubscriptionWalletLedgerUpsertBulk {
+ u.create.conflict = append(u.create.conflict, sql.DoNothing())
+ return u
+}
+
+// Update allows overriding fields `UPDATE` values. See the SubscriptionWalletLedgerCreateBulk.OnConflict
+// documentation for more info.
+func (u *SubscriptionWalletLedgerUpsertBulk) Update(set func(*SubscriptionWalletLedgerUpsert)) *SubscriptionWalletLedgerUpsertBulk {
+ u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) {
+ set(&SubscriptionWalletLedgerUpsert{UpdateSet: update})
+ }))
+ return u
+}
+
+// SetSubscriptionID sets the "subscription_id" field.
+func (u *SubscriptionWalletLedgerUpsertBulk) SetSubscriptionID(v int64) *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.SetSubscriptionID(v)
+ })
+}
+
+// UpdateSubscriptionID sets the "subscription_id" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsertBulk) UpdateSubscriptionID() *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.UpdateSubscriptionID()
+ })
+}
+
+// SetDeltaUsd sets the "delta_usd" field.
+func (u *SubscriptionWalletLedgerUpsertBulk) SetDeltaUsd(v float64) *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.SetDeltaUsd(v)
+ })
+}
+
+// AddDeltaUsd adds v to the "delta_usd" field.
+func (u *SubscriptionWalletLedgerUpsertBulk) AddDeltaUsd(v float64) *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.AddDeltaUsd(v)
+ })
+}
+
+// UpdateDeltaUsd sets the "delta_usd" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsertBulk) UpdateDeltaUsd() *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.UpdateDeltaUsd()
+ })
+}
+
+// SetBalanceAfter sets the "balance_after" field.
+func (u *SubscriptionWalletLedgerUpsertBulk) SetBalanceAfter(v float64) *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.SetBalanceAfter(v)
+ })
+}
+
+// AddBalanceAfter adds v to the "balance_after" field.
+func (u *SubscriptionWalletLedgerUpsertBulk) AddBalanceAfter(v float64) *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.AddBalanceAfter(v)
+ })
+}
+
+// UpdateBalanceAfter sets the "balance_after" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsertBulk) UpdateBalanceAfter() *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.UpdateBalanceAfter()
+ })
+}
+
+// SetReason sets the "reason" field.
+func (u *SubscriptionWalletLedgerUpsertBulk) SetReason(v string) *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.SetReason(v)
+ })
+}
+
+// UpdateReason sets the "reason" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsertBulk) UpdateReason() *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.UpdateReason()
+ })
+}
+
+// SetPaymentOrderID sets the "payment_order_id" field.
+func (u *SubscriptionWalletLedgerUpsertBulk) SetPaymentOrderID(v int64) *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.SetPaymentOrderID(v)
+ })
+}
+
+// AddPaymentOrderID adds v to the "payment_order_id" field.
+func (u *SubscriptionWalletLedgerUpsertBulk) AddPaymentOrderID(v int64) *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.AddPaymentOrderID(v)
+ })
+}
+
+// UpdatePaymentOrderID sets the "payment_order_id" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsertBulk) UpdatePaymentOrderID() *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.UpdatePaymentOrderID()
+ })
+}
+
+// ClearPaymentOrderID clears the value of the "payment_order_id" field.
+func (u *SubscriptionWalletLedgerUpsertBulk) ClearPaymentOrderID() *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.ClearPaymentOrderID()
+ })
+}
+
+// SetUsageLogID sets the "usage_log_id" field.
+func (u *SubscriptionWalletLedgerUpsertBulk) SetUsageLogID(v int64) *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.SetUsageLogID(v)
+ })
+}
+
+// UpdateUsageLogID sets the "usage_log_id" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsertBulk) UpdateUsageLogID() *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.UpdateUsageLogID()
+ })
+}
+
+// ClearUsageLogID clears the value of the "usage_log_id" field.
+func (u *SubscriptionWalletLedgerUpsertBulk) ClearUsageLogID() *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.ClearUsageLogID()
+ })
+}
+
+// SetOperatorID sets the "operator_id" field.
+func (u *SubscriptionWalletLedgerUpsertBulk) SetOperatorID(v int64) *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.SetOperatorID(v)
+ })
+}
+
+// UpdateOperatorID sets the "operator_id" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsertBulk) UpdateOperatorID() *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.UpdateOperatorID()
+ })
+}
+
+// ClearOperatorID clears the value of the "operator_id" field.
+func (u *SubscriptionWalletLedgerUpsertBulk) ClearOperatorID() *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.ClearOperatorID()
+ })
+}
+
+// SetNotes sets the "notes" field.
+func (u *SubscriptionWalletLedgerUpsertBulk) SetNotes(v string) *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.SetNotes(v)
+ })
+}
+
+// UpdateNotes sets the "notes" field to the value that was provided on create.
+func (u *SubscriptionWalletLedgerUpsertBulk) UpdateNotes() *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.UpdateNotes()
+ })
+}
+
+// ClearNotes clears the value of the "notes" field.
+func (u *SubscriptionWalletLedgerUpsertBulk) ClearNotes() *SubscriptionWalletLedgerUpsertBulk {
+ return u.Update(func(s *SubscriptionWalletLedgerUpsert) {
+ s.ClearNotes()
+ })
+}
+
+// Exec executes the query.
+func (u *SubscriptionWalletLedgerUpsertBulk) Exec(ctx context.Context) error {
+ if u.create.err != nil {
+ return u.create.err
+ }
+ for i, b := range u.create.builders {
+ if len(b.conflict) != 0 {
+ return fmt.Errorf("ent: OnConflict was set for builder %d. Set it on the SubscriptionWalletLedgerCreateBulk instead", i)
+ }
+ }
+ if len(u.create.conflict) == 0 {
+ return errors.New("ent: missing options for SubscriptionWalletLedgerCreateBulk.OnConflict")
+ }
+ return u.create.Exec(ctx)
+}
+
+// ExecX is like Exec, but panics if an error occurs.
+func (u *SubscriptionWalletLedgerUpsertBulk) ExecX(ctx context.Context) {
+ if err := u.create.Exec(ctx); err != nil {
+ panic(err)
+ }
+}
diff --git a/backend/ent/subscriptionwalletledger_delete.go b/backend/ent/subscriptionwalletledger_delete.go
new file mode 100644
index 00000000000..fb759a63899
--- /dev/null
+++ b/backend/ent/subscriptionwalletledger_delete.go
@@ -0,0 +1,88 @@
+// Code generated by ent, DO NOT EDIT.
+
+package ent
+
+import (
+ "context"
+
+ "entgo.io/ent/dialect/sql"
+ "entgo.io/ent/dialect/sql/sqlgraph"
+ "entgo.io/ent/schema/field"
+ "github.com/Wei-Shaw/sub2api/ent/predicate"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
+)
+
+// SubscriptionWalletLedgerDelete is the builder for deleting a SubscriptionWalletLedger entity.
+type SubscriptionWalletLedgerDelete struct {
+ config
+ hooks []Hook
+ mutation *SubscriptionWalletLedgerMutation
+}
+
+// Where appends a list predicates to the SubscriptionWalletLedgerDelete builder.
+func (_d *SubscriptionWalletLedgerDelete) Where(ps ...predicate.SubscriptionWalletLedger) *SubscriptionWalletLedgerDelete {
+ _d.mutation.Where(ps...)
+ return _d
+}
+
+// Exec executes the deletion query and returns how many vertices were deleted.
+func (_d *SubscriptionWalletLedgerDelete) Exec(ctx context.Context) (int, error) {
+ return withHooks(ctx, _d.sqlExec, _d.mutation, _d.hooks)
+}
+
+// ExecX is like Exec, but panics if an error occurs.
+func (_d *SubscriptionWalletLedgerDelete) ExecX(ctx context.Context) int {
+ n, err := _d.Exec(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return n
+}
+
+func (_d *SubscriptionWalletLedgerDelete) sqlExec(ctx context.Context) (int, error) {
+ _spec := sqlgraph.NewDeleteSpec(subscriptionwalletledger.Table, sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64))
+ if ps := _d.mutation.predicates; len(ps) > 0 {
+ _spec.Predicate = func(selector *sql.Selector) {
+ for i := range ps {
+ ps[i](selector)
+ }
+ }
+ }
+ affected, err := sqlgraph.DeleteNodes(ctx, _d.driver, _spec)
+ if err != nil && sqlgraph.IsConstraintError(err) {
+ err = &ConstraintError{msg: err.Error(), wrap: err}
+ }
+ _d.mutation.done = true
+ return affected, err
+}
+
+// SubscriptionWalletLedgerDeleteOne is the builder for deleting a single SubscriptionWalletLedger entity.
+type SubscriptionWalletLedgerDeleteOne struct {
+ _d *SubscriptionWalletLedgerDelete
+}
+
+// Where appends a list predicates to the SubscriptionWalletLedgerDelete builder.
+func (_d *SubscriptionWalletLedgerDeleteOne) Where(ps ...predicate.SubscriptionWalletLedger) *SubscriptionWalletLedgerDeleteOne {
+ _d._d.mutation.Where(ps...)
+ return _d
+}
+
+// Exec executes the deletion query.
+func (_d *SubscriptionWalletLedgerDeleteOne) Exec(ctx context.Context) error {
+ n, err := _d._d.Exec(ctx)
+ switch {
+ case err != nil:
+ return err
+ case n == 0:
+ return &NotFoundError{subscriptionwalletledger.Label}
+ default:
+ return nil
+ }
+}
+
+// ExecX is like Exec, but panics if an error occurs.
+func (_d *SubscriptionWalletLedgerDeleteOne) ExecX(ctx context.Context) {
+ if err := _d.Exec(ctx); err != nil {
+ panic(err)
+ }
+}
diff --git a/backend/ent/subscriptionwalletledger_query.go b/backend/ent/subscriptionwalletledger_query.go
new file mode 100644
index 00000000000..67d54070704
--- /dev/null
+++ b/backend/ent/subscriptionwalletledger_query.go
@@ -0,0 +1,799 @@
+// Code generated by ent, DO NOT EDIT.
+
+package ent
+
+import (
+ "context"
+ "fmt"
+ "math"
+
+ "entgo.io/ent"
+ "entgo.io/ent/dialect"
+ "entgo.io/ent/dialect/sql"
+ "entgo.io/ent/dialect/sql/sqlgraph"
+ "entgo.io/ent/schema/field"
+ "github.com/Wei-Shaw/sub2api/ent/predicate"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
+ "github.com/Wei-Shaw/sub2api/ent/usagelog"
+ "github.com/Wei-Shaw/sub2api/ent/user"
+ "github.com/Wei-Shaw/sub2api/ent/usersubscription"
+)
+
+// SubscriptionWalletLedgerQuery is the builder for querying SubscriptionWalletLedger entities.
+type SubscriptionWalletLedgerQuery struct {
+ config
+ ctx *QueryContext
+ order []subscriptionwalletledger.OrderOption
+ inters []Interceptor
+ predicates []predicate.SubscriptionWalletLedger
+ withSubscription *UserSubscriptionQuery
+ withUsageLog *UsageLogQuery
+ withOperator *UserQuery
+ modifiers []func(*sql.Selector)
+ // intermediate query (i.e. traversal path).
+ sql *sql.Selector
+ path func(context.Context) (*sql.Selector, error)
+}
+
+// Where adds a new predicate for the SubscriptionWalletLedgerQuery builder.
+func (_q *SubscriptionWalletLedgerQuery) Where(ps ...predicate.SubscriptionWalletLedger) *SubscriptionWalletLedgerQuery {
+ _q.predicates = append(_q.predicates, ps...)
+ return _q
+}
+
+// Limit the number of records to be returned by this query.
+func (_q *SubscriptionWalletLedgerQuery) Limit(limit int) *SubscriptionWalletLedgerQuery {
+ _q.ctx.Limit = &limit
+ return _q
+}
+
+// Offset to start from.
+func (_q *SubscriptionWalletLedgerQuery) Offset(offset int) *SubscriptionWalletLedgerQuery {
+ _q.ctx.Offset = &offset
+ return _q
+}
+
+// Unique configures the query builder to filter duplicate records on query.
+// By default, unique is set to true, and can be disabled using this method.
+func (_q *SubscriptionWalletLedgerQuery) Unique(unique bool) *SubscriptionWalletLedgerQuery {
+ _q.ctx.Unique = &unique
+ return _q
+}
+
+// Order specifies how the records should be ordered.
+func (_q *SubscriptionWalletLedgerQuery) Order(o ...subscriptionwalletledger.OrderOption) *SubscriptionWalletLedgerQuery {
+ _q.order = append(_q.order, o...)
+ return _q
+}
+
+// QuerySubscription chains the current query on the "subscription" edge.
+func (_q *SubscriptionWalletLedgerQuery) QuerySubscription() *UserSubscriptionQuery {
+ query := (&UserSubscriptionClient{config: _q.config}).Query()
+ query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
+ if err := _q.prepareQuery(ctx); err != nil {
+ return nil, err
+ }
+ selector := _q.sqlQuery(ctx)
+ if err := selector.Err(); err != nil {
+ return nil, err
+ }
+ step := sqlgraph.NewStep(
+ sqlgraph.From(subscriptionwalletledger.Table, subscriptionwalletledger.FieldID, selector),
+ sqlgraph.To(usersubscription.Table, usersubscription.FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, subscriptionwalletledger.SubscriptionTable, subscriptionwalletledger.SubscriptionColumn),
+ )
+ fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
+ return fromU, nil
+ }
+ return query
+}
+
+// QueryUsageLog chains the current query on the "usage_log" edge.
+func (_q *SubscriptionWalletLedgerQuery) QueryUsageLog() *UsageLogQuery {
+ query := (&UsageLogClient{config: _q.config}).Query()
+ query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
+ if err := _q.prepareQuery(ctx); err != nil {
+ return nil, err
+ }
+ selector := _q.sqlQuery(ctx)
+ if err := selector.Err(); err != nil {
+ return nil, err
+ }
+ step := sqlgraph.NewStep(
+ sqlgraph.From(subscriptionwalletledger.Table, subscriptionwalletledger.FieldID, selector),
+ sqlgraph.To(usagelog.Table, usagelog.FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, subscriptionwalletledger.UsageLogTable, subscriptionwalletledger.UsageLogColumn),
+ )
+ fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
+ return fromU, nil
+ }
+ return query
+}
+
+// QueryOperator chains the current query on the "operator" edge.
+func (_q *SubscriptionWalletLedgerQuery) QueryOperator() *UserQuery {
+ query := (&UserClient{config: _q.config}).Query()
+ query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
+ if err := _q.prepareQuery(ctx); err != nil {
+ return nil, err
+ }
+ selector := _q.sqlQuery(ctx)
+ if err := selector.Err(); err != nil {
+ return nil, err
+ }
+ step := sqlgraph.NewStep(
+ sqlgraph.From(subscriptionwalletledger.Table, subscriptionwalletledger.FieldID, selector),
+ sqlgraph.To(user.Table, user.FieldID),
+ sqlgraph.Edge(sqlgraph.M2O, true, subscriptionwalletledger.OperatorTable, subscriptionwalletledger.OperatorColumn),
+ )
+ fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
+ return fromU, nil
+ }
+ return query
+}
+
+// First returns the first SubscriptionWalletLedger entity from the query.
+// Returns a *NotFoundError when no SubscriptionWalletLedger was found.
+func (_q *SubscriptionWalletLedgerQuery) First(ctx context.Context) (*SubscriptionWalletLedger, error) {
+ nodes, err := _q.Limit(1).All(setContextOp(ctx, _q.ctx, ent.OpQueryFirst))
+ if err != nil {
+ return nil, err
+ }
+ if len(nodes) == 0 {
+ return nil, &NotFoundError{subscriptionwalletledger.Label}
+ }
+ return nodes[0], nil
+}
+
+// FirstX is like First, but panics if an error occurs.
+func (_q *SubscriptionWalletLedgerQuery) FirstX(ctx context.Context) *SubscriptionWalletLedger {
+ node, err := _q.First(ctx)
+ if err != nil && !IsNotFound(err) {
+ panic(err)
+ }
+ return node
+}
+
+// FirstID returns the first SubscriptionWalletLedger ID from the query.
+// Returns a *NotFoundError when no SubscriptionWalletLedger ID was found.
+func (_q *SubscriptionWalletLedgerQuery) FirstID(ctx context.Context) (id int64, err error) {
+ var ids []int64
+ if ids, err = _q.Limit(1).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryFirstID)); err != nil {
+ return
+ }
+ if len(ids) == 0 {
+ err = &NotFoundError{subscriptionwalletledger.Label}
+ return
+ }
+ return ids[0], nil
+}
+
+// FirstIDX is like FirstID, but panics if an error occurs.
+func (_q *SubscriptionWalletLedgerQuery) FirstIDX(ctx context.Context) int64 {
+ id, err := _q.FirstID(ctx)
+ if err != nil && !IsNotFound(err) {
+ panic(err)
+ }
+ return id
+}
+
+// Only returns a single SubscriptionWalletLedger entity found by the query, ensuring it only returns one.
+// Returns a *NotSingularError when more than one SubscriptionWalletLedger entity is found.
+// Returns a *NotFoundError when no SubscriptionWalletLedger entities are found.
+func (_q *SubscriptionWalletLedgerQuery) Only(ctx context.Context) (*SubscriptionWalletLedger, error) {
+ nodes, err := _q.Limit(2).All(setContextOp(ctx, _q.ctx, ent.OpQueryOnly))
+ if err != nil {
+ return nil, err
+ }
+ switch len(nodes) {
+ case 1:
+ return nodes[0], nil
+ case 0:
+ return nil, &NotFoundError{subscriptionwalletledger.Label}
+ default:
+ return nil, &NotSingularError{subscriptionwalletledger.Label}
+ }
+}
+
+// OnlyX is like Only, but panics if an error occurs.
+func (_q *SubscriptionWalletLedgerQuery) OnlyX(ctx context.Context) *SubscriptionWalletLedger {
+ node, err := _q.Only(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return node
+}
+
+// OnlyID is like Only, but returns the only SubscriptionWalletLedger ID in the query.
+// Returns a *NotSingularError when more than one SubscriptionWalletLedger ID is found.
+// Returns a *NotFoundError when no entities are found.
+func (_q *SubscriptionWalletLedgerQuery) OnlyID(ctx context.Context) (id int64, err error) {
+ var ids []int64
+ if ids, err = _q.Limit(2).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryOnlyID)); err != nil {
+ return
+ }
+ switch len(ids) {
+ case 1:
+ id = ids[0]
+ case 0:
+ err = &NotFoundError{subscriptionwalletledger.Label}
+ default:
+ err = &NotSingularError{subscriptionwalletledger.Label}
+ }
+ return
+}
+
+// OnlyIDX is like OnlyID, but panics if an error occurs.
+func (_q *SubscriptionWalletLedgerQuery) OnlyIDX(ctx context.Context) int64 {
+ id, err := _q.OnlyID(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return id
+}
+
+// All executes the query and returns a list of SubscriptionWalletLedgers.
+func (_q *SubscriptionWalletLedgerQuery) All(ctx context.Context) ([]*SubscriptionWalletLedger, error) {
+ ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll)
+ if err := _q.prepareQuery(ctx); err != nil {
+ return nil, err
+ }
+ qr := querierAll[[]*SubscriptionWalletLedger, *SubscriptionWalletLedgerQuery]()
+ return withInterceptors[[]*SubscriptionWalletLedger](ctx, _q, qr, _q.inters)
+}
+
+// AllX is like All, but panics if an error occurs.
+func (_q *SubscriptionWalletLedgerQuery) AllX(ctx context.Context) []*SubscriptionWalletLedger {
+ nodes, err := _q.All(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return nodes
+}
+
+// IDs executes the query and returns a list of SubscriptionWalletLedger IDs.
+func (_q *SubscriptionWalletLedgerQuery) IDs(ctx context.Context) (ids []int64, err error) {
+ if _q.ctx.Unique == nil && _q.path != nil {
+ _q.Unique(true)
+ }
+ ctx = setContextOp(ctx, _q.ctx, ent.OpQueryIDs)
+ if err = _q.Select(subscriptionwalletledger.FieldID).Scan(ctx, &ids); err != nil {
+ return nil, err
+ }
+ return ids, nil
+}
+
+// IDsX is like IDs, but panics if an error occurs.
+func (_q *SubscriptionWalletLedgerQuery) IDsX(ctx context.Context) []int64 {
+ ids, err := _q.IDs(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return ids
+}
+
+// Count returns the count of the given query.
+func (_q *SubscriptionWalletLedgerQuery) Count(ctx context.Context) (int, error) {
+ ctx = setContextOp(ctx, _q.ctx, ent.OpQueryCount)
+ if err := _q.prepareQuery(ctx); err != nil {
+ return 0, err
+ }
+ return withInterceptors[int](ctx, _q, querierCount[*SubscriptionWalletLedgerQuery](), _q.inters)
+}
+
+// CountX is like Count, but panics if an error occurs.
+func (_q *SubscriptionWalletLedgerQuery) CountX(ctx context.Context) int {
+ count, err := _q.Count(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return count
+}
+
+// Exist returns true if the query has elements in the graph.
+func (_q *SubscriptionWalletLedgerQuery) Exist(ctx context.Context) (bool, error) {
+ ctx = setContextOp(ctx, _q.ctx, ent.OpQueryExist)
+ switch _, err := _q.FirstID(ctx); {
+ case IsNotFound(err):
+ return false, nil
+ case err != nil:
+ return false, fmt.Errorf("ent: check existence: %w", err)
+ default:
+ return true, nil
+ }
+}
+
+// ExistX is like Exist, but panics if an error occurs.
+func (_q *SubscriptionWalletLedgerQuery) ExistX(ctx context.Context) bool {
+ exist, err := _q.Exist(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return exist
+}
+
+// Clone returns a duplicate of the SubscriptionWalletLedgerQuery builder, including all associated steps. It can be
+// used to prepare common query builders and use them differently after the clone is made.
+func (_q *SubscriptionWalletLedgerQuery) Clone() *SubscriptionWalletLedgerQuery {
+ if _q == nil {
+ return nil
+ }
+ return &SubscriptionWalletLedgerQuery{
+ config: _q.config,
+ ctx: _q.ctx.Clone(),
+ order: append([]subscriptionwalletledger.OrderOption{}, _q.order...),
+ inters: append([]Interceptor{}, _q.inters...),
+ predicates: append([]predicate.SubscriptionWalletLedger{}, _q.predicates...),
+ withSubscription: _q.withSubscription.Clone(),
+ withUsageLog: _q.withUsageLog.Clone(),
+ withOperator: _q.withOperator.Clone(),
+ // clone intermediate query.
+ sql: _q.sql.Clone(),
+ path: _q.path,
+ }
+}
+
+// WithSubscription tells the query-builder to eager-load the nodes that are connected to
+// the "subscription" edge. The optional arguments are used to configure the query builder of the edge.
+func (_q *SubscriptionWalletLedgerQuery) WithSubscription(opts ...func(*UserSubscriptionQuery)) *SubscriptionWalletLedgerQuery {
+ query := (&UserSubscriptionClient{config: _q.config}).Query()
+ for _, opt := range opts {
+ opt(query)
+ }
+ _q.withSubscription = query
+ return _q
+}
+
+// WithUsageLog tells the query-builder to eager-load the nodes that are connected to
+// the "usage_log" edge. The optional arguments are used to configure the query builder of the edge.
+func (_q *SubscriptionWalletLedgerQuery) WithUsageLog(opts ...func(*UsageLogQuery)) *SubscriptionWalletLedgerQuery {
+ query := (&UsageLogClient{config: _q.config}).Query()
+ for _, opt := range opts {
+ opt(query)
+ }
+ _q.withUsageLog = query
+ return _q
+}
+
+// WithOperator tells the query-builder to eager-load the nodes that are connected to
+// the "operator" edge. The optional arguments are used to configure the query builder of the edge.
+func (_q *SubscriptionWalletLedgerQuery) WithOperator(opts ...func(*UserQuery)) *SubscriptionWalletLedgerQuery {
+ query := (&UserClient{config: _q.config}).Query()
+ for _, opt := range opts {
+ opt(query)
+ }
+ _q.withOperator = query
+ return _q
+}
+
+// GroupBy is used to group vertices by one or more fields/columns.
+// It is often used with aggregate functions, like: count, max, mean, min, sum.
+//
+// Example:
+//
+// var v []struct {
+// SubscriptionID int64 `json:"subscription_id,omitempty"`
+// Count int `json:"count,omitempty"`
+// }
+//
+// client.SubscriptionWalletLedger.Query().
+// GroupBy(subscriptionwalletledger.FieldSubscriptionID).
+// Aggregate(ent.Count()).
+// Scan(ctx, &v)
+func (_q *SubscriptionWalletLedgerQuery) GroupBy(field string, fields ...string) *SubscriptionWalletLedgerGroupBy {
+ _q.ctx.Fields = append([]string{field}, fields...)
+ grbuild := &SubscriptionWalletLedgerGroupBy{build: _q}
+ grbuild.flds = &_q.ctx.Fields
+ grbuild.label = subscriptionwalletledger.Label
+ grbuild.scan = grbuild.Scan
+ return grbuild
+}
+
+// Select allows the selection one or more fields/columns for the given query,
+// instead of selecting all fields in the entity.
+//
+// Example:
+//
+// var v []struct {
+// SubscriptionID int64 `json:"subscription_id,omitempty"`
+// }
+//
+// client.SubscriptionWalletLedger.Query().
+// Select(subscriptionwalletledger.FieldSubscriptionID).
+// Scan(ctx, &v)
+func (_q *SubscriptionWalletLedgerQuery) Select(fields ...string) *SubscriptionWalletLedgerSelect {
+ _q.ctx.Fields = append(_q.ctx.Fields, fields...)
+ sbuild := &SubscriptionWalletLedgerSelect{SubscriptionWalletLedgerQuery: _q}
+ sbuild.label = subscriptionwalletledger.Label
+ sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan
+ return sbuild
+}
+
+// Aggregate returns a SubscriptionWalletLedgerSelect configured with the given aggregations.
+func (_q *SubscriptionWalletLedgerQuery) Aggregate(fns ...AggregateFunc) *SubscriptionWalletLedgerSelect {
+ return _q.Select().Aggregate(fns...)
+}
+
+func (_q *SubscriptionWalletLedgerQuery) prepareQuery(ctx context.Context) error {
+ for _, inter := range _q.inters {
+ if inter == nil {
+ return fmt.Errorf("ent: uninitialized interceptor (forgotten import ent/runtime?)")
+ }
+ if trv, ok := inter.(Traverser); ok {
+ if err := trv.Traverse(ctx, _q); err != nil {
+ return err
+ }
+ }
+ }
+ for _, f := range _q.ctx.Fields {
+ if !subscriptionwalletledger.ValidColumn(f) {
+ return &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)}
+ }
+ }
+ if _q.path != nil {
+ prev, err := _q.path(ctx)
+ if err != nil {
+ return err
+ }
+ _q.sql = prev
+ }
+ return nil
+}
+
+func (_q *SubscriptionWalletLedgerQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*SubscriptionWalletLedger, error) {
+ var (
+ nodes = []*SubscriptionWalletLedger{}
+ _spec = _q.querySpec()
+ loadedTypes = [3]bool{
+ _q.withSubscription != nil,
+ _q.withUsageLog != nil,
+ _q.withOperator != nil,
+ }
+ )
+ _spec.ScanValues = func(columns []string) ([]any, error) {
+ return (*SubscriptionWalletLedger).scanValues(nil, columns)
+ }
+ _spec.Assign = func(columns []string, values []any) error {
+ node := &SubscriptionWalletLedger{config: _q.config}
+ nodes = append(nodes, node)
+ node.Edges.loadedTypes = loadedTypes
+ return node.assignValues(columns, values)
+ }
+ if len(_q.modifiers) > 0 {
+ _spec.Modifiers = _q.modifiers
+ }
+ for i := range hooks {
+ hooks[i](ctx, _spec)
+ }
+ if err := sqlgraph.QueryNodes(ctx, _q.driver, _spec); err != nil {
+ return nil, err
+ }
+ if len(nodes) == 0 {
+ return nodes, nil
+ }
+ if query := _q.withSubscription; query != nil {
+ if err := _q.loadSubscription(ctx, query, nodes, nil,
+ func(n *SubscriptionWalletLedger, e *UserSubscription) { n.Edges.Subscription = e }); err != nil {
+ return nil, err
+ }
+ }
+ if query := _q.withUsageLog; query != nil {
+ if err := _q.loadUsageLog(ctx, query, nodes, nil,
+ func(n *SubscriptionWalletLedger, e *UsageLog) { n.Edges.UsageLog = e }); err != nil {
+ return nil, err
+ }
+ }
+ if query := _q.withOperator; query != nil {
+ if err := _q.loadOperator(ctx, query, nodes, nil,
+ func(n *SubscriptionWalletLedger, e *User) { n.Edges.Operator = e }); err != nil {
+ return nil, err
+ }
+ }
+ return nodes, nil
+}
+
+func (_q *SubscriptionWalletLedgerQuery) loadSubscription(ctx context.Context, query *UserSubscriptionQuery, nodes []*SubscriptionWalletLedger, init func(*SubscriptionWalletLedger), assign func(*SubscriptionWalletLedger, *UserSubscription)) error {
+ ids := make([]int64, 0, len(nodes))
+ nodeids := make(map[int64][]*SubscriptionWalletLedger)
+ for i := range nodes {
+ fk := nodes[i].SubscriptionID
+ if _, ok := nodeids[fk]; !ok {
+ ids = append(ids, fk)
+ }
+ nodeids[fk] = append(nodeids[fk], nodes[i])
+ }
+ if len(ids) == 0 {
+ return nil
+ }
+ query.Where(usersubscription.IDIn(ids...))
+ neighbors, err := query.All(ctx)
+ if err != nil {
+ return err
+ }
+ for _, n := range neighbors {
+ nodes, ok := nodeids[n.ID]
+ if !ok {
+ return fmt.Errorf(`unexpected foreign-key "subscription_id" returned %v`, n.ID)
+ }
+ for i := range nodes {
+ assign(nodes[i], n)
+ }
+ }
+ return nil
+}
+func (_q *SubscriptionWalletLedgerQuery) loadUsageLog(ctx context.Context, query *UsageLogQuery, nodes []*SubscriptionWalletLedger, init func(*SubscriptionWalletLedger), assign func(*SubscriptionWalletLedger, *UsageLog)) error {
+ ids := make([]int64, 0, len(nodes))
+ nodeids := make(map[int64][]*SubscriptionWalletLedger)
+ for i := range nodes {
+ if nodes[i].UsageLogID == nil {
+ continue
+ }
+ fk := *nodes[i].UsageLogID
+ if _, ok := nodeids[fk]; !ok {
+ ids = append(ids, fk)
+ }
+ nodeids[fk] = append(nodeids[fk], nodes[i])
+ }
+ if len(ids) == 0 {
+ return nil
+ }
+ query.Where(usagelog.IDIn(ids...))
+ neighbors, err := query.All(ctx)
+ if err != nil {
+ return err
+ }
+ for _, n := range neighbors {
+ nodes, ok := nodeids[n.ID]
+ if !ok {
+ return fmt.Errorf(`unexpected foreign-key "usage_log_id" returned %v`, n.ID)
+ }
+ for i := range nodes {
+ assign(nodes[i], n)
+ }
+ }
+ return nil
+}
+func (_q *SubscriptionWalletLedgerQuery) loadOperator(ctx context.Context, query *UserQuery, nodes []*SubscriptionWalletLedger, init func(*SubscriptionWalletLedger), assign func(*SubscriptionWalletLedger, *User)) error {
+ ids := make([]int64, 0, len(nodes))
+ nodeids := make(map[int64][]*SubscriptionWalletLedger)
+ for i := range nodes {
+ if nodes[i].OperatorID == nil {
+ continue
+ }
+ fk := *nodes[i].OperatorID
+ if _, ok := nodeids[fk]; !ok {
+ ids = append(ids, fk)
+ }
+ nodeids[fk] = append(nodeids[fk], nodes[i])
+ }
+ if len(ids) == 0 {
+ return nil
+ }
+ query.Where(user.IDIn(ids...))
+ neighbors, err := query.All(ctx)
+ if err != nil {
+ return err
+ }
+ for _, n := range neighbors {
+ nodes, ok := nodeids[n.ID]
+ if !ok {
+ return fmt.Errorf(`unexpected foreign-key "operator_id" returned %v`, n.ID)
+ }
+ for i := range nodes {
+ assign(nodes[i], n)
+ }
+ }
+ return nil
+}
+
+func (_q *SubscriptionWalletLedgerQuery) sqlCount(ctx context.Context) (int, error) {
+ _spec := _q.querySpec()
+ if len(_q.modifiers) > 0 {
+ _spec.Modifiers = _q.modifiers
+ }
+ _spec.Node.Columns = _q.ctx.Fields
+ if len(_q.ctx.Fields) > 0 {
+ _spec.Unique = _q.ctx.Unique != nil && *_q.ctx.Unique
+ }
+ return sqlgraph.CountNodes(ctx, _q.driver, _spec)
+}
+
+func (_q *SubscriptionWalletLedgerQuery) querySpec() *sqlgraph.QuerySpec {
+ _spec := sqlgraph.NewQuerySpec(subscriptionwalletledger.Table, subscriptionwalletledger.Columns, sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64))
+ _spec.From = _q.sql
+ if unique := _q.ctx.Unique; unique != nil {
+ _spec.Unique = *unique
+ } else if _q.path != nil {
+ _spec.Unique = true
+ }
+ if fields := _q.ctx.Fields; len(fields) > 0 {
+ _spec.Node.Columns = make([]string, 0, len(fields))
+ _spec.Node.Columns = append(_spec.Node.Columns, subscriptionwalletledger.FieldID)
+ for i := range fields {
+ if fields[i] != subscriptionwalletledger.FieldID {
+ _spec.Node.Columns = append(_spec.Node.Columns, fields[i])
+ }
+ }
+ if _q.withSubscription != nil {
+ _spec.Node.AddColumnOnce(subscriptionwalletledger.FieldSubscriptionID)
+ }
+ if _q.withUsageLog != nil {
+ _spec.Node.AddColumnOnce(subscriptionwalletledger.FieldUsageLogID)
+ }
+ if _q.withOperator != nil {
+ _spec.Node.AddColumnOnce(subscriptionwalletledger.FieldOperatorID)
+ }
+ }
+ if ps := _q.predicates; len(ps) > 0 {
+ _spec.Predicate = func(selector *sql.Selector) {
+ for i := range ps {
+ ps[i](selector)
+ }
+ }
+ }
+ if limit := _q.ctx.Limit; limit != nil {
+ _spec.Limit = *limit
+ }
+ if offset := _q.ctx.Offset; offset != nil {
+ _spec.Offset = *offset
+ }
+ if ps := _q.order; len(ps) > 0 {
+ _spec.Order = func(selector *sql.Selector) {
+ for i := range ps {
+ ps[i](selector)
+ }
+ }
+ }
+ return _spec
+}
+
+func (_q *SubscriptionWalletLedgerQuery) sqlQuery(ctx context.Context) *sql.Selector {
+ builder := sql.Dialect(_q.driver.Dialect())
+ t1 := builder.Table(subscriptionwalletledger.Table)
+ columns := _q.ctx.Fields
+ if len(columns) == 0 {
+ columns = subscriptionwalletledger.Columns
+ }
+ selector := builder.Select(t1.Columns(columns...)...).From(t1)
+ if _q.sql != nil {
+ selector = _q.sql
+ selector.Select(selector.Columns(columns...)...)
+ }
+ if _q.ctx.Unique != nil && *_q.ctx.Unique {
+ selector.Distinct()
+ }
+ for _, m := range _q.modifiers {
+ m(selector)
+ }
+ for _, p := range _q.predicates {
+ p(selector)
+ }
+ for _, p := range _q.order {
+ p(selector)
+ }
+ if offset := _q.ctx.Offset; offset != nil {
+ // limit is mandatory for offset clause. We start
+ // with default value, and override it below if needed.
+ selector.Offset(*offset).Limit(math.MaxInt32)
+ }
+ if limit := _q.ctx.Limit; limit != nil {
+ selector.Limit(*limit)
+ }
+ return selector
+}
+
+// ForUpdate locks the selected rows against concurrent updates, and prevent them from being
+// updated, deleted or "selected ... for update" by other sessions, until the transaction is
+// either committed or rolled-back.
+func (_q *SubscriptionWalletLedgerQuery) ForUpdate(opts ...sql.LockOption) *SubscriptionWalletLedgerQuery {
+ if _q.driver.Dialect() == dialect.Postgres {
+ _q.Unique(false)
+ }
+ _q.modifiers = append(_q.modifiers, func(s *sql.Selector) {
+ s.ForUpdate(opts...)
+ })
+ return _q
+}
+
+// ForShare behaves similarly to ForUpdate, except that it acquires a shared mode lock
+// on any rows that are read. Other sessions can read the rows, but cannot modify them
+// until your transaction commits.
+func (_q *SubscriptionWalletLedgerQuery) ForShare(opts ...sql.LockOption) *SubscriptionWalletLedgerQuery {
+ if _q.driver.Dialect() == dialect.Postgres {
+ _q.Unique(false)
+ }
+ _q.modifiers = append(_q.modifiers, func(s *sql.Selector) {
+ s.ForShare(opts...)
+ })
+ return _q
+}
+
+// SubscriptionWalletLedgerGroupBy is the group-by builder for SubscriptionWalletLedger entities.
+type SubscriptionWalletLedgerGroupBy struct {
+ selector
+ build *SubscriptionWalletLedgerQuery
+}
+
+// Aggregate adds the given aggregation functions to the group-by query.
+func (_g *SubscriptionWalletLedgerGroupBy) Aggregate(fns ...AggregateFunc) *SubscriptionWalletLedgerGroupBy {
+ _g.fns = append(_g.fns, fns...)
+ return _g
+}
+
+// Scan applies the selector query and scans the result into the given value.
+func (_g *SubscriptionWalletLedgerGroupBy) Scan(ctx context.Context, v any) error {
+ ctx = setContextOp(ctx, _g.build.ctx, ent.OpQueryGroupBy)
+ if err := _g.build.prepareQuery(ctx); err != nil {
+ return err
+ }
+ return scanWithInterceptors[*SubscriptionWalletLedgerQuery, *SubscriptionWalletLedgerGroupBy](ctx, _g.build, _g, _g.build.inters, v)
+}
+
+func (_g *SubscriptionWalletLedgerGroupBy) sqlScan(ctx context.Context, root *SubscriptionWalletLedgerQuery, v any) error {
+ selector := root.sqlQuery(ctx).Select()
+ aggregation := make([]string, 0, len(_g.fns))
+ for _, fn := range _g.fns {
+ aggregation = append(aggregation, fn(selector))
+ }
+ if len(selector.SelectedColumns()) == 0 {
+ columns := make([]string, 0, len(*_g.flds)+len(_g.fns))
+ for _, f := range *_g.flds {
+ columns = append(columns, selector.C(f))
+ }
+ columns = append(columns, aggregation...)
+ selector.Select(columns...)
+ }
+ selector.GroupBy(selector.Columns(*_g.flds...)...)
+ if err := selector.Err(); err != nil {
+ return err
+ }
+ rows := &sql.Rows{}
+ query, args := selector.Query()
+ if err := _g.build.driver.Query(ctx, query, args, rows); err != nil {
+ return err
+ }
+ defer rows.Close()
+ return sql.ScanSlice(rows, v)
+}
+
+// SubscriptionWalletLedgerSelect is the builder for selecting fields of SubscriptionWalletLedger entities.
+type SubscriptionWalletLedgerSelect struct {
+ *SubscriptionWalletLedgerQuery
+ selector
+}
+
+// Aggregate adds the given aggregation functions to the selector query.
+func (_s *SubscriptionWalletLedgerSelect) Aggregate(fns ...AggregateFunc) *SubscriptionWalletLedgerSelect {
+ _s.fns = append(_s.fns, fns...)
+ return _s
+}
+
+// Scan applies the selector query and scans the result into the given value.
+func (_s *SubscriptionWalletLedgerSelect) Scan(ctx context.Context, v any) error {
+ ctx = setContextOp(ctx, _s.ctx, ent.OpQuerySelect)
+ if err := _s.prepareQuery(ctx); err != nil {
+ return err
+ }
+ return scanWithInterceptors[*SubscriptionWalletLedgerQuery, *SubscriptionWalletLedgerSelect](ctx, _s.SubscriptionWalletLedgerQuery, _s, _s.inters, v)
+}
+
+func (_s *SubscriptionWalletLedgerSelect) sqlScan(ctx context.Context, root *SubscriptionWalletLedgerQuery, v any) error {
+ selector := root.sqlQuery(ctx)
+ aggregation := make([]string, 0, len(_s.fns))
+ for _, fn := range _s.fns {
+ aggregation = append(aggregation, fn(selector))
+ }
+ switch n := len(*_s.selector.flds); {
+ case n == 0 && len(aggregation) > 0:
+ selector.Select(aggregation...)
+ case n != 0 && len(aggregation) > 0:
+ selector.AppendSelect(aggregation...)
+ }
+ rows := &sql.Rows{}
+ query, args := selector.Query()
+ if err := _s.driver.Query(ctx, query, args, rows); err != nil {
+ return err
+ }
+ defer rows.Close()
+ return sql.ScanSlice(rows, v)
+}
diff --git a/backend/ent/subscriptionwalletledger_update.go b/backend/ent/subscriptionwalletledger_update.go
new file mode 100644
index 00000000000..a2bc46fde23
--- /dev/null
+++ b/backend/ent/subscriptionwalletledger_update.go
@@ -0,0 +1,824 @@
+// Code generated by ent, DO NOT EDIT.
+
+package ent
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "entgo.io/ent/dialect/sql"
+ "entgo.io/ent/dialect/sql/sqlgraph"
+ "entgo.io/ent/schema/field"
+ "github.com/Wei-Shaw/sub2api/ent/predicate"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
+ "github.com/Wei-Shaw/sub2api/ent/usagelog"
+ "github.com/Wei-Shaw/sub2api/ent/user"
+ "github.com/Wei-Shaw/sub2api/ent/usersubscription"
+)
+
+// SubscriptionWalletLedgerUpdate is the builder for updating SubscriptionWalletLedger entities.
+type SubscriptionWalletLedgerUpdate struct {
+ config
+ hooks []Hook
+ mutation *SubscriptionWalletLedgerMutation
+}
+
+// Where appends a list predicates to the SubscriptionWalletLedgerUpdate builder.
+func (_u *SubscriptionWalletLedgerUpdate) Where(ps ...predicate.SubscriptionWalletLedger) *SubscriptionWalletLedgerUpdate {
+ _u.mutation.Where(ps...)
+ return _u
+}
+
+// SetSubscriptionID sets the "subscription_id" field.
+func (_u *SubscriptionWalletLedgerUpdate) SetSubscriptionID(v int64) *SubscriptionWalletLedgerUpdate {
+ _u.mutation.SetSubscriptionID(v)
+ return _u
+}
+
+// SetNillableSubscriptionID sets the "subscription_id" field if the given value is not nil.
+func (_u *SubscriptionWalletLedgerUpdate) SetNillableSubscriptionID(v *int64) *SubscriptionWalletLedgerUpdate {
+ if v != nil {
+ _u.SetSubscriptionID(*v)
+ }
+ return _u
+}
+
+// SetDeltaUsd sets the "delta_usd" field.
+func (_u *SubscriptionWalletLedgerUpdate) SetDeltaUsd(v float64) *SubscriptionWalletLedgerUpdate {
+ _u.mutation.ResetDeltaUsd()
+ _u.mutation.SetDeltaUsd(v)
+ return _u
+}
+
+// SetNillableDeltaUsd sets the "delta_usd" field if the given value is not nil.
+func (_u *SubscriptionWalletLedgerUpdate) SetNillableDeltaUsd(v *float64) *SubscriptionWalletLedgerUpdate {
+ if v != nil {
+ _u.SetDeltaUsd(*v)
+ }
+ return _u
+}
+
+// AddDeltaUsd adds value to the "delta_usd" field.
+func (_u *SubscriptionWalletLedgerUpdate) AddDeltaUsd(v float64) *SubscriptionWalletLedgerUpdate {
+ _u.mutation.AddDeltaUsd(v)
+ return _u
+}
+
+// SetBalanceAfter sets the "balance_after" field.
+func (_u *SubscriptionWalletLedgerUpdate) SetBalanceAfter(v float64) *SubscriptionWalletLedgerUpdate {
+ _u.mutation.ResetBalanceAfter()
+ _u.mutation.SetBalanceAfter(v)
+ return _u
+}
+
+// SetNillableBalanceAfter sets the "balance_after" field if the given value is not nil.
+func (_u *SubscriptionWalletLedgerUpdate) SetNillableBalanceAfter(v *float64) *SubscriptionWalletLedgerUpdate {
+ if v != nil {
+ _u.SetBalanceAfter(*v)
+ }
+ return _u
+}
+
+// AddBalanceAfter adds value to the "balance_after" field.
+func (_u *SubscriptionWalletLedgerUpdate) AddBalanceAfter(v float64) *SubscriptionWalletLedgerUpdate {
+ _u.mutation.AddBalanceAfter(v)
+ return _u
+}
+
+// SetReason sets the "reason" field.
+func (_u *SubscriptionWalletLedgerUpdate) SetReason(v string) *SubscriptionWalletLedgerUpdate {
+ _u.mutation.SetReason(v)
+ return _u
+}
+
+// SetNillableReason sets the "reason" field if the given value is not nil.
+func (_u *SubscriptionWalletLedgerUpdate) SetNillableReason(v *string) *SubscriptionWalletLedgerUpdate {
+ if v != nil {
+ _u.SetReason(*v)
+ }
+ return _u
+}
+
+// SetPaymentOrderID sets the "payment_order_id" field.
+func (_u *SubscriptionWalletLedgerUpdate) SetPaymentOrderID(v int64) *SubscriptionWalletLedgerUpdate {
+ _u.mutation.ResetPaymentOrderID()
+ _u.mutation.SetPaymentOrderID(v)
+ return _u
+}
+
+// SetNillablePaymentOrderID sets the "payment_order_id" field if the given value is not nil.
+func (_u *SubscriptionWalletLedgerUpdate) SetNillablePaymentOrderID(v *int64) *SubscriptionWalletLedgerUpdate {
+ if v != nil {
+ _u.SetPaymentOrderID(*v)
+ }
+ return _u
+}
+
+// AddPaymentOrderID adds value to the "payment_order_id" field.
+func (_u *SubscriptionWalletLedgerUpdate) AddPaymentOrderID(v int64) *SubscriptionWalletLedgerUpdate {
+ _u.mutation.AddPaymentOrderID(v)
+ return _u
+}
+
+// ClearPaymentOrderID clears the value of the "payment_order_id" field.
+func (_u *SubscriptionWalletLedgerUpdate) ClearPaymentOrderID() *SubscriptionWalletLedgerUpdate {
+ _u.mutation.ClearPaymentOrderID()
+ return _u
+}
+
+// SetUsageLogID sets the "usage_log_id" field.
+func (_u *SubscriptionWalletLedgerUpdate) SetUsageLogID(v int64) *SubscriptionWalletLedgerUpdate {
+ _u.mutation.SetUsageLogID(v)
+ return _u
+}
+
+// SetNillableUsageLogID sets the "usage_log_id" field if the given value is not nil.
+func (_u *SubscriptionWalletLedgerUpdate) SetNillableUsageLogID(v *int64) *SubscriptionWalletLedgerUpdate {
+ if v != nil {
+ _u.SetUsageLogID(*v)
+ }
+ return _u
+}
+
+// ClearUsageLogID clears the value of the "usage_log_id" field.
+func (_u *SubscriptionWalletLedgerUpdate) ClearUsageLogID() *SubscriptionWalletLedgerUpdate {
+ _u.mutation.ClearUsageLogID()
+ return _u
+}
+
+// SetOperatorID sets the "operator_id" field.
+func (_u *SubscriptionWalletLedgerUpdate) SetOperatorID(v int64) *SubscriptionWalletLedgerUpdate {
+ _u.mutation.SetOperatorID(v)
+ return _u
+}
+
+// SetNillableOperatorID sets the "operator_id" field if the given value is not nil.
+func (_u *SubscriptionWalletLedgerUpdate) SetNillableOperatorID(v *int64) *SubscriptionWalletLedgerUpdate {
+ if v != nil {
+ _u.SetOperatorID(*v)
+ }
+ return _u
+}
+
+// ClearOperatorID clears the value of the "operator_id" field.
+func (_u *SubscriptionWalletLedgerUpdate) ClearOperatorID() *SubscriptionWalletLedgerUpdate {
+ _u.mutation.ClearOperatorID()
+ return _u
+}
+
+// SetNotes sets the "notes" field.
+func (_u *SubscriptionWalletLedgerUpdate) SetNotes(v string) *SubscriptionWalletLedgerUpdate {
+ _u.mutation.SetNotes(v)
+ return _u
+}
+
+// SetNillableNotes sets the "notes" field if the given value is not nil.
+func (_u *SubscriptionWalletLedgerUpdate) SetNillableNotes(v *string) *SubscriptionWalletLedgerUpdate {
+ if v != nil {
+ _u.SetNotes(*v)
+ }
+ return _u
+}
+
+// ClearNotes clears the value of the "notes" field.
+func (_u *SubscriptionWalletLedgerUpdate) ClearNotes() *SubscriptionWalletLedgerUpdate {
+ _u.mutation.ClearNotes()
+ return _u
+}
+
+// SetSubscription sets the "subscription" edge to the UserSubscription entity.
+func (_u *SubscriptionWalletLedgerUpdate) SetSubscription(v *UserSubscription) *SubscriptionWalletLedgerUpdate {
+ return _u.SetSubscriptionID(v.ID)
+}
+
+// SetUsageLog sets the "usage_log" edge to the UsageLog entity.
+func (_u *SubscriptionWalletLedgerUpdate) SetUsageLog(v *UsageLog) *SubscriptionWalletLedgerUpdate {
+ return _u.SetUsageLogID(v.ID)
+}
+
+// SetOperator sets the "operator" edge to the User entity.
+func (_u *SubscriptionWalletLedgerUpdate) SetOperator(v *User) *SubscriptionWalletLedgerUpdate {
+ return _u.SetOperatorID(v.ID)
+}
+
+// Mutation returns the SubscriptionWalletLedgerMutation object of the builder.
+func (_u *SubscriptionWalletLedgerUpdate) Mutation() *SubscriptionWalletLedgerMutation {
+ return _u.mutation
+}
+
+// ClearSubscription clears the "subscription" edge to the UserSubscription entity.
+func (_u *SubscriptionWalletLedgerUpdate) ClearSubscription() *SubscriptionWalletLedgerUpdate {
+ _u.mutation.ClearSubscription()
+ return _u
+}
+
+// ClearUsageLog clears the "usage_log" edge to the UsageLog entity.
+func (_u *SubscriptionWalletLedgerUpdate) ClearUsageLog() *SubscriptionWalletLedgerUpdate {
+ _u.mutation.ClearUsageLog()
+ return _u
+}
+
+// ClearOperator clears the "operator" edge to the User entity.
+func (_u *SubscriptionWalletLedgerUpdate) ClearOperator() *SubscriptionWalletLedgerUpdate {
+ _u.mutation.ClearOperator()
+ return _u
+}
+
+// Save executes the query and returns the number of nodes affected by the update operation.
+func (_u *SubscriptionWalletLedgerUpdate) Save(ctx context.Context) (int, error) {
+ return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks)
+}
+
+// SaveX is like Save, but panics if an error occurs.
+func (_u *SubscriptionWalletLedgerUpdate) SaveX(ctx context.Context) int {
+ affected, err := _u.Save(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return affected
+}
+
+// Exec executes the query.
+func (_u *SubscriptionWalletLedgerUpdate) Exec(ctx context.Context) error {
+ _, err := _u.Save(ctx)
+ return err
+}
+
+// ExecX is like Exec, but panics if an error occurs.
+func (_u *SubscriptionWalletLedgerUpdate) ExecX(ctx context.Context) {
+ if err := _u.Exec(ctx); err != nil {
+ panic(err)
+ }
+}
+
+// check runs all checks and user-defined validators on the builder.
+func (_u *SubscriptionWalletLedgerUpdate) check() error {
+ if v, ok := _u.mutation.Reason(); ok {
+ if err := subscriptionwalletledger.ReasonValidator(v); err != nil {
+ return &ValidationError{Name: "reason", err: fmt.Errorf(`ent: validator failed for field "SubscriptionWalletLedger.reason": %w`, err)}
+ }
+ }
+ if _u.mutation.SubscriptionCleared() && len(_u.mutation.SubscriptionIDs()) > 0 {
+ return errors.New(`ent: clearing a required unique edge "SubscriptionWalletLedger.subscription"`)
+ }
+ return nil
+}
+
+func (_u *SubscriptionWalletLedgerUpdate) sqlSave(ctx context.Context) (_node int, err error) {
+ if err := _u.check(); err != nil {
+ return _node, err
+ }
+ _spec := sqlgraph.NewUpdateSpec(subscriptionwalletledger.Table, subscriptionwalletledger.Columns, sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64))
+ if ps := _u.mutation.predicates; len(ps) > 0 {
+ _spec.Predicate = func(selector *sql.Selector) {
+ for i := range ps {
+ ps[i](selector)
+ }
+ }
+ }
+ if value, ok := _u.mutation.DeltaUsd(); ok {
+ _spec.SetField(subscriptionwalletledger.FieldDeltaUsd, field.TypeFloat64, value)
+ }
+ if value, ok := _u.mutation.AddedDeltaUsd(); ok {
+ _spec.AddField(subscriptionwalletledger.FieldDeltaUsd, field.TypeFloat64, value)
+ }
+ if value, ok := _u.mutation.BalanceAfter(); ok {
+ _spec.SetField(subscriptionwalletledger.FieldBalanceAfter, field.TypeFloat64, value)
+ }
+ if value, ok := _u.mutation.AddedBalanceAfter(); ok {
+ _spec.AddField(subscriptionwalletledger.FieldBalanceAfter, field.TypeFloat64, value)
+ }
+ if value, ok := _u.mutation.Reason(); ok {
+ _spec.SetField(subscriptionwalletledger.FieldReason, field.TypeString, value)
+ }
+ if value, ok := _u.mutation.PaymentOrderID(); ok {
+ _spec.SetField(subscriptionwalletledger.FieldPaymentOrderID, field.TypeInt64, value)
+ }
+ if value, ok := _u.mutation.AddedPaymentOrderID(); ok {
+ _spec.AddField(subscriptionwalletledger.FieldPaymentOrderID, field.TypeInt64, value)
+ }
+ if _u.mutation.PaymentOrderIDCleared() {
+ _spec.ClearField(subscriptionwalletledger.FieldPaymentOrderID, field.TypeInt64)
+ }
+ if value, ok := _u.mutation.Notes(); ok {
+ _spec.SetField(subscriptionwalletledger.FieldNotes, field.TypeString, value)
+ }
+ if _u.mutation.NotesCleared() {
+ _spec.ClearField(subscriptionwalletledger.FieldNotes, field.TypeString)
+ }
+ if _u.mutation.SubscriptionCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionwalletledger.SubscriptionTable,
+ Columns: []string{subscriptionwalletledger.SubscriptionColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(usersubscription.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.SubscriptionIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionwalletledger.SubscriptionTable,
+ Columns: []string{subscriptionwalletledger.SubscriptionColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(usersubscription.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
+ if _u.mutation.UsageLogCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionwalletledger.UsageLogTable,
+ Columns: []string{subscriptionwalletledger.UsageLogColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(usagelog.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.UsageLogIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionwalletledger.UsageLogTable,
+ Columns: []string{subscriptionwalletledger.UsageLogColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(usagelog.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
+ if _u.mutation.OperatorCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionwalletledger.OperatorTable,
+ Columns: []string{subscriptionwalletledger.OperatorColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.OperatorIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionwalletledger.OperatorTable,
+ Columns: []string{subscriptionwalletledger.OperatorColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
+ if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil {
+ if _, ok := err.(*sqlgraph.NotFoundError); ok {
+ err = &NotFoundError{subscriptionwalletledger.Label}
+ } else if sqlgraph.IsConstraintError(err) {
+ err = &ConstraintError{msg: err.Error(), wrap: err}
+ }
+ return 0, err
+ }
+ _u.mutation.done = true
+ return _node, nil
+}
+
+// SubscriptionWalletLedgerUpdateOne is the builder for updating a single SubscriptionWalletLedger entity.
+type SubscriptionWalletLedgerUpdateOne struct {
+ config
+ fields []string
+ hooks []Hook
+ mutation *SubscriptionWalletLedgerMutation
+}
+
+// SetSubscriptionID sets the "subscription_id" field.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetSubscriptionID(v int64) *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.SetSubscriptionID(v)
+ return _u
+}
+
+// SetNillableSubscriptionID sets the "subscription_id" field if the given value is not nil.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetNillableSubscriptionID(v *int64) *SubscriptionWalletLedgerUpdateOne {
+ if v != nil {
+ _u.SetSubscriptionID(*v)
+ }
+ return _u
+}
+
+// SetDeltaUsd sets the "delta_usd" field.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetDeltaUsd(v float64) *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.ResetDeltaUsd()
+ _u.mutation.SetDeltaUsd(v)
+ return _u
+}
+
+// SetNillableDeltaUsd sets the "delta_usd" field if the given value is not nil.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetNillableDeltaUsd(v *float64) *SubscriptionWalletLedgerUpdateOne {
+ if v != nil {
+ _u.SetDeltaUsd(*v)
+ }
+ return _u
+}
+
+// AddDeltaUsd adds value to the "delta_usd" field.
+func (_u *SubscriptionWalletLedgerUpdateOne) AddDeltaUsd(v float64) *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.AddDeltaUsd(v)
+ return _u
+}
+
+// SetBalanceAfter sets the "balance_after" field.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetBalanceAfter(v float64) *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.ResetBalanceAfter()
+ _u.mutation.SetBalanceAfter(v)
+ return _u
+}
+
+// SetNillableBalanceAfter sets the "balance_after" field if the given value is not nil.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetNillableBalanceAfter(v *float64) *SubscriptionWalletLedgerUpdateOne {
+ if v != nil {
+ _u.SetBalanceAfter(*v)
+ }
+ return _u
+}
+
+// AddBalanceAfter adds value to the "balance_after" field.
+func (_u *SubscriptionWalletLedgerUpdateOne) AddBalanceAfter(v float64) *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.AddBalanceAfter(v)
+ return _u
+}
+
+// SetReason sets the "reason" field.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetReason(v string) *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.SetReason(v)
+ return _u
+}
+
+// SetNillableReason sets the "reason" field if the given value is not nil.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetNillableReason(v *string) *SubscriptionWalletLedgerUpdateOne {
+ if v != nil {
+ _u.SetReason(*v)
+ }
+ return _u
+}
+
+// SetPaymentOrderID sets the "payment_order_id" field.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetPaymentOrderID(v int64) *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.ResetPaymentOrderID()
+ _u.mutation.SetPaymentOrderID(v)
+ return _u
+}
+
+// SetNillablePaymentOrderID sets the "payment_order_id" field if the given value is not nil.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetNillablePaymentOrderID(v *int64) *SubscriptionWalletLedgerUpdateOne {
+ if v != nil {
+ _u.SetPaymentOrderID(*v)
+ }
+ return _u
+}
+
+// AddPaymentOrderID adds value to the "payment_order_id" field.
+func (_u *SubscriptionWalletLedgerUpdateOne) AddPaymentOrderID(v int64) *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.AddPaymentOrderID(v)
+ return _u
+}
+
+// ClearPaymentOrderID clears the value of the "payment_order_id" field.
+func (_u *SubscriptionWalletLedgerUpdateOne) ClearPaymentOrderID() *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.ClearPaymentOrderID()
+ return _u
+}
+
+// SetUsageLogID sets the "usage_log_id" field.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetUsageLogID(v int64) *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.SetUsageLogID(v)
+ return _u
+}
+
+// SetNillableUsageLogID sets the "usage_log_id" field if the given value is not nil.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetNillableUsageLogID(v *int64) *SubscriptionWalletLedgerUpdateOne {
+ if v != nil {
+ _u.SetUsageLogID(*v)
+ }
+ return _u
+}
+
+// ClearUsageLogID clears the value of the "usage_log_id" field.
+func (_u *SubscriptionWalletLedgerUpdateOne) ClearUsageLogID() *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.ClearUsageLogID()
+ return _u
+}
+
+// SetOperatorID sets the "operator_id" field.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetOperatorID(v int64) *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.SetOperatorID(v)
+ return _u
+}
+
+// SetNillableOperatorID sets the "operator_id" field if the given value is not nil.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetNillableOperatorID(v *int64) *SubscriptionWalletLedgerUpdateOne {
+ if v != nil {
+ _u.SetOperatorID(*v)
+ }
+ return _u
+}
+
+// ClearOperatorID clears the value of the "operator_id" field.
+func (_u *SubscriptionWalletLedgerUpdateOne) ClearOperatorID() *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.ClearOperatorID()
+ return _u
+}
+
+// SetNotes sets the "notes" field.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetNotes(v string) *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.SetNotes(v)
+ return _u
+}
+
+// SetNillableNotes sets the "notes" field if the given value is not nil.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetNillableNotes(v *string) *SubscriptionWalletLedgerUpdateOne {
+ if v != nil {
+ _u.SetNotes(*v)
+ }
+ return _u
+}
+
+// ClearNotes clears the value of the "notes" field.
+func (_u *SubscriptionWalletLedgerUpdateOne) ClearNotes() *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.ClearNotes()
+ return _u
+}
+
+// SetSubscription sets the "subscription" edge to the UserSubscription entity.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetSubscription(v *UserSubscription) *SubscriptionWalletLedgerUpdateOne {
+ return _u.SetSubscriptionID(v.ID)
+}
+
+// SetUsageLog sets the "usage_log" edge to the UsageLog entity.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetUsageLog(v *UsageLog) *SubscriptionWalletLedgerUpdateOne {
+ return _u.SetUsageLogID(v.ID)
+}
+
+// SetOperator sets the "operator" edge to the User entity.
+func (_u *SubscriptionWalletLedgerUpdateOne) SetOperator(v *User) *SubscriptionWalletLedgerUpdateOne {
+ return _u.SetOperatorID(v.ID)
+}
+
+// Mutation returns the SubscriptionWalletLedgerMutation object of the builder.
+func (_u *SubscriptionWalletLedgerUpdateOne) Mutation() *SubscriptionWalletLedgerMutation {
+ return _u.mutation
+}
+
+// ClearSubscription clears the "subscription" edge to the UserSubscription entity.
+func (_u *SubscriptionWalletLedgerUpdateOne) ClearSubscription() *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.ClearSubscription()
+ return _u
+}
+
+// ClearUsageLog clears the "usage_log" edge to the UsageLog entity.
+func (_u *SubscriptionWalletLedgerUpdateOne) ClearUsageLog() *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.ClearUsageLog()
+ return _u
+}
+
+// ClearOperator clears the "operator" edge to the User entity.
+func (_u *SubscriptionWalletLedgerUpdateOne) ClearOperator() *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.ClearOperator()
+ return _u
+}
+
+// Where appends a list predicates to the SubscriptionWalletLedgerUpdate builder.
+func (_u *SubscriptionWalletLedgerUpdateOne) Where(ps ...predicate.SubscriptionWalletLedger) *SubscriptionWalletLedgerUpdateOne {
+ _u.mutation.Where(ps...)
+ return _u
+}
+
+// Select allows selecting one or more fields (columns) of the returned entity.
+// The default is selecting all fields defined in the entity schema.
+func (_u *SubscriptionWalletLedgerUpdateOne) Select(field string, fields ...string) *SubscriptionWalletLedgerUpdateOne {
+ _u.fields = append([]string{field}, fields...)
+ return _u
+}
+
+// Save executes the query and returns the updated SubscriptionWalletLedger entity.
+func (_u *SubscriptionWalletLedgerUpdateOne) Save(ctx context.Context) (*SubscriptionWalletLedger, error) {
+ return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks)
+}
+
+// SaveX is like Save, but panics if an error occurs.
+func (_u *SubscriptionWalletLedgerUpdateOne) SaveX(ctx context.Context) *SubscriptionWalletLedger {
+ node, err := _u.Save(ctx)
+ if err != nil {
+ panic(err)
+ }
+ return node
+}
+
+// Exec executes the query on the entity.
+func (_u *SubscriptionWalletLedgerUpdateOne) Exec(ctx context.Context) error {
+ _, err := _u.Save(ctx)
+ return err
+}
+
+// ExecX is like Exec, but panics if an error occurs.
+func (_u *SubscriptionWalletLedgerUpdateOne) ExecX(ctx context.Context) {
+ if err := _u.Exec(ctx); err != nil {
+ panic(err)
+ }
+}
+
+// check runs all checks and user-defined validators on the builder.
+func (_u *SubscriptionWalletLedgerUpdateOne) check() error {
+ if v, ok := _u.mutation.Reason(); ok {
+ if err := subscriptionwalletledger.ReasonValidator(v); err != nil {
+ return &ValidationError{Name: "reason", err: fmt.Errorf(`ent: validator failed for field "SubscriptionWalletLedger.reason": %w`, err)}
+ }
+ }
+ if _u.mutation.SubscriptionCleared() && len(_u.mutation.SubscriptionIDs()) > 0 {
+ return errors.New(`ent: clearing a required unique edge "SubscriptionWalletLedger.subscription"`)
+ }
+ return nil
+}
+
+func (_u *SubscriptionWalletLedgerUpdateOne) sqlSave(ctx context.Context) (_node *SubscriptionWalletLedger, err error) {
+ if err := _u.check(); err != nil {
+ return _node, err
+ }
+ _spec := sqlgraph.NewUpdateSpec(subscriptionwalletledger.Table, subscriptionwalletledger.Columns, sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64))
+ id, ok := _u.mutation.ID()
+ if !ok {
+ return nil, &ValidationError{Name: "id", err: errors.New(`ent: missing "SubscriptionWalletLedger.id" for update`)}
+ }
+ _spec.Node.ID.Value = id
+ if fields := _u.fields; len(fields) > 0 {
+ _spec.Node.Columns = make([]string, 0, len(fields))
+ _spec.Node.Columns = append(_spec.Node.Columns, subscriptionwalletledger.FieldID)
+ for _, f := range fields {
+ if !subscriptionwalletledger.ValidColumn(f) {
+ return nil, &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)}
+ }
+ if f != subscriptionwalletledger.FieldID {
+ _spec.Node.Columns = append(_spec.Node.Columns, f)
+ }
+ }
+ }
+ if ps := _u.mutation.predicates; len(ps) > 0 {
+ _spec.Predicate = func(selector *sql.Selector) {
+ for i := range ps {
+ ps[i](selector)
+ }
+ }
+ }
+ if value, ok := _u.mutation.DeltaUsd(); ok {
+ _spec.SetField(subscriptionwalletledger.FieldDeltaUsd, field.TypeFloat64, value)
+ }
+ if value, ok := _u.mutation.AddedDeltaUsd(); ok {
+ _spec.AddField(subscriptionwalletledger.FieldDeltaUsd, field.TypeFloat64, value)
+ }
+ if value, ok := _u.mutation.BalanceAfter(); ok {
+ _spec.SetField(subscriptionwalletledger.FieldBalanceAfter, field.TypeFloat64, value)
+ }
+ if value, ok := _u.mutation.AddedBalanceAfter(); ok {
+ _spec.AddField(subscriptionwalletledger.FieldBalanceAfter, field.TypeFloat64, value)
+ }
+ if value, ok := _u.mutation.Reason(); ok {
+ _spec.SetField(subscriptionwalletledger.FieldReason, field.TypeString, value)
+ }
+ if value, ok := _u.mutation.PaymentOrderID(); ok {
+ _spec.SetField(subscriptionwalletledger.FieldPaymentOrderID, field.TypeInt64, value)
+ }
+ if value, ok := _u.mutation.AddedPaymentOrderID(); ok {
+ _spec.AddField(subscriptionwalletledger.FieldPaymentOrderID, field.TypeInt64, value)
+ }
+ if _u.mutation.PaymentOrderIDCleared() {
+ _spec.ClearField(subscriptionwalletledger.FieldPaymentOrderID, field.TypeInt64)
+ }
+ if value, ok := _u.mutation.Notes(); ok {
+ _spec.SetField(subscriptionwalletledger.FieldNotes, field.TypeString, value)
+ }
+ if _u.mutation.NotesCleared() {
+ _spec.ClearField(subscriptionwalletledger.FieldNotes, field.TypeString)
+ }
+ if _u.mutation.SubscriptionCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionwalletledger.SubscriptionTable,
+ Columns: []string{subscriptionwalletledger.SubscriptionColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(usersubscription.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.SubscriptionIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionwalletledger.SubscriptionTable,
+ Columns: []string{subscriptionwalletledger.SubscriptionColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(usersubscription.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
+ if _u.mutation.UsageLogCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionwalletledger.UsageLogTable,
+ Columns: []string{subscriptionwalletledger.UsageLogColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(usagelog.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.UsageLogIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionwalletledger.UsageLogTable,
+ Columns: []string{subscriptionwalletledger.UsageLogColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(usagelog.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
+ if _u.mutation.OperatorCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionwalletledger.OperatorTable,
+ Columns: []string{subscriptionwalletledger.OperatorColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.OperatorIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.M2O,
+ Inverse: true,
+ Table: subscriptionwalletledger.OperatorTable,
+ Columns: []string{subscriptionwalletledger.OperatorColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
+ _node = &SubscriptionWalletLedger{config: _u.config}
+ _spec.Assign = _node.assignValues
+ _spec.ScanValues = _node.scanValues
+ if err = sqlgraph.UpdateNode(ctx, _u.driver, _spec); err != nil {
+ if _, ok := err.(*sqlgraph.NotFoundError); ok {
+ err = &NotFoundError{subscriptionwalletledger.Label}
+ } else if sqlgraph.IsConstraintError(err) {
+ err = &ConstraintError{msg: err.Error(), wrap: err}
+ }
+ return nil, err
+ }
+ _u.mutation.done = true
+ return _node, nil
+}
diff --git a/backend/ent/tx.go b/backend/ent/tx.go
index 611028e9157..9fa15822ab1 100644
--- a/backend/ent/tx.go
+++ b/backend/ent/tx.go
@@ -66,6 +66,10 @@ type Tx struct {
Setting *SettingClient
// SubscriptionPlan is the client for interacting with the SubscriptionPlan builders.
SubscriptionPlan *SubscriptionPlanClient
+ // SubscriptionPlanGroup is the client for interacting with the SubscriptionPlanGroup builders.
+ SubscriptionPlanGroup *SubscriptionPlanGroupClient
+ // SubscriptionWalletLedger is the client for interacting with the SubscriptionWalletLedger builders.
+ SubscriptionWalletLedger *SubscriptionWalletLedgerClient
// TLSFingerprintProfile is the client for interacting with the TLSFingerprintProfile builders.
TLSFingerprintProfile *TLSFingerprintProfileClient
// UsageCleanupTask is the client for interacting with the UsageCleanupTask builders.
@@ -239,6 +243,8 @@ func (tx *Tx) init() {
tx.SecuritySecret = NewSecuritySecretClient(tx.config)
tx.Setting = NewSettingClient(tx.config)
tx.SubscriptionPlan = NewSubscriptionPlanClient(tx.config)
+ tx.SubscriptionPlanGroup = NewSubscriptionPlanGroupClient(tx.config)
+ tx.SubscriptionWalletLedger = NewSubscriptionWalletLedgerClient(tx.config)
tx.TLSFingerprintProfile = NewTLSFingerprintProfileClient(tx.config)
tx.UsageCleanupTask = NewUsageCleanupTaskClient(tx.config)
tx.UsageLog = NewUsageLogClient(tx.config)
diff --git a/backend/ent/usagelog.go b/backend/ent/usagelog.go
index a8e0cc6ce8d..f3210ba6b6e 100644
--- a/backend/ent/usagelog.go
+++ b/backend/ent/usagelog.go
@@ -36,6 +36,14 @@ type UsageLog struct {
RequestedModel *string `json:"requested_model,omitempty"`
// UpstreamModel holds the value of the "upstream_model" field.
UpstreamModel *string `json:"upstream_model,omitempty"`
+ // BillingModel holds the value of the "billing_model" field.
+ BillingModel *string `json:"billing_model,omitempty"`
+ // PricingSource holds the value of the "pricing_source" field.
+ PricingSource *string `json:"pricing_source,omitempty"`
+ // PricingRevision holds the value of the "pricing_revision" field.
+ PricingRevision *string `json:"pricing_revision,omitempty"`
+ // PricingHash holds the value of the "pricing_hash" field.
+ PricingHash *string `json:"pricing_hash,omitempty"`
// 渠道 ID
ChannelID *int64 `json:"channel_id,omitempty"`
// 模型映射链
@@ -114,9 +122,11 @@ type UsageLogEdges struct {
Group *Group `json:"group,omitempty"`
// Subscription holds the value of the subscription edge.
Subscription *UserSubscription `json:"subscription,omitempty"`
+ // WalletLedgerEntries holds the value of the wallet_ledger_entries edge.
+ WalletLedgerEntries []*SubscriptionWalletLedger `json:"wallet_ledger_entries,omitempty"`
// loadedTypes holds the information for reporting if a
// type was loaded (or requested) in eager-loading or not.
- loadedTypes [5]bool
+ loadedTypes [6]bool
}
// UserOrErr returns the User value or an error if the edge
@@ -174,6 +184,15 @@ func (e UsageLogEdges) SubscriptionOrErr() (*UserSubscription, error) {
return nil, &NotLoadedError{edge: "subscription"}
}
+// WalletLedgerEntriesOrErr returns the WalletLedgerEntries value or an error if the edge
+// was not loaded in eager-loading.
+func (e UsageLogEdges) WalletLedgerEntriesOrErr() ([]*SubscriptionWalletLedger, error) {
+ if e.loadedTypes[5] {
+ return e.WalletLedgerEntries, nil
+ }
+ return nil, &NotLoadedError{edge: "wallet_ledger_entries"}
+}
+
// scanValues returns the types for scanning values from sql.Rows.
func (*UsageLog) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
@@ -185,7 +204,7 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullFloat64)
case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldChannelID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount:
values[i] = new(sql.NullInt64)
- case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize:
+ case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldBillingModel, usagelog.FieldPricingSource, usagelog.FieldPricingRevision, usagelog.FieldPricingHash, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize:
values[i] = new(sql.NullString)
case usagelog.FieldCreatedAt:
values[i] = new(sql.NullTime)
@@ -254,6 +273,34 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error {
_m.UpstreamModel = new(string)
*_m.UpstreamModel = value.String
}
+ case usagelog.FieldBillingModel:
+ if value, ok := values[i].(*sql.NullString); !ok {
+ return fmt.Errorf("unexpected type %T for field billing_model", values[i])
+ } else if value.Valid {
+ _m.BillingModel = new(string)
+ *_m.BillingModel = value.String
+ }
+ case usagelog.FieldPricingSource:
+ if value, ok := values[i].(*sql.NullString); !ok {
+ return fmt.Errorf("unexpected type %T for field pricing_source", values[i])
+ } else if value.Valid {
+ _m.PricingSource = new(string)
+ *_m.PricingSource = value.String
+ }
+ case usagelog.FieldPricingRevision:
+ if value, ok := values[i].(*sql.NullString); !ok {
+ return fmt.Errorf("unexpected type %T for field pricing_revision", values[i])
+ } else if value.Valid {
+ _m.PricingRevision = new(string)
+ *_m.PricingRevision = value.String
+ }
+ case usagelog.FieldPricingHash:
+ if value, ok := values[i].(*sql.NullString); !ok {
+ return fmt.Errorf("unexpected type %T for field pricing_hash", values[i])
+ } else if value.Valid {
+ _m.PricingHash = new(string)
+ *_m.PricingHash = value.String
+ }
case usagelog.FieldChannelID:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field channel_id", values[i])
@@ -484,6 +531,11 @@ func (_m *UsageLog) QuerySubscription() *UserSubscriptionQuery {
return NewUsageLogClient(_m.config).QuerySubscription(_m)
}
+// QueryWalletLedgerEntries queries the "wallet_ledger_entries" edge of the UsageLog entity.
+func (_m *UsageLog) QueryWalletLedgerEntries() *SubscriptionWalletLedgerQuery {
+ return NewUsageLogClient(_m.config).QueryWalletLedgerEntries(_m)
+}
+
// Update returns a builder for updating this UsageLog.
// Note that you need to call UsageLog.Unwrap() before calling this method if this UsageLog
// was returned from a transaction, and the transaction was committed or rolled back.
@@ -532,6 +584,26 @@ func (_m *UsageLog) String() string {
builder.WriteString(*v)
}
builder.WriteString(", ")
+ if v := _m.BillingModel; v != nil {
+ builder.WriteString("billing_model=")
+ builder.WriteString(*v)
+ }
+ builder.WriteString(", ")
+ if v := _m.PricingSource; v != nil {
+ builder.WriteString("pricing_source=")
+ builder.WriteString(*v)
+ }
+ builder.WriteString(", ")
+ if v := _m.PricingRevision; v != nil {
+ builder.WriteString("pricing_revision=")
+ builder.WriteString(*v)
+ }
+ builder.WriteString(", ")
+ if v := _m.PricingHash; v != nil {
+ builder.WriteString("pricing_hash=")
+ builder.WriteString(*v)
+ }
+ builder.WriteString(", ")
if v := _m.ChannelID; v != nil {
builder.WriteString("channel_id=")
builder.WriteString(fmt.Sprintf("%v", *v))
diff --git a/backend/ent/usagelog/usagelog.go b/backend/ent/usagelog/usagelog.go
index a7438e604fb..9582cdafd14 100644
--- a/backend/ent/usagelog/usagelog.go
+++ b/backend/ent/usagelog/usagelog.go
@@ -28,6 +28,14 @@ const (
FieldRequestedModel = "requested_model"
// FieldUpstreamModel holds the string denoting the upstream_model field in the database.
FieldUpstreamModel = "upstream_model"
+ // FieldBillingModel holds the string denoting the billing_model field in the database.
+ FieldBillingModel = "billing_model"
+ // FieldPricingSource holds the string denoting the pricing_source field in the database.
+ FieldPricingSource = "pricing_source"
+ // FieldPricingRevision holds the string denoting the pricing_revision field in the database.
+ FieldPricingRevision = "pricing_revision"
+ // FieldPricingHash holds the string denoting the pricing_hash field in the database.
+ FieldPricingHash = "pricing_hash"
// FieldChannelID holds the string denoting the channel_id field in the database.
FieldChannelID = "channel_id"
// FieldModelMappingChain holds the string denoting the model_mapping_chain field in the database.
@@ -98,6 +106,8 @@ const (
EdgeGroup = "group"
// EdgeSubscription holds the string denoting the subscription edge name in mutations.
EdgeSubscription = "subscription"
+ // EdgeWalletLedgerEntries holds the string denoting the wallet_ledger_entries edge name in mutations.
+ EdgeWalletLedgerEntries = "wallet_ledger_entries"
// Table holds the table name of the usagelog in the database.
Table = "usage_logs"
// UserTable is the table that holds the user relation/edge.
@@ -135,6 +145,13 @@ const (
SubscriptionInverseTable = "user_subscriptions"
// SubscriptionColumn is the table column denoting the subscription relation/edge.
SubscriptionColumn = "subscription_id"
+ // WalletLedgerEntriesTable is the table that holds the wallet_ledger_entries relation/edge.
+ WalletLedgerEntriesTable = "subscription_wallet_ledger"
+ // WalletLedgerEntriesInverseTable is the table name for the SubscriptionWalletLedger entity.
+ // It exists in this package in order to avoid circular dependency with the "subscriptionwalletledger" package.
+ WalletLedgerEntriesInverseTable = "subscription_wallet_ledger"
+ // WalletLedgerEntriesColumn is the table column denoting the wallet_ledger_entries relation/edge.
+ WalletLedgerEntriesColumn = "usage_log_id"
)
// Columns holds all SQL columns for usagelog fields.
@@ -147,6 +164,10 @@ var Columns = []string{
FieldModel,
FieldRequestedModel,
FieldUpstreamModel,
+ FieldBillingModel,
+ FieldPricingSource,
+ FieldPricingRevision,
+ FieldPricingHash,
FieldChannelID,
FieldModelMappingChain,
FieldBillingTier,
@@ -198,6 +219,14 @@ var (
RequestedModelValidator func(string) error
// UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save.
UpstreamModelValidator func(string) error
+ // BillingModelValidator is a validator for the "billing_model" field. It is called by the builders before save.
+ BillingModelValidator func(string) error
+ // PricingSourceValidator is a validator for the "pricing_source" field. It is called by the builders before save.
+ PricingSourceValidator func(string) error
+ // PricingRevisionValidator is a validator for the "pricing_revision" field. It is called by the builders before save.
+ PricingRevisionValidator func(string) error
+ // PricingHashValidator is a validator for the "pricing_hash" field. It is called by the builders before save.
+ PricingHashValidator func(string) error
// ModelMappingChainValidator is a validator for the "model_mapping_chain" field. It is called by the builders before save.
ModelMappingChainValidator func(string) error
// BillingTierValidator is a validator for the "billing_tier" field. It is called by the builders before save.
@@ -291,6 +320,26 @@ func ByUpstreamModel(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUpstreamModel, opts...).ToFunc()
}
+// ByBillingModel orders the results by the billing_model field.
+func ByBillingModel(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldBillingModel, opts...).ToFunc()
+}
+
+// ByPricingSource orders the results by the pricing_source field.
+func ByPricingSource(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldPricingSource, opts...).ToFunc()
+}
+
+// ByPricingRevision orders the results by the pricing_revision field.
+func ByPricingRevision(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldPricingRevision, opts...).ToFunc()
+}
+
+// ByPricingHash orders the results by the pricing_hash field.
+func ByPricingHash(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldPricingHash, opts...).ToFunc()
+}
+
// ByChannelID orders the results by the channel_id field.
func ByChannelID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldChannelID, opts...).ToFunc()
@@ -475,6 +524,20 @@ func BySubscriptionField(field string, opts ...sql.OrderTermOption) OrderOption
sqlgraph.OrderByNeighborTerms(s, newSubscriptionStep(), sql.OrderByField(field, opts...))
}
}
+
+// ByWalletLedgerEntriesCount orders the results by wallet_ledger_entries count.
+func ByWalletLedgerEntriesCount(opts ...sql.OrderTermOption) OrderOption {
+ return func(s *sql.Selector) {
+ sqlgraph.OrderByNeighborsCount(s, newWalletLedgerEntriesStep(), opts...)
+ }
+}
+
+// ByWalletLedgerEntries orders the results by wallet_ledger_entries terms.
+func ByWalletLedgerEntries(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
+ return func(s *sql.Selector) {
+ sqlgraph.OrderByNeighborTerms(s, newWalletLedgerEntriesStep(), append([]sql.OrderTerm{term}, terms...)...)
+ }
+}
func newUserStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
@@ -510,3 +573,10 @@ func newSubscriptionStep() *sqlgraph.Step {
sqlgraph.Edge(sqlgraph.M2O, true, SubscriptionTable, SubscriptionColumn),
)
}
+func newWalletLedgerEntriesStep() *sqlgraph.Step {
+ return sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.To(WalletLedgerEntriesInverseTable, FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, WalletLedgerEntriesTable, WalletLedgerEntriesColumn),
+ )
+}
diff --git a/backend/ent/usagelog/where.go b/backend/ent/usagelog/where.go
index b8439a03978..cf002f64ee9 100644
--- a/backend/ent/usagelog/where.go
+++ b/backend/ent/usagelog/where.go
@@ -90,6 +90,26 @@ func UpstreamModel(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModel, v))
}
+// BillingModel applies equality check predicate on the "billing_model" field. It's identical to BillingModelEQ.
+func BillingModel(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldEQ(FieldBillingModel, v))
+}
+
+// PricingSource applies equality check predicate on the "pricing_source" field. It's identical to PricingSourceEQ.
+func PricingSource(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldEQ(FieldPricingSource, v))
+}
+
+// PricingRevision applies equality check predicate on the "pricing_revision" field. It's identical to PricingRevisionEQ.
+func PricingRevision(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldEQ(FieldPricingRevision, v))
+}
+
+// PricingHash applies equality check predicate on the "pricing_hash" field. It's identical to PricingHashEQ.
+func PricingHash(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldEQ(FieldPricingHash, v))
+}
+
// ChannelID applies equality check predicate on the "channel_id" field. It's identical to ChannelIDEQ.
func ChannelID(v int64) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldChannelID, v))
@@ -580,6 +600,306 @@ func UpstreamModelContainsFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContainsFold(FieldUpstreamModel, v))
}
+// BillingModelEQ applies the EQ predicate on the "billing_model" field.
+func BillingModelEQ(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldEQ(FieldBillingModel, v))
+}
+
+// BillingModelNEQ applies the NEQ predicate on the "billing_model" field.
+func BillingModelNEQ(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldNEQ(FieldBillingModel, v))
+}
+
+// BillingModelIn applies the In predicate on the "billing_model" field.
+func BillingModelIn(vs ...string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldIn(FieldBillingModel, vs...))
+}
+
+// BillingModelNotIn applies the NotIn predicate on the "billing_model" field.
+func BillingModelNotIn(vs ...string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldNotIn(FieldBillingModel, vs...))
+}
+
+// BillingModelGT applies the GT predicate on the "billing_model" field.
+func BillingModelGT(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldGT(FieldBillingModel, v))
+}
+
+// BillingModelGTE applies the GTE predicate on the "billing_model" field.
+func BillingModelGTE(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldGTE(FieldBillingModel, v))
+}
+
+// BillingModelLT applies the LT predicate on the "billing_model" field.
+func BillingModelLT(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldLT(FieldBillingModel, v))
+}
+
+// BillingModelLTE applies the LTE predicate on the "billing_model" field.
+func BillingModelLTE(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldLTE(FieldBillingModel, v))
+}
+
+// BillingModelContains applies the Contains predicate on the "billing_model" field.
+func BillingModelContains(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldContains(FieldBillingModel, v))
+}
+
+// BillingModelHasPrefix applies the HasPrefix predicate on the "billing_model" field.
+func BillingModelHasPrefix(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldHasPrefix(FieldBillingModel, v))
+}
+
+// BillingModelHasSuffix applies the HasSuffix predicate on the "billing_model" field.
+func BillingModelHasSuffix(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldHasSuffix(FieldBillingModel, v))
+}
+
+// BillingModelIsNil applies the IsNil predicate on the "billing_model" field.
+func BillingModelIsNil() predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldIsNull(FieldBillingModel))
+}
+
+// BillingModelNotNil applies the NotNil predicate on the "billing_model" field.
+func BillingModelNotNil() predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldNotNull(FieldBillingModel))
+}
+
+// BillingModelEqualFold applies the EqualFold predicate on the "billing_model" field.
+func BillingModelEqualFold(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldEqualFold(FieldBillingModel, v))
+}
+
+// BillingModelContainsFold applies the ContainsFold predicate on the "billing_model" field.
+func BillingModelContainsFold(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldContainsFold(FieldBillingModel, v))
+}
+
+// PricingSourceEQ applies the EQ predicate on the "pricing_source" field.
+func PricingSourceEQ(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldEQ(FieldPricingSource, v))
+}
+
+// PricingSourceNEQ applies the NEQ predicate on the "pricing_source" field.
+func PricingSourceNEQ(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldNEQ(FieldPricingSource, v))
+}
+
+// PricingSourceIn applies the In predicate on the "pricing_source" field.
+func PricingSourceIn(vs ...string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldIn(FieldPricingSource, vs...))
+}
+
+// PricingSourceNotIn applies the NotIn predicate on the "pricing_source" field.
+func PricingSourceNotIn(vs ...string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldNotIn(FieldPricingSource, vs...))
+}
+
+// PricingSourceGT applies the GT predicate on the "pricing_source" field.
+func PricingSourceGT(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldGT(FieldPricingSource, v))
+}
+
+// PricingSourceGTE applies the GTE predicate on the "pricing_source" field.
+func PricingSourceGTE(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldGTE(FieldPricingSource, v))
+}
+
+// PricingSourceLT applies the LT predicate on the "pricing_source" field.
+func PricingSourceLT(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldLT(FieldPricingSource, v))
+}
+
+// PricingSourceLTE applies the LTE predicate on the "pricing_source" field.
+func PricingSourceLTE(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldLTE(FieldPricingSource, v))
+}
+
+// PricingSourceContains applies the Contains predicate on the "pricing_source" field.
+func PricingSourceContains(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldContains(FieldPricingSource, v))
+}
+
+// PricingSourceHasPrefix applies the HasPrefix predicate on the "pricing_source" field.
+func PricingSourceHasPrefix(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldHasPrefix(FieldPricingSource, v))
+}
+
+// PricingSourceHasSuffix applies the HasSuffix predicate on the "pricing_source" field.
+func PricingSourceHasSuffix(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldHasSuffix(FieldPricingSource, v))
+}
+
+// PricingSourceIsNil applies the IsNil predicate on the "pricing_source" field.
+func PricingSourceIsNil() predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldIsNull(FieldPricingSource))
+}
+
+// PricingSourceNotNil applies the NotNil predicate on the "pricing_source" field.
+func PricingSourceNotNil() predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldNotNull(FieldPricingSource))
+}
+
+// PricingSourceEqualFold applies the EqualFold predicate on the "pricing_source" field.
+func PricingSourceEqualFold(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldEqualFold(FieldPricingSource, v))
+}
+
+// PricingSourceContainsFold applies the ContainsFold predicate on the "pricing_source" field.
+func PricingSourceContainsFold(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldContainsFold(FieldPricingSource, v))
+}
+
+// PricingRevisionEQ applies the EQ predicate on the "pricing_revision" field.
+func PricingRevisionEQ(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldEQ(FieldPricingRevision, v))
+}
+
+// PricingRevisionNEQ applies the NEQ predicate on the "pricing_revision" field.
+func PricingRevisionNEQ(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldNEQ(FieldPricingRevision, v))
+}
+
+// PricingRevisionIn applies the In predicate on the "pricing_revision" field.
+func PricingRevisionIn(vs ...string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldIn(FieldPricingRevision, vs...))
+}
+
+// PricingRevisionNotIn applies the NotIn predicate on the "pricing_revision" field.
+func PricingRevisionNotIn(vs ...string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldNotIn(FieldPricingRevision, vs...))
+}
+
+// PricingRevisionGT applies the GT predicate on the "pricing_revision" field.
+func PricingRevisionGT(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldGT(FieldPricingRevision, v))
+}
+
+// PricingRevisionGTE applies the GTE predicate on the "pricing_revision" field.
+func PricingRevisionGTE(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldGTE(FieldPricingRevision, v))
+}
+
+// PricingRevisionLT applies the LT predicate on the "pricing_revision" field.
+func PricingRevisionLT(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldLT(FieldPricingRevision, v))
+}
+
+// PricingRevisionLTE applies the LTE predicate on the "pricing_revision" field.
+func PricingRevisionLTE(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldLTE(FieldPricingRevision, v))
+}
+
+// PricingRevisionContains applies the Contains predicate on the "pricing_revision" field.
+func PricingRevisionContains(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldContains(FieldPricingRevision, v))
+}
+
+// PricingRevisionHasPrefix applies the HasPrefix predicate on the "pricing_revision" field.
+func PricingRevisionHasPrefix(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldHasPrefix(FieldPricingRevision, v))
+}
+
+// PricingRevisionHasSuffix applies the HasSuffix predicate on the "pricing_revision" field.
+func PricingRevisionHasSuffix(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldHasSuffix(FieldPricingRevision, v))
+}
+
+// PricingRevisionIsNil applies the IsNil predicate on the "pricing_revision" field.
+func PricingRevisionIsNil() predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldIsNull(FieldPricingRevision))
+}
+
+// PricingRevisionNotNil applies the NotNil predicate on the "pricing_revision" field.
+func PricingRevisionNotNil() predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldNotNull(FieldPricingRevision))
+}
+
+// PricingRevisionEqualFold applies the EqualFold predicate on the "pricing_revision" field.
+func PricingRevisionEqualFold(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldEqualFold(FieldPricingRevision, v))
+}
+
+// PricingRevisionContainsFold applies the ContainsFold predicate on the "pricing_revision" field.
+func PricingRevisionContainsFold(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldContainsFold(FieldPricingRevision, v))
+}
+
+// PricingHashEQ applies the EQ predicate on the "pricing_hash" field.
+func PricingHashEQ(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldEQ(FieldPricingHash, v))
+}
+
+// PricingHashNEQ applies the NEQ predicate on the "pricing_hash" field.
+func PricingHashNEQ(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldNEQ(FieldPricingHash, v))
+}
+
+// PricingHashIn applies the In predicate on the "pricing_hash" field.
+func PricingHashIn(vs ...string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldIn(FieldPricingHash, vs...))
+}
+
+// PricingHashNotIn applies the NotIn predicate on the "pricing_hash" field.
+func PricingHashNotIn(vs ...string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldNotIn(FieldPricingHash, vs...))
+}
+
+// PricingHashGT applies the GT predicate on the "pricing_hash" field.
+func PricingHashGT(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldGT(FieldPricingHash, v))
+}
+
+// PricingHashGTE applies the GTE predicate on the "pricing_hash" field.
+func PricingHashGTE(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldGTE(FieldPricingHash, v))
+}
+
+// PricingHashLT applies the LT predicate on the "pricing_hash" field.
+func PricingHashLT(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldLT(FieldPricingHash, v))
+}
+
+// PricingHashLTE applies the LTE predicate on the "pricing_hash" field.
+func PricingHashLTE(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldLTE(FieldPricingHash, v))
+}
+
+// PricingHashContains applies the Contains predicate on the "pricing_hash" field.
+func PricingHashContains(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldContains(FieldPricingHash, v))
+}
+
+// PricingHashHasPrefix applies the HasPrefix predicate on the "pricing_hash" field.
+func PricingHashHasPrefix(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldHasPrefix(FieldPricingHash, v))
+}
+
+// PricingHashHasSuffix applies the HasSuffix predicate on the "pricing_hash" field.
+func PricingHashHasSuffix(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldHasSuffix(FieldPricingHash, v))
+}
+
+// PricingHashIsNil applies the IsNil predicate on the "pricing_hash" field.
+func PricingHashIsNil() predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldIsNull(FieldPricingHash))
+}
+
+// PricingHashNotNil applies the NotNil predicate on the "pricing_hash" field.
+func PricingHashNotNil() predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldNotNull(FieldPricingHash))
+}
+
+// PricingHashEqualFold applies the EqualFold predicate on the "pricing_hash" field.
+func PricingHashEqualFold(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldEqualFold(FieldPricingHash, v))
+}
+
+// PricingHashContainsFold applies the ContainsFold predicate on the "pricing_hash" field.
+func PricingHashContainsFold(v string) predicate.UsageLog {
+ return predicate.UsageLog(sql.FieldContainsFold(FieldPricingHash, v))
+}
+
// ChannelIDEQ applies the EQ predicate on the "channel_id" field.
func ChannelIDEQ(v int64) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldChannelID, v))
@@ -2065,6 +2385,29 @@ func HasSubscriptionWith(preds ...predicate.UserSubscription) predicate.UsageLog
})
}
+// HasWalletLedgerEntries applies the HasEdge predicate on the "wallet_ledger_entries" edge.
+func HasWalletLedgerEntries() predicate.UsageLog {
+ return predicate.UsageLog(func(s *sql.Selector) {
+ step := sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, WalletLedgerEntriesTable, WalletLedgerEntriesColumn),
+ )
+ sqlgraph.HasNeighbors(s, step)
+ })
+}
+
+// HasWalletLedgerEntriesWith applies the HasEdge predicate on the "wallet_ledger_entries" edge with a given conditions (other predicates).
+func HasWalletLedgerEntriesWith(preds ...predicate.SubscriptionWalletLedger) predicate.UsageLog {
+ return predicate.UsageLog(func(s *sql.Selector) {
+ step := newWalletLedgerEntriesStep()
+ sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
+ for _, p := range preds {
+ p(s)
+ }
+ })
+ })
+}
+
// And groups predicates with the AND operator between them.
func And(predicates ...predicate.UsageLog) predicate.UsageLog {
return predicate.UsageLog(sql.AndPredicates(predicates...))
diff --git a/backend/ent/usagelog_create.go b/backend/ent/usagelog_create.go
index fded364e0e6..a3ee8c9fc42 100644
--- a/backend/ent/usagelog_create.go
+++ b/backend/ent/usagelog_create.go
@@ -14,6 +14,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/account"
"github.com/Wei-Shaw/sub2api/ent/apikey"
"github.com/Wei-Shaw/sub2api/ent/group"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
@@ -85,6 +86,62 @@ func (_c *UsageLogCreate) SetNillableUpstreamModel(v *string) *UsageLogCreate {
return _c
}
+// SetBillingModel sets the "billing_model" field.
+func (_c *UsageLogCreate) SetBillingModel(v string) *UsageLogCreate {
+ _c.mutation.SetBillingModel(v)
+ return _c
+}
+
+// SetNillableBillingModel sets the "billing_model" field if the given value is not nil.
+func (_c *UsageLogCreate) SetNillableBillingModel(v *string) *UsageLogCreate {
+ if v != nil {
+ _c.SetBillingModel(*v)
+ }
+ return _c
+}
+
+// SetPricingSource sets the "pricing_source" field.
+func (_c *UsageLogCreate) SetPricingSource(v string) *UsageLogCreate {
+ _c.mutation.SetPricingSource(v)
+ return _c
+}
+
+// SetNillablePricingSource sets the "pricing_source" field if the given value is not nil.
+func (_c *UsageLogCreate) SetNillablePricingSource(v *string) *UsageLogCreate {
+ if v != nil {
+ _c.SetPricingSource(*v)
+ }
+ return _c
+}
+
+// SetPricingRevision sets the "pricing_revision" field.
+func (_c *UsageLogCreate) SetPricingRevision(v string) *UsageLogCreate {
+ _c.mutation.SetPricingRevision(v)
+ return _c
+}
+
+// SetNillablePricingRevision sets the "pricing_revision" field if the given value is not nil.
+func (_c *UsageLogCreate) SetNillablePricingRevision(v *string) *UsageLogCreate {
+ if v != nil {
+ _c.SetPricingRevision(*v)
+ }
+ return _c
+}
+
+// SetPricingHash sets the "pricing_hash" field.
+func (_c *UsageLogCreate) SetPricingHash(v string) *UsageLogCreate {
+ _c.mutation.SetPricingHash(v)
+ return _c
+}
+
+// SetNillablePricingHash sets the "pricing_hash" field if the given value is not nil.
+func (_c *UsageLogCreate) SetNillablePricingHash(v *string) *UsageLogCreate {
+ if v != nil {
+ _c.SetPricingHash(*v)
+ }
+ return _c
+}
+
// SetChannelID sets the "channel_id" field.
func (_c *UsageLogCreate) SetChannelID(v int64) *UsageLogCreate {
_c.mutation.SetChannelID(v)
@@ -530,6 +587,21 @@ func (_c *UsageLogCreate) SetSubscription(v *UserSubscription) *UsageLogCreate {
return _c.SetSubscriptionID(v.ID)
}
+// AddWalletLedgerEntryIDs adds the "wallet_ledger_entries" edge to the SubscriptionWalletLedger entity by IDs.
+func (_c *UsageLogCreate) AddWalletLedgerEntryIDs(ids ...int64) *UsageLogCreate {
+ _c.mutation.AddWalletLedgerEntryIDs(ids...)
+ return _c
+}
+
+// AddWalletLedgerEntries adds the "wallet_ledger_entries" edges to the SubscriptionWalletLedger entity.
+func (_c *UsageLogCreate) AddWalletLedgerEntries(v ...*SubscriptionWalletLedger) *UsageLogCreate {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _c.AddWalletLedgerEntryIDs(ids...)
+}
+
// Mutation returns the UsageLogMutation object of the builder.
func (_c *UsageLogCreate) Mutation() *UsageLogMutation {
return _c.mutation
@@ -676,6 +748,26 @@ func (_c *UsageLogCreate) check() error {
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
}
}
+ if v, ok := _c.mutation.BillingModel(); ok {
+ if err := usagelog.BillingModelValidator(v); err != nil {
+ return &ValidationError{Name: "billing_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.billing_model": %w`, err)}
+ }
+ }
+ if v, ok := _c.mutation.PricingSource(); ok {
+ if err := usagelog.PricingSourceValidator(v); err != nil {
+ return &ValidationError{Name: "pricing_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.pricing_source": %w`, err)}
+ }
+ }
+ if v, ok := _c.mutation.PricingRevision(); ok {
+ if err := usagelog.PricingRevisionValidator(v); err != nil {
+ return &ValidationError{Name: "pricing_revision", err: fmt.Errorf(`ent: validator failed for field "UsageLog.pricing_revision": %w`, err)}
+ }
+ }
+ if v, ok := _c.mutation.PricingHash(); ok {
+ if err := usagelog.PricingHashValidator(v); err != nil {
+ return &ValidationError{Name: "pricing_hash", err: fmt.Errorf(`ent: validator failed for field "UsageLog.pricing_hash": %w`, err)}
+ }
+ }
if v, ok := _c.mutation.ModelMappingChain(); ok {
if err := usagelog.ModelMappingChainValidator(v); err != nil {
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
@@ -812,6 +904,22 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) {
_spec.SetField(usagelog.FieldUpstreamModel, field.TypeString, value)
_node.UpstreamModel = &value
}
+ if value, ok := _c.mutation.BillingModel(); ok {
+ _spec.SetField(usagelog.FieldBillingModel, field.TypeString, value)
+ _node.BillingModel = &value
+ }
+ if value, ok := _c.mutation.PricingSource(); ok {
+ _spec.SetField(usagelog.FieldPricingSource, field.TypeString, value)
+ _node.PricingSource = &value
+ }
+ if value, ok := _c.mutation.PricingRevision(); ok {
+ _spec.SetField(usagelog.FieldPricingRevision, field.TypeString, value)
+ _node.PricingRevision = &value
+ }
+ if value, ok := _c.mutation.PricingHash(); ok {
+ _spec.SetField(usagelog.FieldPricingHash, field.TypeString, value)
+ _node.PricingHash = &value
+ }
if value, ok := _c.mutation.ChannelID(); ok {
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
_node.ChannelID = &value
@@ -1009,6 +1117,22 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) {
_node.SubscriptionID = &nodes[0]
_spec.Edges = append(_spec.Edges, edge)
}
+ if nodes := _c.mutation.WalletLedgerEntriesIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: usagelog.WalletLedgerEntriesTable,
+ Columns: []string{usagelog.WalletLedgerEntriesColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges = append(_spec.Edges, edge)
+ }
return _node, _spec
}
@@ -1157,6 +1281,78 @@ func (u *UsageLogUpsert) ClearUpstreamModel() *UsageLogUpsert {
return u
}
+// SetBillingModel sets the "billing_model" field.
+func (u *UsageLogUpsert) SetBillingModel(v string) *UsageLogUpsert {
+ u.Set(usagelog.FieldBillingModel, v)
+ return u
+}
+
+// UpdateBillingModel sets the "billing_model" field to the value that was provided on create.
+func (u *UsageLogUpsert) UpdateBillingModel() *UsageLogUpsert {
+ u.SetExcluded(usagelog.FieldBillingModel)
+ return u
+}
+
+// ClearBillingModel clears the value of the "billing_model" field.
+func (u *UsageLogUpsert) ClearBillingModel() *UsageLogUpsert {
+ u.SetNull(usagelog.FieldBillingModel)
+ return u
+}
+
+// SetPricingSource sets the "pricing_source" field.
+func (u *UsageLogUpsert) SetPricingSource(v string) *UsageLogUpsert {
+ u.Set(usagelog.FieldPricingSource, v)
+ return u
+}
+
+// UpdatePricingSource sets the "pricing_source" field to the value that was provided on create.
+func (u *UsageLogUpsert) UpdatePricingSource() *UsageLogUpsert {
+ u.SetExcluded(usagelog.FieldPricingSource)
+ return u
+}
+
+// ClearPricingSource clears the value of the "pricing_source" field.
+func (u *UsageLogUpsert) ClearPricingSource() *UsageLogUpsert {
+ u.SetNull(usagelog.FieldPricingSource)
+ return u
+}
+
+// SetPricingRevision sets the "pricing_revision" field.
+func (u *UsageLogUpsert) SetPricingRevision(v string) *UsageLogUpsert {
+ u.Set(usagelog.FieldPricingRevision, v)
+ return u
+}
+
+// UpdatePricingRevision sets the "pricing_revision" field to the value that was provided on create.
+func (u *UsageLogUpsert) UpdatePricingRevision() *UsageLogUpsert {
+ u.SetExcluded(usagelog.FieldPricingRevision)
+ return u
+}
+
+// ClearPricingRevision clears the value of the "pricing_revision" field.
+func (u *UsageLogUpsert) ClearPricingRevision() *UsageLogUpsert {
+ u.SetNull(usagelog.FieldPricingRevision)
+ return u
+}
+
+// SetPricingHash sets the "pricing_hash" field.
+func (u *UsageLogUpsert) SetPricingHash(v string) *UsageLogUpsert {
+ u.Set(usagelog.FieldPricingHash, v)
+ return u
+}
+
+// UpdatePricingHash sets the "pricing_hash" field to the value that was provided on create.
+func (u *UsageLogUpsert) UpdatePricingHash() *UsageLogUpsert {
+ u.SetExcluded(usagelog.FieldPricingHash)
+ return u
+}
+
+// ClearPricingHash clears the value of the "pricing_hash" field.
+func (u *UsageLogUpsert) ClearPricingHash() *UsageLogUpsert {
+ u.SetNull(usagelog.FieldPricingHash)
+ return u
+}
+
// SetChannelID sets the "channel_id" field.
func (u *UsageLogUpsert) SetChannelID(v int64) *UsageLogUpsert {
u.Set(usagelog.FieldChannelID, v)
@@ -1848,6 +2044,90 @@ func (u *UsageLogUpsertOne) ClearUpstreamModel() *UsageLogUpsertOne {
})
}
+// SetBillingModel sets the "billing_model" field.
+func (u *UsageLogUpsertOne) SetBillingModel(v string) *UsageLogUpsertOne {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.SetBillingModel(v)
+ })
+}
+
+// UpdateBillingModel sets the "billing_model" field to the value that was provided on create.
+func (u *UsageLogUpsertOne) UpdateBillingModel() *UsageLogUpsertOne {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.UpdateBillingModel()
+ })
+}
+
+// ClearBillingModel clears the value of the "billing_model" field.
+func (u *UsageLogUpsertOne) ClearBillingModel() *UsageLogUpsertOne {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.ClearBillingModel()
+ })
+}
+
+// SetPricingSource sets the "pricing_source" field.
+func (u *UsageLogUpsertOne) SetPricingSource(v string) *UsageLogUpsertOne {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.SetPricingSource(v)
+ })
+}
+
+// UpdatePricingSource sets the "pricing_source" field to the value that was provided on create.
+func (u *UsageLogUpsertOne) UpdatePricingSource() *UsageLogUpsertOne {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.UpdatePricingSource()
+ })
+}
+
+// ClearPricingSource clears the value of the "pricing_source" field.
+func (u *UsageLogUpsertOne) ClearPricingSource() *UsageLogUpsertOne {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.ClearPricingSource()
+ })
+}
+
+// SetPricingRevision sets the "pricing_revision" field.
+func (u *UsageLogUpsertOne) SetPricingRevision(v string) *UsageLogUpsertOne {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.SetPricingRevision(v)
+ })
+}
+
+// UpdatePricingRevision sets the "pricing_revision" field to the value that was provided on create.
+func (u *UsageLogUpsertOne) UpdatePricingRevision() *UsageLogUpsertOne {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.UpdatePricingRevision()
+ })
+}
+
+// ClearPricingRevision clears the value of the "pricing_revision" field.
+func (u *UsageLogUpsertOne) ClearPricingRevision() *UsageLogUpsertOne {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.ClearPricingRevision()
+ })
+}
+
+// SetPricingHash sets the "pricing_hash" field.
+func (u *UsageLogUpsertOne) SetPricingHash(v string) *UsageLogUpsertOne {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.SetPricingHash(v)
+ })
+}
+
+// UpdatePricingHash sets the "pricing_hash" field to the value that was provided on create.
+func (u *UsageLogUpsertOne) UpdatePricingHash() *UsageLogUpsertOne {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.UpdatePricingHash()
+ })
+}
+
+// ClearPricingHash clears the value of the "pricing_hash" field.
+func (u *UsageLogUpsertOne) ClearPricingHash() *UsageLogUpsertOne {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.ClearPricingHash()
+ })
+}
+
// SetChannelID sets the "channel_id" field.
func (u *UsageLogUpsertOne) SetChannelID(v int64) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
@@ -2794,6 +3074,90 @@ func (u *UsageLogUpsertBulk) ClearUpstreamModel() *UsageLogUpsertBulk {
})
}
+// SetBillingModel sets the "billing_model" field.
+func (u *UsageLogUpsertBulk) SetBillingModel(v string) *UsageLogUpsertBulk {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.SetBillingModel(v)
+ })
+}
+
+// UpdateBillingModel sets the "billing_model" field to the value that was provided on create.
+func (u *UsageLogUpsertBulk) UpdateBillingModel() *UsageLogUpsertBulk {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.UpdateBillingModel()
+ })
+}
+
+// ClearBillingModel clears the value of the "billing_model" field.
+func (u *UsageLogUpsertBulk) ClearBillingModel() *UsageLogUpsertBulk {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.ClearBillingModel()
+ })
+}
+
+// SetPricingSource sets the "pricing_source" field.
+func (u *UsageLogUpsertBulk) SetPricingSource(v string) *UsageLogUpsertBulk {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.SetPricingSource(v)
+ })
+}
+
+// UpdatePricingSource sets the "pricing_source" field to the value that was provided on create.
+func (u *UsageLogUpsertBulk) UpdatePricingSource() *UsageLogUpsertBulk {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.UpdatePricingSource()
+ })
+}
+
+// ClearPricingSource clears the value of the "pricing_source" field.
+func (u *UsageLogUpsertBulk) ClearPricingSource() *UsageLogUpsertBulk {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.ClearPricingSource()
+ })
+}
+
+// SetPricingRevision sets the "pricing_revision" field.
+func (u *UsageLogUpsertBulk) SetPricingRevision(v string) *UsageLogUpsertBulk {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.SetPricingRevision(v)
+ })
+}
+
+// UpdatePricingRevision sets the "pricing_revision" field to the value that was provided on create.
+func (u *UsageLogUpsertBulk) UpdatePricingRevision() *UsageLogUpsertBulk {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.UpdatePricingRevision()
+ })
+}
+
+// ClearPricingRevision clears the value of the "pricing_revision" field.
+func (u *UsageLogUpsertBulk) ClearPricingRevision() *UsageLogUpsertBulk {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.ClearPricingRevision()
+ })
+}
+
+// SetPricingHash sets the "pricing_hash" field.
+func (u *UsageLogUpsertBulk) SetPricingHash(v string) *UsageLogUpsertBulk {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.SetPricingHash(v)
+ })
+}
+
+// UpdatePricingHash sets the "pricing_hash" field to the value that was provided on create.
+func (u *UsageLogUpsertBulk) UpdatePricingHash() *UsageLogUpsertBulk {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.UpdatePricingHash()
+ })
+}
+
+// ClearPricingHash clears the value of the "pricing_hash" field.
+func (u *UsageLogUpsertBulk) ClearPricingHash() *UsageLogUpsertBulk {
+ return u.Update(func(s *UsageLogUpsert) {
+ s.ClearPricingHash()
+ })
+}
+
// SetChannelID sets the "channel_id" field.
func (u *UsageLogUpsertBulk) SetChannelID(v int64) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
diff --git a/backend/ent/usagelog_query.go b/backend/ent/usagelog_query.go
index c709bde0802..d9644dc3f42 100644
--- a/backend/ent/usagelog_query.go
+++ b/backend/ent/usagelog_query.go
@@ -4,6 +4,7 @@ package ent
import (
"context"
+ "database/sql/driver"
"fmt"
"math"
@@ -16,6 +17,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/apikey"
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/predicate"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
@@ -24,16 +26,17 @@ import (
// UsageLogQuery is the builder for querying UsageLog entities.
type UsageLogQuery struct {
config
- ctx *QueryContext
- order []usagelog.OrderOption
- inters []Interceptor
- predicates []predicate.UsageLog
- withUser *UserQuery
- withAPIKey *APIKeyQuery
- withAccount *AccountQuery
- withGroup *GroupQuery
- withSubscription *UserSubscriptionQuery
- modifiers []func(*sql.Selector)
+ ctx *QueryContext
+ order []usagelog.OrderOption
+ inters []Interceptor
+ predicates []predicate.UsageLog
+ withUser *UserQuery
+ withAPIKey *APIKeyQuery
+ withAccount *AccountQuery
+ withGroup *GroupQuery
+ withSubscription *UserSubscriptionQuery
+ withWalletLedgerEntries *SubscriptionWalletLedgerQuery
+ modifiers []func(*sql.Selector)
// intermediate query (i.e. traversal path).
sql *sql.Selector
path func(context.Context) (*sql.Selector, error)
@@ -180,6 +183,28 @@ func (_q *UsageLogQuery) QuerySubscription() *UserSubscriptionQuery {
return query
}
+// QueryWalletLedgerEntries chains the current query on the "wallet_ledger_entries" edge.
+func (_q *UsageLogQuery) QueryWalletLedgerEntries() *SubscriptionWalletLedgerQuery {
+ query := (&SubscriptionWalletLedgerClient{config: _q.config}).Query()
+ query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
+ if err := _q.prepareQuery(ctx); err != nil {
+ return nil, err
+ }
+ selector := _q.sqlQuery(ctx)
+ if err := selector.Err(); err != nil {
+ return nil, err
+ }
+ step := sqlgraph.NewStep(
+ sqlgraph.From(usagelog.Table, usagelog.FieldID, selector),
+ sqlgraph.To(subscriptionwalletledger.Table, subscriptionwalletledger.FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, usagelog.WalletLedgerEntriesTable, usagelog.WalletLedgerEntriesColumn),
+ )
+ fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
+ return fromU, nil
+ }
+ return query
+}
+
// First returns the first UsageLog entity from the query.
// Returns a *NotFoundError when no UsageLog was found.
func (_q *UsageLogQuery) First(ctx context.Context) (*UsageLog, error) {
@@ -367,16 +392,17 @@ func (_q *UsageLogQuery) Clone() *UsageLogQuery {
return nil
}
return &UsageLogQuery{
- config: _q.config,
- ctx: _q.ctx.Clone(),
- order: append([]usagelog.OrderOption{}, _q.order...),
- inters: append([]Interceptor{}, _q.inters...),
- predicates: append([]predicate.UsageLog{}, _q.predicates...),
- withUser: _q.withUser.Clone(),
- withAPIKey: _q.withAPIKey.Clone(),
- withAccount: _q.withAccount.Clone(),
- withGroup: _q.withGroup.Clone(),
- withSubscription: _q.withSubscription.Clone(),
+ config: _q.config,
+ ctx: _q.ctx.Clone(),
+ order: append([]usagelog.OrderOption{}, _q.order...),
+ inters: append([]Interceptor{}, _q.inters...),
+ predicates: append([]predicate.UsageLog{}, _q.predicates...),
+ withUser: _q.withUser.Clone(),
+ withAPIKey: _q.withAPIKey.Clone(),
+ withAccount: _q.withAccount.Clone(),
+ withGroup: _q.withGroup.Clone(),
+ withSubscription: _q.withSubscription.Clone(),
+ withWalletLedgerEntries: _q.withWalletLedgerEntries.Clone(),
// clone intermediate query.
sql: _q.sql.Clone(),
path: _q.path,
@@ -438,6 +464,17 @@ func (_q *UsageLogQuery) WithSubscription(opts ...func(*UserSubscriptionQuery))
return _q
}
+// WithWalletLedgerEntries tells the query-builder to eager-load the nodes that are connected to
+// the "wallet_ledger_entries" edge. The optional arguments are used to configure the query builder of the edge.
+func (_q *UsageLogQuery) WithWalletLedgerEntries(opts ...func(*SubscriptionWalletLedgerQuery)) *UsageLogQuery {
+ query := (&SubscriptionWalletLedgerClient{config: _q.config}).Query()
+ for _, opt := range opts {
+ opt(query)
+ }
+ _q.withWalletLedgerEntries = query
+ return _q
+}
+
// GroupBy is used to group vertices by one or more fields/columns.
// It is often used with aggregate functions, like: count, max, mean, min, sum.
//
@@ -516,12 +553,13 @@ func (_q *UsageLogQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Usa
var (
nodes = []*UsageLog{}
_spec = _q.querySpec()
- loadedTypes = [5]bool{
+ loadedTypes = [6]bool{
_q.withUser != nil,
_q.withAPIKey != nil,
_q.withAccount != nil,
_q.withGroup != nil,
_q.withSubscription != nil,
+ _q.withWalletLedgerEntries != nil,
}
)
_spec.ScanValues = func(columns []string) ([]any, error) {
@@ -575,6 +613,15 @@ func (_q *UsageLogQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Usa
return nil, err
}
}
+ if query := _q.withWalletLedgerEntries; query != nil {
+ if err := _q.loadWalletLedgerEntries(ctx, query, nodes,
+ func(n *UsageLog) { n.Edges.WalletLedgerEntries = []*SubscriptionWalletLedger{} },
+ func(n *UsageLog, e *SubscriptionWalletLedger) {
+ n.Edges.WalletLedgerEntries = append(n.Edges.WalletLedgerEntries, e)
+ }); err != nil {
+ return nil, err
+ }
+ }
return nodes, nil
}
@@ -729,6 +776,39 @@ func (_q *UsageLogQuery) loadSubscription(ctx context.Context, query *UserSubscr
}
return nil
}
+func (_q *UsageLogQuery) loadWalletLedgerEntries(ctx context.Context, query *SubscriptionWalletLedgerQuery, nodes []*UsageLog, init func(*UsageLog), assign func(*UsageLog, *SubscriptionWalletLedger)) error {
+ fks := make([]driver.Value, 0, len(nodes))
+ nodeids := make(map[int64]*UsageLog)
+ for i := range nodes {
+ fks = append(fks, nodes[i].ID)
+ nodeids[nodes[i].ID] = nodes[i]
+ if init != nil {
+ init(nodes[i])
+ }
+ }
+ if len(query.ctx.Fields) > 0 {
+ query.ctx.AppendFieldOnce(subscriptionwalletledger.FieldUsageLogID)
+ }
+ query.Where(predicate.SubscriptionWalletLedger(func(s *sql.Selector) {
+ s.Where(sql.InValues(s.C(usagelog.WalletLedgerEntriesColumn), fks...))
+ }))
+ neighbors, err := query.All(ctx)
+ if err != nil {
+ return err
+ }
+ for _, n := range neighbors {
+ fk := n.UsageLogID
+ if fk == nil {
+ return fmt.Errorf(`foreign-key "usage_log_id" is nil for node %v`, n.ID)
+ }
+ node, ok := nodeids[*fk]
+ if !ok {
+ return fmt.Errorf(`unexpected referenced foreign-key "usage_log_id" returned %v for node %v`, *fk, n.ID)
+ }
+ assign(node, n)
+ }
+ return nil
+}
func (_q *UsageLogQuery) sqlCount(ctx context.Context) (int, error) {
_spec := _q.querySpec()
diff --git a/backend/ent/usagelog_update.go b/backend/ent/usagelog_update.go
index bb5ac86c78a..c18afe19272 100644
--- a/backend/ent/usagelog_update.go
+++ b/backend/ent/usagelog_update.go
@@ -14,6 +14,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/apikey"
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/predicate"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
@@ -142,6 +143,86 @@ func (_u *UsageLogUpdate) ClearUpstreamModel() *UsageLogUpdate {
return _u
}
+// SetBillingModel sets the "billing_model" field.
+func (_u *UsageLogUpdate) SetBillingModel(v string) *UsageLogUpdate {
+ _u.mutation.SetBillingModel(v)
+ return _u
+}
+
+// SetNillableBillingModel sets the "billing_model" field if the given value is not nil.
+func (_u *UsageLogUpdate) SetNillableBillingModel(v *string) *UsageLogUpdate {
+ if v != nil {
+ _u.SetBillingModel(*v)
+ }
+ return _u
+}
+
+// ClearBillingModel clears the value of the "billing_model" field.
+func (_u *UsageLogUpdate) ClearBillingModel() *UsageLogUpdate {
+ _u.mutation.ClearBillingModel()
+ return _u
+}
+
+// SetPricingSource sets the "pricing_source" field.
+func (_u *UsageLogUpdate) SetPricingSource(v string) *UsageLogUpdate {
+ _u.mutation.SetPricingSource(v)
+ return _u
+}
+
+// SetNillablePricingSource sets the "pricing_source" field if the given value is not nil.
+func (_u *UsageLogUpdate) SetNillablePricingSource(v *string) *UsageLogUpdate {
+ if v != nil {
+ _u.SetPricingSource(*v)
+ }
+ return _u
+}
+
+// ClearPricingSource clears the value of the "pricing_source" field.
+func (_u *UsageLogUpdate) ClearPricingSource() *UsageLogUpdate {
+ _u.mutation.ClearPricingSource()
+ return _u
+}
+
+// SetPricingRevision sets the "pricing_revision" field.
+func (_u *UsageLogUpdate) SetPricingRevision(v string) *UsageLogUpdate {
+ _u.mutation.SetPricingRevision(v)
+ return _u
+}
+
+// SetNillablePricingRevision sets the "pricing_revision" field if the given value is not nil.
+func (_u *UsageLogUpdate) SetNillablePricingRevision(v *string) *UsageLogUpdate {
+ if v != nil {
+ _u.SetPricingRevision(*v)
+ }
+ return _u
+}
+
+// ClearPricingRevision clears the value of the "pricing_revision" field.
+func (_u *UsageLogUpdate) ClearPricingRevision() *UsageLogUpdate {
+ _u.mutation.ClearPricingRevision()
+ return _u
+}
+
+// SetPricingHash sets the "pricing_hash" field.
+func (_u *UsageLogUpdate) SetPricingHash(v string) *UsageLogUpdate {
+ _u.mutation.SetPricingHash(v)
+ return _u
+}
+
+// SetNillablePricingHash sets the "pricing_hash" field if the given value is not nil.
+func (_u *UsageLogUpdate) SetNillablePricingHash(v *string) *UsageLogUpdate {
+ if v != nil {
+ _u.SetPricingHash(*v)
+ }
+ return _u
+}
+
+// ClearPricingHash clears the value of the "pricing_hash" field.
+func (_u *UsageLogUpdate) ClearPricingHash() *UsageLogUpdate {
+ _u.mutation.ClearPricingHash()
+ return _u
+}
+
// SetChannelID sets the "channel_id" field.
func (_u *UsageLogUpdate) SetChannelID(v int64) *UsageLogUpdate {
_u.mutation.ResetChannelID()
@@ -778,6 +859,21 @@ func (_u *UsageLogUpdate) SetSubscription(v *UserSubscription) *UsageLogUpdate {
return _u.SetSubscriptionID(v.ID)
}
+// AddWalletLedgerEntryIDs adds the "wallet_ledger_entries" edge to the SubscriptionWalletLedger entity by IDs.
+func (_u *UsageLogUpdate) AddWalletLedgerEntryIDs(ids ...int64) *UsageLogUpdate {
+ _u.mutation.AddWalletLedgerEntryIDs(ids...)
+ return _u
+}
+
+// AddWalletLedgerEntries adds the "wallet_ledger_entries" edges to the SubscriptionWalletLedger entity.
+func (_u *UsageLogUpdate) AddWalletLedgerEntries(v ...*SubscriptionWalletLedger) *UsageLogUpdate {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.AddWalletLedgerEntryIDs(ids...)
+}
+
// Mutation returns the UsageLogMutation object of the builder.
func (_u *UsageLogUpdate) Mutation() *UsageLogMutation {
return _u.mutation
@@ -813,6 +909,27 @@ func (_u *UsageLogUpdate) ClearSubscription() *UsageLogUpdate {
return _u
}
+// ClearWalletLedgerEntries clears all "wallet_ledger_entries" edges to the SubscriptionWalletLedger entity.
+func (_u *UsageLogUpdate) ClearWalletLedgerEntries() *UsageLogUpdate {
+ _u.mutation.ClearWalletLedgerEntries()
+ return _u
+}
+
+// RemoveWalletLedgerEntryIDs removes the "wallet_ledger_entries" edge to SubscriptionWalletLedger entities by IDs.
+func (_u *UsageLogUpdate) RemoveWalletLedgerEntryIDs(ids ...int64) *UsageLogUpdate {
+ _u.mutation.RemoveWalletLedgerEntryIDs(ids...)
+ return _u
+}
+
+// RemoveWalletLedgerEntries removes "wallet_ledger_entries" edges to SubscriptionWalletLedger entities.
+func (_u *UsageLogUpdate) RemoveWalletLedgerEntries(v ...*SubscriptionWalletLedger) *UsageLogUpdate {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.RemoveWalletLedgerEntryIDs(ids...)
+}
+
// Save executes the query and returns the number of nodes affected by the update operation.
func (_u *UsageLogUpdate) Save(ctx context.Context) (int, error) {
return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks)
@@ -862,6 +979,26 @@ func (_u *UsageLogUpdate) check() error {
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
}
}
+ if v, ok := _u.mutation.BillingModel(); ok {
+ if err := usagelog.BillingModelValidator(v); err != nil {
+ return &ValidationError{Name: "billing_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.billing_model": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.PricingSource(); ok {
+ if err := usagelog.PricingSourceValidator(v); err != nil {
+ return &ValidationError{Name: "pricing_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.pricing_source": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.PricingRevision(); ok {
+ if err := usagelog.PricingRevisionValidator(v); err != nil {
+ return &ValidationError{Name: "pricing_revision", err: fmt.Errorf(`ent: validator failed for field "UsageLog.pricing_revision": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.PricingHash(); ok {
+ if err := usagelog.PricingHashValidator(v); err != nil {
+ return &ValidationError{Name: "pricing_hash", err: fmt.Errorf(`ent: validator failed for field "UsageLog.pricing_hash": %w`, err)}
+ }
+ }
if v, ok := _u.mutation.ModelMappingChain(); ok {
if err := usagelog.ModelMappingChainValidator(v); err != nil {
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
@@ -934,6 +1071,30 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.UpstreamModelCleared() {
_spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString)
}
+ if value, ok := _u.mutation.BillingModel(); ok {
+ _spec.SetField(usagelog.FieldBillingModel, field.TypeString, value)
+ }
+ if _u.mutation.BillingModelCleared() {
+ _spec.ClearField(usagelog.FieldBillingModel, field.TypeString)
+ }
+ if value, ok := _u.mutation.PricingSource(); ok {
+ _spec.SetField(usagelog.FieldPricingSource, field.TypeString, value)
+ }
+ if _u.mutation.PricingSourceCleared() {
+ _spec.ClearField(usagelog.FieldPricingSource, field.TypeString)
+ }
+ if value, ok := _u.mutation.PricingRevision(); ok {
+ _spec.SetField(usagelog.FieldPricingRevision, field.TypeString, value)
+ }
+ if _u.mutation.PricingRevisionCleared() {
+ _spec.ClearField(usagelog.FieldPricingRevision, field.TypeString)
+ }
+ if value, ok := _u.mutation.PricingHash(); ok {
+ _spec.SetField(usagelog.FieldPricingHash, field.TypeString, value)
+ }
+ if _u.mutation.PricingHashCleared() {
+ _spec.ClearField(usagelog.FieldPricingHash, field.TypeString)
+ }
if value, ok := _u.mutation.ChannelID(); ok {
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
}
@@ -1247,6 +1408,51 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) {
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
+ if _u.mutation.WalletLedgerEntriesCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: usagelog.WalletLedgerEntriesTable,
+ Columns: []string{usagelog.WalletLedgerEntriesColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.RemovedWalletLedgerEntriesIDs(); len(nodes) > 0 && !_u.mutation.WalletLedgerEntriesCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: usagelog.WalletLedgerEntriesTable,
+ Columns: []string{usagelog.WalletLedgerEntriesColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.WalletLedgerEntriesIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: usagelog.WalletLedgerEntriesTable,
+ Columns: []string{usagelog.WalletLedgerEntriesColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil {
if _, ok := err.(*sqlgraph.NotFoundError); ok {
err = &NotFoundError{usagelog.Label}
@@ -1377,6 +1583,86 @@ func (_u *UsageLogUpdateOne) ClearUpstreamModel() *UsageLogUpdateOne {
return _u
}
+// SetBillingModel sets the "billing_model" field.
+func (_u *UsageLogUpdateOne) SetBillingModel(v string) *UsageLogUpdateOne {
+ _u.mutation.SetBillingModel(v)
+ return _u
+}
+
+// SetNillableBillingModel sets the "billing_model" field if the given value is not nil.
+func (_u *UsageLogUpdateOne) SetNillableBillingModel(v *string) *UsageLogUpdateOne {
+ if v != nil {
+ _u.SetBillingModel(*v)
+ }
+ return _u
+}
+
+// ClearBillingModel clears the value of the "billing_model" field.
+func (_u *UsageLogUpdateOne) ClearBillingModel() *UsageLogUpdateOne {
+ _u.mutation.ClearBillingModel()
+ return _u
+}
+
+// SetPricingSource sets the "pricing_source" field.
+func (_u *UsageLogUpdateOne) SetPricingSource(v string) *UsageLogUpdateOne {
+ _u.mutation.SetPricingSource(v)
+ return _u
+}
+
+// SetNillablePricingSource sets the "pricing_source" field if the given value is not nil.
+func (_u *UsageLogUpdateOne) SetNillablePricingSource(v *string) *UsageLogUpdateOne {
+ if v != nil {
+ _u.SetPricingSource(*v)
+ }
+ return _u
+}
+
+// ClearPricingSource clears the value of the "pricing_source" field.
+func (_u *UsageLogUpdateOne) ClearPricingSource() *UsageLogUpdateOne {
+ _u.mutation.ClearPricingSource()
+ return _u
+}
+
+// SetPricingRevision sets the "pricing_revision" field.
+func (_u *UsageLogUpdateOne) SetPricingRevision(v string) *UsageLogUpdateOne {
+ _u.mutation.SetPricingRevision(v)
+ return _u
+}
+
+// SetNillablePricingRevision sets the "pricing_revision" field if the given value is not nil.
+func (_u *UsageLogUpdateOne) SetNillablePricingRevision(v *string) *UsageLogUpdateOne {
+ if v != nil {
+ _u.SetPricingRevision(*v)
+ }
+ return _u
+}
+
+// ClearPricingRevision clears the value of the "pricing_revision" field.
+func (_u *UsageLogUpdateOne) ClearPricingRevision() *UsageLogUpdateOne {
+ _u.mutation.ClearPricingRevision()
+ return _u
+}
+
+// SetPricingHash sets the "pricing_hash" field.
+func (_u *UsageLogUpdateOne) SetPricingHash(v string) *UsageLogUpdateOne {
+ _u.mutation.SetPricingHash(v)
+ return _u
+}
+
+// SetNillablePricingHash sets the "pricing_hash" field if the given value is not nil.
+func (_u *UsageLogUpdateOne) SetNillablePricingHash(v *string) *UsageLogUpdateOne {
+ if v != nil {
+ _u.SetPricingHash(*v)
+ }
+ return _u
+}
+
+// ClearPricingHash clears the value of the "pricing_hash" field.
+func (_u *UsageLogUpdateOne) ClearPricingHash() *UsageLogUpdateOne {
+ _u.mutation.ClearPricingHash()
+ return _u
+}
+
// SetChannelID sets the "channel_id" field.
func (_u *UsageLogUpdateOne) SetChannelID(v int64) *UsageLogUpdateOne {
_u.mutation.ResetChannelID()
@@ -2013,6 +2299,21 @@ func (_u *UsageLogUpdateOne) SetSubscription(v *UserSubscription) *UsageLogUpdat
return _u.SetSubscriptionID(v.ID)
}
+// AddWalletLedgerEntryIDs adds the "wallet_ledger_entries" edge to the SubscriptionWalletLedger entity by IDs.
+func (_u *UsageLogUpdateOne) AddWalletLedgerEntryIDs(ids ...int64) *UsageLogUpdateOne {
+ _u.mutation.AddWalletLedgerEntryIDs(ids...)
+ return _u
+}
+
+// AddWalletLedgerEntries adds the "wallet_ledger_entries" edges to the SubscriptionWalletLedger entity.
+func (_u *UsageLogUpdateOne) AddWalletLedgerEntries(v ...*SubscriptionWalletLedger) *UsageLogUpdateOne {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.AddWalletLedgerEntryIDs(ids...)
+}
+
// Mutation returns the UsageLogMutation object of the builder.
func (_u *UsageLogUpdateOne) Mutation() *UsageLogMutation {
return _u.mutation
@@ -2048,6 +2349,27 @@ func (_u *UsageLogUpdateOne) ClearSubscription() *UsageLogUpdateOne {
return _u
}
+// ClearWalletLedgerEntries clears all "wallet_ledger_entries" edges to the SubscriptionWalletLedger entity.
+func (_u *UsageLogUpdateOne) ClearWalletLedgerEntries() *UsageLogUpdateOne {
+ _u.mutation.ClearWalletLedgerEntries()
+ return _u
+}
+
+// RemoveWalletLedgerEntryIDs removes the "wallet_ledger_entries" edge to SubscriptionWalletLedger entities by IDs.
+func (_u *UsageLogUpdateOne) RemoveWalletLedgerEntryIDs(ids ...int64) *UsageLogUpdateOne {
+ _u.mutation.RemoveWalletLedgerEntryIDs(ids...)
+ return _u
+}
+
+// RemoveWalletLedgerEntries removes "wallet_ledger_entries" edges to SubscriptionWalletLedger entities.
+func (_u *UsageLogUpdateOne) RemoveWalletLedgerEntries(v ...*SubscriptionWalletLedger) *UsageLogUpdateOne {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.RemoveWalletLedgerEntryIDs(ids...)
+}
+
// Where appends a list predicates to the UsageLogUpdate builder.
func (_u *UsageLogUpdateOne) Where(ps ...predicate.UsageLog) *UsageLogUpdateOne {
_u.mutation.Where(ps...)
@@ -2110,6 +2432,26 @@ func (_u *UsageLogUpdateOne) check() error {
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
}
}
+ if v, ok := _u.mutation.BillingModel(); ok {
+ if err := usagelog.BillingModelValidator(v); err != nil {
+ return &ValidationError{Name: "billing_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.billing_model": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.PricingSource(); ok {
+ if err := usagelog.PricingSourceValidator(v); err != nil {
+ return &ValidationError{Name: "pricing_source", err: fmt.Errorf(`ent: validator failed for field "UsageLog.pricing_source": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.PricingRevision(); ok {
+ if err := usagelog.PricingRevisionValidator(v); err != nil {
+ return &ValidationError{Name: "pricing_revision", err: fmt.Errorf(`ent: validator failed for field "UsageLog.pricing_revision": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.PricingHash(); ok {
+ if err := usagelog.PricingHashValidator(v); err != nil {
+ return &ValidationError{Name: "pricing_hash", err: fmt.Errorf(`ent: validator failed for field "UsageLog.pricing_hash": %w`, err)}
+ }
+ }
if v, ok := _u.mutation.ModelMappingChain(); ok {
if err := usagelog.ModelMappingChainValidator(v); err != nil {
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
@@ -2199,6 +2541,30 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err
if _u.mutation.UpstreamModelCleared() {
_spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString)
}
+ if value, ok := _u.mutation.BillingModel(); ok {
+ _spec.SetField(usagelog.FieldBillingModel, field.TypeString, value)
+ }
+ if _u.mutation.BillingModelCleared() {
+ _spec.ClearField(usagelog.FieldBillingModel, field.TypeString)
+ }
+ if value, ok := _u.mutation.PricingSource(); ok {
+ _spec.SetField(usagelog.FieldPricingSource, field.TypeString, value)
+ }
+ if _u.mutation.PricingSourceCleared() {
+ _spec.ClearField(usagelog.FieldPricingSource, field.TypeString)
+ }
+ if value, ok := _u.mutation.PricingRevision(); ok {
+ _spec.SetField(usagelog.FieldPricingRevision, field.TypeString, value)
+ }
+ if _u.mutation.PricingRevisionCleared() {
+ _spec.ClearField(usagelog.FieldPricingRevision, field.TypeString)
+ }
+ if value, ok := _u.mutation.PricingHash(); ok {
+ _spec.SetField(usagelog.FieldPricingHash, field.TypeString, value)
+ }
+ if _u.mutation.PricingHashCleared() {
+ _spec.ClearField(usagelog.FieldPricingHash, field.TypeString)
+ }
if value, ok := _u.mutation.ChannelID(); ok {
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
}
@@ -2512,6 +2878,51 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
+ if _u.mutation.WalletLedgerEntriesCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: usagelog.WalletLedgerEntriesTable,
+ Columns: []string{usagelog.WalletLedgerEntriesColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.RemovedWalletLedgerEntriesIDs(); len(nodes) > 0 && !_u.mutation.WalletLedgerEntriesCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: usagelog.WalletLedgerEntriesTable,
+ Columns: []string{usagelog.WalletLedgerEntriesColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.WalletLedgerEntriesIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: usagelog.WalletLedgerEntriesTable,
+ Columns: []string{usagelog.WalletLedgerEntriesColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
_node = &UsageLog{config: _u.config}
_spec.Assign = _node.assignValues
_spec.ScanValues = _node.scanValues
diff --git a/backend/ent/user.go b/backend/ent/user.go
index 06670444897..20cf5e93dde 100644
--- a/backend/ent/user.go
+++ b/backend/ent/user.go
@@ -63,6 +63,22 @@ type User struct {
TotalRecharged float64 `json:"total_recharged,omitempty"`
// RpmLimit holds the value of the "rpm_limit" field.
RpmLimit int `json:"rpm_limit,omitempty"`
+ // SignupIP holds the value of the "signup_ip" field.
+ SignupIP string `json:"signup_ip,omitempty"`
+ // SignupIPPrefix holds the value of the "signup_ip_prefix" field.
+ SignupIPPrefix string `json:"signup_ip_prefix,omitempty"`
+ // SignupUserAgentHash holds the value of the "signup_user_agent_hash" field.
+ SignupUserAgentHash string `json:"signup_user_agent_hash,omitempty"`
+ // SignupDeviceFingerprintHash holds the value of the "signup_device_fingerprint_hash" field.
+ SignupDeviceFingerprintHash string `json:"signup_device_fingerprint_hash,omitempty"`
+ // TrialBonusEligible holds the value of the "trial_bonus_eligible" field.
+ TrialBonusEligible bool `json:"trial_bonus_eligible,omitempty"`
+ // TrialBonusHoldReason holds the value of the "trial_bonus_hold_reason" field.
+ TrialBonusHoldReason string `json:"trial_bonus_hold_reason,omitempty"`
+ // TrialBonusRiskScore holds the value of the "trial_bonus_risk_score" field.
+ TrialBonusRiskScore int `json:"trial_bonus_risk_score,omitempty"`
+ // TokenVersion holds the value of the "token_version" field.
+ TokenVersion int64 `json:"token_version,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the UserQuery when eager-loading is set.
Edges UserEdges `json:"edges"`
@@ -79,6 +95,8 @@ type UserEdges struct {
Subscriptions []*UserSubscription `json:"subscriptions,omitempty"`
// AssignedSubscriptions holds the value of the assigned_subscriptions edge.
AssignedSubscriptions []*UserSubscription `json:"assigned_subscriptions,omitempty"`
+ // WalletLedgerOperations holds the value of the wallet_ledger_operations edge.
+ WalletLedgerOperations []*SubscriptionWalletLedger `json:"wallet_ledger_operations,omitempty"`
// AnnouncementReads holds the value of the announcement_reads edge.
AnnouncementReads []*AnnouncementRead `json:"announcement_reads,omitempty"`
// AllowedGroups holds the value of the allowed_groups edge.
@@ -99,7 +117,7 @@ type UserEdges struct {
UserAllowedGroups []*UserAllowedGroup `json:"user_allowed_groups,omitempty"`
// loadedTypes holds the information for reporting if a
// type was loaded (or requested) in eager-loading or not.
- loadedTypes [13]bool
+ loadedTypes [14]bool
}
// APIKeysOrErr returns the APIKeys value or an error if the edge
@@ -138,10 +156,19 @@ func (e UserEdges) AssignedSubscriptionsOrErr() ([]*UserSubscription, error) {
return nil, &NotLoadedError{edge: "assigned_subscriptions"}
}
+// WalletLedgerOperationsOrErr returns the WalletLedgerOperations value or an error if the edge
+// was not loaded in eager-loading.
+func (e UserEdges) WalletLedgerOperationsOrErr() ([]*SubscriptionWalletLedger, error) {
+ if e.loadedTypes[4] {
+ return e.WalletLedgerOperations, nil
+ }
+ return nil, &NotLoadedError{edge: "wallet_ledger_operations"}
+}
+
// AnnouncementReadsOrErr returns the AnnouncementReads value or an error if the edge
// was not loaded in eager-loading.
func (e UserEdges) AnnouncementReadsOrErr() ([]*AnnouncementRead, error) {
- if e.loadedTypes[4] {
+ if e.loadedTypes[5] {
return e.AnnouncementReads, nil
}
return nil, &NotLoadedError{edge: "announcement_reads"}
@@ -150,7 +177,7 @@ func (e UserEdges) AnnouncementReadsOrErr() ([]*AnnouncementRead, error) {
// AllowedGroupsOrErr returns the AllowedGroups value or an error if the edge
// was not loaded in eager-loading.
func (e UserEdges) AllowedGroupsOrErr() ([]*Group, error) {
- if e.loadedTypes[5] {
+ if e.loadedTypes[6] {
return e.AllowedGroups, nil
}
return nil, &NotLoadedError{edge: "allowed_groups"}
@@ -159,7 +186,7 @@ func (e UserEdges) AllowedGroupsOrErr() ([]*Group, error) {
// UsageLogsOrErr returns the UsageLogs value or an error if the edge
// was not loaded in eager-loading.
func (e UserEdges) UsageLogsOrErr() ([]*UsageLog, error) {
- if e.loadedTypes[6] {
+ if e.loadedTypes[7] {
return e.UsageLogs, nil
}
return nil, &NotLoadedError{edge: "usage_logs"}
@@ -168,7 +195,7 @@ func (e UserEdges) UsageLogsOrErr() ([]*UsageLog, error) {
// AttributeValuesOrErr returns the AttributeValues value or an error if the edge
// was not loaded in eager-loading.
func (e UserEdges) AttributeValuesOrErr() ([]*UserAttributeValue, error) {
- if e.loadedTypes[7] {
+ if e.loadedTypes[8] {
return e.AttributeValues, nil
}
return nil, &NotLoadedError{edge: "attribute_values"}
@@ -177,7 +204,7 @@ func (e UserEdges) AttributeValuesOrErr() ([]*UserAttributeValue, error) {
// PromoCodeUsagesOrErr returns the PromoCodeUsages value or an error if the edge
// was not loaded in eager-loading.
func (e UserEdges) PromoCodeUsagesOrErr() ([]*PromoCodeUsage, error) {
- if e.loadedTypes[8] {
+ if e.loadedTypes[9] {
return e.PromoCodeUsages, nil
}
return nil, &NotLoadedError{edge: "promo_code_usages"}
@@ -186,7 +213,7 @@ func (e UserEdges) PromoCodeUsagesOrErr() ([]*PromoCodeUsage, error) {
// PaymentOrdersOrErr returns the PaymentOrders value or an error if the edge
// was not loaded in eager-loading.
func (e UserEdges) PaymentOrdersOrErr() ([]*PaymentOrder, error) {
- if e.loadedTypes[9] {
+ if e.loadedTypes[10] {
return e.PaymentOrders, nil
}
return nil, &NotLoadedError{edge: "payment_orders"}
@@ -195,7 +222,7 @@ func (e UserEdges) PaymentOrdersOrErr() ([]*PaymentOrder, error) {
// AuthIdentitiesOrErr returns the AuthIdentities value or an error if the edge
// was not loaded in eager-loading.
func (e UserEdges) AuthIdentitiesOrErr() ([]*AuthIdentity, error) {
- if e.loadedTypes[10] {
+ if e.loadedTypes[11] {
return e.AuthIdentities, nil
}
return nil, &NotLoadedError{edge: "auth_identities"}
@@ -204,7 +231,7 @@ func (e UserEdges) AuthIdentitiesOrErr() ([]*AuthIdentity, error) {
// PendingAuthSessionsOrErr returns the PendingAuthSessions value or an error if the edge
// was not loaded in eager-loading.
func (e UserEdges) PendingAuthSessionsOrErr() ([]*PendingAuthSession, error) {
- if e.loadedTypes[11] {
+ if e.loadedTypes[12] {
return e.PendingAuthSessions, nil
}
return nil, &NotLoadedError{edge: "pending_auth_sessions"}
@@ -213,7 +240,7 @@ func (e UserEdges) PendingAuthSessionsOrErr() ([]*PendingAuthSession, error) {
// UserAllowedGroupsOrErr returns the UserAllowedGroups value or an error if the edge
// was not loaded in eager-loading.
func (e UserEdges) UserAllowedGroupsOrErr() ([]*UserAllowedGroup, error) {
- if e.loadedTypes[12] {
+ if e.loadedTypes[13] {
return e.UserAllowedGroups, nil
}
return nil, &NotLoadedError{edge: "user_allowed_groups"}
@@ -224,13 +251,13 @@ func (*User) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
- case user.FieldTotpEnabled, user.FieldBalanceNotifyEnabled:
+ case user.FieldTotpEnabled, user.FieldBalanceNotifyEnabled, user.FieldTrialBonusEligible:
values[i] = new(sql.NullBool)
case user.FieldBalance, user.FieldBalanceNotifyThreshold, user.FieldTotalRecharged:
values[i] = new(sql.NullFloat64)
- case user.FieldID, user.FieldConcurrency, user.FieldRpmLimit:
+ case user.FieldID, user.FieldConcurrency, user.FieldRpmLimit, user.FieldTrialBonusRiskScore, user.FieldTokenVersion:
values[i] = new(sql.NullInt64)
- case user.FieldEmail, user.FieldPasswordHash, user.FieldRole, user.FieldStatus, user.FieldUsername, user.FieldNotes, user.FieldTotpSecretEncrypted, user.FieldSignupSource, user.FieldBalanceNotifyThresholdType, user.FieldBalanceNotifyExtraEmails:
+ case user.FieldEmail, user.FieldPasswordHash, user.FieldRole, user.FieldStatus, user.FieldUsername, user.FieldNotes, user.FieldTotpSecretEncrypted, user.FieldSignupSource, user.FieldBalanceNotifyThresholdType, user.FieldBalanceNotifyExtraEmails, user.FieldSignupIP, user.FieldSignupIPPrefix, user.FieldSignupUserAgentHash, user.FieldSignupDeviceFingerprintHash, user.FieldTrialBonusHoldReason:
values[i] = new(sql.NullString)
case user.FieldCreatedAt, user.FieldUpdatedAt, user.FieldDeletedAt, user.FieldTotpEnabledAt, user.FieldLastLoginAt, user.FieldLastActiveAt:
values[i] = new(sql.NullTime)
@@ -399,6 +426,54 @@ func (_m *User) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.RpmLimit = int(value.Int64)
}
+ case user.FieldSignupIP:
+ if value, ok := values[i].(*sql.NullString); !ok {
+ return fmt.Errorf("unexpected type %T for field signup_ip", values[i])
+ } else if value.Valid {
+ _m.SignupIP = value.String
+ }
+ case user.FieldSignupIPPrefix:
+ if value, ok := values[i].(*sql.NullString); !ok {
+ return fmt.Errorf("unexpected type %T for field signup_ip_prefix", values[i])
+ } else if value.Valid {
+ _m.SignupIPPrefix = value.String
+ }
+ case user.FieldSignupUserAgentHash:
+ if value, ok := values[i].(*sql.NullString); !ok {
+ return fmt.Errorf("unexpected type %T for field signup_user_agent_hash", values[i])
+ } else if value.Valid {
+ _m.SignupUserAgentHash = value.String
+ }
+ case user.FieldSignupDeviceFingerprintHash:
+ if value, ok := values[i].(*sql.NullString); !ok {
+ return fmt.Errorf("unexpected type %T for field signup_device_fingerprint_hash", values[i])
+ } else if value.Valid {
+ _m.SignupDeviceFingerprintHash = value.String
+ }
+ case user.FieldTrialBonusEligible:
+ if value, ok := values[i].(*sql.NullBool); !ok {
+ return fmt.Errorf("unexpected type %T for field trial_bonus_eligible", values[i])
+ } else if value.Valid {
+ _m.TrialBonusEligible = value.Bool
+ }
+ case user.FieldTrialBonusHoldReason:
+ if value, ok := values[i].(*sql.NullString); !ok {
+ return fmt.Errorf("unexpected type %T for field trial_bonus_hold_reason", values[i])
+ } else if value.Valid {
+ _m.TrialBonusHoldReason = value.String
+ }
+ case user.FieldTrialBonusRiskScore:
+ if value, ok := values[i].(*sql.NullInt64); !ok {
+ return fmt.Errorf("unexpected type %T for field trial_bonus_risk_score", values[i])
+ } else if value.Valid {
+ _m.TrialBonusRiskScore = int(value.Int64)
+ }
+ case user.FieldTokenVersion:
+ if value, ok := values[i].(*sql.NullInt64); !ok {
+ return fmt.Errorf("unexpected type %T for field token_version", values[i])
+ } else if value.Valid {
+ _m.TokenVersion = value.Int64
+ }
default:
_m.selectValues.Set(columns[i], values[i])
}
@@ -432,6 +507,11 @@ func (_m *User) QueryAssignedSubscriptions() *UserSubscriptionQuery {
return NewUserClient(_m.config).QueryAssignedSubscriptions(_m)
}
+// QueryWalletLedgerOperations queries the "wallet_ledger_operations" edge of the User entity.
+func (_m *User) QueryWalletLedgerOperations() *SubscriptionWalletLedgerQuery {
+ return NewUserClient(_m.config).QueryWalletLedgerOperations(_m)
+}
+
// QueryAnnouncementReads queries the "announcement_reads" edge of the User entity.
func (_m *User) QueryAnnouncementReads() *AnnouncementReadQuery {
return NewUserClient(_m.config).QueryAnnouncementReads(_m)
@@ -580,6 +660,30 @@ func (_m *User) String() string {
builder.WriteString(", ")
builder.WriteString("rpm_limit=")
builder.WriteString(fmt.Sprintf("%v", _m.RpmLimit))
+ builder.WriteString(", ")
+ builder.WriteString("signup_ip=")
+ builder.WriteString(_m.SignupIP)
+ builder.WriteString(", ")
+ builder.WriteString("signup_ip_prefix=")
+ builder.WriteString(_m.SignupIPPrefix)
+ builder.WriteString(", ")
+ builder.WriteString("signup_user_agent_hash=")
+ builder.WriteString(_m.SignupUserAgentHash)
+ builder.WriteString(", ")
+ builder.WriteString("signup_device_fingerprint_hash=")
+ builder.WriteString(_m.SignupDeviceFingerprintHash)
+ builder.WriteString(", ")
+ builder.WriteString("trial_bonus_eligible=")
+ builder.WriteString(fmt.Sprintf("%v", _m.TrialBonusEligible))
+ builder.WriteString(", ")
+ builder.WriteString("trial_bonus_hold_reason=")
+ builder.WriteString(_m.TrialBonusHoldReason)
+ builder.WriteString(", ")
+ builder.WriteString("trial_bonus_risk_score=")
+ builder.WriteString(fmt.Sprintf("%v", _m.TrialBonusRiskScore))
+ builder.WriteString(", ")
+ builder.WriteString("token_version=")
+ builder.WriteString(fmt.Sprintf("%v", _m.TokenVersion))
builder.WriteByte(')')
return builder.String()
}
diff --git a/backend/ent/user/user.go b/backend/ent/user/user.go
index e11a8a32e42..9a7e5551e7d 100644
--- a/backend/ent/user/user.go
+++ b/backend/ent/user/user.go
@@ -61,6 +61,22 @@ const (
FieldTotalRecharged = "total_recharged"
// FieldRpmLimit holds the string denoting the rpm_limit field in the database.
FieldRpmLimit = "rpm_limit"
+ // FieldSignupIP holds the string denoting the signup_ip field in the database.
+ FieldSignupIP = "signup_ip"
+ // FieldSignupIPPrefix holds the string denoting the signup_ip_prefix field in the database.
+ FieldSignupIPPrefix = "signup_ip_prefix"
+ // FieldSignupUserAgentHash holds the string denoting the signup_user_agent_hash field in the database.
+ FieldSignupUserAgentHash = "signup_user_agent_hash"
+ // FieldSignupDeviceFingerprintHash holds the string denoting the signup_device_fingerprint_hash field in the database.
+ FieldSignupDeviceFingerprintHash = "signup_device_fingerprint_hash"
+ // FieldTrialBonusEligible holds the string denoting the trial_bonus_eligible field in the database.
+ FieldTrialBonusEligible = "trial_bonus_eligible"
+ // FieldTrialBonusHoldReason holds the string denoting the trial_bonus_hold_reason field in the database.
+ FieldTrialBonusHoldReason = "trial_bonus_hold_reason"
+ // FieldTrialBonusRiskScore holds the string denoting the trial_bonus_risk_score field in the database.
+ FieldTrialBonusRiskScore = "trial_bonus_risk_score"
+ // FieldTokenVersion holds the string denoting the token_version field in the database.
+ FieldTokenVersion = "token_version"
// EdgeAPIKeys holds the string denoting the api_keys edge name in mutations.
EdgeAPIKeys = "api_keys"
// EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations.
@@ -69,6 +85,8 @@ const (
EdgeSubscriptions = "subscriptions"
// EdgeAssignedSubscriptions holds the string denoting the assigned_subscriptions edge name in mutations.
EdgeAssignedSubscriptions = "assigned_subscriptions"
+ // EdgeWalletLedgerOperations holds the string denoting the wallet_ledger_operations edge name in mutations.
+ EdgeWalletLedgerOperations = "wallet_ledger_operations"
// EdgeAnnouncementReads holds the string denoting the announcement_reads edge name in mutations.
EdgeAnnouncementReads = "announcement_reads"
// EdgeAllowedGroups holds the string denoting the allowed_groups edge name in mutations.
@@ -117,6 +135,13 @@ const (
AssignedSubscriptionsInverseTable = "user_subscriptions"
// AssignedSubscriptionsColumn is the table column denoting the assigned_subscriptions relation/edge.
AssignedSubscriptionsColumn = "assigned_by"
+ // WalletLedgerOperationsTable is the table that holds the wallet_ledger_operations relation/edge.
+ WalletLedgerOperationsTable = "subscription_wallet_ledger"
+ // WalletLedgerOperationsInverseTable is the table name for the SubscriptionWalletLedger entity.
+ // It exists in this package in order to avoid circular dependency with the "subscriptionwalletledger" package.
+ WalletLedgerOperationsInverseTable = "subscription_wallet_ledger"
+ // WalletLedgerOperationsColumn is the table column denoting the wallet_ledger_operations relation/edge.
+ WalletLedgerOperationsColumn = "operator_id"
// AnnouncementReadsTable is the table that holds the announcement_reads relation/edge.
AnnouncementReadsTable = "announcement_reads"
// AnnouncementReadsInverseTable is the table name for the AnnouncementRead entity.
@@ -206,6 +231,14 @@ var Columns = []string{
FieldBalanceNotifyExtraEmails,
FieldTotalRecharged,
FieldRpmLimit,
+ FieldSignupIP,
+ FieldSignupIPPrefix,
+ FieldSignupUserAgentHash,
+ FieldSignupDeviceFingerprintHash,
+ FieldTrialBonusEligible,
+ FieldTrialBonusHoldReason,
+ FieldTrialBonusRiskScore,
+ FieldTokenVersion,
}
var (
@@ -276,6 +309,34 @@ var (
DefaultTotalRecharged float64
// DefaultRpmLimit holds the default value on creation for the "rpm_limit" field.
DefaultRpmLimit int
+ // DefaultSignupIP holds the default value on creation for the "signup_ip" field.
+ DefaultSignupIP string
+ // SignupIPValidator is a validator for the "signup_ip" field. It is called by the builders before save.
+ SignupIPValidator func(string) error
+ // DefaultSignupIPPrefix holds the default value on creation for the "signup_ip_prefix" field.
+ DefaultSignupIPPrefix string
+ // SignupIPPrefixValidator is a validator for the "signup_ip_prefix" field. It is called by the builders before save.
+ SignupIPPrefixValidator func(string) error
+ // DefaultSignupUserAgentHash holds the default value on creation for the "signup_user_agent_hash" field.
+ DefaultSignupUserAgentHash string
+ // SignupUserAgentHashValidator is a validator for the "signup_user_agent_hash" field. It is called by the builders before save.
+ SignupUserAgentHashValidator func(string) error
+ // DefaultSignupDeviceFingerprintHash holds the default value on creation for the "signup_device_fingerprint_hash" field.
+ DefaultSignupDeviceFingerprintHash string
+ // SignupDeviceFingerprintHashValidator is a validator for the "signup_device_fingerprint_hash" field. It is called by the builders before save.
+ SignupDeviceFingerprintHashValidator func(string) error
+ // DefaultTrialBonusEligible holds the default value on creation for the "trial_bonus_eligible" field.
+ DefaultTrialBonusEligible bool
+ // DefaultTrialBonusHoldReason holds the default value on creation for the "trial_bonus_hold_reason" field.
+ DefaultTrialBonusHoldReason string
+ // TrialBonusHoldReasonValidator is a validator for the "trial_bonus_hold_reason" field. It is called by the builders before save.
+ TrialBonusHoldReasonValidator func(string) error
+ // DefaultTrialBonusRiskScore holds the default value on creation for the "trial_bonus_risk_score" field.
+ DefaultTrialBonusRiskScore int
+ // DefaultTokenVersion holds the default value on creation for the "token_version" field.
+ DefaultTokenVersion int64
+ // TokenVersionValidator is a validator for the "token_version" field. It is called by the builders before save.
+ TokenVersionValidator func(int64) error
)
// OrderOption defines the ordering options for the User queries.
@@ -401,6 +462,46 @@ func ByRpmLimit(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldRpmLimit, opts...).ToFunc()
}
+// BySignupIP orders the results by the signup_ip field.
+func BySignupIP(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldSignupIP, opts...).ToFunc()
+}
+
+// BySignupIPPrefix orders the results by the signup_ip_prefix field.
+func BySignupIPPrefix(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldSignupIPPrefix, opts...).ToFunc()
+}
+
+// BySignupUserAgentHash orders the results by the signup_user_agent_hash field.
+func BySignupUserAgentHash(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldSignupUserAgentHash, opts...).ToFunc()
+}
+
+// BySignupDeviceFingerprintHash orders the results by the signup_device_fingerprint_hash field.
+func BySignupDeviceFingerprintHash(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldSignupDeviceFingerprintHash, opts...).ToFunc()
+}
+
+// ByTrialBonusEligible orders the results by the trial_bonus_eligible field.
+func ByTrialBonusEligible(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldTrialBonusEligible, opts...).ToFunc()
+}
+
+// ByTrialBonusHoldReason orders the results by the trial_bonus_hold_reason field.
+func ByTrialBonusHoldReason(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldTrialBonusHoldReason, opts...).ToFunc()
+}
+
+// ByTrialBonusRiskScore orders the results by the trial_bonus_risk_score field.
+func ByTrialBonusRiskScore(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldTrialBonusRiskScore, opts...).ToFunc()
+}
+
+// ByTokenVersion orders the results by the token_version field.
+func ByTokenVersion(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldTokenVersion, opts...).ToFunc()
+}
+
// ByAPIKeysCount orders the results by api_keys count.
func ByAPIKeysCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
@@ -457,6 +558,20 @@ func ByAssignedSubscriptions(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOp
}
}
+// ByWalletLedgerOperationsCount orders the results by wallet_ledger_operations count.
+func ByWalletLedgerOperationsCount(opts ...sql.OrderTermOption) OrderOption {
+ return func(s *sql.Selector) {
+ sqlgraph.OrderByNeighborsCount(s, newWalletLedgerOperationsStep(), opts...)
+ }
+}
+
+// ByWalletLedgerOperations orders the results by wallet_ledger_operations terms.
+func ByWalletLedgerOperations(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
+ return func(s *sql.Selector) {
+ sqlgraph.OrderByNeighborTerms(s, newWalletLedgerOperationsStep(), append([]sql.OrderTerm{term}, terms...)...)
+ }
+}
+
// ByAnnouncementReadsCount orders the results by announcement_reads count.
func ByAnnouncementReadsCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
@@ -610,6 +725,13 @@ func newAssignedSubscriptionsStep() *sqlgraph.Step {
sqlgraph.Edge(sqlgraph.O2M, false, AssignedSubscriptionsTable, AssignedSubscriptionsColumn),
)
}
+func newWalletLedgerOperationsStep() *sqlgraph.Step {
+ return sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.To(WalletLedgerOperationsInverseTable, FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, WalletLedgerOperationsTable, WalletLedgerOperationsColumn),
+ )
+}
func newAnnouncementReadsStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
diff --git a/backend/ent/user/where.go b/backend/ent/user/where.go
index 05d3b35b962..bc53e3a9fa4 100644
--- a/backend/ent/user/where.go
+++ b/backend/ent/user/where.go
@@ -170,6 +170,46 @@ func RpmLimit(v int) predicate.User {
return predicate.User(sql.FieldEQ(FieldRpmLimit, v))
}
+// SignupIP applies equality check predicate on the "signup_ip" field. It's identical to SignupIPEQ.
+func SignupIP(v string) predicate.User {
+ return predicate.User(sql.FieldEQ(FieldSignupIP, v))
+}
+
+// SignupIPPrefix applies equality check predicate on the "signup_ip_prefix" field. It's identical to SignupIPPrefixEQ.
+func SignupIPPrefix(v string) predicate.User {
+ return predicate.User(sql.FieldEQ(FieldSignupIPPrefix, v))
+}
+
+// SignupUserAgentHash applies equality check predicate on the "signup_user_agent_hash" field. It's identical to SignupUserAgentHashEQ.
+func SignupUserAgentHash(v string) predicate.User {
+ return predicate.User(sql.FieldEQ(FieldSignupUserAgentHash, v))
+}
+
+// SignupDeviceFingerprintHash applies equality check predicate on the "signup_device_fingerprint_hash" field. It's identical to SignupDeviceFingerprintHashEQ.
+func SignupDeviceFingerprintHash(v string) predicate.User {
+ return predicate.User(sql.FieldEQ(FieldSignupDeviceFingerprintHash, v))
+}
+
+// TrialBonusEligible applies equality check predicate on the "trial_bonus_eligible" field. It's identical to TrialBonusEligibleEQ.
+func TrialBonusEligible(v bool) predicate.User {
+ return predicate.User(sql.FieldEQ(FieldTrialBonusEligible, v))
+}
+
+// TrialBonusHoldReason applies equality check predicate on the "trial_bonus_hold_reason" field. It's identical to TrialBonusHoldReasonEQ.
+func TrialBonusHoldReason(v string) predicate.User {
+ return predicate.User(sql.FieldEQ(FieldTrialBonusHoldReason, v))
+}
+
+// TrialBonusRiskScore applies equality check predicate on the "trial_bonus_risk_score" field. It's identical to TrialBonusRiskScoreEQ.
+func TrialBonusRiskScore(v int) predicate.User {
+ return predicate.User(sql.FieldEQ(FieldTrialBonusRiskScore, v))
+}
+
+// TokenVersion applies equality check predicate on the "token_version" field. It's identical to TokenVersionEQ.
+func TokenVersion(v int64) predicate.User {
+ return predicate.User(sql.FieldEQ(FieldTokenVersion, v))
+}
+
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.User {
return predicate.User(sql.FieldEQ(FieldCreatedAt, v))
@@ -1340,6 +1380,421 @@ func RpmLimitLTE(v int) predicate.User {
return predicate.User(sql.FieldLTE(FieldRpmLimit, v))
}
+// SignupIPEQ applies the EQ predicate on the "signup_ip" field.
+func SignupIPEQ(v string) predicate.User {
+ return predicate.User(sql.FieldEQ(FieldSignupIP, v))
+}
+
+// SignupIPNEQ applies the NEQ predicate on the "signup_ip" field.
+func SignupIPNEQ(v string) predicate.User {
+ return predicate.User(sql.FieldNEQ(FieldSignupIP, v))
+}
+
+// SignupIPIn applies the In predicate on the "signup_ip" field.
+func SignupIPIn(vs ...string) predicate.User {
+ return predicate.User(sql.FieldIn(FieldSignupIP, vs...))
+}
+
+// SignupIPNotIn applies the NotIn predicate on the "signup_ip" field.
+func SignupIPNotIn(vs ...string) predicate.User {
+ return predicate.User(sql.FieldNotIn(FieldSignupIP, vs...))
+}
+
+// SignupIPGT applies the GT predicate on the "signup_ip" field.
+func SignupIPGT(v string) predicate.User {
+ return predicate.User(sql.FieldGT(FieldSignupIP, v))
+}
+
+// SignupIPGTE applies the GTE predicate on the "signup_ip" field.
+func SignupIPGTE(v string) predicate.User {
+ return predicate.User(sql.FieldGTE(FieldSignupIP, v))
+}
+
+// SignupIPLT applies the LT predicate on the "signup_ip" field.
+func SignupIPLT(v string) predicate.User {
+ return predicate.User(sql.FieldLT(FieldSignupIP, v))
+}
+
+// SignupIPLTE applies the LTE predicate on the "signup_ip" field.
+func SignupIPLTE(v string) predicate.User {
+ return predicate.User(sql.FieldLTE(FieldSignupIP, v))
+}
+
+// SignupIPContains applies the Contains predicate on the "signup_ip" field.
+func SignupIPContains(v string) predicate.User {
+ return predicate.User(sql.FieldContains(FieldSignupIP, v))
+}
+
+// SignupIPHasPrefix applies the HasPrefix predicate on the "signup_ip" field.
+func SignupIPHasPrefix(v string) predicate.User {
+ return predicate.User(sql.FieldHasPrefix(FieldSignupIP, v))
+}
+
+// SignupIPHasSuffix applies the HasSuffix predicate on the "signup_ip" field.
+func SignupIPHasSuffix(v string) predicate.User {
+ return predicate.User(sql.FieldHasSuffix(FieldSignupIP, v))
+}
+
+// SignupIPEqualFold applies the EqualFold predicate on the "signup_ip" field.
+func SignupIPEqualFold(v string) predicate.User {
+ return predicate.User(sql.FieldEqualFold(FieldSignupIP, v))
+}
+
+// SignupIPContainsFold applies the ContainsFold predicate on the "signup_ip" field.
+func SignupIPContainsFold(v string) predicate.User {
+ return predicate.User(sql.FieldContainsFold(FieldSignupIP, v))
+}
+
+// SignupIPPrefixEQ applies the EQ predicate on the "signup_ip_prefix" field.
+func SignupIPPrefixEQ(v string) predicate.User {
+ return predicate.User(sql.FieldEQ(FieldSignupIPPrefix, v))
+}
+
+// SignupIPPrefixNEQ applies the NEQ predicate on the "signup_ip_prefix" field.
+func SignupIPPrefixNEQ(v string) predicate.User {
+ return predicate.User(sql.FieldNEQ(FieldSignupIPPrefix, v))
+}
+
+// SignupIPPrefixIn applies the In predicate on the "signup_ip_prefix" field.
+func SignupIPPrefixIn(vs ...string) predicate.User {
+ return predicate.User(sql.FieldIn(FieldSignupIPPrefix, vs...))
+}
+
+// SignupIPPrefixNotIn applies the NotIn predicate on the "signup_ip_prefix" field.
+func SignupIPPrefixNotIn(vs ...string) predicate.User {
+ return predicate.User(sql.FieldNotIn(FieldSignupIPPrefix, vs...))
+}
+
+// SignupIPPrefixGT applies the GT predicate on the "signup_ip_prefix" field.
+func SignupIPPrefixGT(v string) predicate.User {
+ return predicate.User(sql.FieldGT(FieldSignupIPPrefix, v))
+}
+
+// SignupIPPrefixGTE applies the GTE predicate on the "signup_ip_prefix" field.
+func SignupIPPrefixGTE(v string) predicate.User {
+ return predicate.User(sql.FieldGTE(FieldSignupIPPrefix, v))
+}
+
+// SignupIPPrefixLT applies the LT predicate on the "signup_ip_prefix" field.
+func SignupIPPrefixLT(v string) predicate.User {
+ return predicate.User(sql.FieldLT(FieldSignupIPPrefix, v))
+}
+
+// SignupIPPrefixLTE applies the LTE predicate on the "signup_ip_prefix" field.
+func SignupIPPrefixLTE(v string) predicate.User {
+ return predicate.User(sql.FieldLTE(FieldSignupIPPrefix, v))
+}
+
+// SignupIPPrefixContains applies the Contains predicate on the "signup_ip_prefix" field.
+func SignupIPPrefixContains(v string) predicate.User {
+ return predicate.User(sql.FieldContains(FieldSignupIPPrefix, v))
+}
+
+// SignupIPPrefixHasPrefix applies the HasPrefix predicate on the "signup_ip_prefix" field.
+func SignupIPPrefixHasPrefix(v string) predicate.User {
+ return predicate.User(sql.FieldHasPrefix(FieldSignupIPPrefix, v))
+}
+
+// SignupIPPrefixHasSuffix applies the HasSuffix predicate on the "signup_ip_prefix" field.
+func SignupIPPrefixHasSuffix(v string) predicate.User {
+ return predicate.User(sql.FieldHasSuffix(FieldSignupIPPrefix, v))
+}
+
+// SignupIPPrefixEqualFold applies the EqualFold predicate on the "signup_ip_prefix" field.
+func SignupIPPrefixEqualFold(v string) predicate.User {
+ return predicate.User(sql.FieldEqualFold(FieldSignupIPPrefix, v))
+}
+
+// SignupIPPrefixContainsFold applies the ContainsFold predicate on the "signup_ip_prefix" field.
+func SignupIPPrefixContainsFold(v string) predicate.User {
+ return predicate.User(sql.FieldContainsFold(FieldSignupIPPrefix, v))
+}
+
+// SignupUserAgentHashEQ applies the EQ predicate on the "signup_user_agent_hash" field.
+func SignupUserAgentHashEQ(v string) predicate.User {
+ return predicate.User(sql.FieldEQ(FieldSignupUserAgentHash, v))
+}
+
+// SignupUserAgentHashNEQ applies the NEQ predicate on the "signup_user_agent_hash" field.
+func SignupUserAgentHashNEQ(v string) predicate.User {
+ return predicate.User(sql.FieldNEQ(FieldSignupUserAgentHash, v))
+}
+
+// SignupUserAgentHashIn applies the In predicate on the "signup_user_agent_hash" field.
+func SignupUserAgentHashIn(vs ...string) predicate.User {
+ return predicate.User(sql.FieldIn(FieldSignupUserAgentHash, vs...))
+}
+
+// SignupUserAgentHashNotIn applies the NotIn predicate on the "signup_user_agent_hash" field.
+func SignupUserAgentHashNotIn(vs ...string) predicate.User {
+ return predicate.User(sql.FieldNotIn(FieldSignupUserAgentHash, vs...))
+}
+
+// SignupUserAgentHashGT applies the GT predicate on the "signup_user_agent_hash" field.
+func SignupUserAgentHashGT(v string) predicate.User {
+ return predicate.User(sql.FieldGT(FieldSignupUserAgentHash, v))
+}
+
+// SignupUserAgentHashGTE applies the GTE predicate on the "signup_user_agent_hash" field.
+func SignupUserAgentHashGTE(v string) predicate.User {
+ return predicate.User(sql.FieldGTE(FieldSignupUserAgentHash, v))
+}
+
+// SignupUserAgentHashLT applies the LT predicate on the "signup_user_agent_hash" field.
+func SignupUserAgentHashLT(v string) predicate.User {
+ return predicate.User(sql.FieldLT(FieldSignupUserAgentHash, v))
+}
+
+// SignupUserAgentHashLTE applies the LTE predicate on the "signup_user_agent_hash" field.
+func SignupUserAgentHashLTE(v string) predicate.User {
+ return predicate.User(sql.FieldLTE(FieldSignupUserAgentHash, v))
+}
+
+// SignupUserAgentHashContains applies the Contains predicate on the "signup_user_agent_hash" field.
+func SignupUserAgentHashContains(v string) predicate.User {
+ return predicate.User(sql.FieldContains(FieldSignupUserAgentHash, v))
+}
+
+// SignupUserAgentHashHasPrefix applies the HasPrefix predicate on the "signup_user_agent_hash" field.
+func SignupUserAgentHashHasPrefix(v string) predicate.User {
+ return predicate.User(sql.FieldHasPrefix(FieldSignupUserAgentHash, v))
+}
+
+// SignupUserAgentHashHasSuffix applies the HasSuffix predicate on the "signup_user_agent_hash" field.
+func SignupUserAgentHashHasSuffix(v string) predicate.User {
+ return predicate.User(sql.FieldHasSuffix(FieldSignupUserAgentHash, v))
+}
+
+// SignupUserAgentHashEqualFold applies the EqualFold predicate on the "signup_user_agent_hash" field.
+func SignupUserAgentHashEqualFold(v string) predicate.User {
+ return predicate.User(sql.FieldEqualFold(FieldSignupUserAgentHash, v))
+}
+
+// SignupUserAgentHashContainsFold applies the ContainsFold predicate on the "signup_user_agent_hash" field.
+func SignupUserAgentHashContainsFold(v string) predicate.User {
+ return predicate.User(sql.FieldContainsFold(FieldSignupUserAgentHash, v))
+}
+
+// SignupDeviceFingerprintHashEQ applies the EQ predicate on the "signup_device_fingerprint_hash" field.
+func SignupDeviceFingerprintHashEQ(v string) predicate.User {
+ return predicate.User(sql.FieldEQ(FieldSignupDeviceFingerprintHash, v))
+}
+
+// SignupDeviceFingerprintHashNEQ applies the NEQ predicate on the "signup_device_fingerprint_hash" field.
+func SignupDeviceFingerprintHashNEQ(v string) predicate.User {
+ return predicate.User(sql.FieldNEQ(FieldSignupDeviceFingerprintHash, v))
+}
+
+// SignupDeviceFingerprintHashIn applies the In predicate on the "signup_device_fingerprint_hash" field.
+func SignupDeviceFingerprintHashIn(vs ...string) predicate.User {
+ return predicate.User(sql.FieldIn(FieldSignupDeviceFingerprintHash, vs...))
+}
+
+// SignupDeviceFingerprintHashNotIn applies the NotIn predicate on the "signup_device_fingerprint_hash" field.
+func SignupDeviceFingerprintHashNotIn(vs ...string) predicate.User {
+ return predicate.User(sql.FieldNotIn(FieldSignupDeviceFingerprintHash, vs...))
+}
+
+// SignupDeviceFingerprintHashGT applies the GT predicate on the "signup_device_fingerprint_hash" field.
+func SignupDeviceFingerprintHashGT(v string) predicate.User {
+ return predicate.User(sql.FieldGT(FieldSignupDeviceFingerprintHash, v))
+}
+
+// SignupDeviceFingerprintHashGTE applies the GTE predicate on the "signup_device_fingerprint_hash" field.
+func SignupDeviceFingerprintHashGTE(v string) predicate.User {
+ return predicate.User(sql.FieldGTE(FieldSignupDeviceFingerprintHash, v))
+}
+
+// SignupDeviceFingerprintHashLT applies the LT predicate on the "signup_device_fingerprint_hash" field.
+func SignupDeviceFingerprintHashLT(v string) predicate.User {
+ return predicate.User(sql.FieldLT(FieldSignupDeviceFingerprintHash, v))
+}
+
+// SignupDeviceFingerprintHashLTE applies the LTE predicate on the "signup_device_fingerprint_hash" field.
+func SignupDeviceFingerprintHashLTE(v string) predicate.User {
+ return predicate.User(sql.FieldLTE(FieldSignupDeviceFingerprintHash, v))
+}
+
+// SignupDeviceFingerprintHashContains applies the Contains predicate on the "signup_device_fingerprint_hash" field.
+func SignupDeviceFingerprintHashContains(v string) predicate.User {
+ return predicate.User(sql.FieldContains(FieldSignupDeviceFingerprintHash, v))
+}
+
+// SignupDeviceFingerprintHashHasPrefix applies the HasPrefix predicate on the "signup_device_fingerprint_hash" field.
+func SignupDeviceFingerprintHashHasPrefix(v string) predicate.User {
+ return predicate.User(sql.FieldHasPrefix(FieldSignupDeviceFingerprintHash, v))
+}
+
+// SignupDeviceFingerprintHashHasSuffix applies the HasSuffix predicate on the "signup_device_fingerprint_hash" field.
+func SignupDeviceFingerprintHashHasSuffix(v string) predicate.User {
+ return predicate.User(sql.FieldHasSuffix(FieldSignupDeviceFingerprintHash, v))
+}
+
+// SignupDeviceFingerprintHashEqualFold applies the EqualFold predicate on the "signup_device_fingerprint_hash" field.
+func SignupDeviceFingerprintHashEqualFold(v string) predicate.User {
+ return predicate.User(sql.FieldEqualFold(FieldSignupDeviceFingerprintHash, v))
+}
+
+// SignupDeviceFingerprintHashContainsFold applies the ContainsFold predicate on the "signup_device_fingerprint_hash" field.
+func SignupDeviceFingerprintHashContainsFold(v string) predicate.User {
+ return predicate.User(sql.FieldContainsFold(FieldSignupDeviceFingerprintHash, v))
+}
+
+// TrialBonusEligibleEQ applies the EQ predicate on the "trial_bonus_eligible" field.
+func TrialBonusEligibleEQ(v bool) predicate.User {
+ return predicate.User(sql.FieldEQ(FieldTrialBonusEligible, v))
+}
+
+// TrialBonusEligibleNEQ applies the NEQ predicate on the "trial_bonus_eligible" field.
+func TrialBonusEligibleNEQ(v bool) predicate.User {
+ return predicate.User(sql.FieldNEQ(FieldTrialBonusEligible, v))
+}
+
+// TrialBonusHoldReasonEQ applies the EQ predicate on the "trial_bonus_hold_reason" field.
+func TrialBonusHoldReasonEQ(v string) predicate.User {
+ return predicate.User(sql.FieldEQ(FieldTrialBonusHoldReason, v))
+}
+
+// TrialBonusHoldReasonNEQ applies the NEQ predicate on the "trial_bonus_hold_reason" field.
+func TrialBonusHoldReasonNEQ(v string) predicate.User {
+ return predicate.User(sql.FieldNEQ(FieldTrialBonusHoldReason, v))
+}
+
+// TrialBonusHoldReasonIn applies the In predicate on the "trial_bonus_hold_reason" field.
+func TrialBonusHoldReasonIn(vs ...string) predicate.User {
+ return predicate.User(sql.FieldIn(FieldTrialBonusHoldReason, vs...))
+}
+
+// TrialBonusHoldReasonNotIn applies the NotIn predicate on the "trial_bonus_hold_reason" field.
+func TrialBonusHoldReasonNotIn(vs ...string) predicate.User {
+ return predicate.User(sql.FieldNotIn(FieldTrialBonusHoldReason, vs...))
+}
+
+// TrialBonusHoldReasonGT applies the GT predicate on the "trial_bonus_hold_reason" field.
+func TrialBonusHoldReasonGT(v string) predicate.User {
+ return predicate.User(sql.FieldGT(FieldTrialBonusHoldReason, v))
+}
+
+// TrialBonusHoldReasonGTE applies the GTE predicate on the "trial_bonus_hold_reason" field.
+func TrialBonusHoldReasonGTE(v string) predicate.User {
+ return predicate.User(sql.FieldGTE(FieldTrialBonusHoldReason, v))
+}
+
+// TrialBonusHoldReasonLT applies the LT predicate on the "trial_bonus_hold_reason" field.
+func TrialBonusHoldReasonLT(v string) predicate.User {
+ return predicate.User(sql.FieldLT(FieldTrialBonusHoldReason, v))
+}
+
+// TrialBonusHoldReasonLTE applies the LTE predicate on the "trial_bonus_hold_reason" field.
+func TrialBonusHoldReasonLTE(v string) predicate.User {
+ return predicate.User(sql.FieldLTE(FieldTrialBonusHoldReason, v))
+}
+
+// TrialBonusHoldReasonContains applies the Contains predicate on the "trial_bonus_hold_reason" field.
+func TrialBonusHoldReasonContains(v string) predicate.User {
+ return predicate.User(sql.FieldContains(FieldTrialBonusHoldReason, v))
+}
+
+// TrialBonusHoldReasonHasPrefix applies the HasPrefix predicate on the "trial_bonus_hold_reason" field.
+func TrialBonusHoldReasonHasPrefix(v string) predicate.User {
+ return predicate.User(sql.FieldHasPrefix(FieldTrialBonusHoldReason, v))
+}
+
+// TrialBonusHoldReasonHasSuffix applies the HasSuffix predicate on the "trial_bonus_hold_reason" field.
+func TrialBonusHoldReasonHasSuffix(v string) predicate.User {
+ return predicate.User(sql.FieldHasSuffix(FieldTrialBonusHoldReason, v))
+}
+
+// TrialBonusHoldReasonEqualFold applies the EqualFold predicate on the "trial_bonus_hold_reason" field.
+func TrialBonusHoldReasonEqualFold(v string) predicate.User {
+ return predicate.User(sql.FieldEqualFold(FieldTrialBonusHoldReason, v))
+}
+
+// TrialBonusHoldReasonContainsFold applies the ContainsFold predicate on the "trial_bonus_hold_reason" field.
+func TrialBonusHoldReasonContainsFold(v string) predicate.User {
+ return predicate.User(sql.FieldContainsFold(FieldTrialBonusHoldReason, v))
+}
+
+// TrialBonusRiskScoreEQ applies the EQ predicate on the "trial_bonus_risk_score" field.
+func TrialBonusRiskScoreEQ(v int) predicate.User {
+ return predicate.User(sql.FieldEQ(FieldTrialBonusRiskScore, v))
+}
+
+// TrialBonusRiskScoreNEQ applies the NEQ predicate on the "trial_bonus_risk_score" field.
+func TrialBonusRiskScoreNEQ(v int) predicate.User {
+ return predicate.User(sql.FieldNEQ(FieldTrialBonusRiskScore, v))
+}
+
+// TrialBonusRiskScoreIn applies the In predicate on the "trial_bonus_risk_score" field.
+func TrialBonusRiskScoreIn(vs ...int) predicate.User {
+ return predicate.User(sql.FieldIn(FieldTrialBonusRiskScore, vs...))
+}
+
+// TrialBonusRiskScoreNotIn applies the NotIn predicate on the "trial_bonus_risk_score" field.
+func TrialBonusRiskScoreNotIn(vs ...int) predicate.User {
+ return predicate.User(sql.FieldNotIn(FieldTrialBonusRiskScore, vs...))
+}
+
+// TrialBonusRiskScoreGT applies the GT predicate on the "trial_bonus_risk_score" field.
+func TrialBonusRiskScoreGT(v int) predicate.User {
+ return predicate.User(sql.FieldGT(FieldTrialBonusRiskScore, v))
+}
+
+// TrialBonusRiskScoreGTE applies the GTE predicate on the "trial_bonus_risk_score" field.
+func TrialBonusRiskScoreGTE(v int) predicate.User {
+ return predicate.User(sql.FieldGTE(FieldTrialBonusRiskScore, v))
+}
+
+// TrialBonusRiskScoreLT applies the LT predicate on the "trial_bonus_risk_score" field.
+func TrialBonusRiskScoreLT(v int) predicate.User {
+ return predicate.User(sql.FieldLT(FieldTrialBonusRiskScore, v))
+}
+
+// TrialBonusRiskScoreLTE applies the LTE predicate on the "trial_bonus_risk_score" field.
+func TrialBonusRiskScoreLTE(v int) predicate.User {
+ return predicate.User(sql.FieldLTE(FieldTrialBonusRiskScore, v))
+}
+
+// TokenVersionEQ applies the EQ predicate on the "token_version" field.
+func TokenVersionEQ(v int64) predicate.User {
+ return predicate.User(sql.FieldEQ(FieldTokenVersion, v))
+}
+
+// TokenVersionNEQ applies the NEQ predicate on the "token_version" field.
+func TokenVersionNEQ(v int64) predicate.User {
+ return predicate.User(sql.FieldNEQ(FieldTokenVersion, v))
+}
+
+// TokenVersionIn applies the In predicate on the "token_version" field.
+func TokenVersionIn(vs ...int64) predicate.User {
+ return predicate.User(sql.FieldIn(FieldTokenVersion, vs...))
+}
+
+// TokenVersionNotIn applies the NotIn predicate on the "token_version" field.
+func TokenVersionNotIn(vs ...int64) predicate.User {
+ return predicate.User(sql.FieldNotIn(FieldTokenVersion, vs...))
+}
+
+// TokenVersionGT applies the GT predicate on the "token_version" field.
+func TokenVersionGT(v int64) predicate.User {
+ return predicate.User(sql.FieldGT(FieldTokenVersion, v))
+}
+
+// TokenVersionGTE applies the GTE predicate on the "token_version" field.
+func TokenVersionGTE(v int64) predicate.User {
+ return predicate.User(sql.FieldGTE(FieldTokenVersion, v))
+}
+
+// TokenVersionLT applies the LT predicate on the "token_version" field.
+func TokenVersionLT(v int64) predicate.User {
+ return predicate.User(sql.FieldLT(FieldTokenVersion, v))
+}
+
+// TokenVersionLTE applies the LTE predicate on the "token_version" field.
+func TokenVersionLTE(v int64) predicate.User {
+ return predicate.User(sql.FieldLTE(FieldTokenVersion, v))
+}
+
// HasAPIKeys applies the HasEdge predicate on the "api_keys" edge.
func HasAPIKeys() predicate.User {
return predicate.User(func(s *sql.Selector) {
@@ -1432,6 +1887,29 @@ func HasAssignedSubscriptionsWith(preds ...predicate.UserSubscription) predicate
})
}
+// HasWalletLedgerOperations applies the HasEdge predicate on the "wallet_ledger_operations" edge.
+func HasWalletLedgerOperations() predicate.User {
+ return predicate.User(func(s *sql.Selector) {
+ step := sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, WalletLedgerOperationsTable, WalletLedgerOperationsColumn),
+ )
+ sqlgraph.HasNeighbors(s, step)
+ })
+}
+
+// HasWalletLedgerOperationsWith applies the HasEdge predicate on the "wallet_ledger_operations" edge with a given conditions (other predicates).
+func HasWalletLedgerOperationsWith(preds ...predicate.SubscriptionWalletLedger) predicate.User {
+ return predicate.User(func(s *sql.Selector) {
+ step := newWalletLedgerOperationsStep()
+ sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
+ for _, p := range preds {
+ p(s)
+ }
+ })
+ })
+}
+
// HasAnnouncementReads applies the HasEdge predicate on the "announcement_reads" edge.
func HasAnnouncementReads() predicate.User {
return predicate.User(func(s *sql.Selector) {
diff --git a/backend/ent/user_create.go b/backend/ent/user_create.go
index b4161128fd6..3155a7e3167 100644
--- a/backend/ent/user_create.go
+++ b/backend/ent/user_create.go
@@ -19,6 +19,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
"github.com/Wei-Shaw/sub2api/ent/promocodeusage"
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/userattributevalue"
@@ -339,6 +340,118 @@ func (_c *UserCreate) SetNillableRpmLimit(v *int) *UserCreate {
return _c
}
+// SetSignupIP sets the "signup_ip" field.
+func (_c *UserCreate) SetSignupIP(v string) *UserCreate {
+ _c.mutation.SetSignupIP(v)
+ return _c
+}
+
+// SetNillableSignupIP sets the "signup_ip" field if the given value is not nil.
+func (_c *UserCreate) SetNillableSignupIP(v *string) *UserCreate {
+ if v != nil {
+ _c.SetSignupIP(*v)
+ }
+ return _c
+}
+
+// SetSignupIPPrefix sets the "signup_ip_prefix" field.
+func (_c *UserCreate) SetSignupIPPrefix(v string) *UserCreate {
+ _c.mutation.SetSignupIPPrefix(v)
+ return _c
+}
+
+// SetNillableSignupIPPrefix sets the "signup_ip_prefix" field if the given value is not nil.
+func (_c *UserCreate) SetNillableSignupIPPrefix(v *string) *UserCreate {
+ if v != nil {
+ _c.SetSignupIPPrefix(*v)
+ }
+ return _c
+}
+
+// SetSignupUserAgentHash sets the "signup_user_agent_hash" field.
+func (_c *UserCreate) SetSignupUserAgentHash(v string) *UserCreate {
+ _c.mutation.SetSignupUserAgentHash(v)
+ return _c
+}
+
+// SetNillableSignupUserAgentHash sets the "signup_user_agent_hash" field if the given value is not nil.
+func (_c *UserCreate) SetNillableSignupUserAgentHash(v *string) *UserCreate {
+ if v != nil {
+ _c.SetSignupUserAgentHash(*v)
+ }
+ return _c
+}
+
+// SetSignupDeviceFingerprintHash sets the "signup_device_fingerprint_hash" field.
+func (_c *UserCreate) SetSignupDeviceFingerprintHash(v string) *UserCreate {
+ _c.mutation.SetSignupDeviceFingerprintHash(v)
+ return _c
+}
+
+// SetNillableSignupDeviceFingerprintHash sets the "signup_device_fingerprint_hash" field if the given value is not nil.
+func (_c *UserCreate) SetNillableSignupDeviceFingerprintHash(v *string) *UserCreate {
+ if v != nil {
+ _c.SetSignupDeviceFingerprintHash(*v)
+ }
+ return _c
+}
+
+// SetTrialBonusEligible sets the "trial_bonus_eligible" field.
+func (_c *UserCreate) SetTrialBonusEligible(v bool) *UserCreate {
+ _c.mutation.SetTrialBonusEligible(v)
+ return _c
+}
+
+// SetNillableTrialBonusEligible sets the "trial_bonus_eligible" field if the given value is not nil.
+func (_c *UserCreate) SetNillableTrialBonusEligible(v *bool) *UserCreate {
+ if v != nil {
+ _c.SetTrialBonusEligible(*v)
+ }
+ return _c
+}
+
+// SetTrialBonusHoldReason sets the "trial_bonus_hold_reason" field.
+func (_c *UserCreate) SetTrialBonusHoldReason(v string) *UserCreate {
+ _c.mutation.SetTrialBonusHoldReason(v)
+ return _c
+}
+
+// SetNillableTrialBonusHoldReason sets the "trial_bonus_hold_reason" field if the given value is not nil.
+func (_c *UserCreate) SetNillableTrialBonusHoldReason(v *string) *UserCreate {
+ if v != nil {
+ _c.SetTrialBonusHoldReason(*v)
+ }
+ return _c
+}
+
+// SetTrialBonusRiskScore sets the "trial_bonus_risk_score" field.
+func (_c *UserCreate) SetTrialBonusRiskScore(v int) *UserCreate {
+ _c.mutation.SetTrialBonusRiskScore(v)
+ return _c
+}
+
+// SetNillableTrialBonusRiskScore sets the "trial_bonus_risk_score" field if the given value is not nil.
+func (_c *UserCreate) SetNillableTrialBonusRiskScore(v *int) *UserCreate {
+ if v != nil {
+ _c.SetTrialBonusRiskScore(*v)
+ }
+ return _c
+}
+
+// SetTokenVersion sets the "token_version" field.
+func (_c *UserCreate) SetTokenVersion(v int64) *UserCreate {
+ _c.mutation.SetTokenVersion(v)
+ return _c
+}
+
+// SetNillableTokenVersion sets the "token_version" field if the given value is not nil.
+func (_c *UserCreate) SetNillableTokenVersion(v *int64) *UserCreate {
+ if v != nil {
+ _c.SetTokenVersion(*v)
+ }
+ return _c
+}
+
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
func (_c *UserCreate) AddAPIKeyIDs(ids ...int64) *UserCreate {
_c.mutation.AddAPIKeyIDs(ids...)
@@ -399,6 +512,21 @@ func (_c *UserCreate) AddAssignedSubscriptions(v ...*UserSubscription) *UserCrea
return _c.AddAssignedSubscriptionIDs(ids...)
}
+// AddWalletLedgerOperationIDs adds the "wallet_ledger_operations" edge to the SubscriptionWalletLedger entity by IDs.
+func (_c *UserCreate) AddWalletLedgerOperationIDs(ids ...int64) *UserCreate {
+ _c.mutation.AddWalletLedgerOperationIDs(ids...)
+ return _c
+}
+
+// AddWalletLedgerOperations adds the "wallet_ledger_operations" edges to the SubscriptionWalletLedger entity.
+func (_c *UserCreate) AddWalletLedgerOperations(v ...*SubscriptionWalletLedger) *UserCreate {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _c.AddWalletLedgerOperationIDs(ids...)
+}
+
// AddAnnouncementReadIDs adds the "announcement_reads" edge to the AnnouncementRead entity by IDs.
func (_c *UserCreate) AddAnnouncementReadIDs(ids ...int64) *UserCreate {
_c.mutation.AddAnnouncementReadIDs(ids...)
@@ -622,6 +750,38 @@ func (_c *UserCreate) defaults() error {
v := user.DefaultRpmLimit
_c.mutation.SetRpmLimit(v)
}
+ if _, ok := _c.mutation.SignupIP(); !ok {
+ v := user.DefaultSignupIP
+ _c.mutation.SetSignupIP(v)
+ }
+ if _, ok := _c.mutation.SignupIPPrefix(); !ok {
+ v := user.DefaultSignupIPPrefix
+ _c.mutation.SetSignupIPPrefix(v)
+ }
+ if _, ok := _c.mutation.SignupUserAgentHash(); !ok {
+ v := user.DefaultSignupUserAgentHash
+ _c.mutation.SetSignupUserAgentHash(v)
+ }
+ if _, ok := _c.mutation.SignupDeviceFingerprintHash(); !ok {
+ v := user.DefaultSignupDeviceFingerprintHash
+ _c.mutation.SetSignupDeviceFingerprintHash(v)
+ }
+ if _, ok := _c.mutation.TrialBonusEligible(); !ok {
+ v := user.DefaultTrialBonusEligible
+ _c.mutation.SetTrialBonusEligible(v)
+ }
+ if _, ok := _c.mutation.TrialBonusHoldReason(); !ok {
+ v := user.DefaultTrialBonusHoldReason
+ _c.mutation.SetTrialBonusHoldReason(v)
+ }
+ if _, ok := _c.mutation.TrialBonusRiskScore(); !ok {
+ v := user.DefaultTrialBonusRiskScore
+ _c.mutation.SetTrialBonusRiskScore(v)
+ }
+ if _, ok := _c.mutation.TokenVersion(); !ok {
+ v := user.DefaultTokenVersion
+ _c.mutation.SetTokenVersion(v)
+ }
return nil
}
@@ -708,6 +868,60 @@ func (_c *UserCreate) check() error {
if _, ok := _c.mutation.RpmLimit(); !ok {
return &ValidationError{Name: "rpm_limit", err: errors.New(`ent: missing required field "User.rpm_limit"`)}
}
+ if _, ok := _c.mutation.SignupIP(); !ok {
+ return &ValidationError{Name: "signup_ip", err: errors.New(`ent: missing required field "User.signup_ip"`)}
+ }
+ if v, ok := _c.mutation.SignupIP(); ok {
+ if err := user.SignupIPValidator(v); err != nil {
+ return &ValidationError{Name: "signup_ip", err: fmt.Errorf(`ent: validator failed for field "User.signup_ip": %w`, err)}
+ }
+ }
+ if _, ok := _c.mutation.SignupIPPrefix(); !ok {
+ return &ValidationError{Name: "signup_ip_prefix", err: errors.New(`ent: missing required field "User.signup_ip_prefix"`)}
+ }
+ if v, ok := _c.mutation.SignupIPPrefix(); ok {
+ if err := user.SignupIPPrefixValidator(v); err != nil {
+ return &ValidationError{Name: "signup_ip_prefix", err: fmt.Errorf(`ent: validator failed for field "User.signup_ip_prefix": %w`, err)}
+ }
+ }
+ if _, ok := _c.mutation.SignupUserAgentHash(); !ok {
+ return &ValidationError{Name: "signup_user_agent_hash", err: errors.New(`ent: missing required field "User.signup_user_agent_hash"`)}
+ }
+ if v, ok := _c.mutation.SignupUserAgentHash(); ok {
+ if err := user.SignupUserAgentHashValidator(v); err != nil {
+ return &ValidationError{Name: "signup_user_agent_hash", err: fmt.Errorf(`ent: validator failed for field "User.signup_user_agent_hash": %w`, err)}
+ }
+ }
+ if _, ok := _c.mutation.SignupDeviceFingerprintHash(); !ok {
+ return &ValidationError{Name: "signup_device_fingerprint_hash", err: errors.New(`ent: missing required field "User.signup_device_fingerprint_hash"`)}
+ }
+ if v, ok := _c.mutation.SignupDeviceFingerprintHash(); ok {
+ if err := user.SignupDeviceFingerprintHashValidator(v); err != nil {
+ return &ValidationError{Name: "signup_device_fingerprint_hash", err: fmt.Errorf(`ent: validator failed for field "User.signup_device_fingerprint_hash": %w`, err)}
+ }
+ }
+ if _, ok := _c.mutation.TrialBonusEligible(); !ok {
+ return &ValidationError{Name: "trial_bonus_eligible", err: errors.New(`ent: missing required field "User.trial_bonus_eligible"`)}
+ }
+ if _, ok := _c.mutation.TrialBonusHoldReason(); !ok {
+ return &ValidationError{Name: "trial_bonus_hold_reason", err: errors.New(`ent: missing required field "User.trial_bonus_hold_reason"`)}
+ }
+ if v, ok := _c.mutation.TrialBonusHoldReason(); ok {
+ if err := user.TrialBonusHoldReasonValidator(v); err != nil {
+ return &ValidationError{Name: "trial_bonus_hold_reason", err: fmt.Errorf(`ent: validator failed for field "User.trial_bonus_hold_reason": %w`, err)}
+ }
+ }
+ if _, ok := _c.mutation.TrialBonusRiskScore(); !ok {
+ return &ValidationError{Name: "trial_bonus_risk_score", err: errors.New(`ent: missing required field "User.trial_bonus_risk_score"`)}
+ }
+ if _, ok := _c.mutation.TokenVersion(); !ok {
+ return &ValidationError{Name: "token_version", err: errors.New(`ent: missing required field "User.token_version"`)}
+ }
+ if v, ok := _c.mutation.TokenVersion(); ok {
+ if err := user.TokenVersionValidator(v); err != nil {
+ return &ValidationError{Name: "token_version", err: fmt.Errorf(`ent: validator failed for field "User.token_version": %w`, err)}
+ }
+ }
return nil
}
@@ -827,6 +1041,38 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
_spec.SetField(user.FieldRpmLimit, field.TypeInt, value)
_node.RpmLimit = value
}
+ if value, ok := _c.mutation.SignupIP(); ok {
+ _spec.SetField(user.FieldSignupIP, field.TypeString, value)
+ _node.SignupIP = value
+ }
+ if value, ok := _c.mutation.SignupIPPrefix(); ok {
+ _spec.SetField(user.FieldSignupIPPrefix, field.TypeString, value)
+ _node.SignupIPPrefix = value
+ }
+ if value, ok := _c.mutation.SignupUserAgentHash(); ok {
+ _spec.SetField(user.FieldSignupUserAgentHash, field.TypeString, value)
+ _node.SignupUserAgentHash = value
+ }
+ if value, ok := _c.mutation.SignupDeviceFingerprintHash(); ok {
+ _spec.SetField(user.FieldSignupDeviceFingerprintHash, field.TypeString, value)
+ _node.SignupDeviceFingerprintHash = value
+ }
+ if value, ok := _c.mutation.TrialBonusEligible(); ok {
+ _spec.SetField(user.FieldTrialBonusEligible, field.TypeBool, value)
+ _node.TrialBonusEligible = value
+ }
+ if value, ok := _c.mutation.TrialBonusHoldReason(); ok {
+ _spec.SetField(user.FieldTrialBonusHoldReason, field.TypeString, value)
+ _node.TrialBonusHoldReason = value
+ }
+ if value, ok := _c.mutation.TrialBonusRiskScore(); ok {
+ _spec.SetField(user.FieldTrialBonusRiskScore, field.TypeInt, value)
+ _node.TrialBonusRiskScore = value
+ }
+ if value, ok := _c.mutation.TokenVersion(); ok {
+ _spec.SetField(user.FieldTokenVersion, field.TypeInt64, value)
+ _node.TokenVersion = value
+ }
if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -891,6 +1137,22 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
}
_spec.Edges = append(_spec.Edges, edge)
}
+ if nodes := _c.mutation.WalletLedgerOperationsIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: user.WalletLedgerOperationsTable,
+ Columns: []string{user.WalletLedgerOperationsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges = append(_spec.Edges, edge)
+ }
if nodes := _c.mutation.AnnouncementReadsIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -1405,6 +1667,114 @@ func (u *UserUpsert) AddRpmLimit(v int) *UserUpsert {
return u
}
+// SetSignupIP sets the "signup_ip" field.
+func (u *UserUpsert) SetSignupIP(v string) *UserUpsert {
+ u.Set(user.FieldSignupIP, v)
+ return u
+}
+
+// UpdateSignupIP sets the "signup_ip" field to the value that was provided on create.
+func (u *UserUpsert) UpdateSignupIP() *UserUpsert {
+ u.SetExcluded(user.FieldSignupIP)
+ return u
+}
+
+// SetSignupIPPrefix sets the "signup_ip_prefix" field.
+func (u *UserUpsert) SetSignupIPPrefix(v string) *UserUpsert {
+ u.Set(user.FieldSignupIPPrefix, v)
+ return u
+}
+
+// UpdateSignupIPPrefix sets the "signup_ip_prefix" field to the value that was provided on create.
+func (u *UserUpsert) UpdateSignupIPPrefix() *UserUpsert {
+ u.SetExcluded(user.FieldSignupIPPrefix)
+ return u
+}
+
+// SetSignupUserAgentHash sets the "signup_user_agent_hash" field.
+func (u *UserUpsert) SetSignupUserAgentHash(v string) *UserUpsert {
+ u.Set(user.FieldSignupUserAgentHash, v)
+ return u
+}
+
+// UpdateSignupUserAgentHash sets the "signup_user_agent_hash" field to the value that was provided on create.
+func (u *UserUpsert) UpdateSignupUserAgentHash() *UserUpsert {
+ u.SetExcluded(user.FieldSignupUserAgentHash)
+ return u
+}
+
+// SetSignupDeviceFingerprintHash sets the "signup_device_fingerprint_hash" field.
+func (u *UserUpsert) SetSignupDeviceFingerprintHash(v string) *UserUpsert {
+ u.Set(user.FieldSignupDeviceFingerprintHash, v)
+ return u
+}
+
+// UpdateSignupDeviceFingerprintHash sets the "signup_device_fingerprint_hash" field to the value that was provided on create.
+func (u *UserUpsert) UpdateSignupDeviceFingerprintHash() *UserUpsert {
+ u.SetExcluded(user.FieldSignupDeviceFingerprintHash)
+ return u
+}
+
+// SetTrialBonusEligible sets the "trial_bonus_eligible" field.
+func (u *UserUpsert) SetTrialBonusEligible(v bool) *UserUpsert {
+ u.Set(user.FieldTrialBonusEligible, v)
+ return u
+}
+
+// UpdateTrialBonusEligible sets the "trial_bonus_eligible" field to the value that was provided on create.
+func (u *UserUpsert) UpdateTrialBonusEligible() *UserUpsert {
+ u.SetExcluded(user.FieldTrialBonusEligible)
+ return u
+}
+
+// SetTrialBonusHoldReason sets the "trial_bonus_hold_reason" field.
+func (u *UserUpsert) SetTrialBonusHoldReason(v string) *UserUpsert {
+ u.Set(user.FieldTrialBonusHoldReason, v)
+ return u
+}
+
+// UpdateTrialBonusHoldReason sets the "trial_bonus_hold_reason" field to the value that was provided on create.
+func (u *UserUpsert) UpdateTrialBonusHoldReason() *UserUpsert {
+ u.SetExcluded(user.FieldTrialBonusHoldReason)
+ return u
+}
+
+// SetTrialBonusRiskScore sets the "trial_bonus_risk_score" field.
+func (u *UserUpsert) SetTrialBonusRiskScore(v int) *UserUpsert {
+ u.Set(user.FieldTrialBonusRiskScore, v)
+ return u
+}
+
+// UpdateTrialBonusRiskScore sets the "trial_bonus_risk_score" field to the value that was provided on create.
+func (u *UserUpsert) UpdateTrialBonusRiskScore() *UserUpsert {
+ u.SetExcluded(user.FieldTrialBonusRiskScore)
+ return u
+}
+
+// AddTrialBonusRiskScore adds v to the "trial_bonus_risk_score" field.
+func (u *UserUpsert) AddTrialBonusRiskScore(v int) *UserUpsert {
+ u.Add(user.FieldTrialBonusRiskScore, v)
+ return u
+}
+
+// SetTokenVersion sets the "token_version" field.
+func (u *UserUpsert) SetTokenVersion(v int64) *UserUpsert {
+ u.Set(user.FieldTokenVersion, v)
+ return u
+}
+
+// UpdateTokenVersion sets the "token_version" field to the value that was provided on create.
+func (u *UserUpsert) UpdateTokenVersion() *UserUpsert {
+ u.SetExcluded(user.FieldTokenVersion)
+ return u
+}
+
+// AddTokenVersion adds v to the "token_version" field.
+func (u *UserUpsert) AddTokenVersion(v int64) *UserUpsert {
+ u.Add(user.FieldTokenVersion, v)
+ return u
+}
+
// UpdateNewValues updates the mutable fields using the new values that were set on create.
// Using this option is equivalent to using:
//
@@ -1835,6 +2205,132 @@ func (u *UserUpsertOne) UpdateRpmLimit() *UserUpsertOne {
})
}
+// SetSignupIP sets the "signup_ip" field.
+func (u *UserUpsertOne) SetSignupIP(v string) *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.SetSignupIP(v)
+ })
+}
+
+// UpdateSignupIP sets the "signup_ip" field to the value that was provided on create.
+func (u *UserUpsertOne) UpdateSignupIP() *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.UpdateSignupIP()
+ })
+}
+
+// SetSignupIPPrefix sets the "signup_ip_prefix" field.
+func (u *UserUpsertOne) SetSignupIPPrefix(v string) *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.SetSignupIPPrefix(v)
+ })
+}
+
+// UpdateSignupIPPrefix sets the "signup_ip_prefix" field to the value that was provided on create.
+func (u *UserUpsertOne) UpdateSignupIPPrefix() *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.UpdateSignupIPPrefix()
+ })
+}
+
+// SetSignupUserAgentHash sets the "signup_user_agent_hash" field.
+func (u *UserUpsertOne) SetSignupUserAgentHash(v string) *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.SetSignupUserAgentHash(v)
+ })
+}
+
+// UpdateSignupUserAgentHash sets the "signup_user_agent_hash" field to the value that was provided on create.
+func (u *UserUpsertOne) UpdateSignupUserAgentHash() *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.UpdateSignupUserAgentHash()
+ })
+}
+
+// SetSignupDeviceFingerprintHash sets the "signup_device_fingerprint_hash" field.
+func (u *UserUpsertOne) SetSignupDeviceFingerprintHash(v string) *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.SetSignupDeviceFingerprintHash(v)
+ })
+}
+
+// UpdateSignupDeviceFingerprintHash sets the "signup_device_fingerprint_hash" field to the value that was provided on create.
+func (u *UserUpsertOne) UpdateSignupDeviceFingerprintHash() *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.UpdateSignupDeviceFingerprintHash()
+ })
+}
+
+// SetTrialBonusEligible sets the "trial_bonus_eligible" field.
+func (u *UserUpsertOne) SetTrialBonusEligible(v bool) *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.SetTrialBonusEligible(v)
+ })
+}
+
+// UpdateTrialBonusEligible sets the "trial_bonus_eligible" field to the value that was provided on create.
+func (u *UserUpsertOne) UpdateTrialBonusEligible() *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.UpdateTrialBonusEligible()
+ })
+}
+
+// SetTrialBonusHoldReason sets the "trial_bonus_hold_reason" field.
+func (u *UserUpsertOne) SetTrialBonusHoldReason(v string) *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.SetTrialBonusHoldReason(v)
+ })
+}
+
+// UpdateTrialBonusHoldReason sets the "trial_bonus_hold_reason" field to the value that was provided on create.
+func (u *UserUpsertOne) UpdateTrialBonusHoldReason() *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.UpdateTrialBonusHoldReason()
+ })
+}
+
+// SetTrialBonusRiskScore sets the "trial_bonus_risk_score" field.
+func (u *UserUpsertOne) SetTrialBonusRiskScore(v int) *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.SetTrialBonusRiskScore(v)
+ })
+}
+
+// AddTrialBonusRiskScore adds v to the "trial_bonus_risk_score" field.
+func (u *UserUpsertOne) AddTrialBonusRiskScore(v int) *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.AddTrialBonusRiskScore(v)
+ })
+}
+
+// UpdateTrialBonusRiskScore sets the "trial_bonus_risk_score" field to the value that was provided on create.
+func (u *UserUpsertOne) UpdateTrialBonusRiskScore() *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.UpdateTrialBonusRiskScore()
+ })
+}
+
+// SetTokenVersion sets the "token_version" field.
+func (u *UserUpsertOne) SetTokenVersion(v int64) *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.SetTokenVersion(v)
+ })
+}
+
+// AddTokenVersion adds v to the "token_version" field.
+func (u *UserUpsertOne) AddTokenVersion(v int64) *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.AddTokenVersion(v)
+ })
+}
+
+// UpdateTokenVersion sets the "token_version" field to the value that was provided on create.
+func (u *UserUpsertOne) UpdateTokenVersion() *UserUpsertOne {
+ return u.Update(func(s *UserUpsert) {
+ s.UpdateTokenVersion()
+ })
+}
+
// Exec executes the query.
func (u *UserUpsertOne) Exec(ctx context.Context) error {
if len(u.create.conflict) == 0 {
@@ -2431,6 +2927,132 @@ func (u *UserUpsertBulk) UpdateRpmLimit() *UserUpsertBulk {
})
}
+// SetSignupIP sets the "signup_ip" field.
+func (u *UserUpsertBulk) SetSignupIP(v string) *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.SetSignupIP(v)
+ })
+}
+
+// UpdateSignupIP sets the "signup_ip" field to the value that was provided on create.
+func (u *UserUpsertBulk) UpdateSignupIP() *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.UpdateSignupIP()
+ })
+}
+
+// SetSignupIPPrefix sets the "signup_ip_prefix" field.
+func (u *UserUpsertBulk) SetSignupIPPrefix(v string) *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.SetSignupIPPrefix(v)
+ })
+}
+
+// UpdateSignupIPPrefix sets the "signup_ip_prefix" field to the value that was provided on create.
+func (u *UserUpsertBulk) UpdateSignupIPPrefix() *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.UpdateSignupIPPrefix()
+ })
+}
+
+// SetSignupUserAgentHash sets the "signup_user_agent_hash" field.
+func (u *UserUpsertBulk) SetSignupUserAgentHash(v string) *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.SetSignupUserAgentHash(v)
+ })
+}
+
+// UpdateSignupUserAgentHash sets the "signup_user_agent_hash" field to the value that was provided on create.
+func (u *UserUpsertBulk) UpdateSignupUserAgentHash() *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.UpdateSignupUserAgentHash()
+ })
+}
+
+// SetSignupDeviceFingerprintHash sets the "signup_device_fingerprint_hash" field.
+func (u *UserUpsertBulk) SetSignupDeviceFingerprintHash(v string) *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.SetSignupDeviceFingerprintHash(v)
+ })
+}
+
+// UpdateSignupDeviceFingerprintHash sets the "signup_device_fingerprint_hash" field to the value that was provided on create.
+func (u *UserUpsertBulk) UpdateSignupDeviceFingerprintHash() *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.UpdateSignupDeviceFingerprintHash()
+ })
+}
+
+// SetTrialBonusEligible sets the "trial_bonus_eligible" field.
+func (u *UserUpsertBulk) SetTrialBonusEligible(v bool) *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.SetTrialBonusEligible(v)
+ })
+}
+
+// UpdateTrialBonusEligible sets the "trial_bonus_eligible" field to the value that was provided on create.
+func (u *UserUpsertBulk) UpdateTrialBonusEligible() *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.UpdateTrialBonusEligible()
+ })
+}
+
+// SetTrialBonusHoldReason sets the "trial_bonus_hold_reason" field.
+func (u *UserUpsertBulk) SetTrialBonusHoldReason(v string) *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.SetTrialBonusHoldReason(v)
+ })
+}
+
+// UpdateTrialBonusHoldReason sets the "trial_bonus_hold_reason" field to the value that was provided on create.
+func (u *UserUpsertBulk) UpdateTrialBonusHoldReason() *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.UpdateTrialBonusHoldReason()
+ })
+}
+
+// SetTrialBonusRiskScore sets the "trial_bonus_risk_score" field.
+func (u *UserUpsertBulk) SetTrialBonusRiskScore(v int) *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.SetTrialBonusRiskScore(v)
+ })
+}
+
+// AddTrialBonusRiskScore adds v to the "trial_bonus_risk_score" field.
+func (u *UserUpsertBulk) AddTrialBonusRiskScore(v int) *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.AddTrialBonusRiskScore(v)
+ })
+}
+
+// UpdateTrialBonusRiskScore sets the "trial_bonus_risk_score" field to the value that was provided on create.
+func (u *UserUpsertBulk) UpdateTrialBonusRiskScore() *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.UpdateTrialBonusRiskScore()
+ })
+}
+
+// SetTokenVersion sets the "token_version" field.
+func (u *UserUpsertBulk) SetTokenVersion(v int64) *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.SetTokenVersion(v)
+ })
+}
+
+// AddTokenVersion adds v to the "token_version" field.
+func (u *UserUpsertBulk) AddTokenVersion(v int64) *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.AddTokenVersion(v)
+ })
+}
+
+// UpdateTokenVersion sets the "token_version" field to the value that was provided on create.
+func (u *UserUpsertBulk) UpdateTokenVersion() *UserUpsertBulk {
+ return u.Update(func(s *UserUpsert) {
+ s.UpdateTokenVersion()
+ })
+}
+
// Exec executes the query.
func (u *UserUpsertBulk) Exec(ctx context.Context) error {
if u.create.err != nil {
diff --git a/backend/ent/user_query.go b/backend/ent/user_query.go
index f1ee5cfe0aa..efdb6e2ebc7 100644
--- a/backend/ent/user_query.go
+++ b/backend/ent/user_query.go
@@ -22,6 +22,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/predicate"
"github.com/Wei-Shaw/sub2api/ent/promocodeusage"
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
@@ -32,24 +33,25 @@ import (
// UserQuery is the builder for querying User entities.
type UserQuery struct {
config
- ctx *QueryContext
- order []user.OrderOption
- inters []Interceptor
- predicates []predicate.User
- withAPIKeys *APIKeyQuery
- withRedeemCodes *RedeemCodeQuery
- withSubscriptions *UserSubscriptionQuery
- withAssignedSubscriptions *UserSubscriptionQuery
- withAnnouncementReads *AnnouncementReadQuery
- withAllowedGroups *GroupQuery
- withUsageLogs *UsageLogQuery
- withAttributeValues *UserAttributeValueQuery
- withPromoCodeUsages *PromoCodeUsageQuery
- withPaymentOrders *PaymentOrderQuery
- withAuthIdentities *AuthIdentityQuery
- withPendingAuthSessions *PendingAuthSessionQuery
- withUserAllowedGroups *UserAllowedGroupQuery
- modifiers []func(*sql.Selector)
+ ctx *QueryContext
+ order []user.OrderOption
+ inters []Interceptor
+ predicates []predicate.User
+ withAPIKeys *APIKeyQuery
+ withRedeemCodes *RedeemCodeQuery
+ withSubscriptions *UserSubscriptionQuery
+ withAssignedSubscriptions *UserSubscriptionQuery
+ withWalletLedgerOperations *SubscriptionWalletLedgerQuery
+ withAnnouncementReads *AnnouncementReadQuery
+ withAllowedGroups *GroupQuery
+ withUsageLogs *UsageLogQuery
+ withAttributeValues *UserAttributeValueQuery
+ withPromoCodeUsages *PromoCodeUsageQuery
+ withPaymentOrders *PaymentOrderQuery
+ withAuthIdentities *AuthIdentityQuery
+ withPendingAuthSessions *PendingAuthSessionQuery
+ withUserAllowedGroups *UserAllowedGroupQuery
+ modifiers []func(*sql.Selector)
// intermediate query (i.e. traversal path).
sql *sql.Selector
path func(context.Context) (*sql.Selector, error)
@@ -174,6 +176,28 @@ func (_q *UserQuery) QueryAssignedSubscriptions() *UserSubscriptionQuery {
return query
}
+// QueryWalletLedgerOperations chains the current query on the "wallet_ledger_operations" edge.
+func (_q *UserQuery) QueryWalletLedgerOperations() *SubscriptionWalletLedgerQuery {
+ query := (&SubscriptionWalletLedgerClient{config: _q.config}).Query()
+ query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
+ if err := _q.prepareQuery(ctx); err != nil {
+ return nil, err
+ }
+ selector := _q.sqlQuery(ctx)
+ if err := selector.Err(); err != nil {
+ return nil, err
+ }
+ step := sqlgraph.NewStep(
+ sqlgraph.From(user.Table, user.FieldID, selector),
+ sqlgraph.To(subscriptionwalletledger.Table, subscriptionwalletledger.FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, user.WalletLedgerOperationsTable, user.WalletLedgerOperationsColumn),
+ )
+ fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
+ return fromU, nil
+ }
+ return query
+}
+
// QueryAnnouncementReads chains the current query on the "announcement_reads" edge.
func (_q *UserQuery) QueryAnnouncementReads() *AnnouncementReadQuery {
query := (&AnnouncementReadClient{config: _q.config}).Query()
@@ -559,24 +583,25 @@ func (_q *UserQuery) Clone() *UserQuery {
return nil
}
return &UserQuery{
- config: _q.config,
- ctx: _q.ctx.Clone(),
- order: append([]user.OrderOption{}, _q.order...),
- inters: append([]Interceptor{}, _q.inters...),
- predicates: append([]predicate.User{}, _q.predicates...),
- withAPIKeys: _q.withAPIKeys.Clone(),
- withRedeemCodes: _q.withRedeemCodes.Clone(),
- withSubscriptions: _q.withSubscriptions.Clone(),
- withAssignedSubscriptions: _q.withAssignedSubscriptions.Clone(),
- withAnnouncementReads: _q.withAnnouncementReads.Clone(),
- withAllowedGroups: _q.withAllowedGroups.Clone(),
- withUsageLogs: _q.withUsageLogs.Clone(),
- withAttributeValues: _q.withAttributeValues.Clone(),
- withPromoCodeUsages: _q.withPromoCodeUsages.Clone(),
- withPaymentOrders: _q.withPaymentOrders.Clone(),
- withAuthIdentities: _q.withAuthIdentities.Clone(),
- withPendingAuthSessions: _q.withPendingAuthSessions.Clone(),
- withUserAllowedGroups: _q.withUserAllowedGroups.Clone(),
+ config: _q.config,
+ ctx: _q.ctx.Clone(),
+ order: append([]user.OrderOption{}, _q.order...),
+ inters: append([]Interceptor{}, _q.inters...),
+ predicates: append([]predicate.User{}, _q.predicates...),
+ withAPIKeys: _q.withAPIKeys.Clone(),
+ withRedeemCodes: _q.withRedeemCodes.Clone(),
+ withSubscriptions: _q.withSubscriptions.Clone(),
+ withAssignedSubscriptions: _q.withAssignedSubscriptions.Clone(),
+ withWalletLedgerOperations: _q.withWalletLedgerOperations.Clone(),
+ withAnnouncementReads: _q.withAnnouncementReads.Clone(),
+ withAllowedGroups: _q.withAllowedGroups.Clone(),
+ withUsageLogs: _q.withUsageLogs.Clone(),
+ withAttributeValues: _q.withAttributeValues.Clone(),
+ withPromoCodeUsages: _q.withPromoCodeUsages.Clone(),
+ withPaymentOrders: _q.withPaymentOrders.Clone(),
+ withAuthIdentities: _q.withAuthIdentities.Clone(),
+ withPendingAuthSessions: _q.withPendingAuthSessions.Clone(),
+ withUserAllowedGroups: _q.withUserAllowedGroups.Clone(),
// clone intermediate query.
sql: _q.sql.Clone(),
path: _q.path,
@@ -627,6 +652,17 @@ func (_q *UserQuery) WithAssignedSubscriptions(opts ...func(*UserSubscriptionQue
return _q
}
+// WithWalletLedgerOperations tells the query-builder to eager-load the nodes that are connected to
+// the "wallet_ledger_operations" edge. The optional arguments are used to configure the query builder of the edge.
+func (_q *UserQuery) WithWalletLedgerOperations(opts ...func(*SubscriptionWalletLedgerQuery)) *UserQuery {
+ query := (&SubscriptionWalletLedgerClient{config: _q.config}).Query()
+ for _, opt := range opts {
+ opt(query)
+ }
+ _q.withWalletLedgerOperations = query
+ return _q
+}
+
// WithAnnouncementReads tells the query-builder to eager-load the nodes that are connected to
// the "announcement_reads" edge. The optional arguments are used to configure the query builder of the edge.
func (_q *UserQuery) WithAnnouncementReads(opts ...func(*AnnouncementReadQuery)) *UserQuery {
@@ -804,11 +840,12 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e
var (
nodes = []*User{}
_spec = _q.querySpec()
- loadedTypes = [13]bool{
+ loadedTypes = [14]bool{
_q.withAPIKeys != nil,
_q.withRedeemCodes != nil,
_q.withSubscriptions != nil,
_q.withAssignedSubscriptions != nil,
+ _q.withWalletLedgerOperations != nil,
_q.withAnnouncementReads != nil,
_q.withAllowedGroups != nil,
_q.withUsageLogs != nil,
@@ -871,6 +908,15 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e
return nil, err
}
}
+ if query := _q.withWalletLedgerOperations; query != nil {
+ if err := _q.loadWalletLedgerOperations(ctx, query, nodes,
+ func(n *User) { n.Edges.WalletLedgerOperations = []*SubscriptionWalletLedger{} },
+ func(n *User, e *SubscriptionWalletLedger) {
+ n.Edges.WalletLedgerOperations = append(n.Edges.WalletLedgerOperations, e)
+ }); err != nil {
+ return nil, err
+ }
+ }
if query := _q.withAnnouncementReads; query != nil {
if err := _q.loadAnnouncementReads(ctx, query, nodes,
func(n *User) { n.Edges.AnnouncementReads = []*AnnouncementRead{} },
@@ -1065,6 +1111,39 @@ func (_q *UserQuery) loadAssignedSubscriptions(ctx context.Context, query *UserS
}
return nil
}
+func (_q *UserQuery) loadWalletLedgerOperations(ctx context.Context, query *SubscriptionWalletLedgerQuery, nodes []*User, init func(*User), assign func(*User, *SubscriptionWalletLedger)) error {
+ fks := make([]driver.Value, 0, len(nodes))
+ nodeids := make(map[int64]*User)
+ for i := range nodes {
+ fks = append(fks, nodes[i].ID)
+ nodeids[nodes[i].ID] = nodes[i]
+ if init != nil {
+ init(nodes[i])
+ }
+ }
+ if len(query.ctx.Fields) > 0 {
+ query.ctx.AppendFieldOnce(subscriptionwalletledger.FieldOperatorID)
+ }
+ query.Where(predicate.SubscriptionWalletLedger(func(s *sql.Selector) {
+ s.Where(sql.InValues(s.C(user.WalletLedgerOperationsColumn), fks...))
+ }))
+ neighbors, err := query.All(ctx)
+ if err != nil {
+ return err
+ }
+ for _, n := range neighbors {
+ fk := n.OperatorID
+ if fk == nil {
+ return fmt.Errorf(`foreign-key "operator_id" is nil for node %v`, n.ID)
+ }
+ node, ok := nodeids[*fk]
+ if !ok {
+ return fmt.Errorf(`unexpected referenced foreign-key "operator_id" returned %v for node %v`, *fk, n.ID)
+ }
+ assign(node, n)
+ }
+ return nil
+}
func (_q *UserQuery) loadAnnouncementReads(ctx context.Context, query *AnnouncementReadQuery, nodes []*User, init func(*User), assign func(*User, *AnnouncementRead)) error {
fks := make([]driver.Value, 0, len(nodes))
nodeids := make(map[int64]*User)
diff --git a/backend/ent/user_update.go b/backend/ent/user_update.go
index f1d759ce440..f7d16df3511 100644
--- a/backend/ent/user_update.go
+++ b/backend/ent/user_update.go
@@ -20,6 +20,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/predicate"
"github.com/Wei-Shaw/sub2api/ent/promocodeusage"
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/userattributevalue"
@@ -410,6 +411,132 @@ func (_u *UserUpdate) AddRpmLimit(v int) *UserUpdate {
return _u
}
+// SetSignupIP sets the "signup_ip" field.
+func (_u *UserUpdate) SetSignupIP(v string) *UserUpdate {
+ _u.mutation.SetSignupIP(v)
+ return _u
+}
+
+// SetNillableSignupIP sets the "signup_ip" field if the given value is not nil.
+func (_u *UserUpdate) SetNillableSignupIP(v *string) *UserUpdate {
+ if v != nil {
+ _u.SetSignupIP(*v)
+ }
+ return _u
+}
+
+// SetSignupIPPrefix sets the "signup_ip_prefix" field.
+func (_u *UserUpdate) SetSignupIPPrefix(v string) *UserUpdate {
+ _u.mutation.SetSignupIPPrefix(v)
+ return _u
+}
+
+// SetNillableSignupIPPrefix sets the "signup_ip_prefix" field if the given value is not nil.
+func (_u *UserUpdate) SetNillableSignupIPPrefix(v *string) *UserUpdate {
+ if v != nil {
+ _u.SetSignupIPPrefix(*v)
+ }
+ return _u
+}
+
+// SetSignupUserAgentHash sets the "signup_user_agent_hash" field.
+func (_u *UserUpdate) SetSignupUserAgentHash(v string) *UserUpdate {
+ _u.mutation.SetSignupUserAgentHash(v)
+ return _u
+}
+
+// SetNillableSignupUserAgentHash sets the "signup_user_agent_hash" field if the given value is not nil.
+func (_u *UserUpdate) SetNillableSignupUserAgentHash(v *string) *UserUpdate {
+ if v != nil {
+ _u.SetSignupUserAgentHash(*v)
+ }
+ return _u
+}
+
+// SetSignupDeviceFingerprintHash sets the "signup_device_fingerprint_hash" field.
+func (_u *UserUpdate) SetSignupDeviceFingerprintHash(v string) *UserUpdate {
+ _u.mutation.SetSignupDeviceFingerprintHash(v)
+ return _u
+}
+
+// SetNillableSignupDeviceFingerprintHash sets the "signup_device_fingerprint_hash" field if the given value is not nil.
+func (_u *UserUpdate) SetNillableSignupDeviceFingerprintHash(v *string) *UserUpdate {
+ if v != nil {
+ _u.SetSignupDeviceFingerprintHash(*v)
+ }
+ return _u
+}
+
+// SetTrialBonusEligible sets the "trial_bonus_eligible" field.
+func (_u *UserUpdate) SetTrialBonusEligible(v bool) *UserUpdate {
+ _u.mutation.SetTrialBonusEligible(v)
+ return _u
+}
+
+// SetNillableTrialBonusEligible sets the "trial_bonus_eligible" field if the given value is not nil.
+func (_u *UserUpdate) SetNillableTrialBonusEligible(v *bool) *UserUpdate {
+ if v != nil {
+ _u.SetTrialBonusEligible(*v)
+ }
+ return _u
+}
+
+// SetTrialBonusHoldReason sets the "trial_bonus_hold_reason" field.
+func (_u *UserUpdate) SetTrialBonusHoldReason(v string) *UserUpdate {
+ _u.mutation.SetTrialBonusHoldReason(v)
+ return _u
+}
+
+// SetNillableTrialBonusHoldReason sets the "trial_bonus_hold_reason" field if the given value is not nil.
+func (_u *UserUpdate) SetNillableTrialBonusHoldReason(v *string) *UserUpdate {
+ if v != nil {
+ _u.SetTrialBonusHoldReason(*v)
+ }
+ return _u
+}
+
+// SetTrialBonusRiskScore sets the "trial_bonus_risk_score" field.
+func (_u *UserUpdate) SetTrialBonusRiskScore(v int) *UserUpdate {
+ _u.mutation.ResetTrialBonusRiskScore()
+ _u.mutation.SetTrialBonusRiskScore(v)
+ return _u
+}
+
+// SetNillableTrialBonusRiskScore sets the "trial_bonus_risk_score" field if the given value is not nil.
+func (_u *UserUpdate) SetNillableTrialBonusRiskScore(v *int) *UserUpdate {
+ if v != nil {
+ _u.SetTrialBonusRiskScore(*v)
+ }
+ return _u
+}
+
+// AddTrialBonusRiskScore adds value to the "trial_bonus_risk_score" field.
+func (_u *UserUpdate) AddTrialBonusRiskScore(v int) *UserUpdate {
+ _u.mutation.AddTrialBonusRiskScore(v)
+ return _u
+}
+
+// SetTokenVersion sets the "token_version" field.
+func (_u *UserUpdate) SetTokenVersion(v int64) *UserUpdate {
+ _u.mutation.ResetTokenVersion()
+ _u.mutation.SetTokenVersion(v)
+ return _u
+}
+
+// SetNillableTokenVersion sets the "token_version" field if the given value is not nil.
+func (_u *UserUpdate) SetNillableTokenVersion(v *int64) *UserUpdate {
+ if v != nil {
+ _u.SetTokenVersion(*v)
+ }
+ return _u
+}
+
+// AddTokenVersion adds value to the "token_version" field.
+func (_u *UserUpdate) AddTokenVersion(v int64) *UserUpdate {
+ _u.mutation.AddTokenVersion(v)
+ return _u
+}
+
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
func (_u *UserUpdate) AddAPIKeyIDs(ids ...int64) *UserUpdate {
_u.mutation.AddAPIKeyIDs(ids...)
@@ -470,6 +597,21 @@ func (_u *UserUpdate) AddAssignedSubscriptions(v ...*UserSubscription) *UserUpda
return _u.AddAssignedSubscriptionIDs(ids...)
}
+// AddWalletLedgerOperationIDs adds the "wallet_ledger_operations" edge to the SubscriptionWalletLedger entity by IDs.
+func (_u *UserUpdate) AddWalletLedgerOperationIDs(ids ...int64) *UserUpdate {
+ _u.mutation.AddWalletLedgerOperationIDs(ids...)
+ return _u
+}
+
+// AddWalletLedgerOperations adds the "wallet_ledger_operations" edges to the SubscriptionWalletLedger entity.
+func (_u *UserUpdate) AddWalletLedgerOperations(v ...*SubscriptionWalletLedger) *UserUpdate {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.AddWalletLedgerOperationIDs(ids...)
+}
+
// AddAnnouncementReadIDs adds the "announcement_reads" edge to the AnnouncementRead entity by IDs.
func (_u *UserUpdate) AddAnnouncementReadIDs(ids ...int64) *UserUpdate {
_u.mutation.AddAnnouncementReadIDs(ids...)
@@ -679,6 +821,27 @@ func (_u *UserUpdate) RemoveAssignedSubscriptions(v ...*UserSubscription) *UserU
return _u.RemoveAssignedSubscriptionIDs(ids...)
}
+// ClearWalletLedgerOperations clears all "wallet_ledger_operations" edges to the SubscriptionWalletLedger entity.
+func (_u *UserUpdate) ClearWalletLedgerOperations() *UserUpdate {
+ _u.mutation.ClearWalletLedgerOperations()
+ return _u
+}
+
+// RemoveWalletLedgerOperationIDs removes the "wallet_ledger_operations" edge to SubscriptionWalletLedger entities by IDs.
+func (_u *UserUpdate) RemoveWalletLedgerOperationIDs(ids ...int64) *UserUpdate {
+ _u.mutation.RemoveWalletLedgerOperationIDs(ids...)
+ return _u
+}
+
+// RemoveWalletLedgerOperations removes "wallet_ledger_operations" edges to SubscriptionWalletLedger entities.
+func (_u *UserUpdate) RemoveWalletLedgerOperations(v ...*SubscriptionWalletLedger) *UserUpdate {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.RemoveWalletLedgerOperationIDs(ids...)
+}
+
// ClearAnnouncementReads clears all "announcement_reads" edges to the AnnouncementRead entity.
func (_u *UserUpdate) ClearAnnouncementReads() *UserUpdate {
_u.mutation.ClearAnnouncementReads()
@@ -921,6 +1084,36 @@ func (_u *UserUpdate) check() error {
return &ValidationError{Name: "signup_source", err: fmt.Errorf(`ent: validator failed for field "User.signup_source": %w`, err)}
}
}
+ if v, ok := _u.mutation.SignupIP(); ok {
+ if err := user.SignupIPValidator(v); err != nil {
+ return &ValidationError{Name: "signup_ip", err: fmt.Errorf(`ent: validator failed for field "User.signup_ip": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.SignupIPPrefix(); ok {
+ if err := user.SignupIPPrefixValidator(v); err != nil {
+ return &ValidationError{Name: "signup_ip_prefix", err: fmt.Errorf(`ent: validator failed for field "User.signup_ip_prefix": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.SignupUserAgentHash(); ok {
+ if err := user.SignupUserAgentHashValidator(v); err != nil {
+ return &ValidationError{Name: "signup_user_agent_hash", err: fmt.Errorf(`ent: validator failed for field "User.signup_user_agent_hash": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.SignupDeviceFingerprintHash(); ok {
+ if err := user.SignupDeviceFingerprintHashValidator(v); err != nil {
+ return &ValidationError{Name: "signup_device_fingerprint_hash", err: fmt.Errorf(`ent: validator failed for field "User.signup_device_fingerprint_hash": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.TrialBonusHoldReason(); ok {
+ if err := user.TrialBonusHoldReasonValidator(v); err != nil {
+ return &ValidationError{Name: "trial_bonus_hold_reason", err: fmt.Errorf(`ent: validator failed for field "User.trial_bonus_hold_reason": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.TokenVersion(); ok {
+ if err := user.TokenVersionValidator(v); err != nil {
+ return &ValidationError{Name: "token_version", err: fmt.Errorf(`ent: validator failed for field "User.token_version": %w`, err)}
+ }
+ }
return nil
}
@@ -1035,6 +1228,36 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.AddedRpmLimit(); ok {
_spec.AddField(user.FieldRpmLimit, field.TypeInt, value)
}
+ if value, ok := _u.mutation.SignupIP(); ok {
+ _spec.SetField(user.FieldSignupIP, field.TypeString, value)
+ }
+ if value, ok := _u.mutation.SignupIPPrefix(); ok {
+ _spec.SetField(user.FieldSignupIPPrefix, field.TypeString, value)
+ }
+ if value, ok := _u.mutation.SignupUserAgentHash(); ok {
+ _spec.SetField(user.FieldSignupUserAgentHash, field.TypeString, value)
+ }
+ if value, ok := _u.mutation.SignupDeviceFingerprintHash(); ok {
+ _spec.SetField(user.FieldSignupDeviceFingerprintHash, field.TypeString, value)
+ }
+ if value, ok := _u.mutation.TrialBonusEligible(); ok {
+ _spec.SetField(user.FieldTrialBonusEligible, field.TypeBool, value)
+ }
+ if value, ok := _u.mutation.TrialBonusHoldReason(); ok {
+ _spec.SetField(user.FieldTrialBonusHoldReason, field.TypeString, value)
+ }
+ if value, ok := _u.mutation.TrialBonusRiskScore(); ok {
+ _spec.SetField(user.FieldTrialBonusRiskScore, field.TypeInt, value)
+ }
+ if value, ok := _u.mutation.AddedTrialBonusRiskScore(); ok {
+ _spec.AddField(user.FieldTrialBonusRiskScore, field.TypeInt, value)
+ }
+ if value, ok := _u.mutation.TokenVersion(); ok {
+ _spec.SetField(user.FieldTokenVersion, field.TypeInt64, value)
+ }
+ if value, ok := _u.mutation.AddedTokenVersion(); ok {
+ _spec.AddField(user.FieldTokenVersion, field.TypeInt64, value)
+ }
if _u.mutation.APIKeysCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -1215,6 +1438,51 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) {
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
+ if _u.mutation.WalletLedgerOperationsCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: user.WalletLedgerOperationsTable,
+ Columns: []string{user.WalletLedgerOperationsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.RemovedWalletLedgerOperationsIDs(); len(nodes) > 0 && !_u.mutation.WalletLedgerOperationsCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: user.WalletLedgerOperationsTable,
+ Columns: []string{user.WalletLedgerOperationsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.WalletLedgerOperationsIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: user.WalletLedgerOperationsTable,
+ Columns: []string{user.WalletLedgerOperationsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
if _u.mutation.AnnouncementReadsCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -1978,6 +2246,132 @@ func (_u *UserUpdateOne) AddRpmLimit(v int) *UserUpdateOne {
return _u
}
+// SetSignupIP sets the "signup_ip" field.
+func (_u *UserUpdateOne) SetSignupIP(v string) *UserUpdateOne {
+ _u.mutation.SetSignupIP(v)
+ return _u
+}
+
+// SetNillableSignupIP sets the "signup_ip" field if the given value is not nil.
+func (_u *UserUpdateOne) SetNillableSignupIP(v *string) *UserUpdateOne {
+ if v != nil {
+ _u.SetSignupIP(*v)
+ }
+ return _u
+}
+
+// SetSignupIPPrefix sets the "signup_ip_prefix" field.
+func (_u *UserUpdateOne) SetSignupIPPrefix(v string) *UserUpdateOne {
+ _u.mutation.SetSignupIPPrefix(v)
+ return _u
+}
+
+// SetNillableSignupIPPrefix sets the "signup_ip_prefix" field if the given value is not nil.
+func (_u *UserUpdateOne) SetNillableSignupIPPrefix(v *string) *UserUpdateOne {
+ if v != nil {
+ _u.SetSignupIPPrefix(*v)
+ }
+ return _u
+}
+
+// SetSignupUserAgentHash sets the "signup_user_agent_hash" field.
+func (_u *UserUpdateOne) SetSignupUserAgentHash(v string) *UserUpdateOne {
+ _u.mutation.SetSignupUserAgentHash(v)
+ return _u
+}
+
+// SetNillableSignupUserAgentHash sets the "signup_user_agent_hash" field if the given value is not nil.
+func (_u *UserUpdateOne) SetNillableSignupUserAgentHash(v *string) *UserUpdateOne {
+ if v != nil {
+ _u.SetSignupUserAgentHash(*v)
+ }
+ return _u
+}
+
+// SetSignupDeviceFingerprintHash sets the "signup_device_fingerprint_hash" field.
+func (_u *UserUpdateOne) SetSignupDeviceFingerprintHash(v string) *UserUpdateOne {
+ _u.mutation.SetSignupDeviceFingerprintHash(v)
+ return _u
+}
+
+// SetNillableSignupDeviceFingerprintHash sets the "signup_device_fingerprint_hash" field if the given value is not nil.
+func (_u *UserUpdateOne) SetNillableSignupDeviceFingerprintHash(v *string) *UserUpdateOne {
+ if v != nil {
+ _u.SetSignupDeviceFingerprintHash(*v)
+ }
+ return _u
+}
+
+// SetTrialBonusEligible sets the "trial_bonus_eligible" field.
+func (_u *UserUpdateOne) SetTrialBonusEligible(v bool) *UserUpdateOne {
+ _u.mutation.SetTrialBonusEligible(v)
+ return _u
+}
+
+// SetNillableTrialBonusEligible sets the "trial_bonus_eligible" field if the given value is not nil.
+func (_u *UserUpdateOne) SetNillableTrialBonusEligible(v *bool) *UserUpdateOne {
+ if v != nil {
+ _u.SetTrialBonusEligible(*v)
+ }
+ return _u
+}
+
+// SetTrialBonusHoldReason sets the "trial_bonus_hold_reason" field.
+func (_u *UserUpdateOne) SetTrialBonusHoldReason(v string) *UserUpdateOne {
+ _u.mutation.SetTrialBonusHoldReason(v)
+ return _u
+}
+
+// SetNillableTrialBonusHoldReason sets the "trial_bonus_hold_reason" field if the given value is not nil.
+func (_u *UserUpdateOne) SetNillableTrialBonusHoldReason(v *string) *UserUpdateOne {
+ if v != nil {
+ _u.SetTrialBonusHoldReason(*v)
+ }
+ return _u
+}
+
+// SetTrialBonusRiskScore sets the "trial_bonus_risk_score" field.
+func (_u *UserUpdateOne) SetTrialBonusRiskScore(v int) *UserUpdateOne {
+ _u.mutation.ResetTrialBonusRiskScore()
+ _u.mutation.SetTrialBonusRiskScore(v)
+ return _u
+}
+
+// SetNillableTrialBonusRiskScore sets the "trial_bonus_risk_score" field if the given value is not nil.
+func (_u *UserUpdateOne) SetNillableTrialBonusRiskScore(v *int) *UserUpdateOne {
+ if v != nil {
+ _u.SetTrialBonusRiskScore(*v)
+ }
+ return _u
+}
+
+// AddTrialBonusRiskScore adds value to the "trial_bonus_risk_score" field.
+func (_u *UserUpdateOne) AddTrialBonusRiskScore(v int) *UserUpdateOne {
+ _u.mutation.AddTrialBonusRiskScore(v)
+ return _u
+}
+
+// SetTokenVersion sets the "token_version" field.
+func (_u *UserUpdateOne) SetTokenVersion(v int64) *UserUpdateOne {
+ _u.mutation.ResetTokenVersion()
+ _u.mutation.SetTokenVersion(v)
+ return _u
+}
+
+// SetNillableTokenVersion sets the "token_version" field if the given value is not nil.
+func (_u *UserUpdateOne) SetNillableTokenVersion(v *int64) *UserUpdateOne {
+ if v != nil {
+ _u.SetTokenVersion(*v)
+ }
+ return _u
+}
+
+// AddTokenVersion adds value to the "token_version" field.
+func (_u *UserUpdateOne) AddTokenVersion(v int64) *UserUpdateOne {
+ _u.mutation.AddTokenVersion(v)
+ return _u
+}
+
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
func (_u *UserUpdateOne) AddAPIKeyIDs(ids ...int64) *UserUpdateOne {
_u.mutation.AddAPIKeyIDs(ids...)
@@ -2038,6 +2432,21 @@ func (_u *UserUpdateOne) AddAssignedSubscriptions(v ...*UserSubscription) *UserU
return _u.AddAssignedSubscriptionIDs(ids...)
}
+// AddWalletLedgerOperationIDs adds the "wallet_ledger_operations" edge to the SubscriptionWalletLedger entity by IDs.
+func (_u *UserUpdateOne) AddWalletLedgerOperationIDs(ids ...int64) *UserUpdateOne {
+ _u.mutation.AddWalletLedgerOperationIDs(ids...)
+ return _u
+}
+
+// AddWalletLedgerOperations adds the "wallet_ledger_operations" edges to the SubscriptionWalletLedger entity.
+func (_u *UserUpdateOne) AddWalletLedgerOperations(v ...*SubscriptionWalletLedger) *UserUpdateOne {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.AddWalletLedgerOperationIDs(ids...)
+}
+
// AddAnnouncementReadIDs adds the "announcement_reads" edge to the AnnouncementRead entity by IDs.
func (_u *UserUpdateOne) AddAnnouncementReadIDs(ids ...int64) *UserUpdateOne {
_u.mutation.AddAnnouncementReadIDs(ids...)
@@ -2247,6 +2656,27 @@ func (_u *UserUpdateOne) RemoveAssignedSubscriptions(v ...*UserSubscription) *Us
return _u.RemoveAssignedSubscriptionIDs(ids...)
}
+// ClearWalletLedgerOperations clears all "wallet_ledger_operations" edges to the SubscriptionWalletLedger entity.
+func (_u *UserUpdateOne) ClearWalletLedgerOperations() *UserUpdateOne {
+ _u.mutation.ClearWalletLedgerOperations()
+ return _u
+}
+
+// RemoveWalletLedgerOperationIDs removes the "wallet_ledger_operations" edge to SubscriptionWalletLedger entities by IDs.
+func (_u *UserUpdateOne) RemoveWalletLedgerOperationIDs(ids ...int64) *UserUpdateOne {
+ _u.mutation.RemoveWalletLedgerOperationIDs(ids...)
+ return _u
+}
+
+// RemoveWalletLedgerOperations removes "wallet_ledger_operations" edges to SubscriptionWalletLedger entities.
+func (_u *UserUpdateOne) RemoveWalletLedgerOperations(v ...*SubscriptionWalletLedger) *UserUpdateOne {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.RemoveWalletLedgerOperationIDs(ids...)
+}
+
// ClearAnnouncementReads clears all "announcement_reads" edges to the AnnouncementRead entity.
func (_u *UserUpdateOne) ClearAnnouncementReads() *UserUpdateOne {
_u.mutation.ClearAnnouncementReads()
@@ -2502,6 +2932,36 @@ func (_u *UserUpdateOne) check() error {
return &ValidationError{Name: "signup_source", err: fmt.Errorf(`ent: validator failed for field "User.signup_source": %w`, err)}
}
}
+ if v, ok := _u.mutation.SignupIP(); ok {
+ if err := user.SignupIPValidator(v); err != nil {
+ return &ValidationError{Name: "signup_ip", err: fmt.Errorf(`ent: validator failed for field "User.signup_ip": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.SignupIPPrefix(); ok {
+ if err := user.SignupIPPrefixValidator(v); err != nil {
+ return &ValidationError{Name: "signup_ip_prefix", err: fmt.Errorf(`ent: validator failed for field "User.signup_ip_prefix": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.SignupUserAgentHash(); ok {
+ if err := user.SignupUserAgentHashValidator(v); err != nil {
+ return &ValidationError{Name: "signup_user_agent_hash", err: fmt.Errorf(`ent: validator failed for field "User.signup_user_agent_hash": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.SignupDeviceFingerprintHash(); ok {
+ if err := user.SignupDeviceFingerprintHashValidator(v); err != nil {
+ return &ValidationError{Name: "signup_device_fingerprint_hash", err: fmt.Errorf(`ent: validator failed for field "User.signup_device_fingerprint_hash": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.TrialBonusHoldReason(); ok {
+ if err := user.TrialBonusHoldReasonValidator(v); err != nil {
+ return &ValidationError{Name: "trial_bonus_hold_reason", err: fmt.Errorf(`ent: validator failed for field "User.trial_bonus_hold_reason": %w`, err)}
+ }
+ }
+ if v, ok := _u.mutation.TokenVersion(); ok {
+ if err := user.TokenVersionValidator(v); err != nil {
+ return &ValidationError{Name: "token_version", err: fmt.Errorf(`ent: validator failed for field "User.token_version": %w`, err)}
+ }
+ }
return nil
}
@@ -2633,6 +3093,36 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) {
if value, ok := _u.mutation.AddedRpmLimit(); ok {
_spec.AddField(user.FieldRpmLimit, field.TypeInt, value)
}
+ if value, ok := _u.mutation.SignupIP(); ok {
+ _spec.SetField(user.FieldSignupIP, field.TypeString, value)
+ }
+ if value, ok := _u.mutation.SignupIPPrefix(); ok {
+ _spec.SetField(user.FieldSignupIPPrefix, field.TypeString, value)
+ }
+ if value, ok := _u.mutation.SignupUserAgentHash(); ok {
+ _spec.SetField(user.FieldSignupUserAgentHash, field.TypeString, value)
+ }
+ if value, ok := _u.mutation.SignupDeviceFingerprintHash(); ok {
+ _spec.SetField(user.FieldSignupDeviceFingerprintHash, field.TypeString, value)
+ }
+ if value, ok := _u.mutation.TrialBonusEligible(); ok {
+ _spec.SetField(user.FieldTrialBonusEligible, field.TypeBool, value)
+ }
+ if value, ok := _u.mutation.TrialBonusHoldReason(); ok {
+ _spec.SetField(user.FieldTrialBonusHoldReason, field.TypeString, value)
+ }
+ if value, ok := _u.mutation.TrialBonusRiskScore(); ok {
+ _spec.SetField(user.FieldTrialBonusRiskScore, field.TypeInt, value)
+ }
+ if value, ok := _u.mutation.AddedTrialBonusRiskScore(); ok {
+ _spec.AddField(user.FieldTrialBonusRiskScore, field.TypeInt, value)
+ }
+ if value, ok := _u.mutation.TokenVersion(); ok {
+ _spec.SetField(user.FieldTokenVersion, field.TypeInt64, value)
+ }
+ if value, ok := _u.mutation.AddedTokenVersion(); ok {
+ _spec.AddField(user.FieldTokenVersion, field.TypeInt64, value)
+ }
if _u.mutation.APIKeysCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -2813,6 +3303,51 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) {
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
+ if _u.mutation.WalletLedgerOperationsCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: user.WalletLedgerOperationsTable,
+ Columns: []string{user.WalletLedgerOperationsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.RemovedWalletLedgerOperationsIDs(); len(nodes) > 0 && !_u.mutation.WalletLedgerOperationsCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: user.WalletLedgerOperationsTable,
+ Columns: []string{user.WalletLedgerOperationsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.WalletLedgerOperationsIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: user.WalletLedgerOperationsTable,
+ Columns: []string{user.WalletLedgerOperationsColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
if _u.mutation.AnnouncementReadsCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
diff --git a/backend/ent/usersubscription.go b/backend/ent/usersubscription.go
index 01beb2fcc9b..9ae37895691 100644
--- a/backend/ent/usersubscription.go
+++ b/backend/ent/usersubscription.go
@@ -3,6 +3,7 @@
package ent
import (
+ "encoding/json"
"fmt"
"strings"
"time"
@@ -28,7 +29,7 @@ type UserSubscription struct {
// UserID holds the value of the "user_id" field.
UserID int64 `json:"user_id,omitempty"`
// GroupID holds the value of the "group_id" field.
- GroupID int64 `json:"group_id,omitempty"`
+ GroupID *int64 `json:"group_id,omitempty"`
// StartsAt holds the value of the "starts_at" field.
StartsAt time.Time `json:"starts_at,omitempty"`
// ExpiresAt holds the value of the "expires_at" field.
@@ -47,6 +48,12 @@ type UserSubscription struct {
WeeklyUsageUsd float64 `json:"weekly_usage_usd,omitempty"`
// MonthlyUsageUsd holds the value of the "monthly_usage_usd" field.
MonthlyUsageUsd float64 `json:"monthly_usage_usd,omitempty"`
+ // 钱包模式当前余额(USD,含倍率扣减)。NULL = 老 group 订阅模式
+ WalletBalanceUsd *float64 `json:"wallet_balance_usd,omitempty"`
+ // 钱包模式激活时的总额度(用于 UI 进度条)
+ WalletInitialUsd *float64 `json:"wallet_initial_usd,omitempty"`
+ // 订阅级锁定倍率:group_id 字符串 -> rate_multiplier;存在时优先于用户专属倍率和 group 默认倍率
+ LockedRates map[string]float64 `json:"locked_rates,omitempty"`
// AssignedBy holds the value of the "assigned_by" field.
AssignedBy *int64 `json:"assigned_by,omitempty"`
// AssignedAt holds the value of the "assigned_at" field.
@@ -69,9 +76,11 @@ type UserSubscriptionEdges struct {
AssignedByUser *User `json:"assigned_by_user,omitempty"`
// UsageLogs holds the value of the usage_logs edge.
UsageLogs []*UsageLog `json:"usage_logs,omitempty"`
+ // WalletLedgerEntries holds the value of the wallet_ledger_entries edge.
+ WalletLedgerEntries []*SubscriptionWalletLedger `json:"wallet_ledger_entries,omitempty"`
// loadedTypes holds the information for reporting if a
// type was loaded (or requested) in eager-loading or not.
- loadedTypes [4]bool
+ loadedTypes [5]bool
}
// UserOrErr returns the User value or an error if the edge
@@ -116,12 +125,23 @@ func (e UserSubscriptionEdges) UsageLogsOrErr() ([]*UsageLog, error) {
return nil, &NotLoadedError{edge: "usage_logs"}
}
+// WalletLedgerEntriesOrErr returns the WalletLedgerEntries value or an error if the edge
+// was not loaded in eager-loading.
+func (e UserSubscriptionEdges) WalletLedgerEntriesOrErr() ([]*SubscriptionWalletLedger, error) {
+ if e.loadedTypes[4] {
+ return e.WalletLedgerEntries, nil
+ }
+ return nil, &NotLoadedError{edge: "wallet_ledger_entries"}
+}
+
// scanValues returns the types for scanning values from sql.Rows.
func (*UserSubscription) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
- case usersubscription.FieldDailyUsageUsd, usersubscription.FieldWeeklyUsageUsd, usersubscription.FieldMonthlyUsageUsd:
+ case usersubscription.FieldLockedRates:
+ values[i] = new([]byte)
+ case usersubscription.FieldDailyUsageUsd, usersubscription.FieldWeeklyUsageUsd, usersubscription.FieldMonthlyUsageUsd, usersubscription.FieldWalletBalanceUsd, usersubscription.FieldWalletInitialUsd:
values[i] = new(sql.NullFloat64)
case usersubscription.FieldID, usersubscription.FieldUserID, usersubscription.FieldGroupID, usersubscription.FieldAssignedBy:
values[i] = new(sql.NullInt64)
@@ -179,7 +199,8 @@ func (_m *UserSubscription) assignValues(columns []string, values []any) error {
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field group_id", values[i])
} else if value.Valid {
- _m.GroupID = value.Int64
+ _m.GroupID = new(int64)
+ *_m.GroupID = value.Int64
}
case usersubscription.FieldStartsAt:
if value, ok := values[i].(*sql.NullTime); !ok {
@@ -238,6 +259,28 @@ func (_m *UserSubscription) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.MonthlyUsageUsd = value.Float64
}
+ case usersubscription.FieldWalletBalanceUsd:
+ if value, ok := values[i].(*sql.NullFloat64); !ok {
+ return fmt.Errorf("unexpected type %T for field wallet_balance_usd", values[i])
+ } else if value.Valid {
+ _m.WalletBalanceUsd = new(float64)
+ *_m.WalletBalanceUsd = value.Float64
+ }
+ case usersubscription.FieldWalletInitialUsd:
+ if value, ok := values[i].(*sql.NullFloat64); !ok {
+ return fmt.Errorf("unexpected type %T for field wallet_initial_usd", values[i])
+ } else if value.Valid {
+ _m.WalletInitialUsd = new(float64)
+ *_m.WalletInitialUsd = value.Float64
+ }
+ case usersubscription.FieldLockedRates:
+ if value, ok := values[i].(*[]byte); !ok {
+ return fmt.Errorf("unexpected type %T for field locked_rates", values[i])
+ } else if value != nil && len(*value) > 0 {
+ if err := json.Unmarshal(*value, &_m.LockedRates); err != nil {
+ return fmt.Errorf("unmarshal field locked_rates: %w", err)
+ }
+ }
case usersubscription.FieldAssignedBy:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field assigned_by", values[i])
@@ -291,6 +334,11 @@ func (_m *UserSubscription) QueryUsageLogs() *UsageLogQuery {
return NewUserSubscriptionClient(_m.config).QueryUsageLogs(_m)
}
+// QueryWalletLedgerEntries queries the "wallet_ledger_entries" edge of the UserSubscription entity.
+func (_m *UserSubscription) QueryWalletLedgerEntries() *SubscriptionWalletLedgerQuery {
+ return NewUserSubscriptionClient(_m.config).QueryWalletLedgerEntries(_m)
+}
+
// Update returns a builder for updating this UserSubscription.
// Note that you need to call UserSubscription.Unwrap() before calling this method if this UserSubscription
// was returned from a transaction, and the transaction was committed or rolled back.
@@ -328,8 +376,10 @@ func (_m *UserSubscription) String() string {
builder.WriteString("user_id=")
builder.WriteString(fmt.Sprintf("%v", _m.UserID))
builder.WriteString(", ")
- builder.WriteString("group_id=")
- builder.WriteString(fmt.Sprintf("%v", _m.GroupID))
+ if v := _m.GroupID; v != nil {
+ builder.WriteString("group_id=")
+ builder.WriteString(fmt.Sprintf("%v", *v))
+ }
builder.WriteString(", ")
builder.WriteString("starts_at=")
builder.WriteString(_m.StartsAt.Format(time.ANSIC))
@@ -364,6 +414,19 @@ func (_m *UserSubscription) String() string {
builder.WriteString("monthly_usage_usd=")
builder.WriteString(fmt.Sprintf("%v", _m.MonthlyUsageUsd))
builder.WriteString(", ")
+ if v := _m.WalletBalanceUsd; v != nil {
+ builder.WriteString("wallet_balance_usd=")
+ builder.WriteString(fmt.Sprintf("%v", *v))
+ }
+ builder.WriteString(", ")
+ if v := _m.WalletInitialUsd; v != nil {
+ builder.WriteString("wallet_initial_usd=")
+ builder.WriteString(fmt.Sprintf("%v", *v))
+ }
+ builder.WriteString(", ")
+ builder.WriteString("locked_rates=")
+ builder.WriteString(fmt.Sprintf("%v", _m.LockedRates))
+ builder.WriteString(", ")
if v := _m.AssignedBy; v != nil {
builder.WriteString("assigned_by=")
builder.WriteString(fmt.Sprintf("%v", *v))
diff --git a/backend/ent/usersubscription/usersubscription.go b/backend/ent/usersubscription/usersubscription.go
index 064416461f8..4dfae67267e 100644
--- a/backend/ent/usersubscription/usersubscription.go
+++ b/backend/ent/usersubscription/usersubscription.go
@@ -43,6 +43,12 @@ const (
FieldWeeklyUsageUsd = "weekly_usage_usd"
// FieldMonthlyUsageUsd holds the string denoting the monthly_usage_usd field in the database.
FieldMonthlyUsageUsd = "monthly_usage_usd"
+ // FieldWalletBalanceUsd holds the string denoting the wallet_balance_usd field in the database.
+ FieldWalletBalanceUsd = "wallet_balance_usd"
+ // FieldWalletInitialUsd holds the string denoting the wallet_initial_usd field in the database.
+ FieldWalletInitialUsd = "wallet_initial_usd"
+ // FieldLockedRates holds the string denoting the locked_rates field in the database.
+ FieldLockedRates = "locked_rates"
// FieldAssignedBy holds the string denoting the assigned_by field in the database.
FieldAssignedBy = "assigned_by"
// FieldAssignedAt holds the string denoting the assigned_at field in the database.
@@ -57,6 +63,8 @@ const (
EdgeAssignedByUser = "assigned_by_user"
// EdgeUsageLogs holds the string denoting the usage_logs edge name in mutations.
EdgeUsageLogs = "usage_logs"
+ // EdgeWalletLedgerEntries holds the string denoting the wallet_ledger_entries edge name in mutations.
+ EdgeWalletLedgerEntries = "wallet_ledger_entries"
// Table holds the table name of the usersubscription in the database.
Table = "user_subscriptions"
// UserTable is the table that holds the user relation/edge.
@@ -87,6 +95,13 @@ const (
UsageLogsInverseTable = "usage_logs"
// UsageLogsColumn is the table column denoting the usage_logs relation/edge.
UsageLogsColumn = "subscription_id"
+ // WalletLedgerEntriesTable is the table that holds the wallet_ledger_entries relation/edge.
+ WalletLedgerEntriesTable = "subscription_wallet_ledger"
+ // WalletLedgerEntriesInverseTable is the table name for the SubscriptionWalletLedger entity.
+ // It exists in this package in order to avoid circular dependency with the "subscriptionwalletledger" package.
+ WalletLedgerEntriesInverseTable = "subscription_wallet_ledger"
+ // WalletLedgerEntriesColumn is the table column denoting the wallet_ledger_entries relation/edge.
+ WalletLedgerEntriesColumn = "subscription_id"
)
// Columns holds all SQL columns for usersubscription fields.
@@ -106,6 +121,9 @@ var Columns = []string{
FieldDailyUsageUsd,
FieldWeeklyUsageUsd,
FieldMonthlyUsageUsd,
+ FieldWalletBalanceUsd,
+ FieldWalletInitialUsd,
+ FieldLockedRates,
FieldAssignedBy,
FieldAssignedAt,
FieldNotes,
@@ -227,6 +245,16 @@ func ByMonthlyUsageUsd(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldMonthlyUsageUsd, opts...).ToFunc()
}
+// ByWalletBalanceUsd orders the results by the wallet_balance_usd field.
+func ByWalletBalanceUsd(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldWalletBalanceUsd, opts...).ToFunc()
+}
+
+// ByWalletInitialUsd orders the results by the wallet_initial_usd field.
+func ByWalletInitialUsd(opts ...sql.OrderTermOption) OrderOption {
+ return sql.OrderByField(FieldWalletInitialUsd, opts...).ToFunc()
+}
+
// ByAssignedBy orders the results by the assigned_by field.
func ByAssignedBy(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAssignedBy, opts...).ToFunc()
@@ -276,6 +304,20 @@ func ByUsageLogs(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
sqlgraph.OrderByNeighborTerms(s, newUsageLogsStep(), append([]sql.OrderTerm{term}, terms...)...)
}
}
+
+// ByWalletLedgerEntriesCount orders the results by wallet_ledger_entries count.
+func ByWalletLedgerEntriesCount(opts ...sql.OrderTermOption) OrderOption {
+ return func(s *sql.Selector) {
+ sqlgraph.OrderByNeighborsCount(s, newWalletLedgerEntriesStep(), opts...)
+ }
+}
+
+// ByWalletLedgerEntries orders the results by wallet_ledger_entries terms.
+func ByWalletLedgerEntries(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
+ return func(s *sql.Selector) {
+ sqlgraph.OrderByNeighborTerms(s, newWalletLedgerEntriesStep(), append([]sql.OrderTerm{term}, terms...)...)
+ }
+}
func newUserStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
@@ -304,3 +346,10 @@ func newUsageLogsStep() *sqlgraph.Step {
sqlgraph.Edge(sqlgraph.O2M, false, UsageLogsTable, UsageLogsColumn),
)
}
+func newWalletLedgerEntriesStep() *sqlgraph.Step {
+ return sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.To(WalletLedgerEntriesInverseTable, FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, WalletLedgerEntriesTable, WalletLedgerEntriesColumn),
+ )
+}
diff --git a/backend/ent/usersubscription/where.go b/backend/ent/usersubscription/where.go
index 250e5ed5691..23da0f6d386 100644
--- a/backend/ent/usersubscription/where.go
+++ b/backend/ent/usersubscription/where.go
@@ -125,6 +125,16 @@ func MonthlyUsageUsd(v float64) predicate.UserSubscription {
return predicate.UserSubscription(sql.FieldEQ(FieldMonthlyUsageUsd, v))
}
+// WalletBalanceUsd applies equality check predicate on the "wallet_balance_usd" field. It's identical to WalletBalanceUsdEQ.
+func WalletBalanceUsd(v float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldEQ(FieldWalletBalanceUsd, v))
+}
+
+// WalletInitialUsd applies equality check predicate on the "wallet_initial_usd" field. It's identical to WalletInitialUsdEQ.
+func WalletInitialUsd(v float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldEQ(FieldWalletInitialUsd, v))
+}
+
// AssignedBy applies equality check predicate on the "assigned_by" field. It's identical to AssignedByEQ.
func AssignedBy(v int64) predicate.UserSubscription {
return predicate.UserSubscription(sql.FieldEQ(FieldAssignedBy, v))
@@ -310,6 +320,16 @@ func GroupIDNotIn(vs ...int64) predicate.UserSubscription {
return predicate.UserSubscription(sql.FieldNotIn(FieldGroupID, vs...))
}
+// GroupIDIsNil applies the IsNil predicate on the "group_id" field.
+func GroupIDIsNil() predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldIsNull(FieldGroupID))
+}
+
+// GroupIDNotNil applies the NotNil predicate on the "group_id" field.
+func GroupIDNotNil() predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldNotNull(FieldGroupID))
+}
+
// StartsAtEQ applies the EQ predicate on the "starts_at" field.
func StartsAtEQ(v time.Time) predicate.UserSubscription {
return predicate.UserSubscription(sql.FieldEQ(FieldStartsAt, v))
@@ -725,6 +745,116 @@ func MonthlyUsageUsdLTE(v float64) predicate.UserSubscription {
return predicate.UserSubscription(sql.FieldLTE(FieldMonthlyUsageUsd, v))
}
+// WalletBalanceUsdEQ applies the EQ predicate on the "wallet_balance_usd" field.
+func WalletBalanceUsdEQ(v float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldEQ(FieldWalletBalanceUsd, v))
+}
+
+// WalletBalanceUsdNEQ applies the NEQ predicate on the "wallet_balance_usd" field.
+func WalletBalanceUsdNEQ(v float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldNEQ(FieldWalletBalanceUsd, v))
+}
+
+// WalletBalanceUsdIn applies the In predicate on the "wallet_balance_usd" field.
+func WalletBalanceUsdIn(vs ...float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldIn(FieldWalletBalanceUsd, vs...))
+}
+
+// WalletBalanceUsdNotIn applies the NotIn predicate on the "wallet_balance_usd" field.
+func WalletBalanceUsdNotIn(vs ...float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldNotIn(FieldWalletBalanceUsd, vs...))
+}
+
+// WalletBalanceUsdGT applies the GT predicate on the "wallet_balance_usd" field.
+func WalletBalanceUsdGT(v float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldGT(FieldWalletBalanceUsd, v))
+}
+
+// WalletBalanceUsdGTE applies the GTE predicate on the "wallet_balance_usd" field.
+func WalletBalanceUsdGTE(v float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldGTE(FieldWalletBalanceUsd, v))
+}
+
+// WalletBalanceUsdLT applies the LT predicate on the "wallet_balance_usd" field.
+func WalletBalanceUsdLT(v float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldLT(FieldWalletBalanceUsd, v))
+}
+
+// WalletBalanceUsdLTE applies the LTE predicate on the "wallet_balance_usd" field.
+func WalletBalanceUsdLTE(v float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldLTE(FieldWalletBalanceUsd, v))
+}
+
+// WalletBalanceUsdIsNil applies the IsNil predicate on the "wallet_balance_usd" field.
+func WalletBalanceUsdIsNil() predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldIsNull(FieldWalletBalanceUsd))
+}
+
+// WalletBalanceUsdNotNil applies the NotNil predicate on the "wallet_balance_usd" field.
+func WalletBalanceUsdNotNil() predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldNotNull(FieldWalletBalanceUsd))
+}
+
+// WalletInitialUsdEQ applies the EQ predicate on the "wallet_initial_usd" field.
+func WalletInitialUsdEQ(v float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldEQ(FieldWalletInitialUsd, v))
+}
+
+// WalletInitialUsdNEQ applies the NEQ predicate on the "wallet_initial_usd" field.
+func WalletInitialUsdNEQ(v float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldNEQ(FieldWalletInitialUsd, v))
+}
+
+// WalletInitialUsdIn applies the In predicate on the "wallet_initial_usd" field.
+func WalletInitialUsdIn(vs ...float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldIn(FieldWalletInitialUsd, vs...))
+}
+
+// WalletInitialUsdNotIn applies the NotIn predicate on the "wallet_initial_usd" field.
+func WalletInitialUsdNotIn(vs ...float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldNotIn(FieldWalletInitialUsd, vs...))
+}
+
+// WalletInitialUsdGT applies the GT predicate on the "wallet_initial_usd" field.
+func WalletInitialUsdGT(v float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldGT(FieldWalletInitialUsd, v))
+}
+
+// WalletInitialUsdGTE applies the GTE predicate on the "wallet_initial_usd" field.
+func WalletInitialUsdGTE(v float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldGTE(FieldWalletInitialUsd, v))
+}
+
+// WalletInitialUsdLT applies the LT predicate on the "wallet_initial_usd" field.
+func WalletInitialUsdLT(v float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldLT(FieldWalletInitialUsd, v))
+}
+
+// WalletInitialUsdLTE applies the LTE predicate on the "wallet_initial_usd" field.
+func WalletInitialUsdLTE(v float64) predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldLTE(FieldWalletInitialUsd, v))
+}
+
+// WalletInitialUsdIsNil applies the IsNil predicate on the "wallet_initial_usd" field.
+func WalletInitialUsdIsNil() predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldIsNull(FieldWalletInitialUsd))
+}
+
+// WalletInitialUsdNotNil applies the NotNil predicate on the "wallet_initial_usd" field.
+func WalletInitialUsdNotNil() predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldNotNull(FieldWalletInitialUsd))
+}
+
+// LockedRatesIsNil applies the IsNil predicate on the "locked_rates" field.
+func LockedRatesIsNil() predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldIsNull(FieldLockedRates))
+}
+
+// LockedRatesNotNil applies the NotNil predicate on the "locked_rates" field.
+func LockedRatesNotNil() predicate.UserSubscription {
+ return predicate.UserSubscription(sql.FieldNotNull(FieldLockedRates))
+}
+
// AssignedByEQ applies the EQ predicate on the "assigned_by" field.
func AssignedByEQ(v int64) predicate.UserSubscription {
return predicate.UserSubscription(sql.FieldEQ(FieldAssignedBy, v))
@@ -962,6 +1092,29 @@ func HasUsageLogsWith(preds ...predicate.UsageLog) predicate.UserSubscription {
})
}
+// HasWalletLedgerEntries applies the HasEdge predicate on the "wallet_ledger_entries" edge.
+func HasWalletLedgerEntries() predicate.UserSubscription {
+ return predicate.UserSubscription(func(s *sql.Selector) {
+ step := sqlgraph.NewStep(
+ sqlgraph.From(Table, FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, WalletLedgerEntriesTable, WalletLedgerEntriesColumn),
+ )
+ sqlgraph.HasNeighbors(s, step)
+ })
+}
+
+// HasWalletLedgerEntriesWith applies the HasEdge predicate on the "wallet_ledger_entries" edge with a given conditions (other predicates).
+func HasWalletLedgerEntriesWith(preds ...predicate.SubscriptionWalletLedger) predicate.UserSubscription {
+ return predicate.UserSubscription(func(s *sql.Selector) {
+ step := newWalletLedgerEntriesStep()
+ sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
+ for _, p := range preds {
+ p(s)
+ }
+ })
+ })
+}
+
// And groups predicates with the AND operator between them.
func And(predicates ...predicate.UserSubscription) predicate.UserSubscription {
return predicate.UserSubscription(sql.AndPredicates(predicates...))
diff --git a/backend/ent/usersubscription_create.go b/backend/ent/usersubscription_create.go
index dd03115bb20..12e0db1137d 100644
--- a/backend/ent/usersubscription_create.go
+++ b/backend/ent/usersubscription_create.go
@@ -12,6 +12,7 @@ import (
"entgo.io/ent/dialect/sql/sqlgraph"
"entgo.io/ent/schema/field"
"github.com/Wei-Shaw/sub2api/ent/group"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
@@ -79,6 +80,14 @@ func (_c *UserSubscriptionCreate) SetGroupID(v int64) *UserSubscriptionCreate {
return _c
}
+// SetNillableGroupID sets the "group_id" field if the given value is not nil.
+func (_c *UserSubscriptionCreate) SetNillableGroupID(v *int64) *UserSubscriptionCreate {
+ if v != nil {
+ _c.SetGroupID(*v)
+ }
+ return _c
+}
+
// SetStartsAt sets the "starts_at" field.
func (_c *UserSubscriptionCreate) SetStartsAt(v time.Time) *UserSubscriptionCreate {
_c.mutation.SetStartsAt(v)
@@ -189,6 +198,40 @@ func (_c *UserSubscriptionCreate) SetNillableMonthlyUsageUsd(v *float64) *UserSu
return _c
}
+// SetWalletBalanceUsd sets the "wallet_balance_usd" field.
+func (_c *UserSubscriptionCreate) SetWalletBalanceUsd(v float64) *UserSubscriptionCreate {
+ _c.mutation.SetWalletBalanceUsd(v)
+ return _c
+}
+
+// SetNillableWalletBalanceUsd sets the "wallet_balance_usd" field if the given value is not nil.
+func (_c *UserSubscriptionCreate) SetNillableWalletBalanceUsd(v *float64) *UserSubscriptionCreate {
+ if v != nil {
+ _c.SetWalletBalanceUsd(*v)
+ }
+ return _c
+}
+
+// SetWalletInitialUsd sets the "wallet_initial_usd" field.
+func (_c *UserSubscriptionCreate) SetWalletInitialUsd(v float64) *UserSubscriptionCreate {
+ _c.mutation.SetWalletInitialUsd(v)
+ return _c
+}
+
+// SetNillableWalletInitialUsd sets the "wallet_initial_usd" field if the given value is not nil.
+func (_c *UserSubscriptionCreate) SetNillableWalletInitialUsd(v *float64) *UserSubscriptionCreate {
+ if v != nil {
+ _c.SetWalletInitialUsd(*v)
+ }
+ return _c
+}
+
+// SetLockedRates sets the "locked_rates" field.
+func (_c *UserSubscriptionCreate) SetLockedRates(v map[string]float64) *UserSubscriptionCreate {
+ _c.mutation.SetLockedRates(v)
+ return _c
+}
+
// SetAssignedBy sets the "assigned_by" field.
func (_c *UserSubscriptionCreate) SetAssignedBy(v int64) *UserSubscriptionCreate {
_c.mutation.SetAssignedBy(v)
@@ -275,6 +318,21 @@ func (_c *UserSubscriptionCreate) AddUsageLogs(v ...*UsageLog) *UserSubscription
return _c.AddUsageLogIDs(ids...)
}
+// AddWalletLedgerEntryIDs adds the "wallet_ledger_entries" edge to the SubscriptionWalletLedger entity by IDs.
+func (_c *UserSubscriptionCreate) AddWalletLedgerEntryIDs(ids ...int64) *UserSubscriptionCreate {
+ _c.mutation.AddWalletLedgerEntryIDs(ids...)
+ return _c
+}
+
+// AddWalletLedgerEntries adds the "wallet_ledger_entries" edges to the SubscriptionWalletLedger entity.
+func (_c *UserSubscriptionCreate) AddWalletLedgerEntries(v ...*SubscriptionWalletLedger) *UserSubscriptionCreate {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _c.AddWalletLedgerEntryIDs(ids...)
+}
+
// Mutation returns the UserSubscriptionMutation object of the builder.
func (_c *UserSubscriptionCreate) Mutation() *UserSubscriptionMutation {
return _c.mutation
@@ -363,9 +421,6 @@ func (_c *UserSubscriptionCreate) check() error {
if _, ok := _c.mutation.UserID(); !ok {
return &ValidationError{Name: "user_id", err: errors.New(`ent: missing required field "UserSubscription.user_id"`)}
}
- if _, ok := _c.mutation.GroupID(); !ok {
- return &ValidationError{Name: "group_id", err: errors.New(`ent: missing required field "UserSubscription.group_id"`)}
- }
if _, ok := _c.mutation.StartsAt(); !ok {
return &ValidationError{Name: "starts_at", err: errors.New(`ent: missing required field "UserSubscription.starts_at"`)}
}
@@ -395,9 +450,6 @@ func (_c *UserSubscriptionCreate) check() error {
if len(_c.mutation.UserIDs()) == 0 {
return &ValidationError{Name: "user", err: errors.New(`ent: missing required edge "UserSubscription.user"`)}
}
- if len(_c.mutation.GroupIDs()) == 0 {
- return &ValidationError{Name: "group", err: errors.New(`ent: missing required edge "UserSubscription.group"`)}
- }
return nil
}
@@ -473,6 +525,18 @@ func (_c *UserSubscriptionCreate) createSpec() (*UserSubscription, *sqlgraph.Cre
_spec.SetField(usersubscription.FieldMonthlyUsageUsd, field.TypeFloat64, value)
_node.MonthlyUsageUsd = value
}
+ if value, ok := _c.mutation.WalletBalanceUsd(); ok {
+ _spec.SetField(usersubscription.FieldWalletBalanceUsd, field.TypeFloat64, value)
+ _node.WalletBalanceUsd = &value
+ }
+ if value, ok := _c.mutation.WalletInitialUsd(); ok {
+ _spec.SetField(usersubscription.FieldWalletInitialUsd, field.TypeFloat64, value)
+ _node.WalletInitialUsd = &value
+ }
+ if value, ok := _c.mutation.LockedRates(); ok {
+ _spec.SetField(usersubscription.FieldLockedRates, field.TypeJSON, value)
+ _node.LockedRates = value
+ }
if value, ok := _c.mutation.AssignedAt(); ok {
_spec.SetField(usersubscription.FieldAssignedAt, field.TypeTime, value)
_node.AssignedAt = value
@@ -512,7 +576,7 @@ func (_c *UserSubscriptionCreate) createSpec() (*UserSubscription, *sqlgraph.Cre
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
- _node.GroupID = nodes[0]
+ _node.GroupID = &nodes[0]
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := _c.mutation.AssignedByUserIDs(); len(nodes) > 0 {
@@ -548,6 +612,22 @@ func (_c *UserSubscriptionCreate) createSpec() (*UserSubscription, *sqlgraph.Cre
}
_spec.Edges = append(_spec.Edges, edge)
}
+ if nodes := _c.mutation.WalletLedgerEntriesIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: usersubscription.WalletLedgerEntriesTable,
+ Columns: []string{usersubscription.WalletLedgerEntriesColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges = append(_spec.Edges, edge)
+ }
return _node, _spec
}
@@ -654,6 +734,12 @@ func (u *UserSubscriptionUpsert) UpdateGroupID() *UserSubscriptionUpsert {
return u
}
+// ClearGroupID clears the value of the "group_id" field.
+func (u *UserSubscriptionUpsert) ClearGroupID() *UserSubscriptionUpsert {
+ u.SetNull(usersubscription.FieldGroupID)
+ return u
+}
+
// SetStartsAt sets the "starts_at" field.
func (u *UserSubscriptionUpsert) SetStartsAt(v time.Time) *UserSubscriptionUpsert {
u.Set(usersubscription.FieldStartsAt, v)
@@ -798,6 +884,72 @@ func (u *UserSubscriptionUpsert) AddMonthlyUsageUsd(v float64) *UserSubscription
return u
}
+// SetWalletBalanceUsd sets the "wallet_balance_usd" field.
+func (u *UserSubscriptionUpsert) SetWalletBalanceUsd(v float64) *UserSubscriptionUpsert {
+ u.Set(usersubscription.FieldWalletBalanceUsd, v)
+ return u
+}
+
+// UpdateWalletBalanceUsd sets the "wallet_balance_usd" field to the value that was provided on create.
+func (u *UserSubscriptionUpsert) UpdateWalletBalanceUsd() *UserSubscriptionUpsert {
+ u.SetExcluded(usersubscription.FieldWalletBalanceUsd)
+ return u
+}
+
+// AddWalletBalanceUsd adds v to the "wallet_balance_usd" field.
+func (u *UserSubscriptionUpsert) AddWalletBalanceUsd(v float64) *UserSubscriptionUpsert {
+ u.Add(usersubscription.FieldWalletBalanceUsd, v)
+ return u
+}
+
+// ClearWalletBalanceUsd clears the value of the "wallet_balance_usd" field.
+func (u *UserSubscriptionUpsert) ClearWalletBalanceUsd() *UserSubscriptionUpsert {
+ u.SetNull(usersubscription.FieldWalletBalanceUsd)
+ return u
+}
+
+// SetWalletInitialUsd sets the "wallet_initial_usd" field.
+func (u *UserSubscriptionUpsert) SetWalletInitialUsd(v float64) *UserSubscriptionUpsert {
+ u.Set(usersubscription.FieldWalletInitialUsd, v)
+ return u
+}
+
+// UpdateWalletInitialUsd sets the "wallet_initial_usd" field to the value that was provided on create.
+func (u *UserSubscriptionUpsert) UpdateWalletInitialUsd() *UserSubscriptionUpsert {
+ u.SetExcluded(usersubscription.FieldWalletInitialUsd)
+ return u
+}
+
+// AddWalletInitialUsd adds v to the "wallet_initial_usd" field.
+func (u *UserSubscriptionUpsert) AddWalletInitialUsd(v float64) *UserSubscriptionUpsert {
+ u.Add(usersubscription.FieldWalletInitialUsd, v)
+ return u
+}
+
+// ClearWalletInitialUsd clears the value of the "wallet_initial_usd" field.
+func (u *UserSubscriptionUpsert) ClearWalletInitialUsd() *UserSubscriptionUpsert {
+ u.SetNull(usersubscription.FieldWalletInitialUsd)
+ return u
+}
+
+// SetLockedRates sets the "locked_rates" field.
+func (u *UserSubscriptionUpsert) SetLockedRates(v map[string]float64) *UserSubscriptionUpsert {
+ u.Set(usersubscription.FieldLockedRates, v)
+ return u
+}
+
+// UpdateLockedRates sets the "locked_rates" field to the value that was provided on create.
+func (u *UserSubscriptionUpsert) UpdateLockedRates() *UserSubscriptionUpsert {
+ u.SetExcluded(usersubscription.FieldLockedRates)
+ return u
+}
+
+// ClearLockedRates clears the value of the "locked_rates" field.
+func (u *UserSubscriptionUpsert) ClearLockedRates() *UserSubscriptionUpsert {
+ u.SetNull(usersubscription.FieldLockedRates)
+ return u
+}
+
// SetAssignedBy sets the "assigned_by" field.
func (u *UserSubscriptionUpsert) SetAssignedBy(v int64) *UserSubscriptionUpsert {
u.Set(usersubscription.FieldAssignedBy, v)
@@ -954,6 +1106,13 @@ func (u *UserSubscriptionUpsertOne) UpdateGroupID() *UserSubscriptionUpsertOne {
})
}
+// ClearGroupID clears the value of the "group_id" field.
+func (u *UserSubscriptionUpsertOne) ClearGroupID() *UserSubscriptionUpsertOne {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.ClearGroupID()
+ })
+}
+
// SetStartsAt sets the "starts_at" field.
func (u *UserSubscriptionUpsertOne) SetStartsAt(v time.Time) *UserSubscriptionUpsertOne {
return u.Update(func(s *UserSubscriptionUpsert) {
@@ -1122,6 +1281,83 @@ func (u *UserSubscriptionUpsertOne) UpdateMonthlyUsageUsd() *UserSubscriptionUps
})
}
+// SetWalletBalanceUsd sets the "wallet_balance_usd" field.
+func (u *UserSubscriptionUpsertOne) SetWalletBalanceUsd(v float64) *UserSubscriptionUpsertOne {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.SetWalletBalanceUsd(v)
+ })
+}
+
+// AddWalletBalanceUsd adds v to the "wallet_balance_usd" field.
+func (u *UserSubscriptionUpsertOne) AddWalletBalanceUsd(v float64) *UserSubscriptionUpsertOne {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.AddWalletBalanceUsd(v)
+ })
+}
+
+// UpdateWalletBalanceUsd sets the "wallet_balance_usd" field to the value that was provided on create.
+func (u *UserSubscriptionUpsertOne) UpdateWalletBalanceUsd() *UserSubscriptionUpsertOne {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.UpdateWalletBalanceUsd()
+ })
+}
+
+// ClearWalletBalanceUsd clears the value of the "wallet_balance_usd" field.
+func (u *UserSubscriptionUpsertOne) ClearWalletBalanceUsd() *UserSubscriptionUpsertOne {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.ClearWalletBalanceUsd()
+ })
+}
+
+// SetWalletInitialUsd sets the "wallet_initial_usd" field.
+func (u *UserSubscriptionUpsertOne) SetWalletInitialUsd(v float64) *UserSubscriptionUpsertOne {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.SetWalletInitialUsd(v)
+ })
+}
+
+// AddWalletInitialUsd adds v to the "wallet_initial_usd" field.
+func (u *UserSubscriptionUpsertOne) AddWalletInitialUsd(v float64) *UserSubscriptionUpsertOne {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.AddWalletInitialUsd(v)
+ })
+}
+
+// UpdateWalletInitialUsd sets the "wallet_initial_usd" field to the value that was provided on create.
+func (u *UserSubscriptionUpsertOne) UpdateWalletInitialUsd() *UserSubscriptionUpsertOne {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.UpdateWalletInitialUsd()
+ })
+}
+
+// ClearWalletInitialUsd clears the value of the "wallet_initial_usd" field.
+func (u *UserSubscriptionUpsertOne) ClearWalletInitialUsd() *UserSubscriptionUpsertOne {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.ClearWalletInitialUsd()
+ })
+}
+
+// SetLockedRates sets the "locked_rates" field.
+func (u *UserSubscriptionUpsertOne) SetLockedRates(v map[string]float64) *UserSubscriptionUpsertOne {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.SetLockedRates(v)
+ })
+}
+
+// UpdateLockedRates sets the "locked_rates" field to the value that was provided on create.
+func (u *UserSubscriptionUpsertOne) UpdateLockedRates() *UserSubscriptionUpsertOne {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.UpdateLockedRates()
+ })
+}
+
+// ClearLockedRates clears the value of the "locked_rates" field.
+func (u *UserSubscriptionUpsertOne) ClearLockedRates() *UserSubscriptionUpsertOne {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.ClearLockedRates()
+ })
+}
+
// SetAssignedBy sets the "assigned_by" field.
func (u *UserSubscriptionUpsertOne) SetAssignedBy(v int64) *UserSubscriptionUpsertOne {
return u.Update(func(s *UserSubscriptionUpsert) {
@@ -1452,6 +1688,13 @@ func (u *UserSubscriptionUpsertBulk) UpdateGroupID() *UserSubscriptionUpsertBulk
})
}
+// ClearGroupID clears the value of the "group_id" field.
+func (u *UserSubscriptionUpsertBulk) ClearGroupID() *UserSubscriptionUpsertBulk {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.ClearGroupID()
+ })
+}
+
// SetStartsAt sets the "starts_at" field.
func (u *UserSubscriptionUpsertBulk) SetStartsAt(v time.Time) *UserSubscriptionUpsertBulk {
return u.Update(func(s *UserSubscriptionUpsert) {
@@ -1620,6 +1863,83 @@ func (u *UserSubscriptionUpsertBulk) UpdateMonthlyUsageUsd() *UserSubscriptionUp
})
}
+// SetWalletBalanceUsd sets the "wallet_balance_usd" field.
+func (u *UserSubscriptionUpsertBulk) SetWalletBalanceUsd(v float64) *UserSubscriptionUpsertBulk {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.SetWalletBalanceUsd(v)
+ })
+}
+
+// AddWalletBalanceUsd adds v to the "wallet_balance_usd" field.
+func (u *UserSubscriptionUpsertBulk) AddWalletBalanceUsd(v float64) *UserSubscriptionUpsertBulk {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.AddWalletBalanceUsd(v)
+ })
+}
+
+// UpdateWalletBalanceUsd sets the "wallet_balance_usd" field to the value that was provided on create.
+func (u *UserSubscriptionUpsertBulk) UpdateWalletBalanceUsd() *UserSubscriptionUpsertBulk {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.UpdateWalletBalanceUsd()
+ })
+}
+
+// ClearWalletBalanceUsd clears the value of the "wallet_balance_usd" field.
+func (u *UserSubscriptionUpsertBulk) ClearWalletBalanceUsd() *UserSubscriptionUpsertBulk {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.ClearWalletBalanceUsd()
+ })
+}
+
+// SetWalletInitialUsd sets the "wallet_initial_usd" field.
+func (u *UserSubscriptionUpsertBulk) SetWalletInitialUsd(v float64) *UserSubscriptionUpsertBulk {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.SetWalletInitialUsd(v)
+ })
+}
+
+// AddWalletInitialUsd adds v to the "wallet_initial_usd" field.
+func (u *UserSubscriptionUpsertBulk) AddWalletInitialUsd(v float64) *UserSubscriptionUpsertBulk {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.AddWalletInitialUsd(v)
+ })
+}
+
+// UpdateWalletInitialUsd sets the "wallet_initial_usd" field to the value that was provided on create.
+func (u *UserSubscriptionUpsertBulk) UpdateWalletInitialUsd() *UserSubscriptionUpsertBulk {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.UpdateWalletInitialUsd()
+ })
+}
+
+// ClearWalletInitialUsd clears the value of the "wallet_initial_usd" field.
+func (u *UserSubscriptionUpsertBulk) ClearWalletInitialUsd() *UserSubscriptionUpsertBulk {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.ClearWalletInitialUsd()
+ })
+}
+
+// SetLockedRates sets the "locked_rates" field.
+func (u *UserSubscriptionUpsertBulk) SetLockedRates(v map[string]float64) *UserSubscriptionUpsertBulk {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.SetLockedRates(v)
+ })
+}
+
+// UpdateLockedRates sets the "locked_rates" field to the value that was provided on create.
+func (u *UserSubscriptionUpsertBulk) UpdateLockedRates() *UserSubscriptionUpsertBulk {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.UpdateLockedRates()
+ })
+}
+
+// ClearLockedRates clears the value of the "locked_rates" field.
+func (u *UserSubscriptionUpsertBulk) ClearLockedRates() *UserSubscriptionUpsertBulk {
+ return u.Update(func(s *UserSubscriptionUpsert) {
+ s.ClearLockedRates()
+ })
+}
+
// SetAssignedBy sets the "assigned_by" field.
func (u *UserSubscriptionUpsertBulk) SetAssignedBy(v int64) *UserSubscriptionUpsertBulk {
return u.Update(func(s *UserSubscriptionUpsert) {
diff --git a/backend/ent/usersubscription_query.go b/backend/ent/usersubscription_query.go
index 288b7b1d04f..17188d501a1 100644
--- a/backend/ent/usersubscription_query.go
+++ b/backend/ent/usersubscription_query.go
@@ -15,6 +15,7 @@ import (
"entgo.io/ent/schema/field"
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/predicate"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
@@ -23,15 +24,16 @@ import (
// UserSubscriptionQuery is the builder for querying UserSubscription entities.
type UserSubscriptionQuery struct {
config
- ctx *QueryContext
- order []usersubscription.OrderOption
- inters []Interceptor
- predicates []predicate.UserSubscription
- withUser *UserQuery
- withGroup *GroupQuery
- withAssignedByUser *UserQuery
- withUsageLogs *UsageLogQuery
- modifiers []func(*sql.Selector)
+ ctx *QueryContext
+ order []usersubscription.OrderOption
+ inters []Interceptor
+ predicates []predicate.UserSubscription
+ withUser *UserQuery
+ withGroup *GroupQuery
+ withAssignedByUser *UserQuery
+ withUsageLogs *UsageLogQuery
+ withWalletLedgerEntries *SubscriptionWalletLedgerQuery
+ modifiers []func(*sql.Selector)
// intermediate query (i.e. traversal path).
sql *sql.Selector
path func(context.Context) (*sql.Selector, error)
@@ -156,6 +158,28 @@ func (_q *UserSubscriptionQuery) QueryUsageLogs() *UsageLogQuery {
return query
}
+// QueryWalletLedgerEntries chains the current query on the "wallet_ledger_entries" edge.
+func (_q *UserSubscriptionQuery) QueryWalletLedgerEntries() *SubscriptionWalletLedgerQuery {
+ query := (&SubscriptionWalletLedgerClient{config: _q.config}).Query()
+ query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
+ if err := _q.prepareQuery(ctx); err != nil {
+ return nil, err
+ }
+ selector := _q.sqlQuery(ctx)
+ if err := selector.Err(); err != nil {
+ return nil, err
+ }
+ step := sqlgraph.NewStep(
+ sqlgraph.From(usersubscription.Table, usersubscription.FieldID, selector),
+ sqlgraph.To(subscriptionwalletledger.Table, subscriptionwalletledger.FieldID),
+ sqlgraph.Edge(sqlgraph.O2M, false, usersubscription.WalletLedgerEntriesTable, usersubscription.WalletLedgerEntriesColumn),
+ )
+ fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
+ return fromU, nil
+ }
+ return query
+}
+
// First returns the first UserSubscription entity from the query.
// Returns a *NotFoundError when no UserSubscription was found.
func (_q *UserSubscriptionQuery) First(ctx context.Context) (*UserSubscription, error) {
@@ -343,15 +367,16 @@ func (_q *UserSubscriptionQuery) Clone() *UserSubscriptionQuery {
return nil
}
return &UserSubscriptionQuery{
- config: _q.config,
- ctx: _q.ctx.Clone(),
- order: append([]usersubscription.OrderOption{}, _q.order...),
- inters: append([]Interceptor{}, _q.inters...),
- predicates: append([]predicate.UserSubscription{}, _q.predicates...),
- withUser: _q.withUser.Clone(),
- withGroup: _q.withGroup.Clone(),
- withAssignedByUser: _q.withAssignedByUser.Clone(),
- withUsageLogs: _q.withUsageLogs.Clone(),
+ config: _q.config,
+ ctx: _q.ctx.Clone(),
+ order: append([]usersubscription.OrderOption{}, _q.order...),
+ inters: append([]Interceptor{}, _q.inters...),
+ predicates: append([]predicate.UserSubscription{}, _q.predicates...),
+ withUser: _q.withUser.Clone(),
+ withGroup: _q.withGroup.Clone(),
+ withAssignedByUser: _q.withAssignedByUser.Clone(),
+ withUsageLogs: _q.withUsageLogs.Clone(),
+ withWalletLedgerEntries: _q.withWalletLedgerEntries.Clone(),
// clone intermediate query.
sql: _q.sql.Clone(),
path: _q.path,
@@ -402,6 +427,17 @@ func (_q *UserSubscriptionQuery) WithUsageLogs(opts ...func(*UsageLogQuery)) *Us
return _q
}
+// WithWalletLedgerEntries tells the query-builder to eager-load the nodes that are connected to
+// the "wallet_ledger_entries" edge. The optional arguments are used to configure the query builder of the edge.
+func (_q *UserSubscriptionQuery) WithWalletLedgerEntries(opts ...func(*SubscriptionWalletLedgerQuery)) *UserSubscriptionQuery {
+ query := (&SubscriptionWalletLedgerClient{config: _q.config}).Query()
+ for _, opt := range opts {
+ opt(query)
+ }
+ _q.withWalletLedgerEntries = query
+ return _q
+}
+
// GroupBy is used to group vertices by one or more fields/columns.
// It is often used with aggregate functions, like: count, max, mean, min, sum.
//
@@ -480,11 +516,12 @@ func (_q *UserSubscriptionQuery) sqlAll(ctx context.Context, hooks ...queryHook)
var (
nodes = []*UserSubscription{}
_spec = _q.querySpec()
- loadedTypes = [4]bool{
+ loadedTypes = [5]bool{
_q.withUser != nil,
_q.withGroup != nil,
_q.withAssignedByUser != nil,
_q.withUsageLogs != nil,
+ _q.withWalletLedgerEntries != nil,
}
)
_spec.ScanValues = func(columns []string) ([]any, error) {
@@ -533,6 +570,15 @@ func (_q *UserSubscriptionQuery) sqlAll(ctx context.Context, hooks ...queryHook)
return nil, err
}
}
+ if query := _q.withWalletLedgerEntries; query != nil {
+ if err := _q.loadWalletLedgerEntries(ctx, query, nodes,
+ func(n *UserSubscription) { n.Edges.WalletLedgerEntries = []*SubscriptionWalletLedger{} },
+ func(n *UserSubscription, e *SubscriptionWalletLedger) {
+ n.Edges.WalletLedgerEntries = append(n.Edges.WalletLedgerEntries, e)
+ }); err != nil {
+ return nil, err
+ }
+ }
return nodes, nil
}
@@ -569,7 +615,10 @@ func (_q *UserSubscriptionQuery) loadGroup(ctx context.Context, query *GroupQuer
ids := make([]int64, 0, len(nodes))
nodeids := make(map[int64][]*UserSubscription)
for i := range nodes {
- fk := nodes[i].GroupID
+ if nodes[i].GroupID == nil {
+ continue
+ }
+ fk := *nodes[i].GroupID
if _, ok := nodeids[fk]; !ok {
ids = append(ids, fk)
}
@@ -659,6 +708,36 @@ func (_q *UserSubscriptionQuery) loadUsageLogs(ctx context.Context, query *Usage
}
return nil
}
+func (_q *UserSubscriptionQuery) loadWalletLedgerEntries(ctx context.Context, query *SubscriptionWalletLedgerQuery, nodes []*UserSubscription, init func(*UserSubscription), assign func(*UserSubscription, *SubscriptionWalletLedger)) error {
+ fks := make([]driver.Value, 0, len(nodes))
+ nodeids := make(map[int64]*UserSubscription)
+ for i := range nodes {
+ fks = append(fks, nodes[i].ID)
+ nodeids[nodes[i].ID] = nodes[i]
+ if init != nil {
+ init(nodes[i])
+ }
+ }
+ if len(query.ctx.Fields) > 0 {
+ query.ctx.AppendFieldOnce(subscriptionwalletledger.FieldSubscriptionID)
+ }
+ query.Where(predicate.SubscriptionWalletLedger(func(s *sql.Selector) {
+ s.Where(sql.InValues(s.C(usersubscription.WalletLedgerEntriesColumn), fks...))
+ }))
+ neighbors, err := query.All(ctx)
+ if err != nil {
+ return err
+ }
+ for _, n := range neighbors {
+ fk := n.SubscriptionID
+ node, ok := nodeids[fk]
+ if !ok {
+ return fmt.Errorf(`unexpected referenced foreign-key "subscription_id" returned %v for node %v`, fk, n.ID)
+ }
+ assign(node, n)
+ }
+ return nil
+}
func (_q *UserSubscriptionQuery) sqlCount(ctx context.Context) (int, error) {
_spec := _q.querySpec()
diff --git a/backend/ent/usersubscription_update.go b/backend/ent/usersubscription_update.go
index 811dae7ede5..4635f262e41 100644
--- a/backend/ent/usersubscription_update.go
+++ b/backend/ent/usersubscription_update.go
@@ -13,6 +13,7 @@ import (
"entgo.io/ent/schema/field"
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/predicate"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
"github.com/Wei-Shaw/sub2api/ent/usagelog"
"github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
@@ -85,6 +86,12 @@ func (_u *UserSubscriptionUpdate) SetNillableGroupID(v *int64) *UserSubscription
return _u
}
+// ClearGroupID clears the value of the "group_id" field.
+func (_u *UserSubscriptionUpdate) ClearGroupID() *UserSubscriptionUpdate {
+ _u.mutation.ClearGroupID()
+ return _u
+}
+
// SetStartsAt sets the "starts_at" field.
func (_u *UserSubscriptionUpdate) SetStartsAt(v time.Time) *UserSubscriptionUpdate {
_u.mutation.SetStartsAt(v)
@@ -250,6 +257,72 @@ func (_u *UserSubscriptionUpdate) AddMonthlyUsageUsd(v float64) *UserSubscriptio
return _u
}
+// SetWalletBalanceUsd sets the "wallet_balance_usd" field.
+func (_u *UserSubscriptionUpdate) SetWalletBalanceUsd(v float64) *UserSubscriptionUpdate {
+ _u.mutation.ResetWalletBalanceUsd()
+ _u.mutation.SetWalletBalanceUsd(v)
+ return _u
+}
+
+// SetNillableWalletBalanceUsd sets the "wallet_balance_usd" field if the given value is not nil.
+func (_u *UserSubscriptionUpdate) SetNillableWalletBalanceUsd(v *float64) *UserSubscriptionUpdate {
+ if v != nil {
+ _u.SetWalletBalanceUsd(*v)
+ }
+ return _u
+}
+
+// AddWalletBalanceUsd adds value to the "wallet_balance_usd" field.
+func (_u *UserSubscriptionUpdate) AddWalletBalanceUsd(v float64) *UserSubscriptionUpdate {
+ _u.mutation.AddWalletBalanceUsd(v)
+ return _u
+}
+
+// ClearWalletBalanceUsd clears the value of the "wallet_balance_usd" field.
+func (_u *UserSubscriptionUpdate) ClearWalletBalanceUsd() *UserSubscriptionUpdate {
+ _u.mutation.ClearWalletBalanceUsd()
+ return _u
+}
+
+// SetWalletInitialUsd sets the "wallet_initial_usd" field.
+func (_u *UserSubscriptionUpdate) SetWalletInitialUsd(v float64) *UserSubscriptionUpdate {
+ _u.mutation.ResetWalletInitialUsd()
+ _u.mutation.SetWalletInitialUsd(v)
+ return _u
+}
+
+// SetNillableWalletInitialUsd sets the "wallet_initial_usd" field if the given value is not nil.
+func (_u *UserSubscriptionUpdate) SetNillableWalletInitialUsd(v *float64) *UserSubscriptionUpdate {
+ if v != nil {
+ _u.SetWalletInitialUsd(*v)
+ }
+ return _u
+}
+
+// AddWalletInitialUsd adds value to the "wallet_initial_usd" field.
+func (_u *UserSubscriptionUpdate) AddWalletInitialUsd(v float64) *UserSubscriptionUpdate {
+ _u.mutation.AddWalletInitialUsd(v)
+ return _u
+}
+
+// ClearWalletInitialUsd clears the value of the "wallet_initial_usd" field.
+func (_u *UserSubscriptionUpdate) ClearWalletInitialUsd() *UserSubscriptionUpdate {
+ _u.mutation.ClearWalletInitialUsd()
+ return _u
+}
+
+// SetLockedRates sets the "locked_rates" field.
+func (_u *UserSubscriptionUpdate) SetLockedRates(v map[string]float64) *UserSubscriptionUpdate {
+ _u.mutation.SetLockedRates(v)
+ return _u
+}
+
+// ClearLockedRates clears the value of the "locked_rates" field.
+func (_u *UserSubscriptionUpdate) ClearLockedRates() *UserSubscriptionUpdate {
+ _u.mutation.ClearLockedRates()
+ return _u
+}
+
// SetAssignedBy sets the "assigned_by" field.
func (_u *UserSubscriptionUpdate) SetAssignedBy(v int64) *UserSubscriptionUpdate {
_u.mutation.SetAssignedBy(v)
@@ -348,6 +421,21 @@ func (_u *UserSubscriptionUpdate) AddUsageLogs(v ...*UsageLog) *UserSubscription
return _u.AddUsageLogIDs(ids...)
}
+// AddWalletLedgerEntryIDs adds the "wallet_ledger_entries" edge to the SubscriptionWalletLedger entity by IDs.
+func (_u *UserSubscriptionUpdate) AddWalletLedgerEntryIDs(ids ...int64) *UserSubscriptionUpdate {
+ _u.mutation.AddWalletLedgerEntryIDs(ids...)
+ return _u
+}
+
+// AddWalletLedgerEntries adds the "wallet_ledger_entries" edges to the SubscriptionWalletLedger entity.
+func (_u *UserSubscriptionUpdate) AddWalletLedgerEntries(v ...*SubscriptionWalletLedger) *UserSubscriptionUpdate {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.AddWalletLedgerEntryIDs(ids...)
+}
+
// Mutation returns the UserSubscriptionMutation object of the builder.
func (_u *UserSubscriptionUpdate) Mutation() *UserSubscriptionMutation {
return _u.mutation
@@ -392,6 +480,27 @@ func (_u *UserSubscriptionUpdate) RemoveUsageLogs(v ...*UsageLog) *UserSubscript
return _u.RemoveUsageLogIDs(ids...)
}
+// ClearWalletLedgerEntries clears all "wallet_ledger_entries" edges to the SubscriptionWalletLedger entity.
+func (_u *UserSubscriptionUpdate) ClearWalletLedgerEntries() *UserSubscriptionUpdate {
+ _u.mutation.ClearWalletLedgerEntries()
+ return _u
+}
+
+// RemoveWalletLedgerEntryIDs removes the "wallet_ledger_entries" edge to SubscriptionWalletLedger entities by IDs.
+func (_u *UserSubscriptionUpdate) RemoveWalletLedgerEntryIDs(ids ...int64) *UserSubscriptionUpdate {
+ _u.mutation.RemoveWalletLedgerEntryIDs(ids...)
+ return _u
+}
+
+// RemoveWalletLedgerEntries removes "wallet_ledger_entries" edges to SubscriptionWalletLedger entities.
+func (_u *UserSubscriptionUpdate) RemoveWalletLedgerEntries(v ...*SubscriptionWalletLedger) *UserSubscriptionUpdate {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.RemoveWalletLedgerEntryIDs(ids...)
+}
+
// Save executes the query and returns the number of nodes affected by the update operation.
func (_u *UserSubscriptionUpdate) Save(ctx context.Context) (int, error) {
if err := _u.defaults(); err != nil {
@@ -444,9 +553,6 @@ func (_u *UserSubscriptionUpdate) check() error {
if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 {
return errors.New(`ent: clearing a required unique edge "UserSubscription.user"`)
}
- if _u.mutation.GroupCleared() && len(_u.mutation.GroupIDs()) > 0 {
- return errors.New(`ent: clearing a required unique edge "UserSubscription.group"`)
- }
return nil
}
@@ -516,6 +622,30 @@ func (_u *UserSubscriptionUpdate) sqlSave(ctx context.Context) (_node int, err e
if value, ok := _u.mutation.AddedMonthlyUsageUsd(); ok {
_spec.AddField(usersubscription.FieldMonthlyUsageUsd, field.TypeFloat64, value)
}
+ if value, ok := _u.mutation.WalletBalanceUsd(); ok {
+ _spec.SetField(usersubscription.FieldWalletBalanceUsd, field.TypeFloat64, value)
+ }
+ if value, ok := _u.mutation.AddedWalletBalanceUsd(); ok {
+ _spec.AddField(usersubscription.FieldWalletBalanceUsd, field.TypeFloat64, value)
+ }
+ if _u.mutation.WalletBalanceUsdCleared() {
+ _spec.ClearField(usersubscription.FieldWalletBalanceUsd, field.TypeFloat64)
+ }
+ if value, ok := _u.mutation.WalletInitialUsd(); ok {
+ _spec.SetField(usersubscription.FieldWalletInitialUsd, field.TypeFloat64, value)
+ }
+ if value, ok := _u.mutation.AddedWalletInitialUsd(); ok {
+ _spec.AddField(usersubscription.FieldWalletInitialUsd, field.TypeFloat64, value)
+ }
+ if _u.mutation.WalletInitialUsdCleared() {
+ _spec.ClearField(usersubscription.FieldWalletInitialUsd, field.TypeFloat64)
+ }
+ if value, ok := _u.mutation.LockedRates(); ok {
+ _spec.SetField(usersubscription.FieldLockedRates, field.TypeJSON, value)
+ }
+ if _u.mutation.LockedRatesCleared() {
+ _spec.ClearField(usersubscription.FieldLockedRates, field.TypeJSON)
+ }
if value, ok := _u.mutation.AssignedAt(); ok {
_spec.SetField(usersubscription.FieldAssignedAt, field.TypeTime, value)
}
@@ -657,6 +787,51 @@ func (_u *UserSubscriptionUpdate) sqlSave(ctx context.Context) (_node int, err e
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
+ if _u.mutation.WalletLedgerEntriesCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: usersubscription.WalletLedgerEntriesTable,
+ Columns: []string{usersubscription.WalletLedgerEntriesColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.RemovedWalletLedgerEntriesIDs(); len(nodes) > 0 && !_u.mutation.WalletLedgerEntriesCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: usersubscription.WalletLedgerEntriesTable,
+ Columns: []string{usersubscription.WalletLedgerEntriesColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.WalletLedgerEntriesIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: usersubscription.WalletLedgerEntriesTable,
+ Columns: []string{usersubscription.WalletLedgerEntriesColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil {
if _, ok := err.(*sqlgraph.NotFoundError); ok {
err = &NotFoundError{usersubscription.Label}
@@ -731,6 +906,12 @@ func (_u *UserSubscriptionUpdateOne) SetNillableGroupID(v *int64) *UserSubscript
return _u
}
+// ClearGroupID clears the value of the "group_id" field.
+func (_u *UserSubscriptionUpdateOne) ClearGroupID() *UserSubscriptionUpdateOne {
+ _u.mutation.ClearGroupID()
+ return _u
+}
+
// SetStartsAt sets the "starts_at" field.
func (_u *UserSubscriptionUpdateOne) SetStartsAt(v time.Time) *UserSubscriptionUpdateOne {
_u.mutation.SetStartsAt(v)
@@ -896,6 +1077,72 @@ func (_u *UserSubscriptionUpdateOne) AddMonthlyUsageUsd(v float64) *UserSubscrip
return _u
}
+// SetWalletBalanceUsd sets the "wallet_balance_usd" field.
+func (_u *UserSubscriptionUpdateOne) SetWalletBalanceUsd(v float64) *UserSubscriptionUpdateOne {
+ _u.mutation.ResetWalletBalanceUsd()
+ _u.mutation.SetWalletBalanceUsd(v)
+ return _u
+}
+
+// SetNillableWalletBalanceUsd sets the "wallet_balance_usd" field if the given value is not nil.
+func (_u *UserSubscriptionUpdateOne) SetNillableWalletBalanceUsd(v *float64) *UserSubscriptionUpdateOne {
+ if v != nil {
+ _u.SetWalletBalanceUsd(*v)
+ }
+ return _u
+}
+
+// AddWalletBalanceUsd adds value to the "wallet_balance_usd" field.
+func (_u *UserSubscriptionUpdateOne) AddWalletBalanceUsd(v float64) *UserSubscriptionUpdateOne {
+ _u.mutation.AddWalletBalanceUsd(v)
+ return _u
+}
+
+// ClearWalletBalanceUsd clears the value of the "wallet_balance_usd" field.
+func (_u *UserSubscriptionUpdateOne) ClearWalletBalanceUsd() *UserSubscriptionUpdateOne {
+ _u.mutation.ClearWalletBalanceUsd()
+ return _u
+}
+
+// SetWalletInitialUsd sets the "wallet_initial_usd" field.
+func (_u *UserSubscriptionUpdateOne) SetWalletInitialUsd(v float64) *UserSubscriptionUpdateOne {
+ _u.mutation.ResetWalletInitialUsd()
+ _u.mutation.SetWalletInitialUsd(v)
+ return _u
+}
+
+// SetNillableWalletInitialUsd sets the "wallet_initial_usd" field if the given value is not nil.
+func (_u *UserSubscriptionUpdateOne) SetNillableWalletInitialUsd(v *float64) *UserSubscriptionUpdateOne {
+ if v != nil {
+ _u.SetWalletInitialUsd(*v)
+ }
+ return _u
+}
+
+// AddWalletInitialUsd adds value to the "wallet_initial_usd" field.
+func (_u *UserSubscriptionUpdateOne) AddWalletInitialUsd(v float64) *UserSubscriptionUpdateOne {
+ _u.mutation.AddWalletInitialUsd(v)
+ return _u
+}
+
+// ClearWalletInitialUsd clears the value of the "wallet_initial_usd" field.
+func (_u *UserSubscriptionUpdateOne) ClearWalletInitialUsd() *UserSubscriptionUpdateOne {
+ _u.mutation.ClearWalletInitialUsd()
+ return _u
+}
+
+// SetLockedRates sets the "locked_rates" field.
+func (_u *UserSubscriptionUpdateOne) SetLockedRates(v map[string]float64) *UserSubscriptionUpdateOne {
+ _u.mutation.SetLockedRates(v)
+ return _u
+}
+
+// ClearLockedRates clears the value of the "locked_rates" field.
+func (_u *UserSubscriptionUpdateOne) ClearLockedRates() *UserSubscriptionUpdateOne {
+ _u.mutation.ClearLockedRates()
+ return _u
+}
+
// SetAssignedBy sets the "assigned_by" field.
func (_u *UserSubscriptionUpdateOne) SetAssignedBy(v int64) *UserSubscriptionUpdateOne {
_u.mutation.SetAssignedBy(v)
@@ -994,6 +1241,21 @@ func (_u *UserSubscriptionUpdateOne) AddUsageLogs(v ...*UsageLog) *UserSubscript
return _u.AddUsageLogIDs(ids...)
}
+// AddWalletLedgerEntryIDs adds the "wallet_ledger_entries" edge to the SubscriptionWalletLedger entity by IDs.
+func (_u *UserSubscriptionUpdateOne) AddWalletLedgerEntryIDs(ids ...int64) *UserSubscriptionUpdateOne {
+ _u.mutation.AddWalletLedgerEntryIDs(ids...)
+ return _u
+}
+
+// AddWalletLedgerEntries adds the "wallet_ledger_entries" edges to the SubscriptionWalletLedger entity.
+func (_u *UserSubscriptionUpdateOne) AddWalletLedgerEntries(v ...*SubscriptionWalletLedger) *UserSubscriptionUpdateOne {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.AddWalletLedgerEntryIDs(ids...)
+}
+
// Mutation returns the UserSubscriptionMutation object of the builder.
func (_u *UserSubscriptionUpdateOne) Mutation() *UserSubscriptionMutation {
return _u.mutation
@@ -1038,6 +1300,27 @@ func (_u *UserSubscriptionUpdateOne) RemoveUsageLogs(v ...*UsageLog) *UserSubscr
return _u.RemoveUsageLogIDs(ids...)
}
+// ClearWalletLedgerEntries clears all "wallet_ledger_entries" edges to the SubscriptionWalletLedger entity.
+func (_u *UserSubscriptionUpdateOne) ClearWalletLedgerEntries() *UserSubscriptionUpdateOne {
+ _u.mutation.ClearWalletLedgerEntries()
+ return _u
+}
+
+// RemoveWalletLedgerEntryIDs removes the "wallet_ledger_entries" edge to SubscriptionWalletLedger entities by IDs.
+func (_u *UserSubscriptionUpdateOne) RemoveWalletLedgerEntryIDs(ids ...int64) *UserSubscriptionUpdateOne {
+ _u.mutation.RemoveWalletLedgerEntryIDs(ids...)
+ return _u
+}
+
+// RemoveWalletLedgerEntries removes "wallet_ledger_entries" edges to SubscriptionWalletLedger entities.
+func (_u *UserSubscriptionUpdateOne) RemoveWalletLedgerEntries(v ...*SubscriptionWalletLedger) *UserSubscriptionUpdateOne {
+ ids := make([]int64, len(v))
+ for i := range v {
+ ids[i] = v[i].ID
+ }
+ return _u.RemoveWalletLedgerEntryIDs(ids...)
+}
+
// Where appends a list predicates to the UserSubscriptionUpdate builder.
func (_u *UserSubscriptionUpdateOne) Where(ps ...predicate.UserSubscription) *UserSubscriptionUpdateOne {
_u.mutation.Where(ps...)
@@ -1103,9 +1386,6 @@ func (_u *UserSubscriptionUpdateOne) check() error {
if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 {
return errors.New(`ent: clearing a required unique edge "UserSubscription.user"`)
}
- if _u.mutation.GroupCleared() && len(_u.mutation.GroupIDs()) > 0 {
- return errors.New(`ent: clearing a required unique edge "UserSubscription.group"`)
- }
return nil
}
@@ -1192,6 +1472,30 @@ func (_u *UserSubscriptionUpdateOne) sqlSave(ctx context.Context) (_node *UserSu
if value, ok := _u.mutation.AddedMonthlyUsageUsd(); ok {
_spec.AddField(usersubscription.FieldMonthlyUsageUsd, field.TypeFloat64, value)
}
+ if value, ok := _u.mutation.WalletBalanceUsd(); ok {
+ _spec.SetField(usersubscription.FieldWalletBalanceUsd, field.TypeFloat64, value)
+ }
+ if value, ok := _u.mutation.AddedWalletBalanceUsd(); ok {
+ _spec.AddField(usersubscription.FieldWalletBalanceUsd, field.TypeFloat64, value)
+ }
+ if _u.mutation.WalletBalanceUsdCleared() {
+ _spec.ClearField(usersubscription.FieldWalletBalanceUsd, field.TypeFloat64)
+ }
+ if value, ok := _u.mutation.WalletInitialUsd(); ok {
+ _spec.SetField(usersubscription.FieldWalletInitialUsd, field.TypeFloat64, value)
+ }
+ if value, ok := _u.mutation.AddedWalletInitialUsd(); ok {
+ _spec.AddField(usersubscription.FieldWalletInitialUsd, field.TypeFloat64, value)
+ }
+ if _u.mutation.WalletInitialUsdCleared() {
+ _spec.ClearField(usersubscription.FieldWalletInitialUsd, field.TypeFloat64)
+ }
+ if value, ok := _u.mutation.LockedRates(); ok {
+ _spec.SetField(usersubscription.FieldLockedRates, field.TypeJSON, value)
+ }
+ if _u.mutation.LockedRatesCleared() {
+ _spec.ClearField(usersubscription.FieldLockedRates, field.TypeJSON)
+ }
if value, ok := _u.mutation.AssignedAt(); ok {
_spec.SetField(usersubscription.FieldAssignedAt, field.TypeTime, value)
}
@@ -1333,6 +1637,51 @@ func (_u *UserSubscriptionUpdateOne) sqlSave(ctx context.Context) (_node *UserSu
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
+ if _u.mutation.WalletLedgerEntriesCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: usersubscription.WalletLedgerEntriesTable,
+ Columns: []string{usersubscription.WalletLedgerEntriesColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.RemovedWalletLedgerEntriesIDs(); len(nodes) > 0 && !_u.mutation.WalletLedgerEntriesCleared() {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: usersubscription.WalletLedgerEntriesTable,
+ Columns: []string{usersubscription.WalletLedgerEntriesColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Clear = append(_spec.Edges.Clear, edge)
+ }
+ if nodes := _u.mutation.WalletLedgerEntriesIDs(); len(nodes) > 0 {
+ edge := &sqlgraph.EdgeSpec{
+ Rel: sqlgraph.O2M,
+ Inverse: false,
+ Table: usersubscription.WalletLedgerEntriesTable,
+ Columns: []string{usersubscription.WalletLedgerEntriesColumn},
+ Bidi: false,
+ Target: &sqlgraph.EdgeTarget{
+ IDSpec: sqlgraph.NewFieldSpec(subscriptionwalletledger.FieldID, field.TypeInt64),
+ },
+ }
+ for _, k := range nodes {
+ edge.Target.Nodes = append(edge.Target.Nodes, k)
+ }
+ _spec.Edges.Add = append(_spec.Edges.Add, edge)
+ }
_node = &UserSubscription{config: _u.config}
_spec.Assign = _node.assignValues
_spec.ScanValues = _node.scanValues
diff --git a/backend/go.mod b/backend/go.mod
index 982bf91b916..840e07cb361 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -1,25 +1,27 @@
module github.com/Wei-Shaw/sub2api
-go 1.26.2
+go 1.26.5
require (
entgo.io/ent v0.14.5
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/alitto/pond/v2 v2.6.2
github.com/andybalholm/brotli v1.2.0
- github.com/aws/aws-sdk-go-v2 v1.41.3
+ github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.32.10
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
- github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
github.com/cespare/xxhash/v2 v2.3.0
github.com/coder/websocket v1.8.14
github.com/dgraph-io/ristretto v0.2.0
+ github.com/fsnotify/fsnotify v1.7.0
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0
github.com/gorilla/websocket v1.5.3
github.com/imroc/req/v3 v3.57.0
+ github.com/klauspost/compress v1.18.2
github.com/lib/pq v1.10.9
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.5.0
@@ -39,11 +41,11 @@ require (
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
github.com/zeromicro/go-zero v1.9.4
go.uber.org/zap v1.24.0
- golang.org/x/crypto v0.49.0
+ golang.org/x/crypto v0.51.0
golang.org/x/image v0.39.0
- golang.org/x/net v0.52.0
+ golang.org/x/net v0.55.0
golang.org/x/sync v0.20.0
- golang.org/x/term v0.41.0
+ golang.org/x/term v0.43.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.44.3
@@ -56,16 +58,16 @@ require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/agext/levenshtein v1.2.3 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
- github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
- github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
- github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
- github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 // indirect
- github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 // indirect
@@ -91,7 +93,6 @@ require (
github.com/ebitengine/purego v0.8.4 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
- github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
@@ -104,13 +105,11 @@ require (
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
- github.com/google/subcommands v1.2.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
github.com/icholy/digest v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
- github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
@@ -173,10 +172,10 @@ require (
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
- golang.org/x/mod v0.34.0 // indirect
- golang.org/x/sys v0.42.0 // indirect
- golang.org/x/text v0.36.0 // indirect
- golang.org/x/tools v0.43.0 // indirect
+ golang.org/x/mod v0.35.0 // indirect
+ golang.org/x/sys v0.45.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
+ golang.org/x/tools v0.44.0 // indirect
google.golang.org/grpc v1.75.1 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
diff --git a/backend/go.sum b/backend/go.sum
index 0f366ee1079..22019427321 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -22,34 +22,34 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
-github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
-github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
-github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
+github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
+github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI=
github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 h1:Ii4s+Sq3yDfaMLpjrJsqD6SmG/Wq/P5L/hw2qa78UAY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18/go.mod h1:6x81qnY++ovptLE6nWQeWrpXxbnlIex+4H4eYYGcqfc=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
-github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
-github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18 h1:eZioDaZGJ0tMM4gzmkNIO2aAoQd+je7Ug7TkvAzlmkU=
-github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.18/go.mod h1:CCXwUKAJdoWr6/NcxZ+zsiPr6oH/Q5aTooRGYieAyj4=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5 h1:CeY9LUdur+Dxoeldqoun6y4WtJ3RQtzk0JMP2gfUay0=
-github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.5/go.mod h1:AZLZf2fMaahW5s/wMRciu1sYbdsikT/UHwbUjOdEVTc=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10 h1:fJvQ5mIBVfKtiyx0AHY6HeWcRX5LGANLpq8SVR+Uazs=
-github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.10/go.mod h1:Kzm5e6OmNH8VMkgK9t+ry5jEih4Y8whqs+1hrkxim1I=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18 h1:LTRCYFlnnKFlKsyIQxKhJuDuA3ZkrDQMRYm6rXiHlLY=
-github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.18/go.mod h1:XhwkgGG6bHSd00nO/mexWTcTjgd6PjuvWQMqSn2UaEk=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18 h1:/A/xDuZAVD2BpsS2fftFRo/NoEKQJ8YTnJDEHBy2Gtg=
-github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.18/go.mod h1:hWe9b4f+djUQGmyiGEeOnZv69dtMSgpDRIvNMvuvzvY=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2 h1:M1A9AjcFwlxTLuf0Faj88L8Iqw0n/AJHjpZTQzMMsSc=
-github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2/go.mod h1:KsdTV6Q9WKUZm2mNJnUFmIoXfZux91M3sr/a4REX8e0=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6 h1:MzORe+J94I+hYu2a6XmV5yC9huoTv8NRcCrUNedDypQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.6/go.mod h1:hXzcHLARD7GeWnifd8j9RWqtfIgxj4/cAtIVIK7hg8g=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.11 h1:7oGD8KPfBOJGXiCoRKrrrQkbvCp8N++u36hrLMPey6o=
@@ -183,8 +183,6 @@ github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI=
github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00=
-github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
-github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -413,16 +411,16 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
-golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
-golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
+golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
+golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
-golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
-golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
-golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
-golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
+golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
+golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -434,16 +432,16 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
-golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
-golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
-golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
-golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
+golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
-golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
-golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
+golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
+golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU=
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index 87263db09e6..2d0723e29fc 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"fmt"
"log/slog"
+ "math"
"net/url"
"os"
"strings"
@@ -57,6 +58,15 @@ const (
// 可通过 gateway.upstream_response_read_max_bytes 配置项覆盖。
const DefaultUpstreamResponseReadMaxBytes int64 = 128 * 1024 * 1024
+const (
+ // DefaultGatewayMaxLineSize bounds one untrusted SSE token while retaining
+ // room for large tool-call payloads.
+ DefaultGatewayMaxLineSize = 8 * 1024 * 1024
+ // MaxGatewayMaxLineSize is a hard safety ceiling. A higher configured value
+ // would permit a single upstream line to consume excessive heap per stream.
+ MaxGatewayMaxLineSize = 16 * 1024 * 1024
+)
+
type Config struct {
Server ServerConfig `mapstructure:"server"`
Log LogConfig `mapstructure:"log"`
@@ -69,13 +79,19 @@ type Config struct {
Ops OpsConfig `mapstructure:"ops"`
JWT JWTConfig `mapstructure:"jwt"`
Totp TotpConfig `mapstructure:"totp"`
+ SecretEncryption SecretEncryptionConfig `mapstructure:"secret_encryption"`
+ APIKey APIKeyConfig `mapstructure:"api_key"`
LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
WeChat WeChatConnectConfig `mapstructure:"wechat_connect"`
OIDC OIDCConnectConfig `mapstructure:"oidc_connect"`
+ GitHubOAuth EmailOAuthProviderConfig `mapstructure:"github_oauth"`
+ GoogleOAuth EmailOAuthProviderConfig `mapstructure:"google_oauth"`
Default DefaultConfig `mapstructure:"default"`
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
Pricing PricingConfig `mapstructure:"pricing"`
Gateway GatewayConfig `mapstructure:"gateway"`
+ Kiro KiroConfig `mapstructure:"kiro"`
+ Cursor CursorConfig `mapstructure:"cursor"`
APIKeyAuth APIKeyAuthCacheConfig `mapstructure:"api_key_auth_cache"`
SubscriptionCache SubscriptionCacheConfig `mapstructure:"subscription_cache"`
SubscriptionMaintenance SubscriptionMaintenanceConfig `mapstructure:"subscription_maintenance"`
@@ -171,6 +187,43 @@ type IdempotencyConfig struct {
CleanupBatchSize int `mapstructure:"cleanup_batch_size"`
}
+type KiroConfig struct {
+ // Enabled is the global kill switch for the Kiro channel. It defaults to
+ // false so production deploys can carry the code without exposing traffic.
+ Enabled bool `mapstructure:"enabled"`
+ // RouteEnabled controls the dedicated /kiro routes independently from the
+ // global switch. Both flags must be true before Kiro routes are registered.
+ RouteEnabled bool `mapstructure:"route_enabled"`
+ // AutoRouteOnV1 is reserved for a later cutover where /v1 can dispatch a
+ // kiro group automatically. The first rollout keeps it false.
+ AutoRouteOnV1 bool `mapstructure:"auto_route_on_v1"`
+ // SidecarURL points at the local Kiro sidecar once it exists.
+ SidecarURL string `mapstructure:"sidecar_url"`
+ // MaxConcurrency caps sidecar requests when forwarding is implemented.
+ MaxConcurrency int `mapstructure:"max_concurrency"`
+ // RequestTimeoutSeconds is the Kiro sidecar request timeout.
+ RequestTimeoutSeconds int `mapstructure:"request_timeout_seconds"`
+}
+
+type CursorConfig struct {
+ // Enabled is the global kill switch for the Cursor upstream channel.
+ Enabled bool `mapstructure:"enabled"`
+ // RouteEnabled controls the dedicated /cursor routes independently from the
+ // global switch. The first rollout keeps shared /v1 routing disabled.
+ RouteEnabled bool `mapstructure:"route_enabled"`
+ // AutoRouteOnV1 is reserved for a later cutover where /v1 can dispatch a
+ // cursor group automatically. It must stay false for the canary phase.
+ AutoRouteOnV1 bool `mapstructure:"auto_route_on_v1"`
+ // SidecarURL points at the local Cursor sidecar.
+ SidecarURL string `mapstructure:"sidecar_url"`
+ // SidecarAPIKey is an internal-only secret passed from sub2api to the sidecar.
+ SidecarAPIKey string `mapstructure:"sidecar_api_key"`
+ // MaxConcurrency caps sidecar requests.
+ MaxConcurrency int `mapstructure:"max_concurrency"`
+ // RequestTimeoutSeconds is the Cursor sidecar request timeout.
+ RequestTimeoutSeconds int `mapstructure:"request_timeout_seconds"`
+}
+
type LinuxDoConnectConfig struct {
Enabled bool `mapstructure:"enabled"`
ClientID string `mapstructure:"client_id"`
@@ -240,6 +293,19 @@ type OIDCConnectConfig struct {
UserInfoUsernamePath string `mapstructure:"userinfo_username_path"`
}
+type EmailOAuthProviderConfig struct {
+ Enabled bool `mapstructure:"enabled"`
+ ClientID string `mapstructure:"client_id"`
+ ClientSecret string `mapstructure:"client_secret"`
+ AuthorizeURL string `mapstructure:"authorize_url"`
+ TokenURL string `mapstructure:"token_url"`
+ UserInfoURL string `mapstructure:"userinfo_url"`
+ EmailsURL string `mapstructure:"emails_url"`
+ Scopes string `mapstructure:"scopes"`
+ RedirectURL string `mapstructure:"redirect_url"`
+ FrontendRedirectURL string `mapstructure:"frontend_redirect_url"`
+}
+
const (
defaultWeChatConnectMode = "open"
defaultWeChatConnectScopes = "snsapi_login"
@@ -524,7 +590,9 @@ type SecurityConfig struct {
}
type URLAllowlistConfig struct {
- Enabled bool `mapstructure:"enabled"`
+ Enabled bool `mapstructure:"enabled"`
+ // UpstreamHosts is retained for backward-compatible config parsing.
+ // Upstream account base_url values are no longer host-allowlisted.
UpstreamHosts []string `mapstructure:"upstream_hosts"`
PricingHosts []string `mapstructure:"pricing_hosts"`
CRSHosts []string `mapstructure:"crs_hosts"`
@@ -575,6 +643,24 @@ type ConcurrencyConfig struct {
PingInterval int `mapstructure:"ping_interval"`
}
+type ImageConcurrencyConfig struct {
+ // Enabled: 是否启用图片生成独立并发限制,默认关闭以保持现有行为
+ Enabled bool `mapstructure:"enabled"`
+ // MaxConcurrentRequests: 当前进程允许同时处理的图片生成请求数,0表示不限制
+ MaxConcurrentRequests int `mapstructure:"max_concurrent_requests"`
+ // OverflowMode: 图片并发达到上限后的处理方式:reject/wait
+ OverflowMode string `mapstructure:"overflow_mode"`
+ // WaitTimeoutSeconds: overflow_mode=wait 时等待图片并发槽位的超时时间(秒)
+ WaitTimeoutSeconds int `mapstructure:"wait_timeout_seconds"`
+ // MaxWaitingRequests: overflow_mode=wait 时当前进程允许排队等待的图片请求数
+ MaxWaitingRequests int `mapstructure:"max_waiting_requests"`
+}
+
+const (
+ ImageConcurrencyOverflowModeReject = "reject"
+ ImageConcurrencyOverflowModeWait = "wait"
+)
+
// GatewayConfig API网关相关配置
type GatewayConfig struct {
// 等待上游响应头的超时时间(秒),0表示无超时
@@ -593,6 +679,9 @@ type GatewayConfig struct {
// ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。
// 用于网关未透传/改写 User-Agent 时的兼容兜底(默认关闭,避免影响其他客户端)。
ForceCodexCLI bool `mapstructure:"force_codex_cli"`
+ // CodexImageGenerationBridgeEnabled: 是否为 Codex `/v1/responses` 自动注入 image_generation 工具和桥接指令。
+ // 默认关闭,避免纯文本 Codex 请求被意外改写;显式携带 image_generation 工具的请求仍按分组能力转发。
+ CodexImageGenerationBridgeEnabled bool `mapstructure:"codex_image_generation_bridge_enabled"`
// ForcedCodexInstructionsTemplateFile: 服务端强制附加到 Codex 顶层 instructions 的模板文件路径。
// 模板渲染后会直接覆盖最终 instructions;若需要保留客户端 system 转换结果,请在模板中显式引用 {{ .ExistingInstructions }}。
ForcedCodexInstructionsTemplateFile string `mapstructure:"forced_codex_instructions_template_file"`
@@ -604,6 +693,8 @@ type GatewayConfig struct {
OpenAIPassthroughAllowTimeoutHeaders bool `mapstructure:"openai_passthrough_allow_timeout_headers"`
// OpenAIWS: OpenAI Responses WebSocket 配置(默认开启,可按需回滚到 HTTP)
OpenAIWS GatewayOpenAIWSConfig `mapstructure:"openai_ws"`
+ // ImageConcurrency: 图片生成独立并发限制配置(默认关闭)
+ ImageConcurrency ImageConcurrencyConfig `mapstructure:"image_concurrency"`
// HTTP 上游连接池配置(性能优化:支持高并发场景调优)
// MaxIdleConns: 所有主机的最大空闲连接总数
@@ -635,6 +726,10 @@ type GatewayConfig struct {
StreamDataIntervalTimeout int `mapstructure:"stream_data_interval_timeout"`
// StreamKeepaliveInterval: 流式 keepalive 间隔(秒),0表示禁用
StreamKeepaliveInterval int `mapstructure:"stream_keepalive_interval"`
+ // ImageStreamDataIntervalTimeout: 图片流数据间隔超时(秒),0表示禁用
+ ImageStreamDataIntervalTimeout int `mapstructure:"image_stream_data_interval_timeout"`
+ // ImageStreamKeepaliveInterval: 图片流式 keepalive 间隔(秒),0表示禁用
+ ImageStreamKeepaliveInterval int `mapstructure:"image_stream_keepalive_interval"`
// MaxLineSize: 上游 SSE 单行最大字节数(0使用默认值)
MaxLineSize int `mapstructure:"max_line_size"`
@@ -1071,14 +1166,38 @@ type JWTConfig struct {
// TotpConfig TOTP 双因素认证配置
type TotpConfig struct {
- // EncryptionKey 用于加密 TOTP 密钥的 AES-256 密钥(32 字节 hex 编码)
- // 如果为空,将自动生成一个随机密钥(仅适用于开发环境)
+ // EncryptionKey is the optional legacy v1/v2 root retained only while
+ // startup migration or payment-resume legacy verification still needs it.
EncryptionKey string `mapstructure:"encryption_key"`
- // EncryptionKeyConfigured 标记加密密钥是否为手动配置(非自动生成)
- // 只有手动配置了密钥才允许在管理后台启用 TOTP 功能
+ // EncryptionKeyConfigured records whether the legacy root was explicit.
EncryptionKeyConfigured bool `mapstructure:"-"`
}
+// SecretEncryptionConfig contains independent roots for unrelated application
+// secret domains. The legacy TOTP key is deliberately not a fallback for any
+// of these values; it may only be used by startup migration code.
+type SecretEncryptionConfig struct {
+ TOTPSecretKey string `mapstructure:"totp_secret_key"`
+ TOTPCacheKey string `mapstructure:"totp_cache_key"`
+ AccountCredentialKey string `mapstructure:"account_credential_key"`
+ BackupS3Key string `mapstructure:"backup_s3_key"`
+ ContentModerationKey string `mapstructure:"content_moderation_key"`
+ ChannelMonitorKey string `mapstructure:"channel_monitor_key"`
+ PaymentProviderKey string `mapstructure:"payment_provider_key"`
+ ProxyCredentialKey string `mapstructure:"proxy_credential_key"`
+ SchedulerCacheKey string `mapstructure:"scheduler_cache_key"`
+ OAuthTokenCacheKey string `mapstructure:"oauth_token_cache_key"`
+ JWTHMACKey string `mapstructure:"jwt_hmac_key"`
+ SettingSecretKey string `mapstructure:"setting_secret_key"`
+}
+
+// APIKeyConfig contains the independent master key used to derive the
+// API-key AES-GCM and HMAC lookup subkeys.
+type APIKeyConfig struct {
+ EncryptionKey string `mapstructure:"encryption_key"`
+ EncryptionKeyConfigured bool `mapstructure:"-"`
+}
+
type TurnstileConfig struct {
Required bool `mapstructure:"required"`
}
@@ -1286,6 +1405,9 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
cfg.Log.StacktraceLevel = strings.ToLower(strings.TrimSpace(cfg.Log.StacktraceLevel))
cfg.Log.Output.FilePath = strings.TrimSpace(cfg.Log.Output.FilePath)
cfg.Gateway.ForcedCodexInstructionsTemplateFile = strings.TrimSpace(cfg.Gateway.ForcedCodexInstructionsTemplateFile)
+ cfg.Kiro.SidecarURL = strings.TrimSpace(cfg.Kiro.SidecarURL)
+ cfg.Cursor.SidecarURL = strings.TrimSpace(cfg.Cursor.SidecarURL)
+ cfg.Cursor.SidecarAPIKey = strings.TrimSpace(cfg.Cursor.SidecarAPIKey)
if cfg.Gateway.ForcedCodexInstructionsTemplateFile != "" {
content, err := os.ReadFile(cfg.Gateway.ForcedCodexInstructionsTemplateFile)
if err != nil {
@@ -1308,19 +1430,42 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
cfg.Gateway.UserMessageQueue.Mode = ""
}
- // Auto-generate TOTP encryption key if not set (32 bytes = 64 hex chars for AES-256)
+ // TOTP_ENCRYPTION_KEY is migration-only. Never synthesize a replacement:
+ // random fallback cannot decrypt legacy data and obscures key retirement.
cfg.Totp.EncryptionKey = strings.TrimSpace(cfg.Totp.EncryptionKey)
if cfg.Totp.EncryptionKey == "" {
- key, err := generateJWTSecret(32) // Reuse the same random generation function
- if err != nil {
- return nil, fmt.Errorf("generate totp encryption key error: %w", err)
- }
- cfg.Totp.EncryptionKey = key
cfg.Totp.EncryptionKeyConfigured = false
- slog.Warn("TOTP encryption key auto-generated. Consider setting a fixed key for production.")
} else {
cfg.Totp.EncryptionKeyConfigured = true
}
+ cfg.SecretEncryption.TOTPSecretKey = strings.TrimSpace(cfg.SecretEncryption.TOTPSecretKey)
+ cfg.SecretEncryption.TOTPCacheKey = strings.TrimSpace(cfg.SecretEncryption.TOTPCacheKey)
+ cfg.SecretEncryption.AccountCredentialKey = strings.TrimSpace(cfg.SecretEncryption.AccountCredentialKey)
+ cfg.SecretEncryption.BackupS3Key = strings.TrimSpace(cfg.SecretEncryption.BackupS3Key)
+ cfg.SecretEncryption.ContentModerationKey = strings.TrimSpace(cfg.SecretEncryption.ContentModerationKey)
+ cfg.SecretEncryption.ChannelMonitorKey = strings.TrimSpace(cfg.SecretEncryption.ChannelMonitorKey)
+ cfg.SecretEncryption.PaymentProviderKey = strings.TrimSpace(cfg.SecretEncryption.PaymentProviderKey)
+ cfg.SecretEncryption.ProxyCredentialKey = strings.TrimSpace(cfg.SecretEncryption.ProxyCredentialKey)
+ cfg.SecretEncryption.SchedulerCacheKey = strings.TrimSpace(cfg.SecretEncryption.SchedulerCacheKey)
+ cfg.SecretEncryption.OAuthTokenCacheKey = strings.TrimSpace(cfg.SecretEncryption.OAuthTokenCacheKey)
+ cfg.SecretEncryption.JWTHMACKey = strings.TrimSpace(cfg.SecretEncryption.JWTHMACKey)
+ cfg.SecretEncryption.SettingSecretKey = strings.TrimSpace(cfg.SecretEncryption.SettingSecretKey)
+
+ // Keep API-key protection independent from TOTP/payment/JWT secrets. A
+ // generated value lets configuration inspection and setup mode proceed, but
+ // the production protector rejects it unless the operator explicitly sets
+ // API_KEY_ENCRYPTION_KEY.
+ cfg.APIKey.EncryptionKey = strings.TrimSpace(cfg.APIKey.EncryptionKey)
+ if cfg.APIKey.EncryptionKey == "" {
+ key, err := generateJWTSecret(32)
+ if err != nil {
+ return nil, fmt.Errorf("generate API key encryption key error: %w", err)
+ }
+ cfg.APIKey.EncryptionKey = key
+ cfg.APIKey.EncryptionKeyConfigured = false
+ } else {
+ cfg.APIKey.EncryptionKeyConfigured = true
+ }
originalJWTSecret := cfg.JWT.Secret
if allowMissingJWTSecret && originalJWTSecret == "" {
@@ -1337,7 +1482,13 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
}
if !cfg.Security.URLAllowlist.Enabled {
- slog.Warn("security.url_allowlist.enabled=false; allowlist/SSRF checks disabled (minimal format validation only).")
+ slog.Warn("security.url_allowlist.enabled=false; pricing/CRS host allowlist checks disabled. Public-address and DNS-rebinding checks remain active unless private hosts are explicitly enabled.")
+ }
+ if cfg.Security.URLAllowlist.AllowPrivateHosts {
+ slog.Warn("security.url_allowlist.allow_private_hosts=true; outbound credential-bearing requests may reach private networks")
+ }
+ if cfg.Security.URLAllowlist.AllowInsecureHTTP {
+ slog.Warn("security.url_allowlist.allow_insecure_http=true; upstream credentials may be sent over plaintext HTTP")
}
if !cfg.Security.ResponseHeaders.Enabled {
slog.Warn("security.response_headers.enabled=false; configurable header filtering disabled (default allowlist only).")
@@ -1415,8 +1566,8 @@ func setDefaults() {
"raw.githubusercontent.com",
})
viper.SetDefault("security.url_allowlist.crs_hosts", []string{})
- viper.SetDefault("security.url_allowlist.allow_private_hosts", true)
- viper.SetDefault("security.url_allowlist.allow_insecure_http", true)
+ viper.SetDefault("security.url_allowlist.allow_private_hosts", false)
+ viper.SetDefault("security.url_allowlist.allow_insecure_http", false)
viper.SetDefault("security.response_headers.enabled", true)
viper.SetDefault("security.response_headers.additional_allowed", []string{})
viper.SetDefault("security.response_headers.force_remove", []string{})
@@ -1541,6 +1692,22 @@ func setDefaults() {
// TOTP
viper.SetDefault("totp.encryption_key", "")
+ // Independent application secret roots. There are intentionally no random
+ // defaults: the server-side encryptor fails closed until all are explicit.
+ viper.SetDefault("secret_encryption.totp_secret_key", "")
+ viper.SetDefault("secret_encryption.totp_cache_key", "")
+ viper.SetDefault("secret_encryption.account_credential_key", "")
+ viper.SetDefault("secret_encryption.backup_s3_key", "")
+ viper.SetDefault("secret_encryption.content_moderation_key", "")
+ viper.SetDefault("secret_encryption.channel_monitor_key", "")
+ viper.SetDefault("secret_encryption.payment_provider_key", "")
+ viper.SetDefault("secret_encryption.proxy_credential_key", "")
+ viper.SetDefault("secret_encryption.scheduler_cache_key", "")
+ viper.SetDefault("secret_encryption.oauth_token_cache_key", "")
+ viper.SetDefault("secret_encryption.jwt_hmac_key", "")
+ viper.SetDefault("secret_encryption.setting_secret_key", "")
+ // Customer API keys (independent from TOTP/JWT/payment secrets)
+ viper.SetDefault("api_key.encryption_key", "")
// Default
// Admin credentials are created via the setup flow (web wizard / CLI / AUTO_SETUP).
@@ -1556,9 +1723,12 @@ func setDefaults() {
viper.SetDefault("rate_limit.overload_cooldown_minutes", 10)
viper.SetDefault("rate_limit.oauth_401_cooldown_minutes", 10)
- // Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据(固定到 commit,避免分支漂移)
- viper.SetDefault("pricing.remote_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.json")
- viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256")
+ // Pricing - defaults are pinned to one reviewed model-price-repo commit so
+ // billing cannot drift when the upstream branch changes. Bump both URLs
+ // together only after validating the SHA-256 manifest.
+ const defaultPricingRepositoryRevision = "2eaf5efd588b52cd9fd435e809a5b58267259553"
+ viper.SetDefault("pricing.remote_url", fmt.Sprintf("https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/%s/model_prices_and_context_window.json", defaultPricingRepositoryRevision))
+ viper.SetDefault("pricing.hash_url", fmt.Sprintf("https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/%s/model_prices_and_context_window.sha256", defaultPricingRepositoryRevision))
viper.SetDefault("pricing.data_dir", "./data")
viper.SetDefault("pricing.fallback_file", "./resources/model-pricing/model_prices_and_context_window.json")
viper.SetDefault("pricing.update_interval_hours", 24)
@@ -1625,6 +1795,7 @@ func setDefaults() {
viper.SetDefault("gateway.max_account_switches", 10)
viper.SetDefault("gateway.max_account_switches_gemini", 3)
viper.SetDefault("gateway.force_codex_cli", false)
+ viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false)
viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false)
// OpenAI Responses WebSocket(默认开启;可通过 force_http 紧急回滚)
viper.SetDefault("gateway.openai_ws.enabled", true)
@@ -1672,6 +1843,11 @@ func setDefaults() {
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.queue", 0.7)
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.error_rate", 0.8)
viper.SetDefault("gateway.openai_ws.scheduler_score_weights.ttft", 0.5)
+ viper.SetDefault("gateway.image_concurrency.enabled", false)
+ viper.SetDefault("gateway.image_concurrency.max_concurrent_requests", 0)
+ viper.SetDefault("gateway.image_concurrency.overflow_mode", ImageConcurrencyOverflowModeReject)
+ viper.SetDefault("gateway.image_concurrency.wait_timeout_seconds", 30)
+ viper.SetDefault("gateway.image_concurrency.max_waiting_requests", 100)
viper.SetDefault("gateway.antigravity_fallback_cooldown_minutes", 1)
viper.SetDefault("gateway.antigravity_extra_retries", 10)
viper.SetDefault("gateway.max_body_size", int64(256*1024*1024))
@@ -1689,7 +1865,9 @@ func setDefaults() {
viper.SetDefault("gateway.concurrency_slot_ttl_minutes", 30) // 并发槽位过期时间(支持超长请求)
viper.SetDefault("gateway.stream_data_interval_timeout", 180)
viper.SetDefault("gateway.stream_keepalive_interval", 10)
- viper.SetDefault("gateway.max_line_size", 500*1024*1024)
+ viper.SetDefault("gateway.image_stream_data_interval_timeout", 900)
+ viper.SetDefault("gateway.image_stream_keepalive_interval", 10)
+ viper.SetDefault("gateway.max_line_size", DefaultGatewayMaxLineSize)
viper.SetDefault("gateway.scheduling.sticky_session_max_waiting", 3)
viper.SetDefault("gateway.scheduling.sticky_session_wait_timeout", 120*time.Second)
viper.SetDefault("gateway.scheduling.fallback_wait_timeout", 30*time.Second)
@@ -1711,7 +1889,9 @@ func setDefaults() {
viper.SetDefault("gateway.usage_record.worker_count", 128)
viper.SetDefault("gateway.usage_record.queue_size", 16384)
viper.SetDefault("gateway.usage_record.task_timeout_seconds", 5)
- viper.SetDefault("gateway.usage_record.overflow_policy", UsageRecordOverflowPolicySample)
+ // Queue pressure must not make successful customer usage free. The runtime
+ // also hardens explicit legacy sample/drop values to synchronous fallback.
+ viper.SetDefault("gateway.usage_record.overflow_policy", UsageRecordOverflowPolicySync)
viper.SetDefault("gateway.usage_record.overflow_sample_percent", 10)
viper.SetDefault("gateway.usage_record.auto_scale_enabled", true)
viper.SetDefault("gateway.usage_record.auto_scale_min_workers", 128)
@@ -1736,6 +1916,20 @@ func setDefaults() {
viper.SetDefault("gateway.tls_fingerprint.enabled", true)
viper.SetDefault("concurrency.ping_interval", 10)
+ viper.SetDefault("kiro.enabled", false)
+ viper.SetDefault("kiro.route_enabled", false)
+ viper.SetDefault("kiro.auto_route_on_v1", false)
+ viper.SetDefault("kiro.sidecar_url", "")
+ viper.SetDefault("kiro.max_concurrency", 1)
+ viper.SetDefault("kiro.request_timeout_seconds", 90)
+ viper.SetDefault("cursor.enabled", false)
+ viper.SetDefault("cursor.route_enabled", false)
+ viper.SetDefault("cursor.auto_route_on_v1", false)
+ viper.SetDefault("cursor.sidecar_url", "")
+ viper.SetDefault("cursor.sidecar_api_key", "")
+ viper.SetDefault("cursor.max_concurrency", 1)
+ viper.SetDefault("cursor.request_timeout_seconds", 90)
+
// TokenRefresh
viper.SetDefault("token_refresh.enabled", true)
viper.SetDefault("token_refresh.check_interval_minutes", 5) // 每5分钟检查一次
@@ -1767,6 +1961,17 @@ func (c *Config) Validate() error {
if len([]byte(jwtSecret)) < 32 {
return fmt.Errorf("jwt.secret must be at least 32 bytes")
}
+ if c.Server.H2C.Enabled {
+ if c.Server.H2C.MaxReadFrameSize < 1<<14 || c.Server.H2C.MaxReadFrameSize > 1<<24-1 {
+ return fmt.Errorf("server.h2c.max_read_frame_size must be between 16384 and 16777215")
+ }
+ if c.Server.H2C.MaxUploadBufferPerConnection < 0 || c.Server.H2C.MaxUploadBufferPerConnection > math.MaxInt32 {
+ return fmt.Errorf("server.h2c.max_upload_buffer_per_connection must fit int32 and be non-negative")
+ }
+ if c.Server.H2C.MaxUploadBufferPerStream < 0 || c.Server.H2C.MaxUploadBufferPerStream > math.MaxInt32 {
+ return fmt.Errorf("server.h2c.max_upload_buffer_per_stream must fit int32 and be non-negative")
+ }
+ }
switch c.Log.Level {
case "debug", "info", "warn", "error":
case "":
@@ -1830,6 +2035,52 @@ func (c *Config) Validate() error {
if (geminiClientID == "") != (geminiClientSecret == "") {
return fmt.Errorf("gemini.oauth.client_id and gemini.oauth.client_secret must be both set or both empty")
}
+ if c.Kiro.Enabled || c.Kiro.RouteEnabled || c.Kiro.AutoRouteOnV1 {
+ if c.Kiro.MaxConcurrency <= 0 {
+ return fmt.Errorf("kiro.max_concurrency must be positive")
+ }
+ if c.Kiro.RequestTimeoutSeconds <= 0 {
+ return fmt.Errorf("kiro.request_timeout_seconds must be positive")
+ }
+ if strings.TrimSpace(c.Kiro.SidecarURL) != "" {
+ if err := ValidateAbsoluteHTTPURL(c.Kiro.SidecarURL); err != nil {
+ return fmt.Errorf("kiro.sidecar_url invalid: %w", err)
+ }
+ u, err := url.Parse(strings.TrimSpace(c.Kiro.SidecarURL))
+ if err != nil {
+ return fmt.Errorf("kiro.sidecar_url invalid: %w", err)
+ }
+ if u.RawQuery != "" || u.ForceQuery {
+ return fmt.Errorf("kiro.sidecar_url invalid: must not include query")
+ }
+ if u.User != nil {
+ return fmt.Errorf("kiro.sidecar_url invalid: must not include userinfo")
+ }
+ }
+ }
+ if c.Cursor.Enabled || c.Cursor.RouteEnabled || c.Cursor.AutoRouteOnV1 {
+ if c.Cursor.MaxConcurrency <= 0 {
+ return fmt.Errorf("cursor.max_concurrency must be positive")
+ }
+ if c.Cursor.RequestTimeoutSeconds <= 0 {
+ return fmt.Errorf("cursor.request_timeout_seconds must be positive")
+ }
+ if strings.TrimSpace(c.Cursor.SidecarURL) != "" {
+ if err := ValidateAbsoluteHTTPURL(c.Cursor.SidecarURL); err != nil {
+ return fmt.Errorf("cursor.sidecar_url invalid: %w", err)
+ }
+ u, err := url.Parse(strings.TrimSpace(c.Cursor.SidecarURL))
+ if err != nil {
+ return fmt.Errorf("cursor.sidecar_url invalid: %w", err)
+ }
+ if u.RawQuery != "" || u.ForceQuery {
+ return fmt.Errorf("cursor.sidecar_url invalid: must not include query")
+ }
+ if u.User != nil {
+ return fmt.Errorf("cursor.sidecar_url invalid: must not include userinfo")
+ }
+ }
+ }
if strings.TrimSpace(c.Server.FrontendURL) != "" {
if err := ValidateAbsoluteHTTPURL(c.Server.FrontendURL); err != nil {
@@ -2239,6 +2490,21 @@ func (c *Config) Validate() error {
ConnectionPoolIsolationProxy, ConnectionPoolIsolationAccount, ConnectionPoolIsolationAccountProxy)
}
}
+ if c.Gateway.ImageConcurrency.MaxConcurrentRequests < 0 {
+ return fmt.Errorf("gateway.image_concurrency.max_concurrent_requests must be non-negative")
+ }
+ switch strings.TrimSpace(c.Gateway.ImageConcurrency.OverflowMode) {
+ case "", ImageConcurrencyOverflowModeReject, ImageConcurrencyOverflowModeWait:
+ default:
+ return fmt.Errorf("gateway.image_concurrency.overflow_mode must be one of: %s/%s",
+ ImageConcurrencyOverflowModeReject, ImageConcurrencyOverflowModeWait)
+ }
+ if c.Gateway.ImageConcurrency.WaitTimeoutSeconds < 0 {
+ return fmt.Errorf("gateway.image_concurrency.wait_timeout_seconds must be non-negative")
+ }
+ if c.Gateway.ImageConcurrency.MaxWaitingRequests < 0 {
+ return fmt.Errorf("gateway.image_concurrency.max_waiting_requests must be non-negative")
+ }
if c.Gateway.MaxIdleConns <= 0 {
return fmt.Errorf("gateway.max_idle_conns must be positive")
}
@@ -2277,6 +2543,20 @@ func (c *Config) Validate() error {
(c.Gateway.StreamKeepaliveInterval < 5 || c.Gateway.StreamKeepaliveInterval > 30) {
return fmt.Errorf("gateway.stream_keepalive_interval must be 0 or between 5-30 seconds")
}
+ if c.Gateway.ImageStreamDataIntervalTimeout < 0 {
+ return fmt.Errorf("gateway.image_stream_data_interval_timeout must be non-negative")
+ }
+ if c.Gateway.ImageStreamDataIntervalTimeout != 0 &&
+ (c.Gateway.ImageStreamDataIntervalTimeout < 60 || c.Gateway.ImageStreamDataIntervalTimeout > 1800) {
+ return fmt.Errorf("gateway.image_stream_data_interval_timeout must be 0 or between 60-1800 seconds")
+ }
+ if c.Gateway.ImageStreamKeepaliveInterval < 0 {
+ return fmt.Errorf("gateway.image_stream_keepalive_interval must be non-negative")
+ }
+ if c.Gateway.ImageStreamKeepaliveInterval != 0 &&
+ (c.Gateway.ImageStreamKeepaliveInterval < 5 || c.Gateway.ImageStreamKeepaliveInterval > 60) {
+ return fmt.Errorf("gateway.image_stream_keepalive_interval must be 0 or between 5-60 seconds")
+ }
// 兼容旧键 sticky_previous_response_ttl_seconds
if c.Gateway.OpenAIWS.StickyResponseIDTTLSeconds <= 0 && c.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds > 0 {
c.Gateway.OpenAIWS.StickyResponseIDTTLSeconds = c.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds
@@ -2397,6 +2677,9 @@ func (c *Config) Validate() error {
if c.Gateway.MaxLineSize != 0 && c.Gateway.MaxLineSize < 1024*1024 {
return fmt.Errorf("gateway.max_line_size must be at least 1MB")
}
+ if c.Gateway.MaxLineSize > MaxGatewayMaxLineSize {
+ return fmt.Errorf("gateway.max_line_size must be at most %d bytes", MaxGatewayMaxLineSize)
+ }
if c.Gateway.UsageRecord.WorkerCount <= 0 {
return fmt.Errorf("gateway.usage_record.worker_count must be positive")
}
diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go
index 6ba86aa1be3..d4656fb8cd6 100644
--- a/backend/internal/config/config_test.go
+++ b/backend/internal/config/config_test.go
@@ -1,6 +1,7 @@
package config
import (
+ "math"
"os"
"path/filepath"
"strings"
@@ -30,6 +31,47 @@ func TestLoadForBootstrapAllowsMissingJWTSecret(t *testing.T) {
}
}
+func TestLoadIndependentSecretEncryptionRootsFromEnvironment(t *testing.T) {
+ resetViperWithJWTSecret(t)
+ t.Setenv("SECRET_ENCRYPTION_TOTP_SECRET_KEY", strings.Repeat("1", 64))
+ t.Setenv("SECRET_ENCRYPTION_TOTP_CACHE_KEY", strings.Repeat("2", 64))
+ t.Setenv("SECRET_ENCRYPTION_ACCOUNT_CREDENTIAL_KEY", strings.Repeat("3", 64))
+ t.Setenv("SECRET_ENCRYPTION_BACKUP_S3_KEY", strings.Repeat("4", 64))
+ t.Setenv("SECRET_ENCRYPTION_CONTENT_MODERATION_KEY", strings.Repeat("5", 64))
+ t.Setenv("SECRET_ENCRYPTION_CHANNEL_MONITOR_KEY", strings.Repeat("6", 64))
+ t.Setenv("SECRET_ENCRYPTION_PAYMENT_PROVIDER_KEY", strings.Repeat("7", 64))
+ t.Setenv("SECRET_ENCRYPTION_PROXY_CREDENTIAL_KEY", strings.Repeat("8", 64))
+ t.Setenv("SECRET_ENCRYPTION_SCHEDULER_CACHE_KEY", strings.Repeat("9", 64))
+ t.Setenv("SECRET_ENCRYPTION_OAUTH_TOKEN_CACHE_KEY", strings.Repeat("b", 64))
+ t.Setenv("SECRET_ENCRYPTION_JWT_HMAC_KEY", strings.Repeat("c", 64))
+ t.Setenv("SECRET_ENCRYPTION_SETTING_SECRET_KEY", strings.Repeat("d", 64))
+
+ cfg, err := Load()
+ require.NoError(t, err)
+ require.Equal(t, strings.Repeat("1", 64), cfg.SecretEncryption.TOTPSecretKey)
+ require.Equal(t, strings.Repeat("2", 64), cfg.SecretEncryption.TOTPCacheKey)
+ require.Equal(t, strings.Repeat("3", 64), cfg.SecretEncryption.AccountCredentialKey)
+ require.Equal(t, strings.Repeat("4", 64), cfg.SecretEncryption.BackupS3Key)
+ require.Equal(t, strings.Repeat("5", 64), cfg.SecretEncryption.ContentModerationKey)
+ require.Equal(t, strings.Repeat("6", 64), cfg.SecretEncryption.ChannelMonitorKey)
+ require.Equal(t, strings.Repeat("7", 64), cfg.SecretEncryption.PaymentProviderKey)
+ require.Equal(t, strings.Repeat("8", 64), cfg.SecretEncryption.ProxyCredentialKey)
+ require.Equal(t, strings.Repeat("9", 64), cfg.SecretEncryption.SchedulerCacheKey)
+ require.Equal(t, strings.Repeat("b", 64), cfg.SecretEncryption.OAuthTokenCacheKey)
+ require.Equal(t, strings.Repeat("c", 64), cfg.SecretEncryption.JWTHMACKey)
+ require.Equal(t, strings.Repeat("d", 64), cfg.SecretEncryption.SettingSecretKey)
+}
+
+func TestLoadDoesNotGenerateLegacyTOTPEncryptionKey(t *testing.T) {
+ resetViperWithJWTSecret(t)
+ t.Setenv("TOTP_ENCRYPTION_KEY", "")
+
+ cfg, err := Load()
+ require.NoError(t, err)
+ require.Empty(t, cfg.Totp.EncryptionKey)
+ require.False(t, cfg.Totp.EncryptionKeyConfigured)
+}
+
func TestNormalizeRunMode(t *testing.T) {
tests := []struct {
input string
@@ -300,11 +342,14 @@ func TestLoadDefaultSecurityToggles(t *testing.T) {
if cfg.Security.URLAllowlist.Enabled {
t.Fatalf("URLAllowlist.Enabled = true, want false")
}
- if !cfg.Security.URLAllowlist.AllowInsecureHTTP {
- t.Fatalf("URLAllowlist.AllowInsecureHTTP = false, want true")
+ if cfg.Security.URLAllowlist.AllowInsecureHTTP {
+ t.Fatalf("URLAllowlist.AllowInsecureHTTP = true, want false")
}
- if !cfg.Security.URLAllowlist.AllowPrivateHosts {
- t.Fatalf("URLAllowlist.AllowPrivateHosts = false, want true")
+ if cfg.Security.URLAllowlist.AllowPrivateHosts {
+ t.Fatalf("URLAllowlist.AllowPrivateHosts = true, want false")
+ }
+ if cfg.Gateway.MaxLineSize != 8*1024*1024 {
+ t.Fatalf("Gateway.MaxLineSize = %d, want %d", cfg.Gateway.MaxLineSize, 8*1024*1024)
}
if !cfg.Security.ResponseHeaders.Enabled {
t.Fatalf("ResponseHeaders.Enabled = false, want true")
@@ -1217,6 +1262,30 @@ func TestValidateConfigErrors(t *testing.T) {
mutate: func(c *Config) { c.Gateway.MaxBodySize = 0 },
wantErr: "gateway.max_body_size",
},
+ {
+ name: "h2c frame size above protocol limit",
+ mutate: func(c *Config) {
+ c.Server.H2C.Enabled = true
+ c.Server.H2C.MaxReadFrameSize = 1 << 24
+ },
+ wantErr: "server.h2c.max_read_frame_size",
+ },
+ {
+ name: "h2c connection buffer overflows int32",
+ mutate: func(c *Config) {
+ c.Server.H2C.Enabled = true
+ c.Server.H2C.MaxUploadBufferPerConnection = int(math.MaxInt32) + 1
+ },
+ wantErr: "server.h2c.max_upload_buffer_per_connection",
+ },
+ {
+ name: "h2c stream buffer must be non-negative",
+ mutate: func(c *Config) {
+ c.Server.H2C.Enabled = true
+ c.Server.H2C.MaxUploadBufferPerStream = -1
+ },
+ wantErr: "server.h2c.max_upload_buffer_per_stream",
+ },
{
name: "gateway max idle conns",
mutate: func(c *Config) { c.Gateway.MaxIdleConns = 0 },
@@ -1282,6 +1351,46 @@ func TestValidateConfigErrors(t *testing.T) {
mutate: func(c *Config) { c.Gateway.StreamDataIntervalTimeout = -1 },
wantErr: "gateway.stream_data_interval_timeout must be non-negative",
},
+ {
+ name: "gateway image stream keepalive range",
+ mutate: func(c *Config) { c.Gateway.ImageStreamKeepaliveInterval = 4 },
+ wantErr: "gateway.image_stream_keepalive_interval",
+ },
+ {
+ name: "gateway image stream keepalive negative",
+ mutate: func(c *Config) { c.Gateway.ImageStreamKeepaliveInterval = -1 },
+ wantErr: "gateway.image_stream_keepalive_interval must be non-negative",
+ },
+ {
+ name: "gateway image stream data interval range",
+ mutate: func(c *Config) { c.Gateway.ImageStreamDataIntervalTimeout = 30 },
+ wantErr: "gateway.image_stream_data_interval_timeout",
+ },
+ {
+ name: "gateway image stream data interval negative",
+ mutate: func(c *Config) { c.Gateway.ImageStreamDataIntervalTimeout = -1 },
+ wantErr: "gateway.image_stream_data_interval_timeout must be non-negative",
+ },
+ {
+ name: "gateway image concurrency max negative",
+ mutate: func(c *Config) { c.Gateway.ImageConcurrency.MaxConcurrentRequests = -1 },
+ wantErr: "gateway.image_concurrency.max_concurrent_requests must be non-negative",
+ },
+ {
+ name: "gateway image concurrency overflow mode invalid",
+ mutate: func(c *Config) { c.Gateway.ImageConcurrency.OverflowMode = "queue" },
+ wantErr: "gateway.image_concurrency.overflow_mode",
+ },
+ {
+ name: "gateway image concurrency wait timeout negative",
+ mutate: func(c *Config) { c.Gateway.ImageConcurrency.WaitTimeoutSeconds = -1 },
+ wantErr: "gateway.image_concurrency.wait_timeout_seconds must be non-negative",
+ },
+ {
+ name: "gateway image concurrency max waiting negative",
+ mutate: func(c *Config) { c.Gateway.ImageConcurrency.MaxWaitingRequests = -1 },
+ wantErr: "gateway.image_concurrency.max_waiting_requests must be non-negative",
+ },
{
name: "gateway max line size",
mutate: func(c *Config) { c.Gateway.MaxLineSize = 1024 },
@@ -1292,6 +1401,11 @@ func TestValidateConfigErrors(t *testing.T) {
mutate: func(c *Config) { c.Gateway.MaxLineSize = -1 },
wantErr: "gateway.max_line_size must be non-negative",
},
+ {
+ name: "gateway max line size above hard ceiling",
+ mutate: func(c *Config) { c.Gateway.MaxLineSize = 16*1024*1024 + 1 },
+ wantErr: "gateway.max_line_size must be at most",
+ },
{
name: "gateway usage record worker count",
mutate: func(c *Config) { c.Gateway.UsageRecord.WorkerCount = 0 },
@@ -1370,6 +1484,70 @@ func TestValidateConfigErrors(t *testing.T) {
mutate: func(c *Config) { c.Gateway.ModelsListCacheTTLSeconds = 31 },
wantErr: "gateway.models_list_cache_ttl_seconds",
},
+ {
+ name: "kiro max concurrency positive",
+ mutate: func(c *Config) {
+ c.Kiro.Enabled = true
+ c.Kiro.MaxConcurrency = 0
+ },
+ wantErr: "kiro.max_concurrency",
+ },
+ {
+ name: "kiro request timeout positive",
+ mutate: func(c *Config) {
+ c.Kiro.Enabled = true
+ c.Kiro.RequestTimeoutSeconds = 0
+ },
+ wantErr: "kiro.request_timeout_seconds",
+ },
+ {
+ name: "kiro sidecar url must be absolute http url",
+ mutate: func(c *Config) {
+ c.Kiro.Enabled = true
+ c.Kiro.SidecarURL = "localhost:8787"
+ },
+ wantErr: "kiro.sidecar_url",
+ },
+ {
+ name: "kiro sidecar url cannot include userinfo",
+ mutate: func(c *Config) {
+ c.Kiro.Enabled = true
+ c.Kiro.SidecarURL = "http://user:pass@127.0.0.1:8787"
+ },
+ wantErr: "kiro.sidecar_url",
+ },
+ {
+ name: "cursor max concurrency positive",
+ mutate: func(c *Config) {
+ c.Cursor.Enabled = true
+ c.Cursor.MaxConcurrency = 0
+ },
+ wantErr: "cursor.max_concurrency",
+ },
+ {
+ name: "cursor request timeout positive",
+ mutate: func(c *Config) {
+ c.Cursor.Enabled = true
+ c.Cursor.RequestTimeoutSeconds = 0
+ },
+ wantErr: "cursor.request_timeout_seconds",
+ },
+ {
+ name: "cursor sidecar url must be absolute http url",
+ mutate: func(c *Config) {
+ c.Cursor.Enabled = true
+ c.Cursor.SidecarURL = "localhost:8086"
+ },
+ wantErr: "cursor.sidecar_url",
+ },
+ {
+ name: "cursor sidecar url cannot include userinfo",
+ mutate: func(c *Config) {
+ c.Cursor.Enabled = true
+ c.Cursor.SidecarURL = "http://user:pass@127.0.0.1:8086"
+ },
+ wantErr: "cursor.sidecar_url",
+ },
{
name: "gateway scheduling sticky waiting",
mutate: func(c *Config) { c.Gateway.Scheduling.StickySessionMaxWaiting = 0 },
@@ -1720,8 +1898,8 @@ func TestLoad_DefaultGatewayUsageRecordConfig(t *testing.T) {
if cfg.Gateway.UsageRecord.TaskTimeoutSeconds != 5 {
t.Fatalf("task_timeout_seconds = %d, want 5", cfg.Gateway.UsageRecord.TaskTimeoutSeconds)
}
- if cfg.Gateway.UsageRecord.OverflowPolicy != UsageRecordOverflowPolicySample {
- t.Fatalf("overflow_policy = %s, want %s", cfg.Gateway.UsageRecord.OverflowPolicy, UsageRecordOverflowPolicySample)
+ if cfg.Gateway.UsageRecord.OverflowPolicy != UsageRecordOverflowPolicySync {
+ t.Fatalf("overflow_policy = %s, want %s", cfg.Gateway.UsageRecord.OverflowPolicy, UsageRecordOverflowPolicySync)
}
if cfg.Gateway.UsageRecord.OverflowSamplePercent != 10 {
t.Fatalf("overflow_sample_percent = %d, want 10", cfg.Gateway.UsageRecord.OverflowSamplePercent)
@@ -1754,3 +1932,41 @@ func TestLoad_DefaultGatewayUsageRecordConfig(t *testing.T) {
t.Fatalf("auto_scale_cooldown_seconds = %d, want 10", cfg.Gateway.UsageRecord.AutoScaleCooldownSeconds)
}
}
+
+func TestLoad_DefaultGatewayImageStreamConfig(t *testing.T) {
+ resetViperWithJWTSecret(t)
+ cfg, err := Load()
+ if err != nil {
+ t.Fatalf("Load() error: %v", err)
+ }
+ if cfg.Gateway.StreamDataIntervalTimeout != 180 {
+ t.Fatalf("stream_data_interval_timeout = %d, want 180", cfg.Gateway.StreamDataIntervalTimeout)
+ }
+ if cfg.Gateway.StreamKeepaliveInterval != 10 {
+ t.Fatalf("stream_keepalive_interval = %d, want 10", cfg.Gateway.StreamKeepaliveInterval)
+ }
+ if cfg.Gateway.ImageStreamDataIntervalTimeout != 900 {
+ t.Fatalf("image_stream_data_interval_timeout = %d, want 900", cfg.Gateway.ImageStreamDataIntervalTimeout)
+ }
+ if cfg.Gateway.ImageStreamKeepaliveInterval != 10 {
+ t.Fatalf("image_stream_keepalive_interval = %d, want 10", cfg.Gateway.ImageStreamKeepaliveInterval)
+ }
+ if cfg.Gateway.ImageConcurrency.Enabled {
+ t.Fatalf("image_concurrency.enabled = true, want false")
+ }
+ if cfg.Gateway.ImageConcurrency.MaxConcurrentRequests != 0 {
+ t.Fatalf("image_concurrency.max_concurrent_requests = %d, want 0", cfg.Gateway.ImageConcurrency.MaxConcurrentRequests)
+ }
+ if cfg.Gateway.ImageConcurrency.OverflowMode != ImageConcurrencyOverflowModeReject {
+ t.Fatalf("image_concurrency.overflow_mode = %q, want %q", cfg.Gateway.ImageConcurrency.OverflowMode, ImageConcurrencyOverflowModeReject)
+ }
+ if cfg.Gateway.ImageConcurrency.WaitTimeoutSeconds != 30 {
+ t.Fatalf("image_concurrency.wait_timeout_seconds = %d, want 30", cfg.Gateway.ImageConcurrency.WaitTimeoutSeconds)
+ }
+ if cfg.Gateway.ImageConcurrency.MaxWaitingRequests != 100 {
+ t.Fatalf("image_concurrency.max_waiting_requests = %d, want 100", cfg.Gateway.ImageConcurrency.MaxWaitingRequests)
+ }
+ if cfg.Gateway.ImageStreamDataIntervalTimeout <= cfg.Gateway.StreamDataIntervalTimeout {
+ t.Fatalf("image stream timeout = %d, want greater than ordinary stream timeout %d", cfg.Gateway.ImageStreamDataIntervalTimeout, cfg.Gateway.StreamDataIntervalTimeout)
+ }
+}
diff --git a/backend/internal/config/simple_mode_deployment_contract_test.go b/backend/internal/config/simple_mode_deployment_contract_test.go
new file mode 100644
index 00000000000..787f2ee0abc
--- /dev/null
+++ b/backend/internal/config/simple_mode_deployment_contract_test.go
@@ -0,0 +1,15 @@
+package config
+
+import (
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestKiroCanaryDoesNotDefaultToInsecureSimpleMode(t *testing.T) {
+ raw, err := os.ReadFile("../../../deploy/docker-compose.kiro-canary.yml")
+ require.NoError(t, err)
+ require.Contains(t, string(raw), "RUN_MODE=${RUN_MODE:-standard}")
+ require.NotContains(t, string(raw), "RUN_MODE=${RUN_MODE:-simple}")
+}
diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go
index 27c543dd5ec..76d90a36727 100644
--- a/backend/internal/domain/constants.go
+++ b/backend/internal/domain/constants.go
@@ -22,6 +22,8 @@ const (
PlatformOpenAI = "openai"
PlatformGemini = "gemini"
PlatformAntigravity = "antigravity"
+ PlatformKiro = "kiro"
+ PlatformCursor = "cursor"
)
// Account type constants
@@ -40,6 +42,9 @@ const (
RedeemTypeConcurrency = "concurrency"
RedeemTypeSubscription = "subscription"
RedeemTypeInvitation = "invitation"
+ // RedeemTypeWallet 钱包模式额度卡兑换码:兑换时按 plan.WalletQuotaUsd 建 wallet 订阅。
+ // 链动小铺 credits-100 / credits-500 / credits-1500 SKU 走此路径(B2.7)。
+ RedeemTypeWallet = "wallet"
)
// PromoCode status constants
diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go
index 2d00ccc641f..51ee86cb225 100644
--- a/backend/internal/handler/admin/account_handler.go
+++ b/backend/internal/handler/admin/account_handler.go
@@ -528,6 +528,10 @@ func (h *AccountHandler) Create(c *gin.Context) {
// 确定是否跳过混合渠道检查
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
+ // 捕获闭包内创建的账号引用,用于创建成功后触发异步探测。
+ // 幂等重放时闭包不会执行 → createdAccount 为 nil → 不重复调度。
+ var createdAccount *service.Account
+
result, err := executeAdminIdempotent(c, "admin.accounts.create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
account, execErr := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{
Name: req.Name,
@@ -549,6 +553,7 @@ func (h *AccountHandler) Create(c *gin.Context) {
if execErr != nil {
return nil, execErr
}
+ createdAccount = account
// Antigravity OAuth: 新账号直接设置隐私
h.adminService.ForceAntigravityPrivacy(ctx, account)
// OpenAI OAuth: 新账号直接设置隐私
@@ -577,6 +582,9 @@ func (h *AccountHandler) Create(c *gin.Context) {
if result != nil && result.Replayed {
c.Header("X-Idempotency-Replayed", "true")
}
+ // OpenAI APIKey 账号创建后异步探测上游 /v1/responses 能力。
+ // 探测失败不影响账号创建响应。
+ h.scheduleOpenAIResponsesProbe(createdAccount)
response.Success(c, result.Data)
}
@@ -637,9 +645,39 @@ func (h *AccountHandler) Update(c *gin.Context) {
return
}
+ // OpenAI APIKey: credentials 修改后重新探测上游能力(base_url/api_key 可能变更)。
+ // 异步执行,探测失败不影响账号更新响应。
+ if len(req.Credentials) > 0 {
+ h.scheduleOpenAIResponsesProbe(account)
+ }
+
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
}
+// scheduleOpenAIResponsesProbe 异步触发 OpenAI APIKey 账号的 Responses API 能力探测。
+//
+// 仅对 platform=openai && type=apikey 账号生效;其他账号无操作。
+// 探测本身在 goroutine 中执行(会发一次 HTTP 请求到上游),不会阻塞
+// 当前请求。探测错误仅记录日志,不向上下文传播:探测失败时标记保持缺失,
+// 网关会按"现状即证据"默认走 Responses。
+func (h *AccountHandler) scheduleOpenAIResponsesProbe(account *service.Account) {
+ if account == nil || account.Platform != service.PlatformOpenAI || account.Type != service.AccountTypeAPIKey {
+ return
+ }
+ if h.accountTestService == nil {
+ return
+ }
+ accountID := account.ID
+ go func() {
+ defer func() {
+ if r := recover(); r != nil {
+ slog.Error("openai_responses_probe_panic", "account_id", accountID, "recover", r)
+ }
+ }()
+ h.accountTestService.ProbeOpenAIAPIKeyResponsesSupport(context.Background(), accountID)
+ }()
+}
+
// Delete handles deleting an account
// DELETE /api/v1/admin/accounts/:id
func (h *AccountHandler) Delete(c *gin.Context) {
@@ -1231,6 +1269,8 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
openaiPrivacyAccounts = append(openaiPrivacyAccounts, account)
}
}
+ // OpenAI APIKey 账号异步探测 /v1/responses 能力。
+ h.scheduleOpenAIResponsesProbe(account)
success++
results = append(results, gin.H{
"name": item.Name,
@@ -1611,7 +1651,7 @@ func (h *AccountHandler) GetUsage(c *gin.Context) {
return
}
- source := c.DefaultQuery("source", "active")
+ source := normalizeAccountUsageSource(c.Query("source"))
var usage *service.UsageInfo
if source == "passive" {
@@ -1627,6 +1667,13 @@ func (h *AccountHandler) GetUsage(c *gin.Context) {
response.Success(c, usage)
}
+func normalizeAccountUsageSource(source string) string {
+ if source == "active" {
+ return "active"
+ }
+ return "passive"
+}
+
// ClearRateLimit handles clearing account rate limit status
// POST /api/v1/admin/accounts/:id/clear-rate-limit
func (h *AccountHandler) ClearRateLimit(c *gin.Context) {
@@ -1913,6 +1960,28 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
return
}
+ // Handle Kiro accounts: expose Claude-compatible model IDs to downstream users.
+ if account.Platform == service.PlatformKiro {
+ response.Success(c, claude.LatestClaudeCodeModels)
+ return
+ }
+
+ // Handle Cursor accounts: expose Cursor sidecar model IDs, not Claude IDs.
+ if account.Platform == service.PlatformCursor {
+ if h.accountTestService != nil {
+ models, err := h.accountTestService.ListCursorSidecarModels(c.Request.Context())
+ if err == nil && len(models) > 0 {
+ response.Success(c, models)
+ return
+ }
+ if err != nil {
+ slog.Warn("admin.accounts.cursor_models_sidecar_failed", "account_id", account.ID, "error", err)
+ }
+ }
+ response.Success(c, service.DefaultCursorModels)
+ return
+ }
+
// Handle Claude/Anthropic accounts
// For OAuth and Setup-Token accounts: return default models
if account.IsOAuth() {
diff --git a/backend/internal/handler/admin/account_handler_available_models_test.go b/backend/internal/handler/admin/account_handler_available_models_test.go
index c5f1e2d884c..7cc0d220bc7 100644
--- a/backend/internal/handler/admin/account_handler_available_models_test.go
+++ b/backend/internal/handler/admin/account_handler_available_models_test.go
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"testing"
+ "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
@@ -26,9 +27,13 @@ func (s *availableModelsAdminService) GetAccount(_ context.Context, id int64) (*
}
func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine {
+ return setupAvailableModelsRouterWithAccountTestService(adminSvc, nil)
+}
+
+func setupAvailableModelsRouterWithAccountTestService(adminSvc service.AdminService, accountTestSvc *service.AccountTestService) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
- handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, accountTestSvc, nil, nil, nil, nil, nil)
router.GET("/api/v1/admin/accounts/:id/models", handler.GetAvailableModels)
return router
}
@@ -103,3 +108,88 @@ func TestAccountHandlerGetAvailableModels_OpenAIOAuthPassthroughFallsBackToDefau
require.NotEmpty(t, resp.Data)
require.NotEqual(t, "gpt-5", resp.Data[0].ID)
}
+
+func TestAccountHandlerGetAvailableModels_CursorUsesCursorModels(t *testing.T) {
+ svc := &availableModelsAdminService{
+ stubAdminService: newStubAdminService(),
+ account: service.Account{
+ ID: 44,
+ Name: "cursor-sidecar-default",
+ Platform: service.PlatformCursor,
+ Type: service.AccountTypeUpstream,
+ Status: service.StatusActive,
+ Credentials: map[string]any{"sidecar_account_ref": "cursor-prod@example.com"},
+ },
+ }
+ router := setupAvailableModelsRouter(svc)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/44/models", nil)
+ router.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+
+ var resp struct {
+ Data []struct {
+ ID string `json:"id"`
+ } `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ require.NotEmpty(t, resp.Data)
+ require.Equal(t, service.DefaultCursorTestModel, resp.Data[0].ID)
+ for _, model := range resp.Data {
+ require.NotContains(t, model.ID, "claude-")
+ }
+}
+
+func TestAccountHandlerGetAvailableModels_CursorPrefersSidecarModels(t *testing.T) {
+ t.Setenv("CURSOR_SIDECAR_URL", "")
+ t.Setenv("CURSOR_SIDECAR_API_KEY", "")
+ t.Setenv("CURSOR_REQUEST_TIMEOUT_SECONDS", "")
+
+ var seenSidecarKey string
+ sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ seenSidecarKey = r.Header.Get("X-Cursor-Sidecar-Key")
+ require.Equal(t, "/v1/models", r.URL.Path)
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("X-Cursor-Models-Cache", "hit")
+ _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"cursor-live-model","object":"model","created":123,"owned_by":"cursor"}]}`))
+ }))
+ defer sidecar.Close()
+
+ svc := &availableModelsAdminService{
+ stubAdminService: newStubAdminService(),
+ account: service.Account{
+ ID: 45,
+ Name: "cursor-sidecar-live",
+ Platform: service.PlatformCursor,
+ Type: service.AccountTypeUpstream,
+ Status: service.StatusActive,
+ Credentials: map[string]any{"sidecar_account_ref": "cursor-prod@example.com"},
+ },
+ }
+ accountTestSvc := service.NewAccountTestService(nil, nil, nil, nil, nil, &config.Config{
+ Cursor: config.CursorConfig{
+ SidecarURL: sidecar.URL,
+ SidecarAPIKey: "sidecar-key",
+ RequestTimeoutSeconds: 3,
+ },
+ }, nil)
+ router := setupAvailableModelsRouterWithAccountTestService(svc, accountTestSvc)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/45/models", nil)
+ router.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.Equal(t, "sidecar-key", seenSidecarKey)
+
+ var resp struct {
+ Data []struct {
+ ID string `json:"id"`
+ } `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ require.Len(t, resp.Data, 1)
+ require.Equal(t, "cursor-live-model", resp.Data[0].ID)
+}
diff --git a/backend/internal/handler/admin/account_handler_usage_source_test.go b/backend/internal/handler/admin/account_handler_usage_source_test.go
new file mode 100644
index 00000000000..c641834c4ca
--- /dev/null
+++ b/backend/internal/handler/admin/account_handler_usage_source_test.go
@@ -0,0 +1,24 @@
+package admin
+
+import "testing"
+
+func TestNormalizeAccountUsageSourceDefaultsToPassive(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ want string
+ }{
+ {name: "missing", source: "", want: "passive"},
+ {name: "active", source: "active", want: "active"},
+ {name: "passive", source: "passive", want: "passive"},
+ {name: "unknown", source: "unexpected", want: "passive"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := normalizeAccountUsageSource(tt.source); got != tt.want {
+ t.Fatalf("normalizeAccountUsageSource(%q) = %q, want %q", tt.source, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/backend/internal/handler/admin/admin_basic_handlers_test.go b/backend/internal/handler/admin/admin_basic_handlers_test.go
index ddeaab0218a..d8e0592c06f 100644
--- a/backend/internal/handler/admin/admin_basic_handlers_test.go
+++ b/backend/internal/handler/admin/admin_basic_handlers_test.go
@@ -2,15 +2,46 @@ package admin
import (
"bytes"
+ "context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
+ "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
+type dataManagementPageSizeStub struct {
+ dataManagementService
+ listCalled bool
+}
+
+func (s *dataManagementPageSizeStub) EnsureAgentEnabled(context.Context) error {
+ return nil
+}
+
+func (s *dataManagementPageSizeStub) ListBackupJobs(context.Context, service.DataManagementListBackupJobsInput) (service.DataManagementListBackupJobsResult, error) {
+ s.listCalled = true
+ return service.DataManagementListBackupJobsResult{}, nil
+}
+
+func TestDataManagementListBackupJobsRejectsInt32OverflowPageSize(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ stub := &dataManagementPageSizeStub{}
+ handler := &DataManagementHandler{dataManagementService: stub}
+ router := gin.New()
+ router.GET("/", handler.ListBackupJobs)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/?page_size=2147483648", nil)
+ router.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+ require.False(t, stub.listCalled, "overflow input must be rejected before the service call")
+}
+
func setupAdminRouter() (*gin.Engine, *stubAdminService) {
gin.SetMode(gin.TestMode)
router := gin.New()
@@ -64,6 +95,8 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
}
func TestUserHandlerEndpoints(t *testing.T) {
+ service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), service.DefaultIdempotencyConfig()))
+ t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
router, _ := setupAdminRouter()
rec := httptest.NewRecorder()
@@ -118,6 +151,7 @@ func TestUserHandlerEndpoints(t *testing.T) {
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/1/balance", bytes.NewBufferString(`{"balance":1,"operation":"add"}`))
req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Idempotency-Key", "admin-basic-balance-1")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
@@ -272,6 +306,8 @@ func TestProxyHandlerEndpoints(t *testing.T) {
}
func TestRedeemHandlerEndpoints(t *testing.T) {
+ service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), service.DefaultIdempotencyConfig()))
+ t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
router, _ := setupAdminRouter()
rec := httptest.NewRecorder()
@@ -288,6 +324,7 @@ func TestRedeemHandlerEndpoints(t *testing.T) {
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/redeem-codes", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Idempotency-Key", "admin-basic-redeem-generate-1")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go
index b187b47f63b..2fef94f1561 100644
--- a/backend/internal/handler/admin/admin_service_stub_test.go
+++ b/backend/internal/handler/admin/admin_service_stub_test.go
@@ -175,6 +175,10 @@ func (s *stubAdminService) UpdateUserBalance(ctx context.Context, userID int64,
return &user, nil
}
+func (s *stubAdminService) BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) {
+ return len(userIDs), nil
+}
+
func (s *stubAdminService) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]service.APIKey, int64, error) {
return s.apiKeys, int64(len(s.apiKeys)), nil
}
diff --git a/backend/internal/handler/admin/affiliate_handler.go b/backend/internal/handler/admin/affiliate_handler.go
index 97e649ec4df..d443d344d23 100644
--- a/backend/internal/handler/admin/affiliate_handler.go
+++ b/backend/internal/handler/admin/affiliate_handler.go
@@ -2,8 +2,11 @@ package admin
import (
"strconv"
+ "strings"
+ "time"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -181,3 +184,108 @@ func (h *AffiliateHandler) LookupUsers(c *gin.Context) {
}
response.Success(c, result)
}
+
+// GetUserOverview returns one user's affiliate overview.
+// GET /api/v1/admin/affiliates/users/:user_id/overview
+func (h *AffiliateHandler) GetUserOverview(c *gin.Context) {
+ userID, err := strconv.ParseInt(c.Param("user_id"), 10, 64)
+ if err != nil || userID <= 0 {
+ response.BadRequest(c, "Invalid user_id")
+ return
+ }
+ overview, err := h.affiliateService.AdminGetUserOverview(c.Request.Context(), userID)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Success(c, overview)
+}
+
+// ListInviteRecords returns all inviter-invitee relationships.
+// GET /api/v1/admin/affiliates/invites
+func (h *AffiliateHandler) ListInviteRecords(c *gin.Context) {
+ page, pageSize := response.ParsePagination(c)
+ filter := parseAffiliateRecordFilter(c, page, pageSize)
+ items, total, err := h.affiliateService.AdminListInviteRecords(c.Request.Context(), filter)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Paginated(c, items, total, filter.Page, filter.PageSize)
+}
+
+// ListRebateRecords returns all order-level affiliate rebate records.
+// GET /api/v1/admin/affiliates/rebates
+func (h *AffiliateHandler) ListRebateRecords(c *gin.Context) {
+ page, pageSize := response.ParsePagination(c)
+ filter := parseAffiliateRecordFilter(c, page, pageSize)
+ items, total, err := h.affiliateService.AdminListRebateRecords(c.Request.Context(), filter)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Paginated(c, items, total, filter.Page, filter.PageSize)
+}
+
+// ListTransferRecords returns all affiliate quota-to-balance transfer records.
+// GET /api/v1/admin/affiliates/transfers
+func (h *AffiliateHandler) ListTransferRecords(c *gin.Context) {
+ page, pageSize := response.ParsePagination(c)
+ filter := parseAffiliateRecordFilter(c, page, pageSize)
+ items, total, err := h.affiliateService.AdminListTransferRecords(c.Request.Context(), filter)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Paginated(c, items, total, filter.Page, filter.PageSize)
+}
+
+func parseAffiliateRecordFilter(c *gin.Context, page, pageSize int) service.AffiliateRecordFilter {
+ filter := service.AffiliateRecordFilter{
+ Search: c.Query("search"),
+ Page: page,
+ PageSize: pageSize,
+ SortBy: c.Query("sort_by"),
+ SortDesc: c.Query("sort_order") != "asc",
+ }
+ if filter.PageSize > 100 {
+ filter.PageSize = 100
+ }
+ userTZ := c.Query("timezone")
+ if t := parseAffiliateRecordStartTime(c.Query("start_at"), userTZ); t != nil {
+ filter.StartAt = t
+ }
+ if t := parseAffiliateRecordEndTime(c.Query("end_at"), userTZ); t != nil {
+ filter.EndAt = t
+ }
+ return filter
+}
+
+func parseAffiliateRecordStartTime(raw string, userTZ string) *time.Time {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil
+ }
+ if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
+ return &parsed
+ }
+ if parsed, err := timezone.ParseInUserLocation("2006-01-02", raw, userTZ); err == nil {
+ return &parsed
+ }
+ return nil
+}
+
+func parseAffiliateRecordEndTime(raw string, userTZ string) *time.Time {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil
+ }
+ if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
+ return &parsed
+ }
+ if parsed, err := timezone.ParseInUserLocation("2006-01-02", raw, userTZ); err == nil {
+ end := parsed.AddDate(0, 0, 1).Add(-time.Nanosecond)
+ return &end
+ }
+ return nil
+}
diff --git a/backend/internal/handler/admin/apikey_handler.go b/backend/internal/handler/admin/apikey_handler.go
index 5e405bdda3b..b3bf967008b 100644
--- a/backend/internal/handler/admin/apikey_handler.go
+++ b/backend/internal/handler/admin/apikey_handler.go
@@ -67,7 +67,7 @@ func (h *AdminAPIKeyHandler) UpdateGroup(c *gin.Context) {
GrantedGroupID *int64 `json:"granted_group_id,omitempty"`
GrantedGroupName string `json:"granted_group_name,omitempty"`
}{
- APIKey: dto.APIKeyFromService(result.APIKey),
+ APIKey: dto.APIKeyFromServiceMasked(result.APIKey),
AutoGrantedGroupAccess: result.AutoGrantedGroupAccess,
GrantedGroupID: result.GrantedGroupID,
GrantedGroupName: result.GrantedGroupName,
diff --git a/backend/internal/handler/admin/channel_monitor_api_key_binding_security_test.go b/backend/internal/handler/admin/channel_monitor_api_key_binding_security_test.go
new file mode 100644
index 00000000000..f23d1ff8909
--- /dev/null
+++ b/backend/internal/handler/admin/channel_monitor_api_key_binding_security_test.go
@@ -0,0 +1,38 @@
+package admin
+
+import (
+ "context"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+type monitorAPIKeyRepoStub struct {
+ service.APIKeyRepository
+ key *service.APIKey
+}
+
+func (s monitorAPIKeyRepoStub) GetByID(context.Context, int64) (*service.APIKey, error) {
+ if s.key == nil {
+ return nil, service.ErrAPIKeyNotFound
+ }
+ copy := *s.key
+ return ©, nil
+}
+
+func TestChannelMonitorResolvesOwnedAPIKeyServerSide(t *testing.T) {
+ apiKeyService := service.NewAPIKeyService(
+ monitorAPIKeyRepoStub{key: &service.APIKey{ID: 9, UserID: 7, Key: "sk-server-only", Status: service.StatusAPIKeyActive}},
+ nil, nil, nil, nil, nil, &config.Config{},
+ )
+ h := ProvideChannelMonitorHandler(nil, apiKeyService, nil)
+ id := int64(9)
+ resolved, err := h.resolveOwnedAPIKey(context.Background(), 7, &id, "masked-browser-value")
+ require.NoError(t, err)
+ require.Equal(t, "sk-server-only", resolved)
+
+ _, err = h.resolveOwnedAPIKey(context.Background(), 8, &id, "")
+ require.ErrorIs(t, err, service.ErrInsufficientPerms)
+}
diff --git a/backend/internal/handler/admin/channel_monitor_handler.go b/backend/internal/handler/admin/channel_monitor_handler.go
index e92c81fea01..e08a851c0e2 100644
--- a/backend/internal/handler/admin/channel_monitor_handler.go
+++ b/backend/internal/handler/admin/channel_monitor_handler.go
@@ -1,6 +1,7 @@
package admin
import (
+ "context"
"strconv"
"strings"
"time"
@@ -25,7 +26,13 @@ const (
// ChannelMonitorHandler 渠道监控管理后台 handler。
type ChannelMonitorHandler struct {
- monitorService *service.ChannelMonitorService
+ monitorService *service.ChannelMonitorService
+ apiKeyService *service.APIKeyService
+ templateService channelMonitorTemplateResolver
+}
+
+type channelMonitorTemplateResolver interface {
+ Get(ctx context.Context, id int64) (*service.ChannelMonitorRequestTemplate, error)
}
// NewChannelMonitorHandler 创建 handler。
@@ -33,13 +40,26 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
return &ChannelMonitorHandler{monitorService: monitorService}
}
+func ProvideChannelMonitorHandler(
+ monitorService *service.ChannelMonitorService,
+ apiKeyService *service.APIKeyService,
+ templateService *service.ChannelMonitorRequestTemplateService,
+) *ChannelMonitorHandler {
+ return &ChannelMonitorHandler{
+ monitorService: monitorService,
+ apiKeyService: apiKeyService,
+ templateService: templateService,
+ }
+}
+
// --- Request / Response ---
type channelMonitorCreateRequest struct {
Name string `json:"name" binding:"required,max=100"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini"`
Endpoint string `json:"endpoint" binding:"required,max=500"`
- APIKey string `json:"api_key" binding:"required,max=2000"`
+ APIKey string `json:"api_key" binding:"max=2000"`
+ APIKeyID *int64 `json:"api_key_id"`
PrimaryModel string `json:"primary_model" binding:"required,max=200"`
ExtraModels []string `json:"extra_models"`
GroupName string `json:"group_name" binding:"max=100"`
@@ -52,20 +72,22 @@ type channelMonitorCreateRequest struct {
}
type channelMonitorUpdateRequest struct {
- Name *string `json:"name" binding:"omitempty,max=100"`
- Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini"`
- Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
- APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
- PrimaryModel *string `json:"primary_model" binding:"omitempty,max=200"`
- ExtraModels *[]string `json:"extra_models"`
- GroupName *string `json:"group_name" binding:"omitempty,max=100"`
- Enabled *bool `json:"enabled"`
- IntervalSeconds *int `json:"interval_seconds" binding:"omitempty,min=15,max=3600"`
- TemplateID *int64 `json:"template_id"`
- ClearTemplate bool `json:"clear_template"` // true 时把 template_id 置空,忽略 TemplateID
- ExtraHeaders *map[string]string `json:"extra_headers"`
- BodyOverrideMode *string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
- BodyOverride *map[string]any `json:"body_override"`
+ Name *string `json:"name" binding:"omitempty,max=100"`
+ Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini"`
+ Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
+ APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
+ APIKeyID *int64 `json:"api_key_id"`
+ PrimaryModel *string `json:"primary_model" binding:"omitempty,max=200"`
+ ExtraModels *[]string `json:"extra_models"`
+ GroupName *string `json:"group_name" binding:"omitempty,max=100"`
+ Enabled *bool `json:"enabled"`
+ IntervalSeconds *int `json:"interval_seconds" binding:"omitempty,min=15,max=3600"`
+ TemplateID *int64 `json:"template_id"`
+ ClearTemplate bool `json:"clear_template"` // true 时把 template_id 置空,忽略 TemplateID
+ ExtraHeaders *map[string]string `json:"extra_headers"`
+ BodyOverrideMode *string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
+ BodyOverride *map[string]any `json:"body_override"`
+ ReplaceRequestCustomization bool `json:"replace_request_customization"`
}
type channelMonitorResponse struct {
@@ -88,11 +110,12 @@ type channelMonitorResponse struct {
PrimaryLatencyMs *int `json:"primary_latency_ms"`
Availability7d float64 `json:"availability_7d"`
ExtraModelsStatus []dto.ChannelMonitorExtraModelStatus `json:"extra_models_status"`
- // 请求自定义快照:前端编辑 / 展示「高级设置」用
- TemplateID *int64 `json:"template_id"`
- ExtraHeaders map[string]string `json:"extra_headers"`
- BodyOverrideMode string `json:"body_override_mode"`
- BodyOverride map[string]any `json:"body_override"`
+ // 请求自定义字段是 write-only:响应只返回存在性元数据,绝不返回明文。
+ TemplateID *int64 `json:"template_id"`
+ ExtraHeadersConfigured bool `json:"extra_headers_configured"`
+ ExtraHeaderCount int `json:"extra_header_count"`
+ BodyOverrideMode string `json:"body_override_mode"`
+ BodyOverrideConfigured bool `json:"body_override_configured"`
}
type channelMonitorCheckResultResponse struct {
@@ -130,29 +153,26 @@ func channelMonitorToResponse(m *service.ChannelMonitor) *channelMonitorResponse
if extras == nil {
extras = []string{}
}
- headers := m.ExtraHeaders
- if headers == nil {
- headers = map[string]string{}
- }
resp := &channelMonitorResponse{
- ID: m.ID,
- Name: m.Name,
- Provider: m.Provider,
- Endpoint: m.Endpoint,
- APIKeyMasked: maskAPIKey(m.APIKey),
- APIKeyDecryptFailed: m.APIKeyDecryptFailed,
- PrimaryModel: m.PrimaryModel,
- ExtraModels: extras,
- GroupName: m.GroupName,
- Enabled: m.Enabled,
- IntervalSeconds: m.IntervalSeconds,
- CreatedBy: m.CreatedBy,
- CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339),
- UpdatedAt: m.UpdatedAt.UTC().Format(time.RFC3339),
- TemplateID: m.TemplateID,
- ExtraHeaders: headers,
- BodyOverrideMode: m.BodyOverrideMode,
- BodyOverride: m.BodyOverride,
+ ID: m.ID,
+ Name: m.Name,
+ Provider: m.Provider,
+ Endpoint: m.Endpoint,
+ APIKeyMasked: maskAPIKey(m.APIKey),
+ APIKeyDecryptFailed: m.APIKeyDecryptFailed,
+ PrimaryModel: m.PrimaryModel,
+ ExtraModels: extras,
+ GroupName: m.GroupName,
+ Enabled: m.Enabled,
+ IntervalSeconds: m.IntervalSeconds,
+ CreatedBy: m.CreatedBy,
+ CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339),
+ UpdatedAt: m.UpdatedAt.UTC().Format(time.RFC3339),
+ TemplateID: m.TemplateID,
+ ExtraHeadersConfigured: len(m.ExtraHeaders) > 0,
+ ExtraHeaderCount: len(m.ExtraHeaders),
+ BodyOverrideMode: m.BodyOverrideMode,
+ BodyOverrideConfigured: len(m.BodyOverride) > 0,
// PrimaryStatus / PrimaryLatencyMs / Availability7d 由 List handler 在批量聚合后填充。
}
if m.LastCheckedAt != nil {
@@ -294,17 +314,27 @@ func (h *ChannelMonitorHandler) Create(c *gin.Context) {
}
subject, _ := middleware2.GetAuthSubjectFromContext(c)
+ apiKey, err := h.resolveOwnedAPIKey(c.Request.Context(), subject.UserID, req.APIKeyID, req.APIKey)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
+ extraHeaders, bodyMode, bodyOverride, err := h.resolveCreateRequestCustomization(c.Request.Context(), &req)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
m, err := h.monitorService.Create(c.Request.Context(), service.ChannelMonitorCreateParams{
Name: req.Name,
Provider: req.Provider,
Endpoint: req.Endpoint,
- APIKey: req.APIKey,
+ APIKey: apiKey,
PrimaryModel: req.PrimaryModel,
ExtraModels: req.ExtraModels,
GroupName: req.GroupName,
@@ -312,9 +342,9 @@ func (h *ChannelMonitorHandler) Create(c *gin.Context) {
IntervalSeconds: req.IntervalSeconds,
CreatedBy: subject.UserID,
TemplateID: req.TemplateID,
- ExtraHeaders: req.ExtraHeaders,
- BodyOverrideMode: req.BodyOverrideMode,
- BodyOverride: req.BodyOverride,
+ ExtraHeaders: extraHeaders,
+ BodyOverrideMode: bodyMode,
+ BodyOverride: bodyOverride,
})
if err != nil {
response.ErrorFrom(c, err)
@@ -334,12 +364,26 @@ func (h *ChannelMonitorHandler) Update(c *gin.Context) {
response.ErrorFrom(c, infraerrors.BadRequest("VALIDATION_ERROR", err.Error()))
return
}
+ if err := h.prepareMonitorCustomizationUpdate(c.Request.Context(), &req); err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ subject, _ := middleware2.GetAuthSubjectFromContext(c)
+ apiKey := req.APIKey
+ if req.APIKeyID != nil {
+ resolved, err := h.resolveOwnedAPIKey(c.Request.Context(), subject.UserID, req.APIKeyID, "")
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ apiKey = &resolved
+ }
m, err := h.monitorService.Update(c.Request.Context(), id, service.ChannelMonitorUpdateParams{
Name: req.Name,
Provider: req.Provider,
Endpoint: req.Endpoint,
- APIKey: req.APIKey,
+ APIKey: apiKey,
PrimaryModel: req.PrimaryModel,
ExtraModels: req.ExtraModels,
GroupName: req.GroupName,
@@ -358,6 +402,104 @@ func (h *ChannelMonitorHandler) Update(c *gin.Context) {
response.Success(c, channelMonitorToResponse(m))
}
+func (h *ChannelMonitorHandler) resolveOwnedAPIKey(ctx context.Context, userID int64, apiKeyID *int64, raw string) (string, error) {
+ if apiKeyID == nil {
+ return strings.TrimSpace(raw), nil
+ }
+ if *apiKeyID <= 0 || h.apiKeyService == nil {
+ return "", infraerrors.BadRequest("INVALID_API_KEY_ID", "invalid API key id")
+ }
+ key, err := h.apiKeyService.GetByID(ctx, *apiKeyID)
+ if err != nil {
+ return "", err
+ }
+ if key.UserID != userID {
+ return "", service.ErrInsufficientPerms
+ }
+ if !key.IsActive() || key.IsExpired() || key.IsQuotaExhausted() {
+ return "", infraerrors.BadRequest("API_KEY_NOT_USABLE", "selected API key is not active and usable")
+ }
+ return key.Key, nil
+}
+
+func (h *ChannelMonitorHandler) resolveCreateRequestCustomization(
+ ctx context.Context,
+ req *channelMonitorCreateRequest,
+) (map[string]string, string, map[string]any, error) {
+ if req.TemplateID == nil {
+ return req.ExtraHeaders, req.BodyOverrideMode, req.BodyOverride, nil
+ }
+ return h.resolveTemplateSnapshot(ctx, *req.TemplateID, req.Provider)
+}
+
+func (h *ChannelMonitorHandler) prepareMonitorCustomizationUpdate(
+ ctx context.Context,
+ req *channelMonitorUpdateRequest,
+) error {
+ hasPayload := req.ExtraHeaders != nil || req.BodyOverrideMode != nil || req.BodyOverride != nil
+ if !req.ReplaceRequestCustomization {
+ if hasPayload || req.TemplateID != nil {
+ return infraerrors.BadRequest(
+ "REQUEST_CUSTOMIZATION_REPLACEMENT_REQUIRED",
+ "set replace_request_customization=true to replace write-only request customization",
+ )
+ }
+ return nil
+ }
+ if req.ClearTemplate && req.TemplateID != nil {
+ return infraerrors.BadRequest("INVALID_TEMPLATE_UPDATE", "template_id and clear_template cannot be used together")
+ }
+ if req.TemplateID != nil {
+ if req.Provider == nil || strings.TrimSpace(*req.Provider) == "" {
+ return infraerrors.BadRequest("MONITOR_PROVIDER_REQUIRED", "provider is required when applying a request template")
+ }
+ headers, mode, body, err := h.resolveTemplateSnapshot(ctx, *req.TemplateID, *req.Provider)
+ if err != nil {
+ return err
+ }
+ req.ExtraHeaders = &headers
+ req.BodyOverrideMode = &mode
+ req.BodyOverride = &body
+ return nil
+ }
+ if req.ExtraHeaders == nil {
+ headers := map[string]string{}
+ req.ExtraHeaders = &headers
+ }
+ if req.BodyOverrideMode == nil {
+ mode := service.MonitorBodyOverrideModeOff
+ req.BodyOverrideMode = &mode
+ }
+ if req.BodyOverride == nil {
+ var body map[string]any
+ req.BodyOverride = &body
+ }
+ // A direct payload replacement is no longer a snapshot of the previously
+ // associated template. Detach it server-side so stale/non-browser clients
+ // cannot leave a misleading association that a later template apply would
+ // silently overwrite.
+ req.ClearTemplate = true
+ return nil
+}
+
+func (h *ChannelMonitorHandler) resolveTemplateSnapshot(
+ ctx context.Context,
+ templateID int64,
+ provider string,
+) (map[string]string, string, map[string]any, error) {
+ if templateID <= 0 || h.templateService == nil {
+ return nil, "", nil, infraerrors.BadRequest("INVALID_TEMPLATE_ID", "invalid request template id")
+ }
+ tpl, err := h.templateService.Get(ctx, templateID)
+ if err != nil {
+ return nil, "", nil, err
+ }
+ if tpl.Provider != strings.TrimSpace(provider) {
+ return nil, "", nil, service.ErrChannelMonitorTemplateProviderMismatch
+ }
+ return tpl.ExtraHeaders, tpl.BodyOverrideMode, tpl.BodyOverride, nil
+}
+
// Delete DELETE /api/v1/admin/channel-monitors/:id
func (h *ChannelMonitorHandler) Delete(c *gin.Context) {
id, ok := ParseChannelMonitorID(c)
diff --git a/backend/internal/handler/admin/channel_monitor_request_customization_response_security_test.go b/backend/internal/handler/admin/channel_monitor_request_customization_response_security_test.go
new file mode 100644
index 00000000000..a956d01a933
--- /dev/null
+++ b/backend/internal/handler/admin/channel_monitor_request_customization_response_security_test.go
@@ -0,0 +1,115 @@
+package admin
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+type channelMonitorTemplateResolverStub struct {
+ tpl *service.ChannelMonitorRequestTemplate
+ err error
+}
+
+func (s channelMonitorTemplateResolverStub) Get(context.Context, int64) (*service.ChannelMonitorRequestTemplate, error) {
+ return s.tpl, s.err
+}
+
+func TestChannelMonitorResponseOmitsWriteOnlyRequestCustomization(t *testing.T) {
+ m := &service.ChannelMonitor{
+ ID: 41,
+ APIKey: "sk-monitor-secret",
+ ExtraHeaders: map[string]string{"X-Client-Trace": "header-private-value"},
+ BodyOverrideMode: service.MonitorBodyOverrideModeMerge,
+ BodyOverride: map[string]any{"nested": map[string]any{"token": "body-private-value"}},
+ CreatedAt: time.Unix(1, 0),
+ UpdatedAt: time.Unix(2, 0),
+ }
+
+ resp := channelMonitorToResponse(m)
+ raw, err := json.Marshal(resp)
+ require.NoError(t, err)
+ require.NotContains(t, string(raw), "header-private-value")
+ require.NotContains(t, string(raw), "body-private-value")
+ require.True(t, resp.ExtraHeadersConfigured)
+ require.Equal(t, 1, resp.ExtraHeaderCount)
+ require.True(t, resp.BodyOverrideConfigured)
+}
+
+func TestChannelMonitorTemplateResponseOmitsWriteOnlyRequestCustomization(t *testing.T) {
+ tpl := &service.ChannelMonitorRequestTemplate{
+ ID: 72,
+ ExtraHeaders: map[string]string{"X-Template": "template-header-secret"},
+ BodyOverrideMode: service.MonitorBodyOverrideModeReplace,
+ BodyOverride: map[string]any{"token": "template-body-secret"},
+ CreatedAt: time.Unix(3, 0),
+ UpdatedAt: time.Unix(4, 0),
+ }
+
+ resp := channelMonitorTemplateToResponse(tpl, 2)
+ raw, err := json.Marshal(resp)
+ require.NoError(t, err)
+ require.NotContains(t, string(raw), "template-header-secret")
+ require.NotContains(t, string(raw), "template-body-secret")
+ require.True(t, resp.ExtraHeadersConfigured)
+ require.Equal(t, 1, resp.ExtraHeaderCount)
+ require.True(t, resp.BodyOverrideConfigured)
+ require.Equal(t, int64(2), resp.AssociatedMonitors)
+}
+
+func TestChannelMonitorTemplateSnapshotIsResolvedServerSide(t *testing.T) {
+ tpl := &service.ChannelMonitorRequestTemplate{
+ ID: 72, Provider: "anthropic",
+ ExtraHeaders: map[string]string{"User-Agent": "monitor-client"},
+ BodyOverrideMode: service.MonitorBodyOverrideModeMerge,
+ BodyOverride: map[string]any{"system": "server-side-only"},
+ }
+ h := &ChannelMonitorHandler{templateService: channelMonitorTemplateResolverStub{tpl: tpl}}
+ id := int64(72)
+ req := &channelMonitorCreateRequest{Provider: "anthropic", TemplateID: &id}
+
+ headers, mode, body, err := h.resolveCreateRequestCustomization(context.Background(), req)
+ require.NoError(t, err)
+ require.Equal(t, tpl.ExtraHeaders, headers)
+ require.Equal(t, tpl.BodyOverrideMode, mode)
+ require.Equal(t, tpl.BodyOverride, body)
+
+ req.Provider = "openai"
+ _, _, _, err = h.resolveCreateRequestCustomization(context.Background(), req)
+ require.ErrorIs(t, err, service.ErrChannelMonitorTemplateProviderMismatch)
+}
+
+func TestChannelMonitorUpdateRequiresExplicitWriteOnlyReplacement(t *testing.T) {
+ headers := map[string]string{"X-Trace": "private"}
+ req := &channelMonitorUpdateRequest{ExtraHeaders: &headers}
+ h := &ChannelMonitorHandler{}
+
+ err := h.prepareMonitorCustomizationUpdate(context.Background(), req)
+ require.Error(t, err)
+
+ req = &channelMonitorUpdateRequest{ReplaceRequestCustomization: true}
+ require.NoError(t, h.prepareMonitorCustomizationUpdate(context.Background(), req))
+ require.NotNil(t, req.ExtraHeaders)
+ require.Empty(t, *req.ExtraHeaders)
+ require.NotNil(t, req.BodyOverrideMode)
+ require.Equal(t, service.MonitorBodyOverrideModeOff, *req.BodyOverrideMode)
+ require.NotNil(t, req.BodyOverride)
+ require.Nil(t, *req.BodyOverride)
+ require.True(t, req.ClearTemplate)
+}
+
+func TestChannelMonitorTemplateUpdateRequiresExplicitWriteOnlyReplacement(t *testing.T) {
+ body := map[string]any{"secret": "private"}
+ req := &channelMonitorTemplateUpdateRequest{BodyOverride: &body}
+ require.Error(t, prepareTemplateCustomizationUpdate(req))
+
+ req = &channelMonitorTemplateUpdateRequest{ReplaceRequestCustomization: true}
+ require.NoError(t, prepareTemplateCustomizationUpdate(req))
+ require.NotNil(t, req.ExtraHeaders)
+ require.NotNil(t, req.BodyOverrideMode)
+ require.NotNil(t, req.BodyOverride)
+}
diff --git a/backend/internal/handler/admin/channel_monitor_template_handler.go b/backend/internal/handler/admin/channel_monitor_template_handler.go
index bebe092907e..ecfc4d5c2c5 100644
--- a/backend/internal/handler/admin/channel_monitor_template_handler.go
+++ b/backend/internal/handler/admin/channel_monitor_template_handler.go
@@ -34,46 +34,52 @@ type channelMonitorTemplateCreateRequest struct {
}
type channelMonitorTemplateUpdateRequest struct {
- Name *string `json:"name" binding:"omitempty,max=100"`
- Description *string `json:"description" binding:"omitempty,max=500"`
- ExtraHeaders *map[string]string `json:"extra_headers"`
- BodyOverrideMode *string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
- BodyOverride *map[string]any `json:"body_override"`
+ Name *string `json:"name" binding:"omitempty,max=100"`
+ Description *string `json:"description" binding:"omitempty,max=500"`
+ ExtraHeaders *map[string]string `json:"extra_headers"`
+ BodyOverrideMode *string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
+ BodyOverride *map[string]any `json:"body_override"`
+ ReplaceRequestCustomization bool `json:"replace_request_customization"`
}
type channelMonitorTemplateResponse struct {
- ID int64 `json:"id"`
- Name string `json:"name"`
- Provider string `json:"provider"`
- Description string `json:"description"`
- ExtraHeaders map[string]string `json:"extra_headers"`
- BodyOverrideMode string `json:"body_override_mode"`
- BodyOverride map[string]any `json:"body_override"`
- CreatedAt string `json:"created_at"`
- UpdatedAt string `json:"updated_at"`
- AssociatedMonitors int64 `json:"associated_monitors"`
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Provider string `json:"provider"`
+ Description string `json:"description"`
+ ExtraHeadersConfigured bool `json:"extra_headers_configured"`
+ ExtraHeaderCount int `json:"extra_header_count"`
+ BodyOverrideMode string `json:"body_override_mode"`
+ BodyOverrideConfigured bool `json:"body_override_configured"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+ AssociatedMonitors int64 `json:"associated_monitors"`
}
func (h *ChannelMonitorRequestTemplateHandler) toResponse(c *gin.Context, t *service.ChannelMonitorRequestTemplate) *channelMonitorTemplateResponse {
if t == nil {
return nil
}
- headers := t.ExtraHeaders
- if headers == nil {
- headers = map[string]string{}
- }
count, _ := h.templateService.CountAssociatedMonitors(c.Request.Context(), t.ID)
+ return channelMonitorTemplateToResponse(t, count)
+}
+
+func channelMonitorTemplateToResponse(t *service.ChannelMonitorRequestTemplate, count int64) *channelMonitorTemplateResponse {
+ if t == nil {
+ return nil
+ }
return &channelMonitorTemplateResponse{
- ID: t.ID,
- Name: t.Name,
- Provider: t.Provider,
- Description: t.Description,
- ExtraHeaders: headers,
- BodyOverrideMode: t.BodyOverrideMode,
- BodyOverride: t.BodyOverride,
- CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
- UpdatedAt: t.UpdatedAt.UTC().Format(time.RFC3339),
- AssociatedMonitors: count,
+ ID: t.ID,
+ Name: t.Name,
+ Provider: t.Provider,
+ Description: t.Description,
+ ExtraHeadersConfigured: len(t.ExtraHeaders) > 0,
+ ExtraHeaderCount: len(t.ExtraHeaders),
+ BodyOverrideMode: t.BodyOverrideMode,
+ BodyOverrideConfigured: len(t.BodyOverride) > 0,
+ CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339),
+ UpdatedAt: t.UpdatedAt.UTC().Format(time.RFC3339),
+ AssociatedMonitors: count,
}
}
@@ -152,6 +158,10 @@ func (h *ChannelMonitorRequestTemplateHandler) Update(c *gin.Context) {
response.ErrorFrom(c, infraerrors.BadRequest("VALIDATION_ERROR", err.Error()))
return
}
+ if err := prepareTemplateCustomizationUpdate(&req); err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
t, err := h.templateService.Update(c.Request.Context(), id, service.ChannelMonitorRequestTemplateUpdateParams{
Name: req.Name,
Description: req.Description,
@@ -166,6 +176,32 @@ func (h *ChannelMonitorRequestTemplateHandler) Update(c *gin.Context) {
response.Success(c, h.toResponse(c, t))
}
+func prepareTemplateCustomizationUpdate(req *channelMonitorTemplateUpdateRequest) error {
+ hasPayload := req.ExtraHeaders != nil || req.BodyOverrideMode != nil || req.BodyOverride != nil
+ if !req.ReplaceRequestCustomization {
+ if hasPayload {
+ return infraerrors.BadRequest(
+ "REQUEST_CUSTOMIZATION_REPLACEMENT_REQUIRED",
+ "set replace_request_customization=true to replace write-only request customization",
+ )
+ }
+ return nil
+ }
+ if req.ExtraHeaders == nil {
+ headers := map[string]string{}
+ req.ExtraHeaders = &headers
+ }
+ if req.BodyOverrideMode == nil {
+ mode := service.MonitorBodyOverrideModeOff
+ req.BodyOverrideMode = &mode
+ }
+ if req.BodyOverride == nil {
+ var body map[string]any
+ req.BodyOverride = &body
+ }
+ return nil
+}
+
// Delete DELETE /api/v1/admin/channel-monitor-templates/:id
func (h *ChannelMonitorRequestTemplateHandler) Delete(c *gin.Context) {
id, ok := parseTemplateID(c)
diff --git a/backend/internal/handler/admin/content_moderation_handler.go b/backend/internal/handler/admin/content_moderation_handler.go
new file mode 100644
index 00000000000..c962876e1da
--- /dev/null
+++ b/backend/internal/handler/admin/content_moderation_handler.go
@@ -0,0 +1,335 @@
+package admin
+
+import (
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/response"
+ middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+)
+
+type ContentModerationHandler struct {
+ service *service.ContentModerationService
+ abuseRiskService *service.HFCAbuseRiskService
+}
+
+func NewContentModerationHandler(svc *service.ContentModerationService, abuseRiskSvc *service.HFCAbuseRiskService) *ContentModerationHandler {
+ return &ContentModerationHandler{service: svc, abuseRiskService: abuseRiskSvc}
+}
+
+type contentModerationConfigRequest struct {
+ Enabled *bool `json:"enabled"`
+ Mode *string `json:"mode"`
+ BaseURL *string `json:"base_url"`
+ Model *string `json:"model"`
+ APIKey *string `json:"api_key"`
+ APIKeys *[]string `json:"api_keys"`
+ APIKeysMode string `json:"api_keys_mode"`
+ DeleteAPIKeyHashes *[]string `json:"delete_api_key_hashes"`
+ ClearAPIKey bool `json:"clear_api_key"`
+ TimeoutMS *int `json:"timeout_ms"`
+ SampleRate *int `json:"sample_rate"`
+ AllGroups *bool `json:"all_groups"`
+ GroupIDs *[]int64 `json:"group_ids"`
+ RecordNonHits *bool `json:"record_non_hits"`
+ WorkerCount *int `json:"worker_count"`
+ QueueSize *int `json:"queue_size"`
+ BlockStatus *int `json:"block_status"`
+ BlockMessage *string `json:"block_message"`
+ EmailOnHit *bool `json:"email_on_hit"`
+ AutoBanEnabled *bool `json:"auto_ban_enabled"`
+ BanThreshold *int `json:"ban_threshold"`
+ ViolationWindowHours *int `json:"violation_window_hours"`
+ RetryCount *int `json:"retry_count"`
+ HitRetentionDays *int `json:"hit_retention_days"`
+ NonHitRetentionDays *int `json:"non_hit_retention_days"`
+ PreHashCheckEnabled *bool `json:"pre_hash_check_enabled"`
+}
+
+type contentModerationAPIKeyTestRequest struct {
+ APIKeys []string `json:"api_keys"`
+ BaseURL string `json:"base_url"`
+ Model string `json:"model"`
+ TimeoutMS int `json:"timeout_ms"`
+ Prompt string `json:"prompt"`
+ Images []string `json:"images"`
+}
+
+type contentModerationHashRequest struct {
+ InputHash string `json:"input_hash"`
+}
+
+type hfcAbuseRiskActionRequest struct {
+ Action string `json:"action"`
+ Note string `json:"note"`
+}
+
+func (h *ContentModerationHandler) GetConfig(c *gin.Context) {
+ cfg, err := h.service.GetConfig(c.Request.Context())
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Success(c, cfg)
+}
+
+func (h *ContentModerationHandler) UpdateConfig(c *gin.Context) {
+ var req contentModerationConfigRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request: "+err.Error())
+ return
+ }
+ cfg, err := h.service.UpdateConfig(c.Request.Context(), service.UpdateContentModerationConfigInput{
+ Enabled: req.Enabled,
+ Mode: req.Mode,
+ BaseURL: req.BaseURL,
+ Model: req.Model,
+ APIKey: req.APIKey,
+ APIKeys: req.APIKeys,
+ APIKeysMode: req.APIKeysMode,
+ DeleteAPIKeyHashes: req.DeleteAPIKeyHashes,
+ ClearAPIKey: req.ClearAPIKey,
+ TimeoutMS: req.TimeoutMS,
+ SampleRate: req.SampleRate,
+ AllGroups: req.AllGroups,
+ GroupIDs: req.GroupIDs,
+ RecordNonHits: req.RecordNonHits,
+ WorkerCount: req.WorkerCount,
+ QueueSize: req.QueueSize,
+ BlockStatus: req.BlockStatus,
+ BlockMessage: req.BlockMessage,
+ EmailOnHit: req.EmailOnHit,
+ AutoBanEnabled: req.AutoBanEnabled,
+ BanThreshold: req.BanThreshold,
+ ViolationWindowHours: req.ViolationWindowHours,
+ RetryCount: req.RetryCount,
+ HitRetentionDays: req.HitRetentionDays,
+ NonHitRetentionDays: req.NonHitRetentionDays,
+ PreHashCheckEnabled: req.PreHashCheckEnabled,
+ })
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Success(c, cfg)
+}
+
+func (h *ContentModerationHandler) TestAPIKeys(c *gin.Context) {
+ var req contentModerationAPIKeyTestRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request: "+err.Error())
+ return
+ }
+ result, err := h.service.TestAPIKeys(c.Request.Context(), service.TestContentModerationAPIKeysInput{
+ APIKeys: req.APIKeys,
+ BaseURL: req.BaseURL,
+ Model: req.Model,
+ TimeoutMS: req.TimeoutMS,
+ Prompt: req.Prompt,
+ Images: req.Images,
+ })
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Success(c, result)
+}
+
+func (h *ContentModerationHandler) GetStatus(c *gin.Context) {
+ status, err := h.service.GetStatus(c.Request.Context())
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Success(c, status)
+}
+
+func (h *ContentModerationHandler) ListLogs(c *gin.Context) {
+ page, pageSize := response.ParsePagination(c)
+ filter := service.ContentModerationLogFilter{
+ Pagination: pagination.PaginationParams{
+ Page: page,
+ PageSize: pageSize,
+ SortOrder: pagination.SortOrderDesc,
+ },
+ Result: c.Query("result"),
+ Endpoint: c.Query("endpoint"),
+ Search: c.Query("search"),
+ }
+ if raw := strings.TrimSpace(c.Query("group_id")); raw != "" {
+ groupID, err := strconv.ParseInt(raw, 10, 64)
+ if err != nil || groupID <= 0 {
+ response.BadRequest(c, "Invalid group_id")
+ return
+ }
+ filter.GroupID = &groupID
+ }
+ if raw := strings.TrimSpace(c.Query("from")); raw != "" {
+ t, _, err := parseContentModerationDate(raw)
+ if err != nil {
+ response.BadRequest(c, "Invalid from")
+ return
+ }
+ filter.From = &t
+ }
+ if raw := strings.TrimSpace(c.Query("to")); raw != "" {
+ t, dateOnly, err := parseContentModerationDate(raw)
+ if err != nil {
+ response.BadRequest(c, "Invalid to")
+ return
+ }
+ if dateOnly {
+ t = t.Add(24*time.Hour - time.Nanosecond)
+ }
+ filter.To = &t
+ }
+ items, pageResult, err := h.service.ListLogs(c.Request.Context(), filter)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Paginated(c, items, pageResult.Total, pageResult.Page, pageResult.PageSize)
+}
+
+func (h *ContentModerationHandler) ListAbuseRiskEvents(c *gin.Context) {
+ page, pageSize := response.ParsePagination(c)
+ filter := service.HFCAbuseRiskEventFilter{
+ Pagination: pagination.PaginationParams{
+ Page: page,
+ PageSize: pageSize,
+ SortOrder: pagination.SortOrderDesc,
+ },
+ Source: c.Query("source"),
+ Severity: c.Query("severity"),
+ Status: c.Query("status"),
+ Search: c.Query("search"),
+ }
+ if raw := strings.TrimSpace(c.Query("from")); raw != "" {
+ t, _, err := parseContentModerationDate(raw)
+ if err != nil {
+ response.BadRequest(c, "Invalid from")
+ return
+ }
+ filter.From = &t
+ }
+ if raw := strings.TrimSpace(c.Query("to")); raw != "" {
+ t, dateOnly, err := parseContentModerationDate(raw)
+ if err != nil {
+ response.BadRequest(c, "Invalid to")
+ return
+ }
+ if dateOnly {
+ t = t.Add(24*time.Hour - time.Nanosecond)
+ }
+ filter.To = &t
+ }
+ items, pageResult, err := h.abuseRiskService.ListEvents(c.Request.Context(), filter)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Paginated(c, items, pageResult.Total, pageResult.Page, pageResult.PageSize)
+}
+
+func (h *ContentModerationHandler) GetAbuseRiskEvent(c *gin.Context) {
+ id, err := parsePositiveIDParam(c, "id")
+ if err != nil {
+ response.BadRequest(c, "Invalid id")
+ return
+ }
+ event, err := h.abuseRiskService.GetEvent(c.Request.Context(), id)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Success(c, event)
+}
+
+func (h *ContentModerationHandler) ApplyAbuseRiskAction(c *gin.Context) {
+ id, err := parsePositiveIDParam(c, "id")
+ if err != nil {
+ response.BadRequest(c, "Invalid id")
+ return
+ }
+ var req hfcAbuseRiskActionRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request: "+err.Error())
+ return
+ }
+ subject, _ := middleware2.GetAuthSubjectFromContext(c)
+ result, err := h.abuseRiskService.ApplyAction(c.Request.Context(), service.HFCAbuseRiskActionInput{
+ EventID: id,
+ Action: req.Action,
+ AdminUserID: subject.UserID,
+ Note: req.Note,
+ })
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Success(c, result)
+}
+
+func (h *ContentModerationHandler) UnbanUser(c *gin.Context) {
+ userID, err := strconv.ParseInt(strings.TrimSpace(c.Param("user_id")), 10, 64)
+ if err != nil || userID <= 0 {
+ response.BadRequest(c, "Invalid user_id")
+ return
+ }
+ result, err := h.service.UnbanUser(c.Request.Context(), userID)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Success(c, result)
+}
+
+func (h *ContentModerationHandler) DeleteFlaggedHash(c *gin.Context) {
+ var req contentModerationHashRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request: "+err.Error())
+ return
+ }
+ result, err := h.service.DeleteFlaggedInputHash(c.Request.Context(), req.InputHash)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Success(c, result)
+}
+
+func (h *ContentModerationHandler) ClearFlaggedHashes(c *gin.Context) {
+ result, err := h.service.ClearFlaggedInputHashes(c.Request.Context())
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Success(c, result)
+}
+
+func parsePositiveIDParam(c *gin.Context, name string) (int64, error) {
+ id, err := strconv.ParseInt(strings.TrimSpace(c.Param(name)), 10, 64)
+ if err != nil || id <= 0 {
+ if err == nil {
+ err = strconv.ErrSyntax
+ }
+ return 0, err
+ }
+ return id, nil
+}
+
+func parseContentModerationDate(raw string) (time.Time, bool, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return time.Time{}, false, nil
+ }
+ if t, err := time.Parse(time.RFC3339, raw); err == nil {
+ return t, false, nil
+ }
+ t, err := time.Parse("2006-01-02", raw)
+ return t, err == nil, err
+}
diff --git a/backend/internal/handler/admin/cursor_account_handler.go b/backend/internal/handler/admin/cursor_account_handler.go
new file mode 100644
index 00000000000..3925a5e93c3
--- /dev/null
+++ b/backend/internal/handler/admin/cursor_account_handler.go
@@ -0,0 +1,255 @@
+package admin
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ appconfig "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/response"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+)
+
+const cursorSidecarAdminCreatePath = "/admin/accounts/cursor"
+
+type ProvisionCursorAccountRequest struct {
+ Name string `json:"name" binding:"required"`
+ Notes *string `json:"notes"`
+ Email string `json:"email" binding:"required"`
+ AccessToken string `json:"access_token" binding:"required"`
+ RefreshToken string `json:"refresh_token" binding:"required"`
+ CursorTokenExpiresAt string `json:"cursor_token_expires_at"`
+ AccountUUID string `json:"account_uuid"`
+ CursorServiceMachineID string `json:"cursor_service_machine_id"`
+ CursorClientVersion string `json:"cursor_client_version"`
+ CursorConfigVersion string `json:"cursor_config_version"`
+ CursorClientID string `json:"cursor_client_id"`
+ CursorMembershipType string `json:"cursor_membership_type"`
+ Extra map[string]any `json:"extra"`
+ ProxyID *int64 `json:"proxy_id"`
+ Concurrency int `json:"concurrency"`
+ Priority int `json:"priority"`
+ RateMultiplier *float64 `json:"rate_multiplier"`
+ LoadFactor *int `json:"load_factor"`
+ GroupIDs []int64 `json:"group_ids"`
+ ExpiresAt *int64 `json:"expires_at"`
+ AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
+ ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"`
+}
+
+type cursorSidecarProvisionResponse struct {
+ Provider string `json:"provider"`
+ AccountRef string `json:"account_ref"`
+ Email string `json:"email"`
+ ExpiresAt string `json:"expires_at"`
+ AccountUUID string `json:"account_uuid"`
+ CursorServiceMachineID string `json:"cursor_service_machine_id"`
+ CursorClientVersion string `json:"cursor_client_version"`
+ CursorMembershipType string `json:"cursor_membership_type"`
+ GeneratedAt string `json:"generated_at"`
+}
+
+// ProvisionCursorAccount writes Cursor credentials into the sidecar, reloads
+// its in-memory pool, then creates the matching Sub2API account with only a
+// sidecar account reference persisted in the main database.
+func (h *AccountHandler) ProvisionCursorAccount(c *gin.Context) {
+ var req ProvisionCursorAccountRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request: "+err.Error())
+ return
+ }
+ if req.RateMultiplier != nil && *req.RateMultiplier < 0 {
+ response.BadRequest(c, "rate_multiplier must be >= 0")
+ return
+ }
+ if strings.TrimSpace(req.Email) == "" || !strings.Contains(req.Email, "@") {
+ response.BadRequest(c, "email is required")
+ return
+ }
+
+ sanitizeExtraBaseRPM(req.Extra)
+ skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
+
+ result, err := executeAdminIdempotent(c, "admin.accounts.cursor.provision", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
+ sidecarAccount, execErr := provisionCursorSidecarAccount(ctx, req)
+ if execErr != nil {
+ return nil, execErr
+ }
+
+ extra := cloneCursorProvisionExtra(req.Extra)
+ if sidecarAccount.Email != "" {
+ extra["cursor_email"] = sidecarAccount.Email
+ }
+ if sidecarAccount.ExpiresAt != "" {
+ extra["cursor_token_expires_at"] = sidecarAccount.ExpiresAt
+ }
+ if sidecarAccount.CursorMembershipType != "" {
+ extra["cursor_membership_type"] = sidecarAccount.CursorMembershipType
+ }
+
+ account, execErr := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{
+ Name: req.Name,
+ Notes: req.Notes,
+ Platform: service.PlatformCursor,
+ Type: service.AccountTypeUpstream,
+ Credentials: map[string]any{"sidecar_account_ref": sidecarAccount.AccountRef},
+ Extra: extra,
+ ProxyID: req.ProxyID,
+ Concurrency: req.Concurrency,
+ Priority: req.Priority,
+ RateMultiplier: req.RateMultiplier,
+ LoadFactor: req.LoadFactor,
+ GroupIDs: req.GroupIDs,
+ ExpiresAt: req.ExpiresAt,
+ AutoPauseOnExpired: req.AutoPauseOnExpired,
+ SkipMixedChannelCheck: skipCheck,
+ })
+ if execErr != nil {
+ return nil, execErr
+ }
+
+ return gin.H{
+ "account": h.buildAccountResponseWithRuntime(ctx, account),
+ "cursor_sidecar": sidecarAccount,
+ }, nil
+ })
+ if err != nil {
+ var mixedErr *service.MixedChannelError
+ if errors.As(err, &mixedErr) {
+ c.JSON(http.StatusConflict, gin.H{
+ "error": "mixed_channel_warning",
+ "message": mixedErr.Error(),
+ })
+ return
+ }
+ response.ErrorFrom(c, err)
+ return
+ }
+
+ if result != nil && result.Replayed {
+ c.Header("X-Idempotency-Replayed", "true")
+ }
+ response.Success(c, result.Data)
+}
+
+func provisionCursorSidecarAccount(ctx context.Context, req ProvisionCursorAccountRequest) (*cursorSidecarProvisionResponse, error) {
+ sidecarURL, err := cursorSidecarAdminURL()
+ if err != nil {
+ return nil, err
+ }
+
+ payload := map[string]any{
+ "email": strings.TrimSpace(req.Email),
+ "access_token": strings.TrimSpace(req.AccessToken),
+ "refresh_token": strings.TrimSpace(req.RefreshToken),
+ "expires_at": strings.TrimSpace(req.CursorTokenExpiresAt),
+ "account_uuid": strings.TrimSpace(req.AccountUUID),
+ "cursor_service_machine_id": strings.TrimSpace(req.CursorServiceMachineID),
+ "cursor_client_version": strings.TrimSpace(req.CursorClientVersion),
+ "cursor_config_version": strings.TrimSpace(req.CursorConfigVersion),
+ "cursor_client_id": strings.TrimSpace(req.CursorClientID),
+ "cursor_membership_type": strings.TrimSpace(req.CursorMembershipType),
+ }
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, sidecarURL, bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ httpReq.Header.Set("Content-Type", "application/json")
+ if key := strings.TrimSpace(os.Getenv("CURSOR_SIDECAR_API_KEY")); key != "" {
+ httpReq.Header.Set("Authorization", "Bearer "+key)
+ httpReq.Header.Set("x-api-key", key)
+ httpReq.Header.Set("X-Cursor-Sidecar-Key", key)
+ }
+
+ resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(httpReq)
+ if err != nil {
+ return nil, fmt.Errorf("cursor sidecar provisioning request failed: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
+ if err != nil {
+ return nil, fmt.Errorf("cursor sidecar provisioning response read failed: %w", err)
+ }
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+ return nil, fmt.Errorf("cursor sidecar provisioning failed: status %d: %s", resp.StatusCode, cursorSidecarSafeError(respBody))
+ }
+
+ var sidecarAccount cursorSidecarProvisionResponse
+ if err := json.Unmarshal(respBody, &sidecarAccount); err != nil {
+ return nil, fmt.Errorf("cursor sidecar provisioning returned invalid json: %w", err)
+ }
+ if strings.TrimSpace(sidecarAccount.AccountRef) == "" {
+ return nil, errors.New("cursor sidecar provisioning did not return account_ref")
+ }
+ return &sidecarAccount, nil
+}
+
+func cursorSidecarAdminURL() (string, error) {
+ raw := strings.TrimSpace(os.Getenv("CURSOR_SIDECAR_URL"))
+ if raw == "" {
+ return "", errors.New("CURSOR_SIDECAR_URL is not configured")
+ }
+ if err := appconfig.ValidateAbsoluteHTTPURL(raw); err != nil {
+ return "", err
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return "", err
+ }
+ if u.User != nil || u.RawQuery != "" || u.ForceQuery {
+ return "", errors.New("CURSOR_SIDECAR_URL must not include userinfo or query")
+ }
+ u.Path = strings.TrimRight(u.Path, "/") + cursorSidecarAdminCreatePath
+ u.RawQuery = ""
+ u.Fragment = ""
+ return u.String(), nil
+}
+
+func cursorSidecarSafeError(body []byte) string {
+ var parsed struct {
+ Error struct {
+ Message string `json:"message"`
+ } `json:"error"`
+ Message string `json:"message"`
+ }
+ if err := json.Unmarshal(body, &parsed); err == nil {
+ if msg := strings.TrimSpace(parsed.Error.Message); msg != "" {
+ return msg
+ }
+ if msg := strings.TrimSpace(parsed.Message); msg != "" {
+ return msg
+ }
+ }
+ text := strings.TrimSpace(string(body))
+ if len(text) > 300 {
+ text = text[:300]
+ }
+ if text == "" {
+ return "empty response body"
+ }
+ return strconv.Quote(text)
+}
+
+func cloneCursorProvisionExtra(extra map[string]any) map[string]any {
+ cloned := make(map[string]any, len(extra)+3)
+ for key, value := range extra {
+ cloned[key] = value
+ }
+ return cloned
+}
diff --git a/backend/internal/handler/admin/cursor_account_handler_test.go b/backend/internal/handler/admin/cursor_account_handler_test.go
new file mode 100644
index 00000000000..be548a6138c
--- /dev/null
+++ b/backend/internal/handler/admin/cursor_account_handler_test.go
@@ -0,0 +1,69 @@
+package admin
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAccountHandlerProvisionCursorAccountCreatesSidecarAndMainAccount(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ var sidecarAuth string
+ var sidecarPayload map[string]any
+ sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, http.MethodPost, r.Method)
+ require.Equal(t, "/admin/accounts/cursor", r.URL.Path)
+ sidecarAuth = r.Header.Get("Authorization")
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&sidecarPayload))
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"provider":"cursor","account_ref":"cursor-account@example.com","email":"cursor-account@example.com","expires_at":"2026-05-15T11:00:00Z","cursor_client_version":"3.3.30","cursor_membership_type":"pro"}`))
+ }))
+ t.Cleanup(sidecar.Close)
+ t.Setenv("CURSOR_SIDECAR_URL", sidecar.URL)
+ t.Setenv("CURSOR_SIDECAR_API_KEY", "sidecar-secret")
+
+ adminSvc := newStubAdminService()
+ handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
+ router := gin.New()
+ router.POST("/api/v1/admin/accounts/cursor-sidecar", handler.ProvisionCursorAccount)
+
+ raw, err := json.Marshal(map[string]any{
+ "name": "cursor-prod-002",
+ "email": "cursor-account@example.com",
+ "access_token": "cursor-access",
+ "refresh_token": "cursor-refresh",
+ "cursor_token_expires_at": "2026-05-15T11:00:00Z",
+ "cursor_service_machine_id": "machine-1",
+ "cursor_client_version": "3.3.30",
+ "group_ids": []int64{21},
+ "concurrency": 1,
+ })
+ require.NoError(t, err)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/cursor-sidecar", bytes.NewReader(raw))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.Equal(t, "Bearer sidecar-secret", sidecarAuth)
+ require.Equal(t, "cursor-account@example.com", sidecarPayload["email"])
+ require.Equal(t, "cursor-access", sidecarPayload["access_token"])
+ require.Equal(t, "cursor-refresh", sidecarPayload["refresh_token"])
+ require.Len(t, adminSvc.createdAccounts, 1)
+
+ created := adminSvc.createdAccounts[0]
+ require.Equal(t, "cursor-prod-002", created.Name)
+ require.Equal(t, "cursor", created.Platform)
+ require.Equal(t, "upstream", created.Type)
+ require.Equal(t, []int64{int64(21)}, created.GroupIDs)
+ require.Equal(t, "cursor-account@example.com", created.Credentials["sidecar_account_ref"])
+ require.Equal(t, "cursor-account@example.com", created.Extra["cursor_email"])
+ require.Equal(t, "pro", created.Extra["cursor_membership_type"])
+}
diff --git a/backend/internal/handler/admin/dashboard_handler.go b/backend/internal/handler/admin/dashboard_handler.go
index 460f63578ef..3f3f295ee0b 100644
--- a/backend/internal/handler/admin/dashboard_handler.go
+++ b/backend/internal/handler/admin/dashboard_handler.go
@@ -273,7 +273,7 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
// Parse optional filter params
var userID, apiKeyID, accountID, groupID int64
- modelSource := usagestats.ModelSourceRequested
+ modelSource := usagestats.ModelSourceUpstream
var requestType *int16
var stream *bool
var billingType *int8
@@ -627,7 +627,7 @@ func (h *DashboardHandler) GetUserBreakdown(c *gin.Context) {
}
}
dim.Model = c.Query("model")
- rawModelSource := strings.TrimSpace(c.DefaultQuery("model_source", usagestats.ModelSourceRequested))
+ rawModelSource := strings.TrimSpace(c.DefaultQuery("model_source", usagestats.ModelSourceUpstream))
if !usagestats.IsValidModelSource(rawModelSource) {
response.BadRequest(c, "Invalid model_source, use requested/upstream/mapping")
return
diff --git a/backend/internal/handler/admin/dashboard_handler_request_type_test.go b/backend/internal/handler/admin/dashboard_handler_request_type_test.go
index 6056f725b5b..a747c79140e 100644
--- a/backend/internal/handler/admin/dashboard_handler_request_type_test.go
+++ b/backend/internal/handler/admin/dashboard_handler_request_type_test.go
@@ -19,6 +19,7 @@ type dashboardUsageRepoCapture struct {
trendStream *bool
modelRequestType *int16
modelStream *bool
+ modelSource string
rankingLimit int
ranking []usagestats.UserSpendingRankingItem
rankingTotal float64
@@ -52,6 +53,21 @@ func (s *dashboardUsageRepoCapture) GetModelStatsWithFilters(
return []usagestats.ModelStat{}, nil
}
+func (s *dashboardUsageRepoCapture) GetModelStatsWithFiltersBySource(
+ ctx context.Context,
+ startTime, endTime time.Time,
+ userID, apiKeyID, accountID, groupID int64,
+ requestType *int16,
+ stream *bool,
+ billingType *int8,
+ source string,
+) ([]usagestats.ModelStat, error) {
+ s.modelRequestType = requestType
+ s.modelStream = stream
+ s.modelSource = source
+ return []usagestats.ModelStat{}, nil
+}
+
func (s *dashboardUsageRepoCapture) GetUserSpendingRanking(
ctx context.Context,
startTime, endTime time.Time,
@@ -127,6 +143,30 @@ func TestDashboardModelStatsRequestTypePriority(t *testing.T) {
require.Nil(t, repo.modelStream)
}
+func TestDashboardModelStatsDefaultsToUpstreamSource(t *testing.T) {
+ repo := &dashboardUsageRepoCapture{}
+ router := newDashboardRequestTypeTestRouter(repo)
+
+ req := httptest.NewRequest(http.MethodGet, "/admin/dashboard/models", nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.Equal(t, usagestats.ModelSourceUpstream, repo.modelSource)
+}
+
+func TestDashboardModelStatsRequestedSourcePassesThrough(t *testing.T) {
+ repo := &dashboardUsageRepoCapture{}
+ router := newDashboardRequestTypeTestRouter(repo)
+
+ req := httptest.NewRequest(http.MethodGet, "/admin/dashboard/models?model_source=requested", nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.Equal(t, usagestats.ModelSourceRequested, repo.modelSource)
+}
+
func TestDashboardModelStatsInvalidRequestType(t *testing.T) {
repo := &dashboardUsageRepoCapture{}
router := newDashboardRequestTypeTestRouter(repo)
diff --git a/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go b/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go
index b3a05111b30..ba80e4b531e 100644
--- a/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go
+++ b/backend/internal/handler/admin/dashboard_handler_user_breakdown_test.go
@@ -73,7 +73,7 @@ func TestGetUserBreakdown_ModelFilter(t *testing.T) {
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, "claude-opus-4-6", repo.capturedDim.Model)
- require.Equal(t, usagestats.ModelSourceRequested, repo.capturedDim.ModelType)
+ require.Equal(t, usagestats.ModelSourceUpstream, repo.capturedDim.ModelType)
require.Equal(t, int64(0), repo.capturedDim.GroupID)
}
diff --git a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go
index 517ae7bd14b..51d28250be4 100644
--- a/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go
+++ b/backend/internal/handler/admin/dashboard_snapshot_v2_handler.go
@@ -200,7 +200,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
filters.APIKeyID,
filters.AccountID,
filters.GroupID,
- usagestats.ModelSourceRequested,
+ usagestats.ModelSourceUpstream,
filters.RequestType,
filters.Stream,
filters.BillingType,
diff --git a/backend/internal/handler/admin/data_management_handler.go b/backend/internal/handler/admin/data_management_handler.go
index 02fc766f940..eec2995d4b2 100644
--- a/backend/internal/handler/admin/data_management_handler.go
+++ b/backend/internal/handler/admin/data_management_handler.go
@@ -468,7 +468,7 @@ func (h *DataManagementHandler) ListBackupJobs(c *gin.Context) {
pageSize := int32(20)
if raw := strings.TrimSpace(c.Query("page_size")); raw != "" {
- v, err := strconv.Atoi(raw)
+ v, err := strconv.ParseInt(raw, 10, 32)
if err != nil || v <= 0 {
response.BadRequest(c, "Invalid page_size")
return
diff --git a/backend/internal/handler/admin/error_passthrough_handler.go b/backend/internal/handler/admin/error_passthrough_handler.go
index 25aaa5c72b8..ae1204a57cd 100644
--- a/backend/internal/handler/admin/error_passthrough_handler.go
+++ b/backend/internal/handler/admin/error_passthrough_handler.go
@@ -2,6 +2,7 @@ package admin
import (
"strconv"
+ "strings"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
@@ -94,6 +95,10 @@ func (h *ErrorPassthroughHandler) Create(c *gin.Context) {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
+ if req.PassthroughBody != nil && *req.PassthroughBody {
+ response.BadRequest(c, "Raw upstream error body passthrough is disabled for security")
+ return
+ }
rule := &model.ErrorPassthroughRule{
Name: req.Name,
@@ -119,16 +124,12 @@ func (h *ErrorPassthroughHandler) Create(c *gin.Context) {
} else {
rule.PassthroughCode = true
}
- if req.PassthroughBody != nil {
- rule.PassthroughBody = *req.PassthroughBody
- } else {
- rule.PassthroughBody = true
- }
+ rule.PassthroughBody = false
if req.SkipMonitoring != nil {
rule.SkipMonitoring = *req.SkipMonitoring
}
rule.ResponseCode = req.ResponseCode
- rule.CustomMessage = req.CustomMessage
+ rule.CustomMessage = safeErrorPassthroughCustomMessage(req.CustomMessage)
rule.Description = req.Description
// 确保切片不为 nil
@@ -169,6 +170,10 @@ func (h *ErrorPassthroughHandler) Update(c *gin.Context) {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
+ if req.PassthroughBody != nil && *req.PassthroughBody {
+ response.BadRequest(c, "Raw upstream error body passthrough is disabled for security")
+ return
+ }
// 先获取现有规则
existing, err := h.service.GetByID(c.Request.Context(), id)
@@ -193,8 +198,8 @@ func (h *ErrorPassthroughHandler) Update(c *gin.Context) {
Platforms: existing.Platforms,
PassthroughCode: existing.PassthroughCode,
ResponseCode: existing.ResponseCode,
- PassthroughBody: existing.PassthroughBody,
- CustomMessage: existing.CustomMessage,
+ PassthroughBody: false,
+ CustomMessage: safeErrorPassthroughCustomMessage(existing.CustomMessage),
SkipMonitoring: existing.SkipMonitoring,
Description: existing.Description,
}
@@ -227,9 +232,6 @@ func (h *ErrorPassthroughHandler) Update(c *gin.Context) {
if req.ResponseCode != nil {
rule.ResponseCode = req.ResponseCode
}
- if req.PassthroughBody != nil {
- rule.PassthroughBody = *req.PassthroughBody
- }
if req.CustomMessage != nil {
rule.CustomMessage = req.CustomMessage
}
@@ -264,6 +266,14 @@ func (h *ErrorPassthroughHandler) Update(c *gin.Context) {
response.Success(c, updated)
}
+func safeErrorPassthroughCustomMessage(message *string) *string {
+ if message == nil || strings.TrimSpace(*message) == "" {
+ fallback := service.DefaultErrorPassthroughClientMessage
+ return &fallback
+ }
+ return message
+}
+
// Delete 删除规则
// DELETE /api/v1/admin/error-passthrough-rules/:id
func (h *ErrorPassthroughHandler) Delete(c *gin.Context) {
diff --git a/backend/internal/handler/admin/error_passthrough_handler_security_test.go b/backend/internal/handler/admin/error_passthrough_handler_security_test.go
new file mode 100644
index 00000000000..ab97d2685fd
--- /dev/null
+++ b/backend/internal/handler/admin/error_passthrough_handler_security_test.go
@@ -0,0 +1,76 @@
+package admin
+
+import (
+ "bytes"
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/model"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+type errorPassthroughSecurityRepo struct {
+ created *model.ErrorPassthroughRule
+}
+
+func (r *errorPassthroughSecurityRepo) List(context.Context) ([]*model.ErrorPassthroughRule, error) {
+ return nil, nil
+}
+
+func (r *errorPassthroughSecurityRepo) GetByID(context.Context, int64) (*model.ErrorPassthroughRule, error) {
+ return nil, nil
+}
+
+func (r *errorPassthroughSecurityRepo) Create(_ context.Context, rule *model.ErrorPassthroughRule) (*model.ErrorPassthroughRule, error) {
+ r.created = rule
+ return rule, nil
+}
+
+func (r *errorPassthroughSecurityRepo) Update(_ context.Context, rule *model.ErrorPassthroughRule) (*model.ErrorPassthroughRule, error) {
+ return rule, nil
+}
+
+func (r *errorPassthroughSecurityRepo) Delete(context.Context, int64) error { return nil }
+
+func TestErrorPassthroughCreateDefaultsToSafeCustomMessage(t *testing.T) {
+ repo := &errorPassthroughSecurityRepo{}
+ handler := NewErrorPassthroughHandler(service.NewErrorPassthroughService(repo, nil))
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{
+ "name":"safe default",
+ "error_codes":[500]
+ }`))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ handler.Create(c)
+
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ require.NotNil(t, repo.created)
+ assert.False(t, repo.created.PassthroughBody)
+ require.NotNil(t, repo.created.CustomMessage)
+ assert.Equal(t, "Upstream request failed", *repo.created.CustomMessage)
+}
+
+func TestErrorPassthroughCreateRejectsRawBodyPassthrough(t *testing.T) {
+ repo := &errorPassthroughSecurityRepo{}
+ handler := NewErrorPassthroughHandler(service.NewErrorPassthroughService(repo, nil))
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{
+ "name":"unsafe raw body",
+ "error_codes":[500],
+ "passthrough_body":true
+ }`))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ handler.Create(c)
+
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+ assert.Nil(t, repo.created)
+}
diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go
index 65e5ec7802b..40c8eeaa953 100644
--- a/backend/internal/handler/admin/group_handler.go
+++ b/backend/internal/handler/admin/group_handler.go
@@ -84,7 +84,7 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
- Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
+ Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity kiro cursor"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
@@ -92,6 +92,9 @@ type CreateGroupRequest struct {
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
// 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置)
+ AllowImageGeneration bool `json:"allow_image_generation"`
+ ImageRateIndependent bool `json:"image_rate_independent"`
+ ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
@@ -120,7 +123,7 @@ type CreateGroupRequest struct {
type UpdateGroupRequest struct {
Name string `json:"name"`
Description string `json:"description"`
- Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
+ Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity kiro cursor"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
@@ -129,6 +132,9 @@ type UpdateGroupRequest struct {
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
// 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置)
+ AllowImageGeneration *bool `json:"allow_image_generation"`
+ ImageRateIndependent *bool `json:"image_rate_independent"`
+ ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
@@ -251,6 +257,9 @@ func (h *GroupHandler) Create(c *gin.Context) {
DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(),
WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(),
MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(),
+ AllowImageGeneration: req.AllowImageGeneration,
+ ImageRateIndependent: req.ImageRateIndependent,
+ ImageRateMultiplier: req.ImageRateMultiplier,
ImagePrice1K: req.ImagePrice1K,
ImagePrice2K: req.ImagePrice2K,
ImagePrice4K: req.ImagePrice4K,
@@ -303,6 +312,9 @@ func (h *GroupHandler) Update(c *gin.Context) {
DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(),
WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(),
MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(),
+ AllowImageGeneration: req.AllowImageGeneration,
+ ImageRateIndependent: req.ImageRateIndependent,
+ ImageRateMultiplier: req.ImageRateMultiplier,
ImagePrice1K: req.ImagePrice1K,
ImagePrice2K: req.ImagePrice2K,
ImagePrice4K: req.ImagePrice4K,
@@ -412,7 +424,7 @@ func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
outKeys := make([]dto.APIKey, 0, len(keys))
for i := range keys {
- outKeys = append(outKeys, *dto.APIKeyFromService(&keys[i]))
+ outKeys = append(outKeys, *dto.APIKeyFromServiceMasked(&keys[i]))
}
response.Paginated(c, outKeys, total, page, pageSize)
}
diff --git a/backend/internal/handler/admin/group_handler_limit_test.go b/backend/internal/handler/admin/group_handler_limit_test.go
new file mode 100644
index 00000000000..496f38b9d23
--- /dev/null
+++ b/backend/internal/handler/admin/group_handler_limit_test.go
@@ -0,0 +1,27 @@
+//go:build unit
+
+package admin
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestUpdateGroupRequestOmittedUsageLimitsRemainUnset(t *testing.T) {
+ var req UpdateGroupRequest
+ require.NoError(t, json.Unmarshal([]byte(`{"description":"metadata only"}`), &req))
+
+ require.Nil(t, req.DailyLimitUSD.ToServiceInput())
+ require.Nil(t, req.WeeklyLimitUSD.ToServiceInput())
+ require.Nil(t, req.MonthlyLimitUSD.ToServiceInput())
+}
+
+func TestUpdateGroupRequestExplicitNegativeUsageLimitRequestsRemoval(t *testing.T) {
+ var req UpdateGroupRequest
+ require.NoError(t, json.Unmarshal([]byte(`{"daily_limit_usd":-1}`), &req))
+
+ require.NotNil(t, req.DailyLimitUSD.ToServiceInput())
+ require.Equal(t, -1.0, *req.DailyLimitUSD.ToServiceInput())
+}
diff --git a/backend/internal/handler/admin/idempotency_helper.go b/backend/internal/handler/admin/idempotency_helper.go
index aa8eeaaf79f..10514ee179a 100644
--- a/backend/internal/handler/admin/idempotency_helper.go
+++ b/backend/internal/handler/admin/idempotency_helper.go
@@ -36,21 +36,36 @@ func executeAdminIdempotent(
}
return &service.IdempotencyExecuteResult{Data: data}, nil
}
+ return executeAdminIdempotentWithCoordinator(c.Request.Context(), c, coordinator, scope, payload, ttl, c.GetHeader("Idempotency-Key"), false, execute)
+}
+
+func executeAdminIdempotentWithCoordinator(
+ ctx context.Context,
+ c *gin.Context,
+ coordinator *service.IdempotencyCoordinator,
+ scope string,
+ payload any,
+ ttl time.Duration,
+ idempotencyKey string,
+ persistent bool,
+ execute func(context.Context) (any, error),
+) (*service.IdempotencyExecuteResult, error) {
actorScope := "admin:0"
if subject, ok := middleware2.GetAuthSubjectFromContext(c); ok {
actorScope = "admin:" + strconv.FormatInt(subject.UserID, 10)
}
- return coordinator.Execute(c.Request.Context(), service.IdempotencyExecuteOptions{
+ return coordinator.Execute(ctx, service.IdempotencyExecuteOptions{
Scope: scope,
ActorScope: actorScope,
Method: c.Request.Method,
Route: c.FullPath(),
- IdempotencyKey: c.GetHeader("Idempotency-Key"),
+ IdempotencyKey: idempotencyKey,
Payload: payload,
RequireKey: true,
TTL: ttl,
+ Persistent: persistent,
}, execute)
}
@@ -64,6 +79,128 @@ func executeAdminIdempotentJSON(
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, idempotencyStoreUnavailableFailClose, execute)
}
+// executeAdminStrictIdempotentJSON is the fail-closed variant for financial
+// writes. It does not honor the global observe-only bypass: callers must send
+// a valid Idempotency-Key and the coordinator must be available before any
+// business side effect can run.
+func executeAdminStrictIdempotentJSON(
+ c *gin.Context,
+ scope string,
+ payload any,
+ ttl time.Duration,
+ runInTransaction func(context.Context, func(context.Context) error) error,
+ execute func(context.Context) (any, error),
+) {
+ executeAdminStrictIdempotentJSONWithPostCommit(c, scope, payload, ttl, runInTransaction, nil, execute)
+}
+
+// executeAdminStrictIdempotentJSONWithPostCommit runs postCommit only after
+// the business mutation and its persistent idempotency result have committed.
+// A post-commit cache failure must not turn an already committed financial
+// mutation into an API failure, so it is logged and the committed response is
+// still returned.
+func executeAdminStrictIdempotentJSONWithPostCommit(
+ c *gin.Context,
+ scope string,
+ payload any,
+ ttl time.Duration,
+ runInTransaction func(context.Context, func(context.Context) error) error,
+ postCommit func(context.Context) error,
+ execute func(context.Context) (any, error),
+) {
+ key, err := service.NormalizeIdempotencyKey(c.GetHeader("Idempotency-Key"))
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ if key == "" {
+ response.ErrorFrom(c, service.ErrIdempotencyKeyRequired)
+ return
+ }
+ coordinator := service.DefaultIdempotencyCoordinator()
+ if coordinator == nil {
+ service.RecordIdempotencyStoreUnavailable(c.FullPath(), scope, "coordinator_nil")
+ response.ErrorFrom(c, service.ErrIdempotencyStoreUnavail)
+ return
+ }
+ if runInTransaction == nil {
+ response.ErrorFrom(c, infraerrors.ServiceUnavailable("ASSIGNMENT_TRANSACTION_UNAVAILABLE", "assignment transaction is unavailable"))
+ return
+ }
+ var result *service.IdempotencyExecuteResult
+ err = runInTransaction(c.Request.Context(), func(txCtx context.Context) error {
+ var executeErr error
+ result, executeErr = executeAdminIdempotentWithCoordinator(txCtx, c, coordinator, scope, payload, ttl, key, true, execute)
+ return executeErr
+ })
+ if err == nil && postCommit != nil {
+ postCommitCtx, cancel := context.WithTimeout(context.WithoutCancel(c.Request.Context()), 5*time.Second)
+ if postCommitErr := postCommit(postCommitCtx); postCommitErr != nil {
+ logger.LegacyPrintf("handler.idempotency", "[Idempotency] post-commit hook failed: method=%s route=%s scope=%s error=%v", c.Request.Method, c.FullPath(), scope, postCommitErr)
+ }
+ cancel()
+ }
+ writeAdminIdempotentJSONResult(c, scope, idempotencyStoreUnavailableFailClose, execute, result, err)
+}
+
+// executeAdminStrictIdempotentJSONNonTransactional protects independently
+// atomic financial operations and partial batches that cannot share one outer
+// transaction. A crash after the business commit leaves the persistent claim
+// in manual-recovery state instead of ever executing it again.
+func executeAdminStrictIdempotentJSONNonTransactional(
+ c *gin.Context,
+ scope string,
+ payload any,
+ ttl time.Duration,
+ execute func(context.Context) (any, error),
+) {
+ executeAdminStrictIdempotentJSONNonTransactionalWithKey(
+ c,
+ scope,
+ payload,
+ ttl,
+ c.GetHeader("Idempotency-Key"),
+ execute,
+ )
+}
+
+func executeAdminStrictIdempotentJSONNonTransactionalWithKey(
+ c *gin.Context,
+ scope string,
+ payload any,
+ ttl time.Duration,
+ rawKey string,
+ execute func(context.Context) (any, error),
+) {
+ key, err := service.NormalizeIdempotencyKey(rawKey)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ if key == "" {
+ response.ErrorFrom(c, service.ErrIdempotencyKeyRequired)
+ return
+ }
+ coordinator := service.DefaultIdempotencyCoordinator()
+ if coordinator == nil {
+ service.RecordIdempotencyStoreUnavailable(c.FullPath(), scope, "coordinator_nil")
+ response.ErrorFrom(c, service.ErrIdempotencyStoreUnavail)
+ return
+ }
+ result, err := executeAdminIdempotentWithCoordinator(
+ c.Request.Context(),
+ c,
+ coordinator,
+ scope,
+ payload,
+ ttl,
+ key,
+ true,
+ execute,
+ )
+ writeAdminIdempotentJSONResult(c, scope, idempotencyStoreUnavailableFailClose, execute, result, err)
+}
+
func executeAdminIdempotentJSONFailOpenOnStoreUnavailable(
c *gin.Context,
scope string,
@@ -83,6 +220,17 @@ func executeAdminIdempotentJSONWithMode(
execute func(context.Context) (any, error),
) {
result, err := executeAdminIdempotent(c, scope, payload, ttl, execute)
+ writeAdminIdempotentJSONResult(c, scope, mode, execute, result, err)
+}
+
+func writeAdminIdempotentJSONResult(
+ c *gin.Context,
+ scope string,
+ mode idempotencyStoreUnavailableMode,
+ execute func(context.Context) (any, error),
+ result *service.IdempotencyExecuteResult,
+ err error,
+) {
if err != nil {
if infraerrors.Code(err) == infraerrors.Code(service.ErrIdempotencyStoreUnavail) {
strategy := "fail_close"
diff --git a/backend/internal/handler/admin/idempotency_helper_test.go b/backend/internal/handler/admin/idempotency_helper_test.go
index 7dd86e16c80..a32125fccc2 100644
--- a/backend/internal/handler/admin/idempotency_helper_test.go
+++ b/backend/internal/handler/admin/idempotency_helper_test.go
@@ -283,3 +283,65 @@ func TestExecuteAdminIdempotentJSONConcurrentRetryOnlyOneSideEffect(t *testing.T
require.Equal(t, "true", headers3.Get("X-Idempotency-Replayed"))
require.Equal(t, int32(1), executed.Load())
}
+
+func TestExecuteAdminStrictIdempotentJSONPostCommitRunsAfterCommitAndOnReplay(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ repo := newMemoryIdempotencyRepoStub()
+ service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig()))
+ t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
+
+ var events []string
+ router := gin.New()
+ router.POST("/strict", func(c *gin.Context) {
+ runInTransaction := func(ctx context.Context, execute func(context.Context) error) error {
+ events = append(events, "transaction_started")
+ if err := execute(ctx); err != nil {
+ return err
+ }
+ events = append(events, "transaction_committed")
+ return nil
+ }
+ executeAdminStrictIdempotentJSONWithPostCommit(
+ c,
+ "admin.subscriptions.extend",
+ map[string]any{"subscription_id": 7, "days": 1},
+ time.Hour,
+ runInTransaction,
+ func(context.Context) error {
+ events = append(events, "cache_invalidated")
+ return nil
+ },
+ func(context.Context) (any, error) {
+ events = append(events, "business_mutated")
+ return gin.H{"ok": true}, nil
+ },
+ )
+ })
+
+ call := func() *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodPost, "/strict", nil)
+ req.Header.Set("Idempotency-Key", "extend-post-commit")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ return rec
+ }
+
+ first := call()
+ require.Equal(t, http.StatusOK, first.Code)
+ require.Equal(t, []string{
+ "transaction_started",
+ "business_mutated",
+ "transaction_committed",
+ "cache_invalidated",
+ }, events)
+
+ events = nil
+ replay := call()
+ require.Equal(t, http.StatusOK, replay.Code)
+ require.Equal(t, "true", replay.Header().Get("X-Idempotency-Replayed"))
+ require.Equal(t, []string{
+ "transaction_started",
+ "transaction_committed",
+ "cache_invalidated",
+ }, events, "a replay must heal an interrupted post-commit cache eviction without re-running the mutation")
+}
diff --git a/backend/internal/handler/admin/ops_runtime_logging_handler_test.go b/backend/internal/handler/admin/ops_runtime_logging_handler_test.go
index 0e84b4f972e..9a3dadd7ca3 100644
--- a/backend/internal/handler/admin/ops_runtime_logging_handler_test.go
+++ b/backend/internal/handler/admin/ops_runtime_logging_handler_test.go
@@ -4,8 +4,10 @@ import (
"bytes"
"context"
"encoding/json"
+ "math"
"net/http"
"net/http/httptest"
+ "strconv"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
@@ -171,3 +173,17 @@ func TestOpsRuntimeLoggingHandler_UpdateAndResetSuccess(t *testing.T) {
t.Fatalf("reset status=%d, want 200, body=%s", w.Code, w.Body.String())
}
}
+
+func TestLoadOpsWSRuntimeLimitsRejectsInt32Overflow(t *testing.T) {
+ t.Setenv(envOpsWSMaxConns, strconv.FormatInt(int64(math.MaxInt32)+1, 10))
+ t.Setenv(envOpsWSMaxConnsPerIP, strconv.FormatInt(int64(math.MaxInt32)+1, 10))
+
+ limits := loadOpsWSRuntimeLimitsFromEnv()
+
+ if limits.MaxConns != defaultMaxWSConns {
+ t.Fatalf("MaxConns=%d, want safe default %d", limits.MaxConns, defaultMaxWSConns)
+ }
+ if limits.MaxConnsPerIP != defaultMaxWSConnsPerIP {
+ t.Fatalf("MaxConnsPerIP=%d, want safe default %d", limits.MaxConnsPerIP, defaultMaxWSConnsPerIP)
+ }
+}
diff --git a/backend/internal/handler/admin/ops_ws_handler.go b/backend/internal/handler/admin/ops_ws_handler.go
index 75fd7ea002e..5efcb446f10 100644
--- a/backend/internal/handler/admin/ops_ws_handler.go
+++ b/backend/internal/handler/admin/ops_ws_handler.go
@@ -47,8 +47,8 @@ var upgrader = websocket.Upgrader{
return isAllowedOpsWSOrigin(r)
},
// Subprotocol negotiation:
- // - The frontend passes ["sub2api-admin", "jwt."].
- // - We always select "sub2api-admin" so the token is never echoed back in the handshake response.
+ // - The frontend passes ["sub2api-admin", "ticket."].
+ // - We always select "sub2api-admin" so the ticket is never echoed back.
Subprotocols: []string{"sub2api-admin"},
}
@@ -688,14 +688,14 @@ func loadOpsWSRuntimeLimitsFromEnv() opsWSRuntimeLimits {
}
if v := strings.TrimSpace(os.Getenv(envOpsWSMaxConns)); v != "" {
- if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
+ if parsed, err := strconv.ParseInt(v, 10, 32); err == nil && parsed > 0 {
cfg.MaxConns = int32(parsed)
} else {
logger.LegacyPrintf("handler.admin.ops_ws", "[OpsWS] invalid %s=%q (expected int>0); using default=%d", envOpsWSMaxConns, v, cfg.MaxConns)
}
}
if v := strings.TrimSpace(os.Getenv(envOpsWSMaxConnsPerIP)); v != "" {
- if parsed, err := strconv.Atoi(v); err == nil && parsed >= 0 {
+ if parsed, err := strconv.ParseInt(v, 10, 32); err == nil && parsed >= 0 {
cfg.MaxConnsPerIP = int32(parsed)
} else {
logger.LegacyPrintf("handler.admin.ops_ws", "[OpsWS] invalid %s=%q (expected int>=0); using default=%d", envOpsWSMaxConnsPerIP, v, cfg.MaxConnsPerIP)
diff --git a/backend/internal/handler/admin/payment_dashboard_security_test.go b/backend/internal/handler/admin/payment_dashboard_security_test.go
new file mode 100644
index 00000000000..43fc0adf63d
--- /dev/null
+++ b/backend/internal/handler/admin/payment_dashboard_security_test.go
@@ -0,0 +1,32 @@
+//go:build unit
+
+package admin
+
+import (
+ "testing"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParsePaymentDashboardDaysRejectsInvalidOrUnboundedValues(t *testing.T) {
+ t.Parallel()
+
+ for _, raw := range []string{"0", "-1", "not-a-number", "367", "999999999999999999999999"} {
+ raw := raw
+ t.Run(raw, func(t *testing.T) {
+ t.Parallel()
+ _, err := parsePaymentDashboardDays(raw)
+ require.Equal(t, "INVALID_DASHBOARD_DAYS", infraerrors.Reason(err))
+ })
+ }
+
+ days, err := parsePaymentDashboardDays("")
+ require.NoError(t, err)
+ require.Equal(t, 30, days)
+
+ days, err = parsePaymentDashboardDays("366")
+ require.NoError(t, err)
+ require.Equal(t, service.MaxPaymentDashboardDays, days)
+}
diff --git a/backend/internal/handler/admin/payment_handler.go b/backend/internal/handler/admin/payment_handler.go
index 84359cd93bd..5219d43767a 100644
--- a/backend/internal/handler/admin/payment_handler.go
+++ b/backend/internal/handler/admin/payment_handler.go
@@ -1,9 +1,11 @@
package admin
import (
+ "fmt"
"strconv"
dbent "github.com/Wei-Shaw/sub2api/ent"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -29,11 +31,10 @@ func NewPaymentHandler(paymentService *service.PaymentService, configService *se
// GetDashboard returns payment dashboard statistics.
// GET /api/v1/admin/payment/dashboard
func (h *PaymentHandler) GetDashboard(c *gin.Context) {
- days := 30
- if d := c.Query("days"); d != "" {
- if v, err := strconv.Atoi(d); err == nil && v > 0 {
- days = v
- }
+ days, err := parsePaymentDashboardDays(c.Query("days"))
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
}
stats, err := h.paymentService.GetDashboardStats(c.Request.Context(), days)
if err != nil {
@@ -43,6 +44,20 @@ func (h *PaymentHandler) GetDashboard(c *gin.Context) {
response.Success(c, stats)
}
+func parsePaymentDashboardDays(raw string) (int, error) {
+ if raw == "" {
+ return 30, nil
+ }
+ days, err := strconv.Atoi(raw)
+ if err != nil || days <= 0 || days > service.MaxPaymentDashboardDays {
+ return 0, infraerrors.BadRequest(
+ "INVALID_DASHBOARD_DAYS",
+ fmt.Sprintf("days must be between 1 and %d", service.MaxPaymentDashboardDays),
+ )
+ }
+ return days, nil
+}
+
// --- Orders ---
// ListOrders returns a paginated list of all payment orders.
@@ -185,7 +200,7 @@ func (h *PaymentHandler) ListPlans(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
- response.Success(c, plans)
+ response.Success(c, service.NewSubscriptionPlanResponses(plans))
}
// CreatePlan creates a new subscription plan.
@@ -201,7 +216,7 @@ func (h *PaymentHandler) CreatePlan(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
- response.Created(c, plan)
+ response.Created(c, service.NewSubscriptionPlanResponse(plan))
}
// UpdatePlan updates an existing subscription plan.
@@ -221,7 +236,7 @@ func (h *PaymentHandler) UpdatePlan(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
- response.Success(c, plan)
+ response.Success(c, service.NewSubscriptionPlanResponse(plan))
}
// DeletePlan deletes a subscription plan.
diff --git a/backend/internal/handler/admin/redeem_handler.go b/backend/internal/handler/admin/redeem_handler.go
index 24365f3da41..651a1383c49 100644
--- a/backend/internal/handler/admin/redeem_handler.go
+++ b/backend/internal/handler/admin/redeem_handler.go
@@ -34,10 +34,12 @@ func NewRedeemHandler(adminService service.AdminService, redeemService *service.
// GenerateRedeemCodesRequest represents generate redeem codes request
type GenerateRedeemCodesRequest struct {
Count int `json:"count" binding:"required,min=1,max=100"`
- Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"`
+ Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation wallet"`
Value float64 `json:"value"`
GroupID *int64 `json:"group_id"` // 订阅类型必填
ValidityDays int `json:"validity_days"` // 订阅类型使用,正数增加/负数退款扣减
+ // PlanID 钱包模式额度卡 (type='wallet') 必填,链动小铺 credits SKU(B2.7)。
+ PlanID *int64 `json:"plan_id"`
}
// CreateAndRedeemCodeRequest represents creating a fixed code and redeeming it for a target user.
@@ -107,13 +109,14 @@ func (h *RedeemHandler) Generate(c *gin.Context) {
return
}
- executeAdminIdempotentJSON(c, "admin.redeem_codes.generate", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
+ executeAdminStrictIdempotentJSONNonTransactional(c, "admin.redeem_codes.generate", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
codes, execErr := h.adminService.GenerateRedeemCodes(ctx, &service.GenerateRedeemCodesInput{
Count: req.Count,
Type: req.Type,
Value: req.Value,
GroupID: req.GroupID,
ValidityDays: req.ValidityDays,
+ PlanID: req.PlanID,
})
if execErr != nil {
return nil, execErr
@@ -158,7 +161,14 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
}
}
- executeAdminIdempotentJSON(c, "admin.redeem_codes.create_and_redeem", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
+ // Legacy Sub2ApiPay callers did not send Idempotency-Key. The fixed redeem
+ // code is already their stable external order identity, so derive a hashed
+ // key from it without exposing the code in storage or logs.
+ idempotencyKey := c.GetHeader("Idempotency-Key")
+ if strings.TrimSpace(idempotencyKey) == "" {
+ idempotencyKey = "admin-create-redeem-" + service.HashIdempotencyKey(req.Code)
+ }
+ executeAdminStrictIdempotentJSONNonTransactionalWithKey(c, "admin.redeem_codes.create_and_redeem", req, service.DefaultWriteIdempotencyTTL(), idempotencyKey, func(ctx context.Context) (any, error) {
existing, err := h.redeemService.GetByCode(ctx, req.Code)
if err == nil {
return h.resolveCreateAndRedeemExisting(ctx, existing, req.UserID)
diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index 59f4fe85d36..62ef3eb3137 100644
--- a/backend/internal/handler/admin/setting_handler.go
+++ b/backend/internal/handler/admin/setting_handler.go
@@ -117,6 +117,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
+ LoginAgreementEnabled: settings.LoginAgreementEnabled,
+ LoginAgreementMode: settings.LoginAgreementMode,
+ LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
+ LoginAgreementDocuments: loginAgreementDocumentsToDTO(settings.LoginAgreementDocuments),
SMTPHost: settings.SMTPHost,
SMTPPort: settings.SMTPPort,
SMTPUsername: settings.SMTPUsername,
@@ -169,6 +173,16 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
OIDCConnectUserInfoEmailPath: settings.OIDCConnectUserInfoEmailPath,
OIDCConnectUserInfoIDPath: settings.OIDCConnectUserInfoIDPath,
OIDCConnectUserInfoUsernamePath: settings.OIDCConnectUserInfoUsernamePath,
+ GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
+ GitHubOAuthClientID: settings.GitHubOAuthClientID,
+ GitHubOAuthClientSecretConfigured: settings.GitHubOAuthClientSecretConfigured,
+ GitHubOAuthRedirectURL: settings.GitHubOAuthRedirectURL,
+ GitHubOAuthFrontendRedirectURL: settings.GitHubOAuthFrontendRedirectURL,
+ GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
+ GoogleOAuthClientID: settings.GoogleOAuthClientID,
+ GoogleOAuthClientSecretConfigured: settings.GoogleOAuthClientSecretConfigured,
+ GoogleOAuthRedirectURL: settings.GoogleOAuthRedirectURL,
+ GoogleOAuthFrontendRedirectURL: settings.GoogleOAuthFrontendRedirectURL,
SiteName: settings.SiteName,
SiteLogo: settings.SiteLogo,
SiteSubtitle: settings.SiteSubtitle,
@@ -185,6 +199,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
DefaultConcurrency: settings.DefaultConcurrency,
DefaultBalance: settings.DefaultBalance,
+ RiskControlEnabled: settings.RiskControlEnabled,
AffiliateRebateRate: settings.AffiliateRebateRate,
AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours,
AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays,
@@ -260,6 +275,33 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
response.Success(c, systemSettingsResponseData(payload, authSourceDefaults))
}
+// GetAdminUISettings returns the small settings subset used by admin layout.
+// GET /api/v1/admin/settings/admin-ui
+func (h *SettingHandler) GetAdminUISettings(c *gin.Context) {
+ settings, err := h.settingService.GetAllSettings(c.Request.Context())
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+
+ opsEnabled := h.opsService != nil && h.opsService.IsMonitoringEnabled(c.Request.Context())
+
+ var paymentEnabled bool
+ if h.paymentConfigService != nil {
+ if paymentCfg, err := h.paymentConfigService.GetPaymentConfig(c.Request.Context()); err == nil && paymentCfg != nil {
+ paymentEnabled = paymentCfg.Enabled
+ }
+ }
+
+ response.Success(c, dto.AdminUISettings{
+ OpsMonitoringEnabled: opsEnabled && settings.OpsMonitoringEnabled,
+ OpsRealtimeMonitoringEnabled: settings.OpsRealtimeMonitoringEnabled,
+ OpsQueryModeDefault: settings.OpsQueryModeDefault,
+ CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
+ PaymentEnabled: paymentEnabled,
+ })
+}
+
// openaiFastPolicySettingsToDTO converts service -> dto for OpenAI fast policy.
func openaiFastPolicySettingsToDTO(s *service.OpenAIFastPolicySettings) *dto.OpenAIFastPolicySettings {
if s == nil {
@@ -294,17 +336,50 @@ func openaiFastPolicySettingsFromDTO(s *dto.OpenAIFastPolicySettings) *service.O
return &service.OpenAIFastPolicySettings{Rules: rules}
}
+func loginAgreementDocumentsToDTO(items []service.LoginAgreementDocument) []dto.LoginAgreementDocument {
+ result := make([]dto.LoginAgreementDocument, 0, len(items))
+ for _, item := range items {
+ result = append(result, dto.LoginAgreementDocument{
+ ID: item.ID,
+ Title: item.Title,
+ ContentMD: item.ContentMD,
+ })
+ }
+ return result
+}
+
+func loginAgreementDocumentsToService(items []dto.LoginAgreementDocument) []service.LoginAgreementDocument {
+ result := make([]service.LoginAgreementDocument, 0, len(items))
+ for _, item := range items {
+ title := strings.TrimSpace(item.Title)
+ content := strings.TrimSpace(item.ContentMD)
+ if title == "" && content == "" {
+ continue
+ }
+ result = append(result, service.LoginAgreementDocument{
+ ID: strings.TrimSpace(item.ID),
+ Title: title,
+ ContentMD: content,
+ })
+ }
+ return result
+}
+
// UpdateSettingsRequest 更新设置请求
type UpdateSettingsRequest struct {
// 注册设置
- RegistrationEnabled bool `json:"registration_enabled"`
- EmailVerifyEnabled bool `json:"email_verify_enabled"`
- RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
- PromoCodeEnabled bool `json:"promo_code_enabled"`
- PasswordResetEnabled bool `json:"password_reset_enabled"`
- FrontendURL string `json:"frontend_url"`
- InvitationCodeEnabled bool `json:"invitation_code_enabled"`
- TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
+ RegistrationEnabled bool `json:"registration_enabled"`
+ EmailVerifyEnabled bool `json:"email_verify_enabled"`
+ RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
+ PromoCodeEnabled bool `json:"promo_code_enabled"`
+ PasswordResetEnabled bool `json:"password_reset_enabled"`
+ FrontendURL string `json:"frontend_url"`
+ InvitationCodeEnabled bool `json:"invitation_code_enabled"`
+ TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
+ LoginAgreementEnabled bool `json:"login_agreement_enabled"`
+ LoginAgreementMode string `json:"login_agreement_mode"`
+ LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
+ LoginAgreementDocuments []dto.LoginAgreementDocument `json:"login_agreement_documents"`
// 邮件服务设置
SMTPHost string `json:"smtp_host"`
@@ -368,6 +443,17 @@ type UpdateSettingsRequest struct {
OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"`
OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_path"`
+ GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
+ GitHubOAuthClientID string `json:"github_oauth_client_id"`
+ GitHubOAuthClientSecret string `json:"github_oauth_client_secret"`
+ GitHubOAuthRedirectURL string `json:"github_oauth_redirect_url"`
+ GitHubOAuthFrontendRedirectURL string `json:"github_oauth_frontend_redirect_url"`
+ GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
+ GoogleOAuthClientID string `json:"google_oauth_client_id"`
+ GoogleOAuthClientSecret string `json:"google_oauth_client_secret"`
+ GoogleOAuthRedirectURL string `json:"google_oauth_redirect_url"`
+ GoogleOAuthFrontendRedirectURL string `json:"google_oauth_frontend_redirect_url"`
+
// OEM设置
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
@@ -413,6 +499,16 @@ type UpdateSettingsRequest struct {
AuthSourceDefaultWeChatSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_wechat_subscriptions"`
AuthSourceDefaultWeChatGrantOnSignup *bool `json:"auth_source_default_wechat_grant_on_signup"`
AuthSourceDefaultWeChatGrantOnFirstBind *bool `json:"auth_source_default_wechat_grant_on_first_bind"`
+ AuthSourceDefaultGitHubBalance *float64 `json:"auth_source_default_github_balance"`
+ AuthSourceDefaultGitHubConcurrency *int `json:"auth_source_default_github_concurrency"`
+ AuthSourceDefaultGitHubSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_github_subscriptions"`
+ AuthSourceDefaultGitHubGrantOnSignup *bool `json:"auth_source_default_github_grant_on_signup"`
+ AuthSourceDefaultGitHubGrantOnFirstBind *bool `json:"auth_source_default_github_grant_on_first_bind"`
+ AuthSourceDefaultGoogleBalance *float64 `json:"auth_source_default_google_balance"`
+ AuthSourceDefaultGoogleConcurrency *int `json:"auth_source_default_google_concurrency"`
+ AuthSourceDefaultGoogleSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_google_subscriptions"`
+ AuthSourceDefaultGoogleGrantOnSignup *bool `json:"auth_source_default_google_grant_on_signup"`
+ AuthSourceDefaultGoogleGrantOnFirstBind *bool `json:"auth_source_default_google_grant_on_first_bind"`
ForceEmailOnThirdPartySignup *bool `json:"force_email_on_third_party_signup"`
// Model fallback configuration
@@ -497,6 +593,9 @@ type UpdateSettingsRequest struct {
// Affiliate (邀请返利) feature switch
AffiliateEnabled *bool `json:"affiliate_enabled"`
+ // 风控中心功能开关
+ RiskControlEnabled *bool `json:"risk_control_enabled"`
+
// OpenAI fast/flex policy (optional, only updated when provided)
OpenAIFastPolicySettings *dto.OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"`
}
@@ -629,9 +728,47 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
if req.TotpEnabled && !previousSettings.TotpEnabled {
// 尝试启用 TOTP,检查加密密钥是否已手动配置
if !h.settingService.IsTotpEncryptionKeyConfigured() {
- response.BadRequest(c, "Cannot enable TOTP: TOTP_ENCRYPTION_KEY environment variable must be configured first. Generate a key with 'openssl rand -hex 32' and set it in your environment.")
+ response.BadRequest(c, "Cannot enable TOTP: SECRET_ENCRYPTION_TOTP_SECRET_KEY must be configured first. Generate a key with 'openssl rand -hex 32' and set it in your environment.")
+ return
+ }
+ }
+ loginAgreementMode := strings.ToLower(strings.TrimSpace(req.LoginAgreementMode))
+ if loginAgreementMode == "" {
+ loginAgreementMode = strings.ToLower(strings.TrimSpace(previousSettings.LoginAgreementMode))
+ }
+ switch loginAgreementMode {
+ case "", "modal":
+ loginAgreementMode = "modal"
+ case "checkbox":
+ default:
+ response.BadRequest(c, "Login agreement mode must be modal or checkbox")
+ return
+ }
+ loginAgreementUpdatedAt := strings.TrimSpace(req.LoginAgreementUpdatedAt)
+ if loginAgreementUpdatedAt == "" {
+ loginAgreementUpdatedAt = strings.TrimSpace(previousSettings.LoginAgreementUpdatedAt)
+ }
+ loginAgreementDocuments := loginAgreementDocumentsToService(req.LoginAgreementDocuments)
+ if len(loginAgreementDocuments) == 0 {
+ loginAgreementDocuments = previousSettings.LoginAgreementDocuments
+ }
+ for _, doc := range loginAgreementDocuments {
+ if strings.TrimSpace(doc.Title) == "" {
+ response.BadRequest(c, "Login agreement document title is required")
return
}
+ if len(doc.Title) > 80 {
+ response.BadRequest(c, "Login agreement document title is too long (max 80 characters)")
+ return
+ }
+ if len(doc.ContentMD) > 200*1024 {
+ response.BadRequest(c, "Login agreement document content is too large (max 200KB)")
+ return
+ }
+ }
+ if req.LoginAgreementEnabled && len(loginAgreementDocuments) == 0 {
+ response.BadRequest(c, "Login agreement documents are required when enabled")
+ return
}
// LinuxDo Connect 参数验证
@@ -994,17 +1131,27 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response.BadRequest(c, "Custom menu item label is too long (max 50 characters)")
return
}
- if strings.TrimSpace(item.URL) == "" {
- response.BadRequest(c, "Custom menu item URL is required")
- return
- }
- if len(item.URL) > maxMenuItemURLLen {
- response.BadRequest(c, "Custom menu item URL is too long (max 2048 characters)")
- return
- }
- if err := config.ValidateAbsoluteHTTPURL(strings.TrimSpace(item.URL)); err != nil {
- response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL")
- return
+ urlTrimmed := strings.TrimSpace(item.URL)
+ if strings.HasPrefix(urlTrimmed, "md:") {
+ // Markdown page mode: URL = "md:"
+ slug := strings.TrimPrefix(urlTrimmed, "md:")
+ if slug == "" {
+ response.BadRequest(c, "Custom menu item markdown slug cannot be empty (use md:slug format)")
+ return
+ }
+ } else {
+ if urlTrimmed == "" {
+ response.BadRequest(c, "Custom menu item URL is required (use md:slug for markdown pages)")
+ return
+ }
+ if len(item.URL) > maxMenuItemURLLen {
+ response.BadRequest(c, "Custom menu item URL is too long (max 2048 characters)")
+ return
+ }
+ if err := config.ValidateAbsoluteHTTPURL(urlTrimmed); err != nil {
+ response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL or md:")
+ return
+ }
}
if item.Visibility != "user" && item.Visibility != "admin" {
response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'")
@@ -1148,6 +1295,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
FrontendURL: req.FrontendURL,
InvitationCodeEnabled: req.InvitationCodeEnabled,
TotpEnabled: req.TotpEnabled,
+ LoginAgreementEnabled: req.LoginAgreementEnabled,
+ LoginAgreementMode: loginAgreementMode,
+ LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
+ LoginAgreementDocuments: loginAgreementDocuments,
SMTPHost: req.SMTPHost,
SMTPPort: req.SMTPPort,
SMTPUsername: req.SMTPUsername,
@@ -1200,6 +1351,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath,
OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath,
OIDCConnectUserInfoUsernamePath: req.OIDCConnectUserInfoUsernamePath,
+ GitHubOAuthEnabled: req.GitHubOAuthEnabled,
+ GitHubOAuthClientID: req.GitHubOAuthClientID,
+ GitHubOAuthClientSecret: req.GitHubOAuthClientSecret,
+ GitHubOAuthRedirectURL: req.GitHubOAuthRedirectURL,
+ GitHubOAuthFrontendRedirectURL: req.GitHubOAuthFrontendRedirectURL,
+ GoogleOAuthEnabled: req.GoogleOAuthEnabled,
+ GoogleOAuthClientID: req.GoogleOAuthClientID,
+ GoogleOAuthClientSecret: req.GoogleOAuthClientSecret,
+ GoogleOAuthRedirectURL: req.GoogleOAuthRedirectURL,
+ GoogleOAuthFrontendRedirectURL: req.GoogleOAuthFrontendRedirectURL,
SiteName: req.SiteName,
SiteLogo: req.SiteLogo,
SiteSubtitle: req.SiteSubtitle,
@@ -1365,6 +1526,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.AffiliateEnabled
}(),
+ RiskControlEnabled: func() bool {
+ if req.RiskControlEnabled != nil {
+ return *req.RiskControlEnabled
+ }
+ return previousSettings.RiskControlEnabled
+ }(),
}
authSourceDefaults := &service.AuthSourceDefaultSettings{
@@ -1396,56 +1563,54 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnSignup, previousAuthSourceDefaults.WeChat.GrantOnSignup),
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnFirstBind, previousAuthSourceDefaults.WeChat.GrantOnFirstBind),
},
+ GitHub: service.ProviderDefaultGrantSettings{
+ Balance: float64ValueOrDefault(req.AuthSourceDefaultGitHubBalance, previousAuthSourceDefaults.GitHub.Balance),
+ Concurrency: intValueOrDefault(req.AuthSourceDefaultGitHubConcurrency, previousAuthSourceDefaults.GitHub.Concurrency),
+ Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultGitHubSubscriptions, previousAuthSourceDefaults.GitHub.Subscriptions),
+ GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultGitHubGrantOnSignup, previousAuthSourceDefaults.GitHub.GrantOnSignup),
+ GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultGitHubGrantOnFirstBind, previousAuthSourceDefaults.GitHub.GrantOnFirstBind),
+ },
+ Google: service.ProviderDefaultGrantSettings{
+ Balance: float64ValueOrDefault(req.AuthSourceDefaultGoogleBalance, previousAuthSourceDefaults.Google.Balance),
+ Concurrency: intValueOrDefault(req.AuthSourceDefaultGoogleConcurrency, previousAuthSourceDefaults.Google.Concurrency),
+ Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultGoogleSubscriptions, previousAuthSourceDefaults.Google.Subscriptions),
+ GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnSignup, previousAuthSourceDefaults.Google.GrantOnSignup),
+ GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnFirstBind, previousAuthSourceDefaults.Google.GrantOnFirstBind),
+ },
ForceEmailOnThirdPartySignup: boolValueOrDefault(req.ForceEmailOnThirdPartySignup, previousAuthSourceDefaults.ForceEmailOnThirdPartySignup),
}
- if err := h.settingService.UpdateSettingsWithAuthSourceDefaults(c.Request.Context(), settings, authSourceDefaults); err != nil {
- response.ErrorFrom(c, err)
- return
- }
-
- // Update OpenAI fast policy (stored under dedicated key, only when provided).
+ additionalUpdates := make(map[string]string)
if req.OpenAIFastPolicySettings != nil {
- if err := h.settingService.SetOpenAIFastPolicySettings(c.Request.Context(), openaiFastPolicySettingsFromDTO(req.OpenAIFastPolicySettings)); err != nil {
+ updates, err := service.BuildOpenAIFastPolicySettingsUpdates(openaiFastPolicySettingsFromDTO(req.OpenAIFastPolicySettings))
+ if err != nil {
response.BadRequest(c, err.Error())
return
}
+ for key, value := range updates {
+ additionalUpdates[key] = value
+ }
}
- // Update payment configuration (integrated into system settings).
- // Skip if no payment fields were provided (prevents accidental wipe).
- if h.paymentConfigService != nil && hasPaymentFields(req) {
- paymentReq := service.UpdatePaymentConfigRequest{
- Enabled: req.PaymentEnabled,
- MinAmount: req.PaymentMinAmount,
- MaxAmount: req.PaymentMaxAmount,
- DailyLimit: req.PaymentDailyLimit,
- OrderTimeoutMin: req.PaymentOrderTimeoutMin,
- MaxPendingOrders: req.PaymentMaxPendingOrders,
- EnabledTypes: req.PaymentEnabledTypes,
- BalanceDisabled: req.PaymentBalanceDisabled,
- BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier,
- RechargeFeeRate: req.PaymentRechargeFeeRate,
- LoadBalanceStrategy: req.PaymentLoadBalanceStrat,
- ProductNamePrefix: req.PaymentProductNamePrefix,
- ProductNameSuffix: req.PaymentProductNameSuffix,
- HelpImageURL: req.PaymentHelpImageURL,
- HelpText: req.PaymentHelpText,
- CancelRateLimitEnabled: req.PaymentCancelRateLimitEnabled,
- CancelRateLimitMax: req.PaymentCancelRateLimitMax,
- CancelRateLimitWindow: req.PaymentCancelRateLimitWindow,
- CancelRateLimitUnit: req.PaymentCancelRateLimitUnit,
- CancelRateLimitMode: req.PaymentCancelRateLimitMode,
- }
- if err := h.paymentConfigService.UpdatePaymentConfig(c.Request.Context(), paymentReq); err != nil {
+ paymentChanged := h.paymentConfigService != nil && hasPaymentFields(req)
+ if paymentChanged {
+ updates, err := h.paymentConfigService.BuildPaymentConfigUpdates(paymentConfigRequestFromSettingsRequest(req))
+ if err != nil {
response.ErrorFrom(c, err)
return
}
- // Refresh in-memory provider registry so config changes take effect immediately
- if h.paymentService != nil {
- h.paymentService.RefreshProviders(c.Request.Context())
+ for key, value := range updates {
+ additionalUpdates[key] = value
}
}
+ if err := h.settingService.UpdateSettingsWithAuthSourceDefaultsAndAdditional(c.Request.Context(), settings, authSourceDefaults, additionalUpdates); err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ if paymentChanged && h.paymentService != nil {
+ h.paymentService.RefreshProviders(c.Request.Context())
+ }
+
h.auditSettingsUpdate(c, previousSettings, settings, previousAuthSourceDefaults, authSourceDefaults, req)
// 重新获取设置返回
@@ -1486,6 +1651,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
TotpEnabled: updatedSettings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
+ LoginAgreementEnabled: updatedSettings.LoginAgreementEnabled,
+ LoginAgreementMode: updatedSettings.LoginAgreementMode,
+ LoginAgreementUpdatedAt: updatedSettings.LoginAgreementUpdatedAt,
+ LoginAgreementDocuments: loginAgreementDocumentsToDTO(updatedSettings.LoginAgreementDocuments),
SMTPHost: updatedSettings.SMTPHost,
SMTPPort: updatedSettings.SMTPPort,
SMTPUsername: updatedSettings.SMTPUsername,
@@ -1538,6 +1707,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
OIDCConnectUserInfoEmailPath: updatedSettings.OIDCConnectUserInfoEmailPath,
OIDCConnectUserInfoIDPath: updatedSettings.OIDCConnectUserInfoIDPath,
OIDCConnectUserInfoUsernamePath: updatedSettings.OIDCConnectUserInfoUsernamePath,
+ GitHubOAuthEnabled: updatedSettings.GitHubOAuthEnabled,
+ GitHubOAuthClientID: updatedSettings.GitHubOAuthClientID,
+ GitHubOAuthClientSecretConfigured: updatedSettings.GitHubOAuthClientSecretConfigured,
+ GitHubOAuthRedirectURL: updatedSettings.GitHubOAuthRedirectURL,
+ GitHubOAuthFrontendRedirectURL: updatedSettings.GitHubOAuthFrontendRedirectURL,
+ GoogleOAuthEnabled: updatedSettings.GoogleOAuthEnabled,
+ GoogleOAuthClientID: updatedSettings.GoogleOAuthClientID,
+ GoogleOAuthClientSecretConfigured: updatedSettings.GoogleOAuthClientSecretConfigured,
+ GoogleOAuthRedirectURL: updatedSettings.GoogleOAuthRedirectURL,
+ GoogleOAuthFrontendRedirectURL: updatedSettings.GoogleOAuthFrontendRedirectURL,
SiteName: updatedSettings.SiteName,
SiteLogo: updatedSettings.SiteLogo,
SiteSubtitle: updatedSettings.SiteSubtitle,
@@ -1616,6 +1795,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
AffiliateEnabled: updatedSettings.AffiliateEnabled,
+
+ RiskControlEnabled: updatedSettings.RiskControlEnabled,
}
if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil {
slog.Error("openai_fast_policy_settings_get_failed", "error", err)
@@ -1639,6 +1820,31 @@ func hasPaymentFields(req UpdateSettingsRequest) bool {
req.PaymentCancelRateLimitUnit != nil || req.PaymentCancelRateLimitMode != nil
}
+func paymentConfigRequestFromSettingsRequest(req UpdateSettingsRequest) service.UpdatePaymentConfigRequest {
+ return service.UpdatePaymentConfigRequest{
+ Enabled: req.PaymentEnabled,
+ MinAmount: req.PaymentMinAmount,
+ MaxAmount: req.PaymentMaxAmount,
+ DailyLimit: req.PaymentDailyLimit,
+ OrderTimeoutMin: req.PaymentOrderTimeoutMin,
+ MaxPendingOrders: req.PaymentMaxPendingOrders,
+ EnabledTypes: req.PaymentEnabledTypes,
+ BalanceDisabled: req.PaymentBalanceDisabled,
+ BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier,
+ RechargeFeeRate: req.PaymentRechargeFeeRate,
+ LoadBalanceStrategy: req.PaymentLoadBalanceStrat,
+ ProductNamePrefix: req.PaymentProductNamePrefix,
+ ProductNameSuffix: req.PaymentProductNameSuffix,
+ HelpImageURL: req.PaymentHelpImageURL,
+ HelpText: req.PaymentHelpText,
+ CancelRateLimitEnabled: req.PaymentCancelRateLimitEnabled,
+ CancelRateLimitMax: req.PaymentCancelRateLimitMax,
+ CancelRateLimitWindow: req.PaymentCancelRateLimitWindow,
+ CancelRateLimitUnit: req.PaymentCancelRateLimitUnit,
+ CancelRateLimitMode: req.PaymentCancelRateLimitMode,
+ }
+}
+
func (h *SettingHandler) auditSettingsUpdate(c *gin.Context, before *service.SystemSettings, after *service.SystemSettings, beforeAuthSourceDefaults *service.AuthSourceDefaultSettings, afterAuthSourceDefaults *service.AuthSourceDefaultSettings, req UpdateSettingsRequest) {
if before == nil || after == nil {
return
@@ -1685,6 +1891,18 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.TotpEnabled != after.TotpEnabled {
changed = append(changed, "totp_enabled")
}
+ if before.LoginAgreementEnabled != after.LoginAgreementEnabled {
+ changed = append(changed, "login_agreement_enabled")
+ }
+ if before.LoginAgreementMode != after.LoginAgreementMode {
+ changed = append(changed, "login_agreement_mode")
+ }
+ if before.LoginAgreementUpdatedAt != after.LoginAgreementUpdatedAt {
+ changed = append(changed, "login_agreement_updated_at")
+ }
+ if !equalLoginAgreementDocuments(before.LoginAgreementDocuments, after.LoginAgreementDocuments) {
+ changed = append(changed, "login_agreement_documents")
+ }
if before.SMTPHost != after.SMTPHost {
changed = append(changed, "smtp_host")
}
@@ -2004,6 +2222,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.AffiliateEnabled != after.AffiliateEnabled {
changed = append(changed, "affiliate_enabled")
}
+ if before.RiskControlEnabled != after.RiskControlEnabled {
+ changed = append(changed, "risk_control_enabled")
+ }
changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults)
return changed
}
@@ -2027,6 +2248,8 @@ func appendAuthSourceDefaultChanges(changed []string, before *service.AuthSource
{name: "linuxdo", before: before.LinuxDo, after: after.LinuxDo},
{name: "oidc", before: before.OIDC, after: after.OIDC},
{name: "wechat", before: before.WeChat, after: after.WeChat},
+ {name: "github", before: before.GitHub, after: after.GitHub},
+ {name: "google", before: before.Google, after: after.Google},
}
for _, field := range fields {
if field.before.Balance != field.after.Balance {
@@ -2141,6 +2364,16 @@ func systemSettingsResponseData(settings dto.SystemSettings, authSourceDefaults
data["auth_source_default_wechat_subscriptions"] = authSourceDefaults.WeChat.Subscriptions
data["auth_source_default_wechat_grant_on_signup"] = authSourceDefaults.WeChat.GrantOnSignup
data["auth_source_default_wechat_grant_on_first_bind"] = authSourceDefaults.WeChat.GrantOnFirstBind
+ data["auth_source_default_github_balance"] = authSourceDefaults.GitHub.Balance
+ data["auth_source_default_github_concurrency"] = authSourceDefaults.GitHub.Concurrency
+ data["auth_source_default_github_subscriptions"] = authSourceDefaults.GitHub.Subscriptions
+ data["auth_source_default_github_grant_on_signup"] = authSourceDefaults.GitHub.GrantOnSignup
+ data["auth_source_default_github_grant_on_first_bind"] = authSourceDefaults.GitHub.GrantOnFirstBind
+ data["auth_source_default_google_balance"] = authSourceDefaults.Google.Balance
+ data["auth_source_default_google_concurrency"] = authSourceDefaults.Google.Concurrency
+ data["auth_source_default_google_subscriptions"] = authSourceDefaults.Google.Subscriptions
+ data["auth_source_default_google_grant_on_signup"] = authSourceDefaults.Google.GrantOnSignup
+ data["auth_source_default_google_grant_on_first_bind"] = authSourceDefaults.Google.GrantOnFirstBind
data["force_email_on_third_party_signup"] = authSourceDefaults.ForceEmailOnThirdPartySignup
return data
@@ -2170,6 +2403,18 @@ func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool {
return true
}
+func equalLoginAgreementDocuments(a, b []service.LoginAgreementDocument) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i].ID != b[i].ID || a[i].Title != b[i].Title || a[i].ContentMD != b[i].ContentMD {
+ return false
+ }
+ }
+ return true
+}
+
func equalIntSlice(a, b []int) bool {
if len(a) != len(b) {
return false
@@ -2203,6 +2448,23 @@ type TestSMTPRequest struct {
SMTPUseTLS bool `json:"smtp_use_tls"`
}
+func resolveSMTPTestPassword(input string, requested, saved *service.SMTPConfig) (string, error) {
+ if password := strings.TrimSpace(input); password != "" {
+ return password, nil
+ }
+ if saved == nil || strings.TrimSpace(saved.Password) == "" {
+ return "", nil
+ }
+ if requested == nil ||
+ !strings.EqualFold(strings.TrimSpace(requested.Host), strings.TrimSpace(saved.Host)) ||
+ requested.Port != saved.Port ||
+ requested.Username != saved.Username ||
+ requested.UseTLS != saved.UseTLS {
+ return "", fmt.Errorf("smtp_password must be provided when SMTP host, port, username, or TLS mode changes")
+ }
+ return strings.TrimSpace(saved.Password), nil
+}
+
// TestSMTPConnection 测试SMTP连接
// POST /api/v1/admin/settings/test-smtp
func (h *SettingHandler) TestSMTPConnection(c *gin.Context) {
@@ -2233,10 +2495,6 @@ func (h *SettingHandler) TestSMTPConnection(c *gin.Context) {
if req.SMTPUsername == "" && savedConfig != nil {
req.SMTPUsername = savedConfig.Username
}
- password := strings.TrimSpace(req.SMTPPassword)
- if password == "" && savedConfig != nil {
- password = savedConfig.Password
- }
if req.SMTPHost == "" {
response.BadRequest(c, "SMTP host is required")
return
@@ -2246,11 +2504,16 @@ func (h *SettingHandler) TestSMTPConnection(c *gin.Context) {
Host: req.SMTPHost,
Port: req.SMTPPort,
Username: req.SMTPUsername,
- Password: password,
UseTLS: req.SMTPUseTLS,
}
+ password, err := resolveSMTPTestPassword(req.SMTPPassword, config, savedConfig)
+ if err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+ config.Password = password
- err := h.emailService.TestSMTPConnectionWithConfig(config)
+ err = h.emailService.TestSMTPConnectionWithConfig(config)
if err != nil {
response.BadRequest(c, "SMTP connection test failed: "+err.Error())
return
@@ -2303,10 +2566,6 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) {
if req.SMTPUsername == "" && savedConfig != nil {
req.SMTPUsername = savedConfig.Username
}
- password := strings.TrimSpace(req.SMTPPassword)
- if password == "" && savedConfig != nil {
- password = savedConfig.Password
- }
if req.SMTPFrom == "" && savedConfig != nil {
req.SMTPFrom = savedConfig.From
}
@@ -2322,11 +2581,16 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) {
Host: req.SMTPHost,
Port: req.SMTPPort,
Username: req.SMTPUsername,
- Password: password,
From: req.SMTPFrom,
FromName: req.SMTPFromName,
UseTLS: req.SMTPUseTLS,
}
+ password, err := resolveSMTPTestPassword(req.SMTPPassword, config, savedConfig)
+ if err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+ config.Password = password
siteName := h.settingService.GetSiteName(c.Request.Context())
subject := "[" + siteName + "] Test Email"
@@ -2462,6 +2726,58 @@ func (h *SettingHandler) UpdateOverloadCooldownSettings(c *gin.Context) {
})
}
+// GetRateLimit429CooldownSettings 获取429默认回避配置
+// GET /api/v1/admin/settings/rate-limit-429-cooldown
+func (h *SettingHandler) GetRateLimit429CooldownSettings(c *gin.Context) {
+ settings, err := h.settingService.GetRateLimit429CooldownSettings(c.Request.Context())
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+
+ response.Success(c, dto.RateLimit429CooldownSettings{
+ Enabled: settings.Enabled,
+ CooldownSeconds: settings.CooldownSeconds,
+ })
+}
+
+// UpdateRateLimit429CooldownSettingsRequest 更新429默认回避配置请求
+type UpdateRateLimit429CooldownSettingsRequest struct {
+ Enabled bool `json:"enabled"`
+ CooldownSeconds int `json:"cooldown_seconds"`
+}
+
+// UpdateRateLimit429CooldownSettings 更新429默认回避配置
+// PUT /api/v1/admin/settings/rate-limit-429-cooldown
+func (h *SettingHandler) UpdateRateLimit429CooldownSettings(c *gin.Context) {
+ var req UpdateRateLimit429CooldownSettingsRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request: "+err.Error())
+ return
+ }
+
+ settings := &service.RateLimit429CooldownSettings{
+ Enabled: req.Enabled,
+ CooldownSeconds: req.CooldownSeconds,
+ }
+
+ if err := h.settingService.SetRateLimit429CooldownSettings(c.Request.Context(), settings); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ updatedSettings, err := h.settingService.GetRateLimit429CooldownSettings(c.Request.Context())
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+
+ response.Success(c, dto.RateLimit429CooldownSettings{
+ Enabled: updatedSettings.Enabled,
+ CooldownSeconds: updatedSettings.CooldownSeconds,
+ })
+}
+
// GetStreamTimeoutSettings 获取流超时处理配置
// GET /api/v1/admin/settings/stream-timeout
func (h *SettingHandler) GetStreamTimeoutSettings(c *gin.Context) {
diff --git a/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go b/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go
index 085fd2ca788..bdcb2b6474b 100644
--- a/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go
+++ b/backend/internal/handler/admin/setting_handler_auth_source_defaults_test.go
@@ -17,8 +17,9 @@ import (
)
type settingHandlerRepoStub struct {
- values map[string]string
- lastUpdates map[string]string
+ values map[string]string
+ lastUpdates map[string]string
+ setMultipleCalls int
}
func (s *settingHandlerRepoStub) Get(ctx context.Context, key string) (*service.Setting, error) {
@@ -49,6 +50,7 @@ func (s *settingHandlerRepoStub) GetMultiple(ctx context.Context, keys []string)
}
func (s *settingHandlerRepoStub) SetMultiple(ctx context.Context, settings map[string]string) error {
+ s.setMultipleCalls++
s.lastUpdates = make(map[string]string, len(settings))
for key, value := range settings {
s.lastUpdates[key] = value
@@ -472,6 +474,95 @@ func TestSettingHandler_UpdateSettings_DoesNotPersistPartialSystemSettingsWhenAu
require.Equal(t, "9.5", repo.values[service.SettingKeyAuthSourceDefaultEmailBalance])
}
+func TestSettingHandler_UpdateSettings_DoesNotPersistSystemSettingsWhenPaymentValidationFails(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ repo := &settingHandlerRepoStub{values: map[string]string{
+ service.SettingKeyRegistrationEnabled: "false",
+ service.SettingKeyPromoCodeEnabled: "true",
+ }}
+ settingSvc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
+ paymentSvc := service.NewPaymentConfigService(nil, repo, nil)
+ handler := NewSettingHandler(settingSvc, nil, nil, nil, paymentSvc, nil)
+
+ body := map[string]any{
+ "registration_enabled": true,
+ "promo_code_enabled": true,
+ "payment_balance_recharge_multiplier": -1,
+ }
+ rawBody, err := json.Marshal(body)
+ require.NoError(t, err)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
+ ctx.Request.Header.Set("Content-Type", "application/json")
+
+ handler.UpdateSettings(ctx)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code)
+ require.Equal(t, "false", repo.values[service.SettingKeyRegistrationEnabled])
+ require.Zero(t, repo.setMultipleCalls)
+}
+
+func TestSettingHandler_UpdateSettings_DoesNotPersistSystemSettingsWhenFastPolicyValidationFails(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ repo := &settingHandlerRepoStub{values: map[string]string{
+ service.SettingKeyRegistrationEnabled: "false",
+ service.SettingKeyPromoCodeEnabled: "true",
+ }}
+ settingSvc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
+ handler := NewSettingHandler(settingSvc, nil, nil, nil, nil, nil)
+ body := map[string]any{
+ "registration_enabled": true,
+ "promo_code_enabled": true,
+ "openai_fast_policy_settings": map[string]any{
+ "rules": []map[string]any{{"service_tier": "invalid", "action": "pass", "scope": "all"}},
+ },
+ }
+ rawBody, err := json.Marshal(body)
+ require.NoError(t, err)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
+ ctx.Request.Header.Set("Content-Type", "application/json")
+
+ handler.UpdateSettings(ctx)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code)
+ require.Equal(t, "false", repo.values[service.SettingKeyRegistrationEnabled])
+ require.Zero(t, repo.setMultipleCalls)
+}
+
+func TestSettingHandler_UpdateSettings_PersistsAllDomainsInOneBulkWrite(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ repo := &settingHandlerRepoStub{values: map[string]string{
+ service.SettingKeyRegistrationEnabled: "false",
+ service.SettingKeyPromoCodeEnabled: "true",
+ }}
+ settingSvc := service.NewSettingService(repo, &config.Config{Default: config.DefaultConfig{UserConcurrency: 5}})
+ paymentSvc := service.NewPaymentConfigService(nil, repo, nil)
+ handler := NewSettingHandler(settingSvc, nil, nil, nil, paymentSvc, nil)
+ body := map[string]any{
+ "registration_enabled": true,
+ "promo_code_enabled": true,
+ "payment_balance_recharge_multiplier": 1.25,
+ "openai_fast_policy_settings": map[string]any{"rules": []any{}},
+ }
+ rawBody, err := json.Marshal(body)
+ require.NoError(t, err)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody))
+ ctx.Request.Header.Set("Content-Type", "application/json")
+
+ handler.UpdateSettings(ctx)
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+ require.Equal(t, 1, repo.setMultipleCalls)
+ require.Equal(t, "true", repo.values[service.SettingKeyRegistrationEnabled])
+ require.Equal(t, "1.25", repo.values[service.SettingBalanceRechargeMult])
+ require.NotEmpty(t, repo.values[service.SettingKeyOpenAIFastPolicySettings])
+}
+
func TestDiffSettings_IncludesAuthSourceDefaultsAndForceEmail(t *testing.T) {
changed := diffSettings(
&service.SystemSettings{},
diff --git a/backend/internal/handler/admin/setting_handler_smtp_security_test.go b/backend/internal/handler/admin/setting_handler_smtp_security_test.go
new file mode 100644
index 00000000000..3535f390138
--- /dev/null
+++ b/backend/internal/handler/admin/setting_handler_smtp_security_test.go
@@ -0,0 +1,98 @@
+//go:build unit
+
+package admin
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestResolveSMTPTestPasswordRequiresReentryWhenCredentialIdentityChanges(t *testing.T) {
+ saved := &service.SMTPConfig{
+ Host: "smtp.example.com", Port: 587, Username: "mailer", Password: "saved-secret", UseTLS: true,
+ }
+ for _, tc := range []struct {
+ name string
+ config service.SMTPConfig
+ }{
+ {name: "host", config: service.SMTPConfig{Host: "attacker.example.com", Port: 587, Username: "mailer", UseTLS: true}},
+ {name: "port", config: service.SMTPConfig{Host: "smtp.example.com", Port: 2525, Username: "mailer", UseTLS: true}},
+ {name: "username", config: service.SMTPConfig{Host: "smtp.example.com", Port: 587, Username: "other", UseTLS: true}},
+ {name: "tls downgrade", config: service.SMTPConfig{Host: "smtp.example.com", Port: 587, Username: "mailer", UseTLS: false}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ password, err := resolveSMTPTestPassword("", &tc.config, saved)
+ require.Error(t, err)
+ require.Empty(t, password)
+ })
+ }
+}
+
+func TestResolveSMTPTestPasswordPreservesSameIdentityAndExplicitSecret(t *testing.T) {
+ saved := &service.SMTPConfig{
+ Host: "smtp.example.com", Port: 587, Username: "mailer", Password: "saved-secret", UseTLS: true,
+ }
+ same := &service.SMTPConfig{Host: "SMTP.EXAMPLE.COM", Port: 587, Username: "mailer", UseTLS: true}
+
+ password, err := resolveSMTPTestPassword("", same, saved)
+ require.NoError(t, err)
+ require.Equal(t, "saved-secret", password)
+
+ changed := &service.SMTPConfig{Host: "custom.example.com", Port: 465, Username: "custom", UseTLS: true}
+ password, err = resolveSMTPTestPassword("new-secret", changed, saved)
+ require.NoError(t, err)
+ require.Equal(t, "new-secret", password)
+}
+
+func TestSMTPTestHandlersRejectSavedPasswordReuseForChangedDestination(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ repo := &settingHandlerRepoStub{values: map[string]string{
+ service.SettingKeySMTPHost: "smtp.example.com",
+ service.SettingKeySMTPPort: "587",
+ service.SettingKeySMTPUsername: "mailer",
+ service.SettingKeySMTPPassword: "saved-secret",
+ service.SettingKeySMTPUseTLS: "true",
+ }}
+ handler := NewSettingHandler(nil, service.NewEmailService(repo, nil), nil, nil, nil, nil)
+
+ for _, tc := range []struct {
+ name string
+ path string
+ body map[string]any
+ call func(*gin.Context)
+ }{
+ {
+ name: "connection test",
+ path: "/api/v1/admin/settings/test-smtp",
+ body: map[string]any{"smtp_host": "attacker.example.com", "smtp_port": 587, "smtp_username": "mailer", "smtp_use_tls": true},
+ call: handler.TestSMTPConnection,
+ },
+ {
+ name: "test email",
+ path: "/api/v1/admin/settings/send-test-email",
+ body: map[string]any{"email": "admin@example.com", "smtp_host": "attacker.example.com", "smtp_port": 587, "smtp_username": "mailer", "smtp_use_tls": true},
+ call: handler.SendTestEmail,
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ payload, err := json.Marshal(tc.body)
+ require.NoError(t, err)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(http.MethodPost, tc.path, bytes.NewReader(payload))
+ ctx.Request.Header.Set("Content-Type", "application/json")
+
+ tc.call(ctx)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code)
+ require.Contains(t, recorder.Body.String(), "smtp_password must be provided")
+ })
+ }
+}
diff --git a/backend/internal/handler/admin/subscription_assign_idempotency_test.go b/backend/internal/handler/admin/subscription_assign_idempotency_test.go
new file mode 100644
index 00000000000..5ca9b3e5cc0
--- /dev/null
+++ b/backend/internal/handler/admin/subscription_assign_idempotency_test.go
@@ -0,0 +1,385 @@
+package admin
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+type subscriptionAssignerStub struct {
+ mu sync.Mutex
+ calls int
+ inputs []service.AssignSubscriptionInput
+ planWalletInitialUSD float64
+ planWalletBalanceUSD float64
+ planWalletCreditDeltaUSD float64
+ planType string
+}
+
+func (s *subscriptionAssignerStub) AssignAdminSubscription(_ context.Context, input *service.AssignSubscriptionInput) (*service.UserSubscription, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.calls++
+ if input != nil {
+ s.inputs = append(s.inputs, *input)
+ }
+ now := time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)
+ sub := &service.UserSubscription{
+ ID: 701,
+ UserID: input.UserID,
+ StartsAt: now,
+ ExpiresAt: service.MaxExpiresAt,
+ Status: service.SubscriptionStatusActive,
+ AssignedAt: now,
+ Notes: input.Notes,
+ }
+ if input.WalletInitialUSD != nil {
+ initial := *input.WalletInitialUSD
+ sub.WalletInitialUSD = &initial
+ sub.WalletBalanceUSD = &initial
+ return sub, nil
+ }
+ if input.PlanID != nil && s.planWalletInitialUSD > 0 {
+ initial := s.planWalletInitialUSD
+ balance := s.planWalletBalanceUSD
+ creditDelta := s.planWalletCreditDeltaUSD
+ sub.WalletInitialUSD = &initial
+ sub.WalletBalanceUSD = &balance
+ sub.WalletCreditDeltaUSD = &creditDelta
+ sub.AssignmentPlanType = s.planType
+ return sub, nil
+ }
+
+ groupID := input.GroupID
+ monthlyLimit := 99.0
+ sub.GroupID = &groupID
+ sub.Group = &service.Group{ID: groupID, MonthlyLimitUSD: &monthlyLimit}
+ sub.AssignmentPlanType = s.planType
+ return sub, nil
+}
+
+func (s *subscriptionAssignerStub) callCount() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.calls
+}
+
+func (s *subscriptionAssignerStub) RunAssignmentTransaction(ctx context.Context, execute func(context.Context) error) error {
+ return execute(ctx)
+}
+
+type affiliateRebateAccruerStub struct {
+ mu sync.Mutex
+ calls int
+ inviteeID int64
+ baseAmount float64
+ rateOverride *float64
+ err error
+}
+
+func (s *affiliateRebateAccruerStub) AccrueInviteRebateForOrderWithOverride(
+ _ context.Context,
+ inviteeUserID int64,
+ baseRechargeAmount float64,
+ rateOverride *float64,
+ _ *int64,
+) (float64, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.calls++
+ s.inviteeID = inviteeUserID
+ s.baseAmount = baseRechargeAmount
+ s.rateOverride = rateOverride
+ return 0, s.err
+}
+
+func (s *affiliateRebateAccruerStub) callCount() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.calls
+}
+
+func newAdminSubscriptionAssignRouter(t *testing.T, observeOnly bool) (*gin.Engine, *subscriptionAssignerStub, *affiliateRebateAccruerStub) {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+
+ cfg := service.DefaultIdempotencyConfig()
+ cfg.ObserveOnly = observeOnly
+ service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), cfg))
+ t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
+
+ assigner := &subscriptionAssignerStub{}
+ rebate := &affiliateRebateAccruerStub{}
+ handler := &SubscriptionHandler{
+ subscriptionAssigner: assigner,
+ assignmentTransactionRunner: assigner,
+ affiliateRebateAccruer: rebate,
+ }
+
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 99})
+ c.Next()
+ })
+ router.POST("/api/v1/admin/subscriptions/assign", handler.Assign)
+ return router, assigner, rebate
+}
+
+func performAdminSubscriptionAssign(router *gin.Engine, body, idempotencyKey string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/subscriptions/assign", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ if idempotencyKey != "" {
+ req.Header.Set("Idempotency-Key", idempotencyKey)
+ }
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, req)
+ return recorder
+}
+
+func decodeAssignResponse(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
+ t.Helper()
+ var body map[string]any
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
+ return body
+}
+
+func TestAdminSubscriptionAssignSameKeyReplaysWalletWithoutDuplicateSideEffects(t *testing.T) {
+ router, assigner, rebate := newAdminSubscriptionAssignRouter(t, false)
+ body := `{"user_id":173,"wallet_initial_usd":50,"notes":"manual topup"}`
+
+ first := performAdminSubscriptionAssign(router, body, "wallet-topup-ack-lost")
+ second := performAdminSubscriptionAssign(router, body, "wallet-topup-ack-lost")
+
+ require.Equal(t, http.StatusOK, first.Code)
+ require.Equal(t, http.StatusOK, second.Code)
+ require.Empty(t, first.Header().Get("X-Idempotency-Replayed"))
+ require.Equal(t, "true", second.Header().Get("X-Idempotency-Replayed"))
+ require.Equal(t, float64(701), decodeAssignResponse(t, first)["data"].(map[string]any)["id"])
+ require.Equal(t, float64(701), decodeAssignResponse(t, second)["data"].(map[string]any)["id"])
+ require.Equal(t, 1, assigner.callCount(), "wallet topup must execute once after an ACK-loss retry")
+ require.Equal(t, 1, rebate.callCount(), "affiliate rebate must stay inside the same idempotency boundary")
+ require.Equal(t, int64(173), rebate.inviteeID)
+ require.Equal(t, 50.0, rebate.baseAmount)
+ require.NotNil(t, rebate.rateOverride)
+ require.Equal(t, service.AffiliateRebateSubscriptionRate, *rebate.rateOverride,
+ "manual PlanID=nil wallet topups keep the zero-rebate policy")
+}
+
+func TestAdminSubscriptionAssignCreditsPlanTopupRebateUsesCurrentDeltaOnce(t *testing.T) {
+ router, assigner, rebate := newAdminSubscriptionAssignRouter(t, false)
+ assigner.planWalletInitialUSD = 150
+ assigner.planWalletBalanceUSD = 145
+ assigner.planWalletCreditDeltaUSD = 50
+ assigner.planType = service.PlanTypeCredits
+ body := `{"user_id":173,"plan_id":987654,"notes":"credits plan topup"}`
+
+ first := performAdminSubscriptionAssign(router, body, "credits-plan-topup-ack-lost")
+ second := performAdminSubscriptionAssign(router, body, "credits-plan-topup-ack-lost")
+
+ require.Equal(t, http.StatusOK, first.Code)
+ require.Equal(t, http.StatusOK, second.Code)
+ require.Equal(t, "true", second.Header().Get("X-Idempotency-Replayed"))
+ require.Equal(t, 1, assigner.callCount(), "credits plan topup must execute once")
+ require.Equal(t, 1, rebate.callCount(), "credits plan rebate must execute once")
+ require.Equal(t, 50.0, rebate.baseAmount,
+ "rebate must use this plan's wallet credit delta, not cumulative wallet_initial_usd")
+ require.NotNil(t, rebate.rateOverride)
+ require.Equal(t, service.AffiliateRebateCreditsCardRate, *rebate.rateOverride)
+}
+
+func TestAdminSubscriptionAssignRejectsAmbiguousAssignmentModes(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ }{
+ {name: "plan and wallet", body: `{"user_id":173,"plan_id":11,"wallet_initial_usd":50}`},
+ {name: "plan and group", body: `{"user_id":173,"plan_id":11,"group_id":22}`},
+ {name: "wallet and group", body: `{"user_id":173,"wallet_initial_usd":50,"group_id":22}`},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ router, assigner, rebate := newAdminSubscriptionAssignRouter(t, false)
+ response := performAdminSubscriptionAssign(router, tt.body, "ambiguous-"+tt.name)
+
+ require.Equal(t, http.StatusBadRequest, response.Code)
+ require.Zero(t, assigner.callCount())
+ require.Zero(t, rebate.callCount())
+ })
+ }
+}
+
+func TestAdminSubscriptionAssignRejectsInvalidModeSpecificFields(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ }{
+ {name: "negative user", body: `{"user_id":-1,"plan_id":11}`},
+ {name: "negative group", body: `{"user_id":173,"group_id":-22}`},
+ {name: "negative group validity", body: `{"user_id":173,"group_id":22,"validity_days":-1}`},
+ {name: "plan validity", body: `{"user_id":173,"plan_id":11,"validity_days":30}`},
+ {name: "wallet validity", body: `{"user_id":173,"wallet_initial_usd":50,"validity_days":30}`},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ router, assigner, rebate := newAdminSubscriptionAssignRouter(t, false)
+ response := performAdminSubscriptionAssign(router, tt.body, "invalid-"+tt.name)
+
+ require.Equal(t, http.StatusBadRequest, response.Code)
+ require.Zero(t, assigner.callCount())
+ require.Zero(t, rebate.callCount())
+ })
+ }
+}
+
+func TestAdminSubscriptionAssignReusedLegacyCreditsIDCannotChangeMonthlyRebate(t *testing.T) {
+ router, assigner, rebate := newAdminSubscriptionAssignRouter(t, false)
+ assigner.planType = service.PlanTypeSubscription
+ body := `{"user_id":208,"plan_id":11,"notes":"monthly plan with reused id"}`
+
+ response := performAdminSubscriptionAssign(router, body, "monthly-reused-plan-id")
+
+ require.Equal(t, http.StatusOK, response.Code)
+ require.Equal(t, 1, assigner.callCount())
+ require.Equal(t, 1, rebate.callCount())
+ require.NotNil(t, rebate.rateOverride)
+ require.Equal(t, service.AffiliateRebateSubscriptionRate, *rebate.rateOverride)
+}
+
+func TestAdminSubscriptionAssignSameKeyDifferentPayloadConflicts(t *testing.T) {
+ router, assigner, rebate := newAdminSubscriptionAssignRouter(t, false)
+
+ first := performAdminSubscriptionAssign(router, `{"user_id":173,"wallet_initial_usd":50}`, "wallet-topup-conflict")
+ second := performAdminSubscriptionAssign(router, `{"user_id":173,"wallet_initial_usd":75}`, "wallet-topup-conflict")
+
+ require.Equal(t, http.StatusOK, first.Code)
+ require.Equal(t, http.StatusConflict, second.Code)
+ require.Equal(t, "IDEMPOTENCY_KEY_CONFLICT", decodeAssignResponse(t, second)["reason"])
+ require.Equal(t, 1, assigner.callCount())
+ require.Equal(t, 1, rebate.callCount())
+}
+
+func TestAdminSubscriptionAssignRequiresKeyWhenEnforcementEnabled(t *testing.T) {
+ router, assigner, rebate := newAdminSubscriptionAssignRouter(t, false)
+
+ response := performAdminSubscriptionAssign(router, `{"user_id":173,"wallet_initial_usd":50}`, "")
+
+ require.Equal(t, http.StatusBadRequest, response.Code)
+ require.Equal(t, "IDEMPOTENCY_KEY_REQUIRED", decodeAssignResponse(t, response)["reason"])
+ require.Zero(t, assigner.callCount())
+ require.Zero(t, rebate.callCount())
+}
+
+func TestAdminSubscriptionAssignObserveOnlyCannotBypassStrictMissingKeyPolicy(t *testing.T) {
+ router, assigner, rebate := newAdminSubscriptionAssignRouter(t, true)
+
+ response := performAdminSubscriptionAssign(router, `{"user_id":173,"wallet_initial_usd":50}`, "")
+
+ require.Equal(t, http.StatusBadRequest, response.Code)
+ require.Equal(t, "IDEMPOTENCY_KEY_REQUIRED", decodeAssignResponse(t, response)["reason"])
+ require.Zero(t, assigner.callCount(), "financial writes must ignore the global observe-only bypass")
+ require.Zero(t, rebate.callCount())
+}
+
+func TestAdminSubscriptionAssignFailsClosedWhenCoordinatorIsMissing(t *testing.T) {
+ router, assigner, rebate := newAdminSubscriptionAssignRouter(t, false)
+ service.SetDefaultIdempotencyCoordinator(nil)
+
+ response := performAdminSubscriptionAssign(router, `{"user_id":173,"wallet_initial_usd":50}`, "wallet-no-coordinator")
+
+ require.Equal(t, http.StatusServiceUnavailable, response.Code)
+ require.Equal(t, "IDEMPOTENCY_STORE_UNAVAILABLE", decodeAssignResponse(t, response)["reason"])
+ require.Zero(t, assigner.callCount())
+ require.Zero(t, rebate.callCount())
+}
+
+func TestAdminSubscriptionAssignSameKeyReplaysMonthlyAssignment(t *testing.T) {
+ router, assigner, rebate := newAdminSubscriptionAssignRouter(t, false)
+ body := `{"user_id":208,"group_id":22,"validity_days":30,"notes":"monthly vip"}`
+
+ first := performAdminSubscriptionAssign(router, body, "monthly-assign-ack-lost")
+ second := performAdminSubscriptionAssign(router, body, "monthly-assign-ack-lost")
+
+ require.Equal(t, http.StatusOK, first.Code)
+ require.Equal(t, http.StatusOK, second.Code)
+ require.Equal(t, "true", second.Header().Get("X-Idempotency-Replayed"))
+ require.Equal(t, 1, assigner.callCount(), "monthly assignment must not execute twice")
+ require.Equal(t, 1, rebate.callCount(), "monthly affiliate decision must not execute twice")
+ require.Equal(t, 99.0, rebate.baseAmount, "monthly assignment keeps the group quota base")
+ require.NotNil(t, rebate.rateOverride)
+ require.Equal(t, service.AffiliateRebateSubscriptionRate, *rebate.rateOverride)
+}
+
+func TestAdminSubscriptionAssignAffiliateFailureFailsClosed(t *testing.T) {
+ router, assigner, rebate := newAdminSubscriptionAssignRouter(t, false)
+ rebate.err = errors.New("affiliate repository unavailable")
+
+ response := performAdminSubscriptionAssign(router, `{"user_id":173,"wallet_initial_usd":50}`, "wallet-affiliate-failure")
+
+ require.Equal(t, http.StatusInternalServerError, response.Code)
+ require.Equal(t, 1, assigner.callCount())
+ require.Equal(t, 1, rebate.callCount())
+}
+
+func TestAdminAssignBaseAmountUsesCurrentOperationValue(t *testing.T) {
+ manualDelta := 50.0
+ creditDelta := 50.0
+ cumulativeInitial := 150.0
+ monthlyLimit := 99.0
+ creditsPlanID := int64(11)
+ monthlyPlanID := int64(20)
+
+ tests := []struct {
+ name string
+ input *service.AssignSubscriptionInput
+ sub *service.UserSubscription
+ want float64
+ }{
+ {
+ name: "manual wallet uses request delta instead of cumulative initial",
+ input: &service.AssignSubscriptionInput{WalletInitialUSD: &manualDelta},
+ sub: &service.UserSubscription{WalletInitialUSD: &cumulativeInitial},
+ want: manualDelta,
+ },
+ {
+ name: "credits plan uses applied plan quota delta",
+ input: &service.AssignSubscriptionInput{PlanID: &creditsPlanID},
+ sub: &service.UserSubscription{
+ WalletInitialUSD: &cumulativeInitial,
+ WalletCreditDeltaUSD: &creditDelta,
+ },
+ want: creditDelta,
+ },
+ {
+ name: "credits plan fails closed without current operation delta",
+ input: &service.AssignSubscriptionInput{PlanID: &creditsPlanID},
+ sub: &service.UserSubscription{WalletInitialUSD: &cumulativeInitial},
+ want: 0,
+ },
+ {
+ name: "monthly plan keeps group monthly quota",
+ input: &service.AssignSubscriptionInput{PlanID: &monthlyPlanID},
+ sub: &service.UserSubscription{Group: &service.Group{MonthlyLimitUSD: &monthlyLimit}},
+ want: monthlyLimit,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.Equal(t, tt.want, adminAssignBaseAmount(tt.input, tt.sub))
+ })
+ }
+}
diff --git a/backend/internal/handler/admin/subscription_bulk_assign_idempotency_test.go b/backend/internal/handler/admin/subscription_bulk_assign_idempotency_test.go
new file mode 100644
index 00000000000..8313d3b7020
--- /dev/null
+++ b/backend/internal/handler/admin/subscription_bulk_assign_idempotency_test.go
@@ -0,0 +1,141 @@
+package admin
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+type bulkSubscriptionAssignerStub struct {
+ mu sync.Mutex
+ calls int
+ inputs []service.BulkAssignSubscriptionInput
+}
+
+func (s *bulkSubscriptionAssignerStub) BulkAssignSubscription(
+ _ context.Context,
+ input *service.BulkAssignSubscriptionInput,
+) (*service.BulkAssignResult, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.calls++
+ if input != nil {
+ cloned := *input
+ cloned.UserIDs = append([]int64(nil), input.UserIDs...)
+ s.inputs = append(s.inputs, cloned)
+ }
+ return &service.BulkAssignResult{
+ SuccessCount: 2,
+ CreatedCount: 2,
+ Statuses: map[int64]string{
+ 101: "created",
+ 102: "created",
+ },
+ Subscriptions: []service.UserSubscription{
+ {ID: 9001, UserID: 101, Status: service.SubscriptionStatusActive, StartsAt: time.Now(), ExpiresAt: time.Now().Add(30 * 24 * time.Hour)},
+ {ID: 9002, UserID: 102, Status: service.SubscriptionStatusActive, StartsAt: time.Now(), ExpiresAt: time.Now().Add(30 * 24 * time.Hour)},
+ },
+ }, nil
+}
+
+func (s *bulkSubscriptionAssignerStub) callCount() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.calls
+}
+
+func newAdminSubscriptionBulkAssignRouter(t *testing.T, observeOnly bool) (*gin.Engine, *bulkSubscriptionAssignerStub) {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ cfg := service.DefaultIdempotencyConfig()
+ cfg.ObserveOnly = observeOnly
+ service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), cfg))
+ t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
+
+ assigner := &bulkSubscriptionAssignerStub{}
+ handler := &SubscriptionHandler{subscriptionBulkAssigner: assigner}
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 99})
+ c.Next()
+ })
+ router.POST("/api/v1/admin/subscriptions/bulk-assign", handler.BulkAssign)
+ return router, assigner
+}
+
+func performAdminSubscriptionBulkAssign(router *gin.Engine, body, idempotencyKey string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/subscriptions/bulk-assign", bytes.NewBufferString(body))
+ req.Header.Set("Content-Type", "application/json")
+ if idempotencyKey != "" {
+ req.Header.Set("Idempotency-Key", idempotencyKey)
+ }
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, req)
+ return recorder
+}
+
+func decodeBulkAssignResponse(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
+ t.Helper()
+ var body map[string]any
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
+ return body
+}
+
+func TestAdminSubscriptionBulkAssignSameKeyReplaysWithoutDuplicateAssignments(t *testing.T) {
+ router, assigner := newAdminSubscriptionBulkAssignRouter(t, false)
+ body := `{"user_ids":[101,102],"group_id":7,"validity_days":30,"notes":"bulk"}`
+
+ first := performAdminSubscriptionBulkAssign(router, body, "bulk-ack-lost")
+ second := performAdminSubscriptionBulkAssign(router, body, "bulk-ack-lost")
+
+ require.Equal(t, http.StatusOK, first.Code)
+ require.Equal(t, http.StatusOK, second.Code)
+ require.Empty(t, first.Header().Get("X-Idempotency-Replayed"))
+ require.Equal(t, "true", second.Header().Get("X-Idempotency-Replayed"))
+ require.Equal(t, 1, assigner.callCount(), "an ACK-loss retry must not execute bulk assignment again")
+ require.Equal(t, float64(2), decodeBulkAssignResponse(t, first)["data"].(map[string]any)["success_count"])
+ require.Equal(t, float64(2), decodeBulkAssignResponse(t, second)["data"].(map[string]any)["success_count"])
+}
+
+func TestAdminSubscriptionBulkAssignSameKeyDifferentPayloadConflicts(t *testing.T) {
+ router, assigner := newAdminSubscriptionBulkAssignRouter(t, false)
+
+ first := performAdminSubscriptionBulkAssign(router, `{"user_ids":[101,102],"group_id":7,"validity_days":30}`, "bulk-conflict")
+ second := performAdminSubscriptionBulkAssign(router, `{"user_ids":[101,103],"group_id":7,"validity_days":30}`, "bulk-conflict")
+
+ require.Equal(t, http.StatusOK, first.Code)
+ require.Equal(t, http.StatusConflict, second.Code)
+ require.Equal(t, "IDEMPOTENCY_KEY_CONFLICT", decodeBulkAssignResponse(t, second)["reason"])
+ require.Equal(t, 1, assigner.callCount())
+}
+
+func TestAdminSubscriptionBulkAssignRequiresKeyEvenInObserveOnlyMode(t *testing.T) {
+ router, assigner := newAdminSubscriptionBulkAssignRouter(t, true)
+
+ response := performAdminSubscriptionBulkAssign(router, `{"user_ids":[101,102],"group_id":7}`, "")
+
+ require.Equal(t, http.StatusBadRequest, response.Code)
+ require.Equal(t, "IDEMPOTENCY_KEY_REQUIRED", decodeBulkAssignResponse(t, response)["reason"])
+ require.Zero(t, assigner.callCount())
+}
+
+func TestAdminSubscriptionBulkAssignFailsClosedWithoutCoordinator(t *testing.T) {
+ router, assigner := newAdminSubscriptionBulkAssignRouter(t, false)
+ service.SetDefaultIdempotencyCoordinator(nil)
+
+ response := performAdminSubscriptionBulkAssign(router, `{"user_ids":[101,102],"group_id":7}`, "bulk-no-coordinator")
+
+ require.Equal(t, http.StatusServiceUnavailable, response.Code)
+ require.Equal(t, "IDEMPOTENCY_STORE_UNAVAILABLE", decodeBulkAssignResponse(t, response)["reason"])
+ require.Zero(t, assigner.callCount())
+}
diff --git a/backend/internal/handler/admin/subscription_handler.go b/backend/internal/handler/admin/subscription_handler.go
index 611666decf9..794efde4586 100644
--- a/backend/internal/handler/admin/subscription_handler.go
+++ b/backend/internal/handler/admin/subscription_handler.go
@@ -2,6 +2,7 @@ package admin
import (
"context"
+ "log/slog"
"strconv"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
@@ -29,21 +30,105 @@ func toResponsePagination(p *pagination.PaginationResult) *response.PaginationRe
// SubscriptionHandler handles admin subscription management
type SubscriptionHandler struct {
subscriptionService *service.SubscriptionService
+ affiliateService *service.AffiliateService
+
+ // Narrow assignment dependencies keep the high-risk write path testable
+ // without weakening the concrete service used by the read/update handlers.
+ subscriptionAssigner subscriptionAssigner
+ subscriptionBulkAssigner subscriptionBulkAssigner
+ assignmentTransactionRunner assignmentTransactionRunner
+ affiliateRebateAccruer affiliateRebateAccruer
+}
+
+type subscriptionAssigner interface {
+ AssignAdminSubscription(context.Context, *service.AssignSubscriptionInput) (*service.UserSubscription, error)
+}
+
+type subscriptionBulkAssigner interface {
+ BulkAssignSubscription(context.Context, *service.BulkAssignSubscriptionInput) (*service.BulkAssignResult, error)
+}
+
+type assignmentTransactionRunner interface {
+ RunAssignmentTransaction(context.Context, func(context.Context) error) error
+}
+
+type affiliateRebateAccruer interface {
+ AccrueInviteRebateForOrderWithOverride(context.Context, int64, float64, *float64, *int64) (float64, error)
}
// NewSubscriptionHandler creates a new admin subscription handler
-func NewSubscriptionHandler(subscriptionService *service.SubscriptionService) *SubscriptionHandler {
+func NewSubscriptionHandler(subscriptionService *service.SubscriptionService, affiliateService *service.AffiliateService) *SubscriptionHandler {
return &SubscriptionHandler{
- subscriptionService: subscriptionService,
+ subscriptionService: subscriptionService,
+ affiliateService: affiliateService,
+ subscriptionAssigner: subscriptionService,
+ subscriptionBulkAssigner: subscriptionService,
+ assignmentTransactionRunner: subscriptionService,
+ affiliateRebateAccruer: affiliateService,
}
}
-// AssignSubscriptionRequest represents assign subscription request
+// AssignSubscriptionRequest represents assign subscription request.
+//
+// 三种模式三选一:
+// - Plan 模式:只填 plan_id,由 plan 读取额度/有效期
+// - Group 模式(v3):填 group_id,可填 validity_days
+// - 钱包充值模式 (credits):只填 wallet_initial_usd(>0),创建或充值用户级永久 credits 钱包
type AssignSubscriptionRequest struct {
- UserID int64 `json:"user_id" binding:"required"`
- GroupID int64 `json:"group_id" binding:"required"`
- ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // max 100 years
- Notes string `json:"notes"`
+ UserID int64 `json:"user_id" binding:"required"`
+ GroupID int64 `json:"group_id"`
+ ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // max 100 years
+ Notes string `json:"notes"`
+ WalletInitialUSD *float64 `json:"wallet_initial_usd" binding:"omitempty,gt=0,lte=10000000"`
+ PlanID *int64 `json:"plan_id" binding:"omitempty,gt=0"`
+}
+
+type adminAssignIdempotencyPayload struct {
+ Mode string `json:"mode"`
+ UserID int64 `json:"user_id"`
+ GroupID int64 `json:"group_id,omitempty"`
+ ValidityDays int `json:"validity_days,omitempty"`
+ Notes string `json:"notes,omitempty"`
+ WalletInitialUSD float64 `json:"wallet_initial_usd,omitempty"`
+ PlanID int64 `json:"plan_id,omitempty"`
+}
+
+func assignSubscriptionInputFromRequest(req AssignSubscriptionRequest, adminID int64) *service.AssignSubscriptionInput {
+ planType := ""
+ if req.WalletInitialUSD != nil {
+ planType = service.PlanTypeCredits
+ }
+
+ return &service.AssignSubscriptionInput{
+ UserID: req.UserID,
+ GroupID: req.GroupID,
+ ValidityDays: req.ValidityDays,
+ AssignedBy: adminID,
+ Notes: req.Notes,
+ WalletInitialUSD: req.WalletInitialUSD,
+ PlanID: req.PlanID,
+ PlanType: planType,
+ }
+}
+
+func adminAssignPayloadFromInput(input *service.AssignSubscriptionInput) adminAssignIdempotencyPayload {
+ payload := adminAssignIdempotencyPayload{
+ UserID: input.UserID,
+ ValidityDays: input.ValidityDays,
+ Notes: input.Notes,
+ }
+ switch {
+ case input.PlanID != nil:
+ payload.Mode = "plan"
+ payload.PlanID = *input.PlanID
+ case input.WalletInitialUSD != nil:
+ payload.Mode = "wallet"
+ payload.WalletInitialUSD = *input.WalletInitialUSD
+ default:
+ payload.Mode = "group"
+ payload.GroupID = input.GroupID
+ }
+ return payload
}
// BulkAssignSubscriptionRequest represents bulk assign subscription request
@@ -143,20 +228,52 @@ func (h *SubscriptionHandler) Assign(c *gin.Context) {
// Get admin user ID from context
adminID := getAdminIDFromContext(c)
-
- subscription, err := h.subscriptionService.AssignSubscription(c.Request.Context(), &service.AssignSubscriptionInput{
- UserID: req.UserID,
- GroupID: req.GroupID,
- ValidityDays: req.ValidityDays,
- AssignedBy: adminID,
- Notes: req.Notes,
- })
+ adminInput, err := service.NormalizeAdminSubscriptionAssignInput(assignSubscriptionInputFromRequest(req, adminID))
if err != nil {
response.ErrorFrom(c, err)
return
}
+ idempotencyPayload := adminAssignPayloadFromInput(adminInput)
- response.Success(c, dto.UserSubscriptionFromServiceAdmin(subscription))
+ assigner := h.subscriptionAssigner
+ if assigner == nil {
+ assigner = h.subscriptionService
+ }
+ transactionRunner := h.assignmentTransactionRunner
+ if transactionRunner == nil {
+ transactionRunner = h.subscriptionService
+ }
+ rebateAccruer := h.affiliateRebateAccruer
+ if rebateAccruer == nil {
+ rebateAccruer = h.affiliateService
+ }
+ var runInTransaction func(context.Context, func(context.Context) error) error
+ if transactionRunner != nil {
+ runInTransaction = transactionRunner.RunAssignmentTransaction
+ }
+
+ executeAdminStrictIdempotentJSON(c, "admin.subscriptions.assign", idempotencyPayload, service.DefaultWriteIdempotencyTTL(), runInTransaction, func(ctx context.Context) (any, error) {
+ subscription, err := assigner.AssignAdminSubscription(ctx, adminInput)
+ if err != nil {
+ return nil, err
+ }
+
+ // Keep rebate accrual inside the same idempotency boundary as the wallet or
+ // monthly assignment. A replay returns the stored response and reaches
+ // neither side effect again.
+ if rebateAccruer != nil {
+ if baseAmount := adminAssignBaseAmount(adminInput, subscription); baseAmount > 0 {
+ // 差异化返利:余额卡 10% / 月卡及其它 0%(与兑换码口径一致,月卡不给佣金)。
+ override := service.AffiliateRebateOverrideForAdminAssign(adminInput.PlanID, subscription.AssignmentPlanType)
+ if _, rebateErr := rebateAccruer.AccrueInviteRebateForOrderWithOverride(ctx, subscription.UserID, baseAmount, override, nil); rebateErr != nil {
+ slog.Warn("admin assign: affiliate rebate failed", "userID", subscription.UserID, "subscriptionID", subscription.ID, "err", rebateErr)
+ return nil, rebateErr
+ }
+ }
+ }
+
+ return dto.UserSubscriptionFromServiceAdmin(subscription), nil
+ })
}
// BulkAssign handles bulk assigning subscriptions to multiple users
@@ -171,19 +288,23 @@ func (h *SubscriptionHandler) BulkAssign(c *gin.Context) {
// Get admin user ID from context
adminID := getAdminIDFromContext(c)
- result, err := h.subscriptionService.BulkAssignSubscription(c.Request.Context(), &service.BulkAssignSubscriptionInput{
- UserIDs: req.UserIDs,
- GroupID: req.GroupID,
- ValidityDays: req.ValidityDays,
- AssignedBy: adminID,
- Notes: req.Notes,
- })
- if err != nil {
- response.ErrorFrom(c, err)
- return
+ assigner := h.subscriptionBulkAssigner
+ if assigner == nil {
+ assigner = h.subscriptionService
}
-
- response.Success(c, dto.BulkAssignResultFromService(result))
+ executeAdminStrictIdempotentJSONNonTransactional(c, "admin.subscriptions.bulk_assign", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
+ result, err := assigner.BulkAssignSubscription(ctx, &service.BulkAssignSubscriptionInput{
+ UserIDs: req.UserIDs,
+ GroupID: req.GroupID,
+ ValidityDays: req.ValidityDays,
+ AssignedBy: adminID,
+ Notes: req.Notes,
+ })
+ if err != nil {
+ return nil, err
+ }
+ return dto.BulkAssignResultFromService(result), nil
+ })
}
// Extend handles adjusting a subscription (extend or shorten)
@@ -208,7 +329,17 @@ func (h *SubscriptionHandler) Extend(c *gin.Context) {
SubscriptionID: subscriptionID,
Body: req,
}
- executeAdminIdempotentJSON(c, "admin.subscriptions.extend", idempotencyPayload, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
+ transactionRunner := h.assignmentTransactionRunner
+ if transactionRunner == nil {
+ transactionRunner = h.subscriptionService
+ }
+ var runInTransaction func(context.Context, func(context.Context) error) error
+ if transactionRunner != nil {
+ runInTransaction = transactionRunner.RunAssignmentTransaction
+ }
+ executeAdminStrictIdempotentJSONWithPostCommit(c, "admin.subscriptions.extend", idempotencyPayload, service.DefaultWriteIdempotencyTTL(), runInTransaction, func(ctx context.Context) error {
+ return h.subscriptionService.InvalidateSubscriptionCachesAfterCommit(ctx, subscriptionID)
+ }, func(ctx context.Context) (any, error) {
subscription, execErr := h.subscriptionService.ExtendSubscription(ctx, subscriptionID, req.Days)
if execErr != nil {
return nil, execErr
@@ -321,3 +452,28 @@ func getAdminIDFromContext(c *gin.Context) int64 {
}
return subject.UserID
}
+
+// adminAssignBaseAmount returns the USD value applied by this admin assignment.
+// A wallet's wallet_initial_usd is cumulative after top-ups, so it must never be
+// used as the rebate base for a credits plan. Manual wallet assignments use the
+// request delta; plan wallet assignments use the runtime delta produced by the
+// subscription service. Group subscriptions keep their existing monthly-quota
+// semantics. Returns 0 when the current-operation value cannot be determined.
+func adminAssignBaseAmount(input *service.AssignSubscriptionInput, sub *service.UserSubscription) float64 {
+ if input == nil || sub == nil {
+ return 0
+ }
+ if input.WalletInitialUSD != nil && *input.WalletInitialUSD > 0 {
+ return *input.WalletInitialUSD
+ }
+ if input.PlanID != nil && sub.WalletInitialUSD != nil {
+ if sub.WalletCreditDeltaUSD != nil && *sub.WalletCreditDeltaUSD > 0 {
+ return *sub.WalletCreditDeltaUSD
+ }
+ return 0
+ }
+ if sub.Group != nil && sub.Group.MonthlyLimitUSD != nil && *sub.Group.MonthlyLimitUSD > 0 {
+ return *sub.Group.MonthlyLimitUSD
+ }
+ return 0
+}
diff --git a/backend/internal/handler/admin/subscription_handler_test.go b/backend/internal/handler/admin/subscription_handler_test.go
new file mode 100644
index 00000000000..be8918d3712
--- /dev/null
+++ b/backend/internal/handler/admin/subscription_handler_test.go
@@ -0,0 +1,69 @@
+package admin
+
+import (
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAssignSubscriptionInputFromRequestMapsManualWalletToCredits(t *testing.T) {
+ amount := 50.0
+ input := assignSubscriptionInputFromRequest(AssignSubscriptionRequest{
+ UserID: 173,
+ Notes: "manual topup",
+ WalletInitialUSD: &amount,
+ }, 1)
+
+ require.Equal(t, int64(173), input.UserID)
+ require.Equal(t, int64(1), input.AssignedBy)
+ require.Equal(t, service.PlanTypeCredits, input.PlanType)
+ require.Same(t, &amount, input.WalletInitialUSD)
+ require.Zero(t, input.ValidityDays)
+}
+
+func TestAssignSubscriptionInputFromRequestKeepsPlanModeForPlanIDOnly(t *testing.T) {
+ planID := int64(8)
+ input := assignSubscriptionInputFromRequest(AssignSubscriptionRequest{
+ UserID: 88,
+ PlanID: &planID,
+ }, 1)
+
+ require.Equal(t, int64(88), input.UserID)
+ require.Same(t, &planID, input.PlanID)
+ require.Empty(t, input.PlanType)
+ require.Nil(t, input.WalletInitialUSD)
+}
+
+func TestAdminAssignPayloadFromInputUsesCanonicalModeFields(t *testing.T) {
+ planID := int64(987654)
+ walletDelta := 50.0
+
+ tests := []struct {
+ name string
+ input *service.AssignSubscriptionInput
+ want adminAssignIdempotencyPayload
+ }{
+ {
+ name: "plan",
+ input: &service.AssignSubscriptionInput{UserID: 1, PlanID: &planID, Notes: "plan"},
+ want: adminAssignIdempotencyPayload{Mode: "plan", UserID: 1, PlanID: planID, Notes: "plan"},
+ },
+ {
+ name: "wallet",
+ input: &service.AssignSubscriptionInput{UserID: 2, WalletInitialUSD: &walletDelta, PlanType: service.PlanTypeCredits, Notes: "wallet"},
+ want: adminAssignIdempotencyPayload{Mode: "wallet", UserID: 2, WalletInitialUSD: walletDelta, Notes: "wallet"},
+ },
+ {
+ name: "group",
+ input: &service.AssignSubscriptionInput{UserID: 3, GroupID: 22, ValidityDays: 30, Notes: "group"},
+ want: adminAssignIdempotencyPayload{Mode: "group", UserID: 3, GroupID: 22, ValidityDays: 30, Notes: "group"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.Equal(t, tt.want, adminAssignPayloadFromInput(tt.input))
+ })
+ }
+}
diff --git a/backend/internal/handler/admin/usage_billing_reconciliation_handler_test.go b/backend/internal/handler/admin/usage_billing_reconciliation_handler_test.go
new file mode 100644
index 00000000000..fd6bc00140d
--- /dev/null
+++ b/backend/internal/handler/admin/usage_billing_reconciliation_handler_test.go
@@ -0,0 +1,177 @@
+package admin
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+type reconciliationHandlerRepoStub struct {
+ items []service.UsageBillingReconciliationCase
+ resolved *service.UsageBillingReconciliationResolveInput
+ err error
+}
+
+func (s *reconciliationHandlerRepoStub) ListUsageBillingReconciliationCases(
+ context.Context,
+ int,
+) ([]service.UsageBillingReconciliationCase, error) {
+ return s.items, s.err
+}
+
+func (s *reconciliationHandlerRepoStub) ResolveUsageBillingReconciliation(
+ _ context.Context,
+ input service.UsageBillingReconciliationResolveInput,
+) error {
+ copy := input
+ s.resolved = ©
+ return s.err
+}
+
+type reconciliationIdempotencyRepoStub struct{}
+
+func (reconciliationIdempotencyRepoStub) CreateProcessing(_ context.Context, record *service.IdempotencyRecord) (bool, error) {
+ record.ID = 1
+ return true, nil
+}
+
+func (reconciliationIdempotencyRepoStub) GetByScopeAndKeyHash(context.Context, string, string) (*service.IdempotencyRecord, error) {
+ return nil, nil
+}
+
+func (reconciliationIdempotencyRepoStub) TryReclaim(context.Context, int64, string, time.Time, time.Time, time.Time) (bool, error) {
+ return false, nil
+}
+
+func (reconciliationIdempotencyRepoStub) ExtendProcessingLock(context.Context, int64, string, time.Time, time.Time) (bool, error) {
+ return true, nil
+}
+
+func (reconciliationIdempotencyRepoStub) MarkSucceeded(context.Context, int64, int, string, time.Time) error {
+ return nil
+}
+
+func (reconciliationIdempotencyRepoStub) MarkFailedRetryable(context.Context, int64, string, time.Time, time.Time) error {
+ return nil
+}
+
+func (reconciliationIdempotencyRepoStub) DeleteExpired(context.Context, time.Time, int) (int64, error) {
+ return 0, nil
+}
+
+func setupReconciliationRouter(repo *reconciliationHandlerRepoStub, userID int64) *gin.Engine {
+ return setupReconciliationRouterWithAuth(repo, userID, "jwt")
+}
+
+func setupReconciliationRouterWithAuth(
+ repo *reconciliationHandlerRepoStub,
+ userID int64,
+ authMethod string,
+) *gin.Engine {
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ if userID > 0 {
+ router.Use(func(c *gin.Context) {
+ c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: userID})
+ c.Set("auth_method", authMethod)
+ c.Next()
+ })
+ }
+ var reconciliationService *service.UsageBillingReconciliationService
+ if repo != nil {
+ reconciliationService = service.NewUsageBillingReconciliationService(repo)
+ }
+ handler := NewUsageHandler(nil, nil, nil, nil, reconciliationService)
+ router.GET("/api/v1/admin/usage/reconciliation", handler.ListBillingReconciliationCases)
+ router.POST("/api/v1/admin/usage/reconciliation/resolve", handler.ResolveBillingReconciliation)
+ return router
+}
+
+func TestUsageHandlerRejectsSharedAdminKeyForBillingReconciliation(t *testing.T) {
+ repo := &reconciliationHandlerRepoStub{}
+ router := setupReconciliationRouterWithAuth(repo, 7, "admin_api_key")
+
+ recorder := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/usage/reconciliation/resolve", bytes.NewReader(reconciliationRequestBody(t)))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Idempotency-Key", "shared-key-reconciliation-denied")
+ router.ServeHTTP(recorder, req)
+
+ require.Equal(t, http.StatusForbidden, recorder.Code)
+ require.Nil(t, repo.resolved)
+}
+
+func TestUsageHandlerListsBillingReconciliationCasesForAuthenticatedAdmin(t *testing.T) {
+ repo := &reconciliationHandlerRepoStub{items: []service.UsageBillingReconciliationCase{{RequestID: "req-list", APIKeyID: 4}}}
+ router := setupReconciliationRouter(repo, 7)
+
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/admin/usage/reconciliation?limit=25", nil))
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+ require.Contains(t, recorder.Body.String(), "req-list")
+}
+
+func TestUsageHandlerRejectsUnauthenticatedBillingReconciliation(t *testing.T) {
+ repo := &reconciliationHandlerRepoStub{}
+ router := setupReconciliationRouter(repo, 0)
+
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/admin/usage/reconciliation/resolve", bytes.NewBufferString(`{}`)))
+
+ require.Equal(t, http.StatusUnauthorized, recorder.Code)
+ require.Nil(t, repo.resolved)
+}
+
+func TestUsageHandlerRequiresPersistentIdempotencyKeyForBillingReconciliation(t *testing.T) {
+ repo := &reconciliationHandlerRepoStub{}
+ router := setupReconciliationRouter(repo, 7)
+ body := reconciliationRequestBody(t)
+
+ recorder := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/usage/reconciliation/resolve", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(recorder, req)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code)
+ require.Nil(t, repo.resolved)
+}
+
+func TestUsageHandlerResolvesBillingReconciliationWithOperatorEvidence(t *testing.T) {
+ repo := &reconciliationHandlerRepoStub{}
+ router := setupReconciliationRouter(repo, 7)
+ coordinator := service.NewIdempotencyCoordinator(reconciliationIdempotencyRepoStub{}, service.DefaultIdempotencyConfig())
+ service.SetDefaultIdempotencyCoordinator(coordinator)
+ t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
+
+ recorder := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/usage/reconciliation/resolve", bytes.NewReader(reconciliationRequestBody(t)))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Idempotency-Key", "reconcile-req-handler-20260712")
+ router.ServeHTTP(recorder, req)
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+ require.NotNil(t, repo.resolved)
+ require.Equal(t, int64(7), repo.resolved.OperatorID)
+ require.Equal(t, "incident:HFC-2026-0712", repo.resolved.EvidenceRef)
+}
+
+func reconciliationRequestBody(t *testing.T) []byte {
+ t.Helper()
+ body, err := json.Marshal(service.UsageBillingReconciliationResolveInput{
+ RequestID: "req-handler", APIKeyID: 4,
+ Action: service.UsageBillingReconciliationActionReleaseUndelivered,
+ EvidenceRef: "incident:HFC-2026-0712",
+ })
+ require.NoError(t, err)
+ return body
+}
diff --git a/backend/internal/handler/admin/usage_cleanup_handler_test.go b/backend/internal/handler/admin/usage_cleanup_handler_test.go
index 6152d5e9d81..f5eda614a48 100644
--- a/backend/internal/handler/admin/usage_cleanup_handler_test.go
+++ b/backend/internal/handler/admin/usage_cleanup_handler_test.go
@@ -114,7 +114,7 @@ func setupCleanupRouter(cleanupService *service.UsageCleanupService, userID int6
})
}
- handler := NewUsageHandler(nil, nil, nil, cleanupService)
+ handler := NewUsageHandler(nil, nil, nil, cleanupService, nil)
router.POST("/api/v1/admin/usage/cleanup-tasks", handler.CreateCleanupTask)
router.GET("/api/v1/admin/usage/cleanup-tasks", handler.ListCleanupTasks)
router.POST("/api/v1/admin/usage/cleanup-tasks/:id/cancel", handler.CancelCleanupTask)
diff --git a/backend/internal/handler/admin/usage_handler.go b/backend/internal/handler/admin/usage_handler.go
index 0857a13880b..2cc1995dbca 100644
--- a/backend/internal/handler/admin/usage_handler.go
+++ b/backend/internal/handler/admin/usage_handler.go
@@ -2,6 +2,7 @@ package admin
import (
"context"
+ "fmt"
"net/http"
"strconv"
"strings"
@@ -19,12 +20,24 @@ import (
"github.com/gin-gonic/gin"
)
+const (
+ maxAdminUsagePage = 1000
+ maxAdminUsagePageSize = 100
+ maxAdminUsageOffset = 10_000
+ maxAdminUsageRangeDays = 366
+ maxAdminUsageUnscopedDays = 31
+ maxAdminUsageExactTotalDays = 31
+ defaultAdminUsageLookbackDays = 31
+ maxAdminUsageQueryTime = 10 * time.Second
+)
+
// UsageHandler handles admin usage-related requests
type UsageHandler struct {
- usageService *service.UsageService
- apiKeyService *service.APIKeyService
- adminService service.AdminService
- cleanupService *service.UsageCleanupService
+ usageService *service.UsageService
+ apiKeyService *service.APIKeyService
+ adminService service.AdminService
+ cleanupService *service.UsageCleanupService
+ reconciliationService *service.UsageBillingReconciliationService
}
// NewUsageHandler creates a new admin usage handler
@@ -33,12 +46,14 @@ func NewUsageHandler(
apiKeyService *service.APIKeyService,
adminService service.AdminService,
cleanupService *service.UsageCleanupService,
+ reconciliationService *service.UsageBillingReconciliationService,
) *UsageHandler {
return &UsageHandler{
- usageService: usageService,
- apiKeyService: apiKeyService,
- adminService: adminService,
- cleanupService: cleanupService,
+ usageService: usageService,
+ apiKeyService: apiKeyService,
+ adminService: adminService,
+ cleanupService: cleanupService,
+ reconciliationService: reconciliationService,
}
}
@@ -60,7 +75,11 @@ type CreateUsageCleanupTaskRequest struct {
// List handles listing all usage records with filters
// GET /api/v1/admin/usage
func (h *UsageHandler) List(c *gin.Context) {
- page, pageSize := response.ParsePagination(c)
+ page, pageSize, err := parseAdminUsagePagination(c)
+ if err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
exactTotal := false
if exactTotalRaw := strings.TrimSpace(c.Query("exact_total")); exactTotalRaw != "" {
parsed, err := strconv.ParseBool(exactTotalRaw)
@@ -142,27 +161,14 @@ func (h *UsageHandler) List(c *gin.Context) {
billingType = &bt
}
- // Parse date range
- var startTime, endTime *time.Time
- userTZ := c.Query("timezone") // Get user's timezone from request
- if startDateStr := c.Query("start_date"); startDateStr != "" {
- t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
- if err != nil {
- response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
- return
- }
- startTime = &t
+ startTime, endTime, err := parseAdminUsageTimeRange(c, defaultAdminUsageLookbackDays)
+ if err != nil {
+ response.BadRequest(c, err.Error())
+ return
}
-
- if endDateStr := c.Query("end_date"); endDateStr != "" {
- t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
- if err != nil {
- response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
- return
- }
- // Use half-open range [start, end), move to next calendar day start (DST-safe).
- t = t.AddDate(0, 0, 1)
- endTime = &t
+ if err := validateAdminUsageQueryScope(userID, apiKeyID, accountID, groupID, startTime, endTime, exactTotal); err != nil {
+ response.BadRequest(c, err.Error())
+ return
}
params := pagination.PaginationParams{
@@ -181,12 +187,14 @@ func (h *UsageHandler) List(c *gin.Context) {
Stream: stream,
BillingType: billingType,
BillingMode: billingMode,
- StartTime: startTime,
- EndTime: endTime,
+ StartTime: &startTime,
+ EndTime: &endTime,
ExactTotal: exactTotal,
}
- records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
+ requestCtx, cancel := boundedAdminUsageContext(c.Request.Context())
+ defer cancel()
+ records, result, err := h.usageService.ListWithFilters(requestCtx, params, filters)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -281,20 +289,13 @@ func (h *UsageHandler) Stats(c *gin.Context) {
startDateStr := c.Query("start_date")
endDateStr := c.Query("end_date")
- if startDateStr != "" && endDateStr != "" {
+ if startDateStr != "" || endDateStr != "" {
var err error
- startTime, err = timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
- if err != nil {
- response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
- return
- }
- endTime, err = timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
+ startTime, endTime, err = parseAdminUsageTimeRange(c, defaultAdminUsageLookbackDays)
if err != nil {
- response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
+ response.BadRequest(c, err.Error())
return
}
- // 与 SQL 条件 created_at < end 对齐,使用次日 00:00 作为上边界(DST-safe)。
- endTime = endTime.AddDate(0, 0, 1)
} else {
period := c.DefaultQuery("period", "today")
switch period {
@@ -309,6 +310,10 @@ func (h *UsageHandler) Stats(c *gin.Context) {
}
endTime = now
}
+ if err := validateAdminUsageQueryScope(userID, apiKeyID, accountID, groupID, startTime, endTime, false); err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
// Build filters and call GetStatsWithFilters
filters := usagestats.UsageLogFilters{
@@ -325,7 +330,9 @@ func (h *UsageHandler) Stats(c *gin.Context) {
EndTime: &endTime,
}
- stats, err := h.usageService.GetStatsWithFilters(c.Request.Context(), filters)
+ requestCtx, cancel := boundedAdminUsageContext(c.Request.Context())
+ defer cancel()
+ stats, err := h.usageService.GetStatsWithFilters(requestCtx, filters)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -334,6 +341,99 @@ func (h *UsageHandler) Stats(c *gin.Context) {
response.Success(c, stats)
}
+func parseAdminUsagePagination(c *gin.Context) (int, int, error) {
+ parse := func(name string, defaultValue int) (int, error) {
+ raw := strings.TrimSpace(c.Query(name))
+ if raw == "" {
+ return defaultValue, nil
+ }
+ value, err := strconv.ParseInt(raw, 10, 32)
+ if err != nil || value <= 0 {
+ return 0, fmt.Errorf("invalid %s", name)
+ }
+ return int(value), nil
+ }
+
+ page, err := parse("page", 1)
+ if err != nil {
+ return 0, 0, err
+ }
+ if page > maxAdminUsagePage {
+ return 0, 0, fmt.Errorf("page exceeds maximum %d", maxAdminUsagePage)
+ }
+ sizeName := "page_size"
+ if strings.TrimSpace(c.Query(sizeName)) == "" && strings.TrimSpace(c.Query("limit")) != "" {
+ sizeName = "limit"
+ }
+ pageSize, err := parse(sizeName, 20)
+ if err != nil {
+ return 0, 0, err
+ }
+ if pageSize > maxAdminUsagePageSize {
+ return 0, 0, fmt.Errorf("%s exceeds maximum %d", sizeName, maxAdminUsagePageSize)
+ }
+ if page > 1 && page-1 > maxAdminUsageOffset/pageSize {
+ return 0, 0, fmt.Errorf("page offset exceeds maximum %d", maxAdminUsageOffset)
+ }
+ return page, pageSize, nil
+}
+
+func parseAdminUsageTimeRange(c *gin.Context, defaultLookbackDays int) (time.Time, time.Time, error) {
+ userTZ := c.Query("timezone")
+ startRaw := strings.TrimSpace(c.Query("start_date"))
+ endRaw := strings.TrimSpace(c.Query("end_date"))
+ if (startRaw == "") != (endRaw == "") {
+ return time.Time{}, time.Time{}, fmt.Errorf("start_date and end_date must be provided together")
+ }
+
+ now := timezone.NowInUserLocation(userTZ)
+ endTime := timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
+ startTime := endTime.AddDate(0, 0, -defaultLookbackDays)
+ if startRaw != "" {
+ var err error
+ startTime, err = timezone.ParseInUserLocation("2006-01-02", startRaw, userTZ)
+ if err != nil {
+ return time.Time{}, time.Time{}, fmt.Errorf("invalid start_date format, use YYYY-MM-DD")
+ }
+ endTime, err = timezone.ParseInUserLocation("2006-01-02", endRaw, userTZ)
+ if err != nil {
+ return time.Time{}, time.Time{}, fmt.Errorf("invalid end_date format, use YYYY-MM-DD")
+ }
+ endTime = endTime.AddDate(0, 0, 1)
+ }
+ if !startTime.Before(endTime) {
+ return time.Time{}, time.Time{}, fmt.Errorf("start_date must be before or equal to end_date")
+ }
+ if endTime.After(startTime.AddDate(0, 0, maxAdminUsageRangeDays)) {
+ return time.Time{}, time.Time{}, fmt.Errorf("usage date range exceeds maximum %d days", maxAdminUsageRangeDays)
+ }
+ return startTime, endTime, nil
+}
+
+func validateAdminUsageQueryScope(
+ userID, apiKeyID, accountID, groupID int64,
+ startTime, endTime time.Time,
+ exactTotal bool,
+) error {
+ strongScope := userID > 0 || apiKeyID > 0 || accountID > 0 || groupID > 0
+ if !strongScope && endTime.After(startTime.AddDate(0, 0, maxAdminUsageUnscopedDays)) {
+ return fmt.Errorf("usage ranges over %d days require a user, API key, account, or group filter", maxAdminUsageUnscopedDays)
+ }
+ if exactTotal {
+ if !strongScope {
+ return fmt.Errorf("exact_total requires a user, API key, account, or group filter")
+ }
+ if endTime.After(startTime.AddDate(0, 0, maxAdminUsageExactTotalDays)) {
+ return fmt.Errorf("exact_total range exceeds maximum %d days", maxAdminUsageExactTotalDays)
+ }
+ }
+ return nil
+}
+
+func boundedAdminUsageContext(parent context.Context) (context.Context, context.CancelFunc) {
+ return context.WithTimeout(parent, maxAdminUsageQueryTime)
+}
+
// SearchUsers handles searching users by email keyword
// GET /api/v1/admin/usage/search-users
func (h *UsageHandler) SearchUsers(c *gin.Context) {
@@ -592,3 +692,81 @@ func (h *UsageHandler) CancelCleanupTask(c *gin.Context) {
logger.LegacyPrintf("handler.admin.usage", "[UsageCleanup] 清理任务已取消: task=%d operator=%d", taskID, subject.UserID)
response.Success(c, gin.H{"id": taskID, "status": service.UsageCleanupStatusCanceled})
}
+
+// ListBillingReconciliationCases lists admissions that require operator evidence.
+// GET /api/v1/admin/usage/reconciliation
+func (h *UsageHandler) ListBillingReconciliationCases(c *gin.Context) {
+ subject, ok := middleware.GetAuthSubjectFromContext(c)
+ if !ok || subject.UserID <= 0 {
+ response.Unauthorized(c, "Unauthorized")
+ return
+ }
+ if authMethod, _ := c.Get("auth_method"); authMethod != "jwt" {
+ response.Forbidden(c, "JWT administrator authentication is required for financial reconciliation")
+ return
+ }
+ if h.reconciliationService == nil {
+ response.ErrorFrom(c, service.ErrUsageBillingReconciliationUnavailable)
+ return
+ }
+ limit := 50
+ if raw := strings.TrimSpace(c.Query("limit")); raw != "" {
+ parsed, err := strconv.Atoi(raw)
+ if err != nil || parsed <= 0 || parsed > 100 {
+ response.BadRequest(c, "Invalid limit; use an integer from 1 to 100")
+ return
+ }
+ limit = parsed
+ }
+ items, err := h.reconciliationService.List(c.Request.Context(), limit)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Success(c, items)
+}
+
+// ResolveBillingReconciliation applies one evidence-backed financial decision.
+// POST /api/v1/admin/usage/reconciliation/resolve
+func (h *UsageHandler) ResolveBillingReconciliation(c *gin.Context) {
+ subject, ok := middleware.GetAuthSubjectFromContext(c)
+ if !ok || subject.UserID <= 0 {
+ response.Unauthorized(c, "Unauthorized")
+ return
+ }
+ if authMethod, _ := c.Get("auth_method"); authMethod != "jwt" {
+ response.Forbidden(c, "JWT administrator authentication is required for financial reconciliation")
+ return
+ }
+ if h.reconciliationService == nil {
+ response.ErrorFrom(c, service.ErrUsageBillingReconciliationUnavailable)
+ return
+ }
+ var input service.UsageBillingReconciliationResolveInput
+ if err := c.ShouldBindJSON(&input); err != nil {
+ response.BadRequest(c, "Invalid request")
+ return
+ }
+ input.OperatorID = subject.UserID
+ executeAdminStrictIdempotentJSONNonTransactional(
+ c,
+ service.UsageBillingReconciliationIdempotencyScope,
+ input,
+ service.DefaultWriteIdempotencyTTL(),
+ func(ctx context.Context) (any, error) {
+ if err := h.reconciliationService.Resolve(ctx, input); err != nil {
+ return nil, err
+ }
+ status := "resolved"
+ if input.Action == service.UsageBillingReconciliationActionSettleDelivered {
+ status = "queued_for_durable_billing"
+ }
+ return gin.H{
+ "request_id": input.RequestID,
+ "api_key_id": input.APIKeyID,
+ "action": input.Action,
+ "status": status,
+ }, nil
+ },
+ )
+}
diff --git a/backend/internal/handler/admin/usage_handler_request_type_test.go b/backend/internal/handler/admin/usage_handler_request_type_test.go
index 882cbe9362b..f93e60476ab 100644
--- a/backend/internal/handler/admin/usage_handler_request_type_test.go
+++ b/backend/internal/handler/admin/usage_handler_request_type_test.go
@@ -15,12 +15,18 @@ import (
type adminUsageRepoCapture struct {
service.UsageLogRepository
- listParams pagination.PaginationParams
- listFilters usagestats.UsageLogFilters
- statsFilters usagestats.UsageLogFilters
+ listParams pagination.PaginationParams
+ listFilters usagestats.UsageLogFilters
+ statsFilters usagestats.UsageLogFilters
+ listCalls int
+ statsCalls int
+ listContextHadDeadline bool
+ statsContextHadDeadline bool
}
func (s *adminUsageRepoCapture) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) {
+ s.listCalls++
+ _, s.listContextHadDeadline = ctx.Deadline()
s.listParams = params
s.listFilters = filters
return []service.UsageLog{}, &pagination.PaginationResult{
@@ -32,6 +38,8 @@ func (s *adminUsageRepoCapture) ListWithFilters(ctx context.Context, params pagi
}
func (s *adminUsageRepoCapture) GetStatsWithFilters(ctx context.Context, filters usagestats.UsageLogFilters) (*usagestats.UsageStats, error) {
+ s.statsCalls++
+ _, s.statsContextHadDeadline = ctx.Deadline()
s.statsFilters = filters
return &usagestats.UsageStats{}, nil
}
@@ -39,7 +47,7 @@ func (s *adminUsageRepoCapture) GetStatsWithFilters(ctx context.Context, filters
func newAdminUsageRequestTypeTestRouter(repo *adminUsageRepoCapture) *gin.Engine {
gin.SetMode(gin.TestMode)
usageSvc := service.NewUsageService(repo, nil, nil, nil)
- handler := NewUsageHandler(usageSvc, nil, nil, nil)
+ handler := NewUsageHandler(usageSvc, nil, nil, nil, nil)
router := gin.New()
router.GET("/admin/usage", handler.List)
router.GET("/admin/usage/stats", handler.Stats)
@@ -86,7 +94,7 @@ func TestAdminUsageListExactTotalTrue(t *testing.T) {
repo := &adminUsageRepoCapture{}
router := newAdminUsageRequestTypeTestRouter(repo)
- req := httptest.NewRequest(http.MethodGet, "/admin/usage?exact_total=true", nil)
+ req := httptest.NewRequest(http.MethodGet, "/admin/usage?user_id=42&exact_total=true&start_date=2026-01-01&end_date=2026-01-31", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
diff --git a/backend/internal/handler/admin/usage_handler_security_test.go b/backend/internal/handler/admin/usage_handler_security_test.go
new file mode 100644
index 00000000000..9f214c8c34f
--- /dev/null
+++ b/backend/internal/handler/admin/usage_handler_security_test.go
@@ -0,0 +1,103 @@
+//go:build unit
+
+package admin
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestAdminUsageListRejectsDeepOffsetBeforeRepository(t *testing.T) {
+ repo := &adminUsageRepoCapture{}
+ router := newAdminUsageRequestTypeTestRouter(repo)
+
+ req := httptest.NewRequest(http.MethodGet, "/admin/usage?page=102&page_size=100", nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+ require.Zero(t, repo.listCalls)
+
+ req = httptest.NewRequest(http.MethodGet, "/admin/usage?page=101&page_size=100", nil)
+ rec = httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.Equal(t, 101, repo.listParams.Page)
+ require.Equal(t, 100, repo.listParams.PageSize)
+}
+
+func TestAdminUsageListBoundsRangeAndExactTotal(t *testing.T) {
+ for _, target := range []string{
+ "/admin/usage?start_date=2025-01-01&end_date=2026-01-01",
+ "/admin/usage?start_date=2026-01-01",
+ "/admin/usage?exact_total=true",
+ "/admin/usage?user_id=42&exact_total=true&start_date=2026-01-01&end_date=2026-02-15",
+ } {
+ repo := &adminUsageRepoCapture{}
+ router := newAdminUsageRequestTypeTestRouter(repo)
+ req := httptest.NewRequest(http.MethodGet, target, nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ require.Equal(t, http.StatusBadRequest, rec.Code, target)
+ require.Zero(t, repo.listCalls, target)
+ }
+
+ repo := &adminUsageRepoCapture{}
+ router := newAdminUsageRequestTypeTestRouter(repo)
+ req := httptest.NewRequest(http.MethodGet, "/admin/usage?user_id=42&exact_total=true&start_date=2026-01-01&end_date=2026-01-31", nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.True(t, repo.listFilters.ExactTotal)
+ require.NotNil(t, repo.listFilters.StartTime)
+ require.NotNil(t, repo.listFilters.EndTime)
+ require.False(t, repo.listFilters.EndTime.After(repo.listFilters.StartTime.AddDate(0, 0, maxAdminUsageExactTotalDays)))
+}
+
+func TestAdminUsageListDefaultsToBoundedRecentWindowAndDeadline(t *testing.T) {
+ repo := &adminUsageRepoCapture{}
+ router := newAdminUsageRequestTypeTestRouter(repo)
+
+ req := httptest.NewRequest(http.MethodGet, "/admin/usage", nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.NotNil(t, repo.listFilters.StartTime)
+ require.NotNil(t, repo.listFilters.EndTime)
+ require.False(t, repo.listFilters.EndTime.After(repo.listFilters.StartTime.AddDate(0, 0, defaultAdminUsageLookbackDays)))
+ require.True(t, repo.listContextHadDeadline)
+}
+
+func TestAdminUsageStatsRejectsBroadUnscopedRangeAndUsesDeadline(t *testing.T) {
+ repo := &adminUsageRepoCapture{}
+ router := newAdminUsageRequestTypeTestRouter(repo)
+
+ req := httptest.NewRequest(http.MethodGet, "/admin/usage/stats?start_date=2026-01-01&end_date=2026-03-01", nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+ require.Zero(t, repo.statsCalls)
+
+ req = httptest.NewRequest(http.MethodGet, "/admin/usage/stats?user_id=42&start_date=2026-01-01&end_date=2026-03-01", nil)
+ rec = httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.Equal(t, 1, repo.statsCalls)
+ require.True(t, repo.statsContextHadDeadline)
+}
+
+func TestBoundedAdminUsageContextHasQueryDeadline(t *testing.T) {
+ ctx, cancel := boundedAdminUsageContext(context.Background())
+ defer cancel()
+ deadline, ok := ctx.Deadline()
+ require.True(t, ok)
+ require.Greater(t, time.Until(deadline), time.Duration(0))
+ require.LessOrEqual(t, time.Until(deadline), maxAdminUsageQueryTime)
+}
diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go
index 3d80107fed5..779ba98fe77 100644
--- a/backend/internal/handler/admin/user_handler.go
+++ b/backend/internal/handler/admin/user_handler.go
@@ -332,7 +332,7 @@ func (h *UserHandler) UpdateBalance(c *gin.Context) {
UserID: userID,
Body: req,
}
- executeAdminIdempotentJSON(c, "admin.users.balance.update", idempotencyPayload, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
+ executeAdminStrictIdempotentJSONNonTransactional(c, "admin.users.balance.update", idempotencyPayload, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
user, execErr := h.adminService.UpdateUserBalance(ctx, userID, req.Balance, req.Operation, req.Notes)
if execErr != nil {
return nil, execErr
@@ -362,7 +362,7 @@ func (h *UserHandler) GetUserAPIKeys(c *gin.Context) {
out := make([]dto.APIKey, 0, len(keys))
for i := range keys {
- out = append(out, *dto.APIKeyFromService(&keys[i]))
+ out = append(out, *dto.APIKeyFromServiceMasked(&keys[i]))
}
response.Paginated(c, out, total, page, pageSize)
}
@@ -390,7 +390,7 @@ func (h *UserHandler) GetUserUsage(c *gin.Context) {
// GetBalanceHistory handles getting user's balance/concurrency change history
// GET /api/v1/admin/users/:id/balance-history
// Query params:
-// - type: filter by record type (balance, admin_balance, concurrency, admin_concurrency, subscription)
+// - type: filter by record type (balance, affiliate_balance, admin_balance, concurrency, admin_concurrency, subscription)
func (h *UserHandler) GetBalanceHistory(c *gin.Context) {
userID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
@@ -477,3 +477,63 @@ func (h *UserHandler) GetUserRPMStatus(c *gin.Context) {
response.Success(c, status)
}
+
+// BatchUpdateConcurrency 批量修改用户并发数
+// POST /api/v1/admin/users/batch-concurrency
+type BatchUpdateConcurrencyRequest struct {
+ UserIDs []int64 `json:"user_ids"`
+ All bool `json:"all"`
+ Concurrency int `json:"concurrency"`
+ Mode string `json:"mode" binding:"required,oneof=set add"`
+}
+
+func (h *UserHandler) BatchUpdateConcurrency(c *gin.Context) {
+ var req BatchUpdateConcurrencyRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request: "+err.Error())
+ return
+ }
+ if !req.All && len(req.UserIDs) == 0 {
+ response.BadRequest(c, "user_ids is required unless all=true")
+ return
+ }
+ if len(req.UserIDs) > 500 {
+ response.BadRequest(c, "user_ids cannot exceed 500")
+ return
+ }
+
+ var userIDs []int64
+ if req.All {
+ // Fetch all user IDs via pagination
+ page := 1
+ const pageSize = 500
+ for {
+ users, _, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, service.UserListFilters{}, "id", "asc")
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ for _, u := range users {
+ userIDs = append(userIDs, u.ID)
+ }
+ if len(users) < pageSize {
+ break
+ }
+ page++
+ }
+ } else {
+ userIDs = req.UserIDs
+ }
+
+ if len(userIDs) == 0 {
+ response.Success(c, gin.H{"affected": 0})
+ return
+ }
+
+ affected, err := h.adminService.BatchUpdateConcurrency(c.Request.Context(), userIDs, req.Concurrency, req.Mode)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Success(c, gin.H{"affected": affected})
+}
diff --git a/backend/internal/handler/api_key_handler.go b/backend/internal/handler/api_key_handler.go
index 9d6c6c15230..89424800060 100644
--- a/backend/internal/handler/api_key_handler.go
+++ b/backend/internal/handler/api_key_handler.go
@@ -3,6 +3,8 @@ package handler
import (
"context"
+ "errors"
+ "log/slog"
"strconv"
"strings"
"time"
@@ -18,19 +20,33 @@ import (
// APIKeyHandler handles API key-related requests
type APIKeyHandler struct {
- apiKeyService *service.APIKeyService
+ apiKeyService *service.APIKeyService
+ modelRouteProvider service.ModelRouteProvider
+ totpService *service.TotpService
}
// NewAPIKeyHandler creates a new APIKeyHandler
func NewAPIKeyHandler(apiKeyService *service.APIKeyService) *APIKeyHandler {
+ return NewAPIKeyHandlerWithModelRoutes(apiKeyService, nil)
+}
+
+func NewAPIKeyHandlerWithModelRoutes(apiKeyService *service.APIKeyService, modelRouteProvider service.ModelRouteProvider) *APIKeyHandler {
return &APIKeyHandler{
- apiKeyService: apiKeyService,
+ apiKeyService: apiKeyService,
+ modelRouteProvider: modelRouteProvider,
}
}
+func ProvideAPIKeyHandler(apiKeyService *service.APIKeyService, modelRouteProvider service.ModelRouteProvider, totpService *service.TotpService) *APIKeyHandler {
+ h := NewAPIKeyHandlerWithModelRoutes(apiKeyService, modelRouteProvider)
+ h.totpService = totpService
+ return h
+}
+
// CreateAPIKeyRequest represents the create API key request payload
type CreateAPIKeyRequest struct {
Name string `json:"name" binding:"required"`
+ Verification string `json:"verification" binding:"required,max=256"`
GroupID *int64 `json:"group_id"` // nullable
CustomKey *string `json:"custom_key"` // 可选的自定义key
IPWhitelist []string `json:"ip_whitelist"` // IP 白名单
@@ -46,20 +62,21 @@ type CreateAPIKeyRequest struct {
// UpdateAPIKeyRequest represents the update API key request payload
type UpdateAPIKeyRequest struct {
- Name string `json:"name"`
- GroupID *int64 `json:"group_id"`
- Status string `json:"status" binding:"omitempty,oneof=active inactive"`
- IPWhitelist []string `json:"ip_whitelist"` // IP 白名单
- IPBlacklist []string `json:"ip_blacklist"` // IP 黑名单
- Quota *float64 `json:"quota"` // 配额限制 (USD), 0=无限制
- ExpiresAt *string `json:"expires_at"` // 过期时间 (ISO 8601)
- ResetQuota *bool `json:"reset_quota"` // 重置已用配额
+ Name string `json:"name"`
+ GroupID *int64 `json:"group_id"`
+ Status string `json:"status" binding:"omitempty,oneof=active inactive"`
+ IPWhitelist *[]string `json:"ip_whitelist"` // 省略=不修改,显式 []=清空
+ IPBlacklist *[]string `json:"ip_blacklist"` // 省略=不修改,显式 []=清空
+ Quota *float64 `json:"quota"` // 配额限制 (USD), 0=无限制
+ ExpiresAt *string `json:"expires_at"` // 过期时间 (ISO 8601)
+ ResetQuota *bool `json:"reset_quota"` // 重置已用配额
// Rate limit fields (nil = no change, 0 = unlimited)
RateLimit5h *float64 `json:"rate_limit_5h"`
RateLimit1d *float64 `json:"rate_limit_1d"`
RateLimit7d *float64 `json:"rate_limit_7d"`
ResetRateLimitUsage *bool `json:"reset_rate_limit_usage"` // 重置限速用量
+ Verification string `json:"verification" binding:"omitempty,max=256"`
}
// List handles listing user's API keys with pagination
@@ -103,7 +120,7 @@ func (h *APIKeyHandler) List(c *gin.Context) {
out := make([]dto.APIKey, 0, len(keys))
for i := range keys {
- out = append(out, *dto.APIKeyFromService(&keys[i]))
+ out = append(out, *dto.APIKeyFromServiceMasked(&keys[i]))
}
response.Paginated(c, out, result.Total, page, pageSize)
}
@@ -135,7 +152,7 @@ func (h *APIKeyHandler) GetByID(c *gin.Context) {
return
}
- response.Success(c, dto.APIKeyFromService(key))
+ response.Success(c, dto.APIKeyFromServiceMasked(key))
}
// Create handles creating a new API key
@@ -146,12 +163,19 @@ func (h *APIKeyHandler) Create(c *gin.Context) {
response.Unauthorized(c, "User not authenticated")
return
}
+ c.Header("Cache-Control", "no-store, max-age=0")
+ c.Header("Pragma", "no-cache")
var req CreateAPIKeyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
+ verificationMode, err := h.verifyFreshAPIKeyCredential(c.Request.Context(), subject.UserID, req.Verification, service.APIKeyStepUpPurposeCreate)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
svcReq := service.CreateAPIKeyRequest{
Name: req.Name,
@@ -174,12 +198,28 @@ func (h *APIKeyHandler) Create(c *gin.Context) {
svcReq.RateLimit7d = *req.RateLimit7d
}
- executeUserIdempotentJSON(c, "user.api_keys.create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
+ idempotencyRequest := struct {
+ Name string `json:"name"`
+ GroupID *int64 `json:"group_id"`
+ CustomKey *string `json:"custom_key"`
+ IPWhitelist []string `json:"ip_whitelist"`
+ IPBlacklist []string `json:"ip_blacklist"`
+ Quota *float64 `json:"quota"`
+ ExpiresInDays *int `json:"expires_in_days"`
+ RateLimit5h *float64 `json:"rate_limit_5h"`
+ RateLimit1d *float64 `json:"rate_limit_1d"`
+ RateLimit7d *float64 `json:"rate_limit_7d"`
+ }{req.Name, req.GroupID, req.CustomKey, req.IPWhitelist, req.IPBlacklist, req.Quota, req.ExpiresInDays, req.RateLimit5h, req.RateLimit1d, req.RateLimit7d}
+ executeUserIdempotentJSON(c, "user.api_keys.create", idempotencyRequest, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
key, err := h.apiKeyService.Create(ctx, subject.UserID, svcReq)
if err != nil {
return nil, err
}
- return dto.APIKeyFromService(key), nil
+ slog.Info("api_key.created", "user_id", subject.UserID, "api_key_id", key.ID, "verification_mode", verificationMode)
+ return service.NewIdempotencySensitiveResponse(
+ dto.APIKeyFromService(key),
+ dto.APIKeyFromServiceMasked(key),
+ ), nil
})
}
@@ -236,6 +276,17 @@ func (h *APIKeyHandler) Update(c *gin.Context) {
svcReq.ExpiresAt = &t
}
}
+ if service.APIKeyUpdateRequiresStepUp(svcReq) {
+ verificationMode, err := h.verifyFreshAPIKeyCredential(
+ c.Request.Context(), subject.UserID, req.Verification, service.APIKeyStepUpPurposeUpdate,
+ )
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ svcReq.StepUpVerified = true
+ slog.Info("api_key.update_step_up_verified", "user_id", subject.UserID, "api_key_id", keyID, "verification_mode", verificationMode)
+ }
key, err := h.apiKeyService.Update(c.Request.Context(), keyID, subject.UserID, svcReq)
if err != nil {
@@ -243,7 +294,80 @@ func (h *APIKeyHandler) Update(c *gin.Context) {
return
}
- response.Success(c, dto.APIKeyFromService(key))
+ response.Success(c, dto.APIKeyFromServiceMasked(key))
+}
+
+type revealAPIKeyRequest struct {
+ Verification string `json:"verification" binding:"required,max=256"`
+}
+
+func (h *APIKeyHandler) verifyFreshAPIKeyCredential(ctx context.Context, userID int64, verification, purpose string) (string, error) {
+ mode, err := h.apiKeyService.RevealVerificationMode(ctx, userID)
+ if err != nil {
+ return "", err
+ }
+ if mode == "totp" {
+ if h.totpService == nil {
+ return "", service.ErrAPIKeyRevealUnavailable
+ }
+ totpPurpose := service.TotpVerifyPurposeAPIKeyReveal
+ if purpose == service.APIKeyStepUpPurposeCreate {
+ totpPurpose = service.TotpVerifyPurposeAPIKeyCreate
+ } else if purpose == service.APIKeyStepUpPurposeUpdate {
+ totpPurpose = service.TotpVerifyPurposeAPIKeyUpdate
+ }
+ if err := h.totpService.VerifyCodeForPurpose(ctx, userID, strings.TrimSpace(verification), totpPurpose); err != nil {
+ if errors.Is(err, service.ErrTotpInvalidCode) {
+ if purpose == service.APIKeyStepUpPurposeUpdate {
+ return "", service.ErrAPIKeyUpdateVerification
+ }
+ return "", service.ErrAPIKeyRevealVerification
+ }
+ return "", err
+ }
+ return mode, nil
+ }
+ if err := h.apiKeyService.VerifyStepUpPassword(ctx, userID, verification, purpose); err != nil {
+ if purpose == service.APIKeyStepUpPurposeUpdate && errors.Is(err, service.ErrAPIKeyRevealVerification) {
+ return "", service.ErrAPIKeyUpdateVerification
+ }
+ return "", err
+ }
+ return mode, nil
+}
+
+// Reveal returns a single plaintext API key only after fresh password or TOTP
+// proof. List/get/update/admin responses remain masked.
+func (h *APIKeyHandler) Reveal(c *gin.Context) {
+ subject, ok := middleware2.GetAuthSubjectFromContext(c)
+ if !ok {
+ response.Unauthorized(c, "User not authenticated")
+ return
+ }
+ keyID, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil || keyID <= 0 {
+ response.BadRequest(c, "Invalid key ID")
+ return
+ }
+ var req revealAPIKeyRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Fresh password or TOTP verification is required")
+ return
+ }
+ mode, err := h.verifyFreshAPIKeyCredential(c.Request.Context(), subject.UserID, req.Verification, service.APIKeyStepUpPurposeReveal)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ key, err := h.apiKeyService.Reveal(c.Request.Context(), keyID, subject.UserID)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ c.Header("Cache-Control", "no-store, max-age=0")
+ c.Header("Pragma", "no-cache")
+ slog.Info("api_key.revealed", "user_id", subject.UserID, "api_key_id", keyID, "verification_mode", mode)
+ response.Success(c, gin.H{"api_key": key})
}
// Delete handles deleting an API key
@@ -309,3 +433,25 @@ func (h *APIKeyHandler) GetUserGroupRates(c *gin.Context) {
response.Success(c, rates)
}
+
+// GetWalletModelRoutes returns the model-name routes available to wallet universal keys.
+// GET /api/v1/groups/model-routes
+func (h *APIKeyHandler) GetWalletModelRoutes(c *gin.Context) {
+ subject, ok := middleware2.GetAuthSubjectFromContext(c)
+ if !ok {
+ response.Unauthorized(c, "User not authenticated")
+ return
+ }
+
+ routes := service.DefaultModelRoutes()
+ if h.modelRouteProvider != nil {
+ routes = h.modelRouteProvider.Routes()
+ }
+
+ out, err := h.apiKeyService.GetWalletModelRoutes(c.Request.Context(), subject.UserID, routes)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ response.Success(c, out)
+}
diff --git a/backend/internal/handler/auth_browser_session.go b/backend/internal/handler/auth_browser_session.go
new file mode 100644
index 00000000000..d6f4cf1d23d
--- /dev/null
+++ b/backend/internal/handler/auth_browser_session.go
@@ -0,0 +1,140 @@
+package handler
+
+import (
+ "crypto/rand"
+ "crypto/subtle"
+ "encoding/base64"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+)
+
+const (
+ browserSessionHeader = "X-Sub2API-Browser-Session"
+ browserSessionHeaderValue = "1"
+ browserCSRFHeader = "X-CSRF-Token"
+ browserRefreshCookieName = "sub2api_refresh"
+ browserRefreshCookiePath = "/api/v1/auth"
+ browserCSRFCookieName = "sub2api_csrf"
+ defaultRefreshCookieDays = 30
+)
+
+func isBrowserSessionRequest(c *gin.Context) bool {
+ return c != nil && strings.TrimSpace(c.GetHeader(browserSessionHeader)) == browserSessionHeaderValue
+}
+
+func (h *AuthHandler) browserSessionTokenPairPayload(c *gin.Context, tokenPair *service.TokenPair) (gin.H, error) {
+ if tokenPair == nil {
+ return nil, errors.New("token pair is required")
+ }
+ payload := gin.H{
+ "access_token": tokenPair.AccessToken,
+ "refresh_token": tokenPair.RefreshToken,
+ "expires_in": tokenPair.ExpiresIn,
+ "token_type": "Bearer",
+ }
+ c.Header("Cache-Control", "no-store")
+ c.Header("Pragma", "no-cache")
+ if !isBrowserSessionRequest(c) {
+ return payload, nil
+ }
+ if err := h.setBrowserSessionCookies(c, tokenPair.RefreshToken); err != nil {
+ return nil, err
+ }
+ delete(payload, "refresh_token")
+ return payload, nil
+}
+
+func (h *AuthHandler) setBrowserSessionCookies(c *gin.Context, refreshToken string) error {
+ refreshToken = strings.TrimSpace(refreshToken)
+ if refreshToken == "" {
+ return errors.New("refresh token is required")
+ }
+ csrfBytes := make([]byte, 32)
+ if _, err := rand.Read(csrfBytes); err != nil {
+ return err
+ }
+ csrfToken := base64.RawURLEncoding.EncodeToString(csrfBytes)
+ secure := isRequestHTTPS(c)
+ maxAge := defaultRefreshCookieDays * 24 * 60 * 60
+ if h != nil && h.cfg != nil && h.cfg.JWT.RefreshTokenExpireDays > 0 {
+ maxAge = h.cfg.JWT.RefreshTokenExpireDays * 24 * 60 * 60
+ }
+
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: browserRefreshCookieName,
+ Value: encodeCookieValue(refreshToken),
+ Path: browserRefreshCookiePath,
+ MaxAge: maxAge,
+ Expires: time.Now().Add(time.Duration(maxAge) * time.Second),
+ HttpOnly: true,
+ Secure: secure,
+ SameSite: http.SameSiteStrictMode,
+ })
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: browserCSRFCookieName,
+ Value: csrfToken,
+ Path: "/",
+ MaxAge: maxAge,
+ Expires: time.Now().Add(time.Duration(maxAge) * time.Second),
+ HttpOnly: false,
+ Secure: secure,
+ SameSite: http.SameSiteStrictMode,
+ })
+ return nil
+}
+
+func readBrowserRefreshToken(c *gin.Context) (string, error) {
+ cookie, err := c.Request.Cookie(browserRefreshCookieName)
+ if err != nil {
+ return "", err
+ }
+ refreshToken, err := decodeCookieValue(cookie.Value)
+ if err != nil || strings.TrimSpace(refreshToken) == "" {
+ return "", errors.New("browser refresh cookie is invalid")
+ }
+ return refreshToken, nil
+}
+
+func validateBrowserSessionCSRF(c *gin.Context) error {
+ cookie, err := c.Request.Cookie(browserCSRFCookieName)
+ if err != nil {
+ return infraerrors.Forbidden("CSRF_TOKEN_INVALID", "CSRF token is missing or invalid")
+ }
+ headerValue := strings.TrimSpace(c.GetHeader(browserCSRFHeader))
+ cookieValue := strings.TrimSpace(cookie.Value)
+ if headerValue == "" || cookieValue == "" || len(headerValue) != len(cookieValue) ||
+ subtle.ConstantTimeCompare([]byte(headerValue), []byte(cookieValue)) != 1 {
+ return infraerrors.Forbidden("CSRF_TOKEN_INVALID", "CSRF token is missing or invalid")
+ }
+ return nil
+}
+
+func clearBrowserSessionCookies(c *gin.Context) {
+ secure := isRequestHTTPS(c)
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: browserRefreshCookieName,
+ Value: "",
+ Path: browserRefreshCookiePath,
+ MaxAge: -1,
+ Expires: time.Unix(1, 0),
+ HttpOnly: true,
+ Secure: secure,
+ SameSite: http.SameSiteStrictMode,
+ })
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: browserCSRFCookieName,
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ Expires: time.Unix(1, 0),
+ HttpOnly: false,
+ Secure: secure,
+ SameSite: http.SameSiteStrictMode,
+ })
+}
diff --git a/backend/internal/handler/auth_browser_session_test.go b/backend/internal/handler/auth_browser_session_test.go
new file mode 100644
index 00000000000..96a43907ea4
--- /dev/null
+++ b/backend/internal/handler/auth_browser_session_test.go
@@ -0,0 +1,93 @@
+package handler
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBrowserSessionTokenPairUsesHttpOnlyRefreshCookie(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
+ ctx.Request.Header.Set(browserSessionHeader, browserSessionHeaderValue)
+
+ handler := &AuthHandler{cfg: &config.Config{JWT: config.JWTConfig{RefreshTokenExpireDays: 7}}}
+ payload, err := handler.browserSessionTokenPairPayload(ctx, &service.TokenPair{
+ AccessToken: "access-token",
+ RefreshToken: "refresh-token",
+ ExpiresIn: 3600,
+ })
+ require.NoError(t, err)
+ require.Equal(t, "access-token", payload["access_token"])
+ require.NotContains(t, payload, "refresh_token")
+ require.Equal(t, "no-store", recorder.Header().Get("Cache-Control"))
+
+ refreshCookie := findCookie(recorder.Result().Cookies(), browserRefreshCookieName)
+ require.NotNil(t, refreshCookie)
+ require.True(t, refreshCookie.HttpOnly)
+ require.Equal(t, browserRefreshCookiePath, refreshCookie.Path)
+ require.Equal(t, http.SameSiteStrictMode, refreshCookie.SameSite)
+ require.Equal(t, 7*24*60*60, refreshCookie.MaxAge)
+
+ csrfCookie := findCookie(recorder.Result().Cookies(), browserCSRFCookieName)
+ require.NotNil(t, csrfCookie)
+ require.False(t, csrfCookie.HttpOnly)
+ require.Equal(t, "/", csrfCookie.Path)
+ require.NotEmpty(t, csrfCookie.Value)
+}
+
+func TestNonBrowserTokenPairKeepsRefreshTokenInJSON(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
+
+ payload, err := (&AuthHandler{}).browserSessionTokenPairPayload(ctx, &service.TokenPair{
+ AccessToken: "access-token",
+ RefreshToken: "refresh-token",
+ ExpiresIn: 3600,
+ })
+ require.NoError(t, err)
+ require.Equal(t, "refresh-token", payload["refresh_token"])
+ require.Empty(t, recorder.Result().Cookies())
+}
+
+func TestBrowserSessionCSRFRejectsMissingOrMismatchedHeader(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ for _, tc := range []struct {
+ name string
+ header string
+ }{
+ {name: "missing"},
+ {name: "mismatch", header: "wrong"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ctx.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", nil)
+ ctx.Request.Header.Set(browserSessionHeader, browserSessionHeaderValue)
+ ctx.Request.Header.Set(browserCSRFHeader, tc.header)
+ ctx.Request.AddCookie(&http.Cookie{Name: browserCSRFCookieName, Value: "expected"})
+
+ err := validateBrowserSessionCSRF(ctx)
+ require.Error(t, err)
+ })
+ }
+}
+
+func TestBrowserSessionCSRFAcceptsMatchingHeader(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ctx.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", nil)
+ ctx.Request.Header.Set(browserSessionHeader, browserSessionHeaderValue)
+ ctx.Request.Header.Set(browserCSRFHeader, "expected")
+ ctx.Request.AddCookie(&http.Cookie{Name: browserCSRFCookieName, Value: "expected"})
+
+ require.NoError(t, validateBrowserSessionCSRF(ctx))
+}
diff --git a/backend/internal/handler/auth_chat_bridge.go b/backend/internal/handler/auth_chat_bridge.go
new file mode 100644
index 00000000000..c0dac9f4780
--- /dev/null
+++ b/backend/internal/handler/auth_chat_bridge.go
@@ -0,0 +1,154 @@
+package handler
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/response"
+ middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+
+ "github.com/gin-gonic/gin"
+)
+
+const defaultChatBridgeRedirectPath = "/agent/inbox"
+
+type chatBridgeService interface {
+ CreateLoginCode(ctx context.Context, userID int64) (*service.ChatBridgeLoginCode, error)
+ ExchangeLoginCode(ctx context.Context, code string) (*service.ChatBridgeExchangeResult, error)
+}
+
+type CreateChatBridgeCodeRequest struct {
+ RedirectPath string `json:"redirect_path"`
+}
+
+type CreateChatBridgeCodeResponse struct {
+ Code string `json:"code"`
+ ChatURL string `json:"chat_url,omitempty"`
+ ExpiresIn int `json:"expires_in"`
+}
+
+type ExchangeChatBridgeCodeRequest struct {
+ Code string `json:"code" binding:"required"`
+}
+
+type ExchangeChatBridgeCodeResponse struct {
+ AccessToken string `json:"access_token"`
+ ExpiresIn int `json:"expires_in"`
+ TokenType string `json:"token_type"`
+}
+
+func (h *AuthHandler) CreateChatBridgeCode(c *gin.Context) {
+ if h == nil || h.chatBridgeSvc == nil {
+ response.Error(c, http.StatusServiceUnavailable, "chat bridge is not configured")
+ return
+ }
+
+ subject, ok := middleware2.GetAuthSubjectFromContext(c)
+ if !ok || subject.UserID <= 0 {
+ response.Unauthorized(c, "Authorization is required")
+ return
+ }
+
+ var req CreateChatBridgeCodeRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request: "+err.Error())
+ return
+ }
+
+ code, err := h.chatBridgeSvc.CreateLoginCode(c.Request.Context(), subject.UserID)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+
+ chatURL, err := buildChatBridgeURL(resolveChatBridgePublicBaseURL(), req.RedirectPath, code.Code)
+ if err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+
+ response.Success(c, CreateChatBridgeCodeResponse{
+ Code: code.Code,
+ ChatURL: chatURL,
+ ExpiresIn: code.ExpiresIn,
+ })
+}
+
+func (h *AuthHandler) ExchangeChatBridgeCode(c *gin.Context) {
+ if h == nil || h.chatBridgeSvc == nil {
+ response.Error(c, http.StatusServiceUnavailable, "chat bridge is not configured")
+ return
+ }
+
+ var req ExchangeChatBridgeCodeRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request: "+err.Error())
+ return
+ }
+
+ exchanged, err := h.chatBridgeSvc.ExchangeLoginCode(c.Request.Context(), req.Code)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+
+ response.Success(c, ExchangeChatBridgeCodeResponse{
+ AccessToken: exchanged.AccessToken,
+ ExpiresIn: exchanged.ExpiresIn,
+ TokenType: exchanged.TokenType,
+ })
+}
+
+func resolveChatBridgePublicBaseURL() string {
+ for _, key := range []string{"HFC_CHAT_PUBLIC_URL", "HFC_CHAT_BASE_URL"} {
+ if value := strings.TrimSpace(os.Getenv(key)); value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func buildChatBridgeURL(baseURL, redirectPath, code string) (string, error) {
+ baseURL = strings.TrimSpace(baseURL)
+ if baseURL == "" {
+ return "", nil
+ }
+
+ base, err := url.Parse(baseURL)
+ if err != nil || base.Scheme == "" || base.Host == "" {
+ return "", service.ErrIdentityRedirectInvalid
+ }
+
+ targetPath := sanitizeChatBridgeRedirectPath(redirectPath)
+ relative, err := url.Parse(targetPath)
+ if err != nil {
+ return "", service.ErrIdentityRedirectInvalid
+ }
+
+ target := base.ResolveReference(relative)
+ query := target.Query()
+ query.Set("hfc_chat_code", code)
+ target.RawQuery = query.Encode()
+ target.Fragment = ""
+ return target.String(), nil
+}
+
+func sanitizeChatBridgeRedirectPath(raw string) string {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return defaultChatBridgeRedirectPath
+ }
+ parsed, err := url.Parse(raw)
+ if err != nil || parsed.IsAbs() || parsed.Host != "" || !strings.HasPrefix(parsed.Path, "/") || strings.HasPrefix(parsed.Path, "//") {
+ return defaultChatBridgeRedirectPath
+ }
+ parsed.Fragment = ""
+ if parsed.Path == "" {
+ parsed.Path = defaultChatBridgeRedirectPath
+ }
+ return parsed.String()
+}
diff --git a/backend/internal/handler/auth_chat_bridge_test.go b/backend/internal/handler/auth_chat_bridge_test.go
new file mode 100644
index 00000000000..fe2b9fda11e
--- /dev/null
+++ b/backend/internal/handler/auth_chat_bridge_test.go
@@ -0,0 +1,116 @@
+package handler
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+type authChatBridgeServiceStub struct {
+ createCode *service.ChatBridgeLoginCode
+ createUserID int64
+ exchangeCode string
+ exchangeResponse *service.ChatBridgeExchangeResult
+}
+
+func (s *authChatBridgeServiceStub) CreateLoginCode(_ context.Context, userID int64) (*service.ChatBridgeLoginCode, error) {
+ s.createUserID = userID
+ return s.createCode, nil
+}
+
+func (s *authChatBridgeServiceStub) ExchangeLoginCode(_ context.Context, code string) (*service.ChatBridgeExchangeResult, error) {
+ s.exchangeCode = code
+ return s.exchangeResponse, nil
+}
+
+func TestAuthHandlerCreateChatBridgeCodeReturnsChatURL(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ t.Setenv("HFC_CHAT_PUBLIC_URL", "https://chat.handsfreeclub.com")
+
+ stub := &authChatBridgeServiceStub{
+ createCode: &service.ChatBridgeLoginCode{
+ Code: "code-123",
+ ExpiresAt: time.Now().Add(time.Minute),
+ ExpiresIn: 60,
+ },
+ }
+ handler := &AuthHandler{chatBridgeSvc: stub}
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/chat/bridge/code", strings.NewReader(`{"redirect_path":"/agent/inbox"}`))
+ c.Request.Header.Set("Content-Type", "application/json")
+ c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 42})
+
+ handler.CreateChatBridgeCode(c)
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+ require.Equal(t, int64(42), stub.createUserID)
+
+ var resp struct {
+ Code int `json:"code"`
+ Data struct {
+ Code string `json:"code"`
+ ChatURL string `json:"chat_url"`
+ ExpiresIn int `json:"expires_in"`
+ } `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
+ require.Equal(t, 0, resp.Code)
+ require.Equal(t, "code-123", resp.Data.Code)
+ require.Equal(t, 60, resp.Data.ExpiresIn)
+
+ chatURL, err := url.Parse(resp.Data.ChatURL)
+ require.NoError(t, err)
+ require.Equal(t, "https", chatURL.Scheme)
+ require.Equal(t, "chat.handsfreeclub.com", chatURL.Host)
+ require.Equal(t, "/agent/inbox", chatURL.Path)
+ require.Equal(t, "code-123", chatURL.Query().Get("hfc_chat_code"))
+}
+
+func TestAuthHandlerExchangeChatBridgeCodeReturnsToken(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ stub := &authChatBridgeServiceStub{
+ exchangeResponse: &service.ChatBridgeExchangeResult{
+ AccessToken: "user-jwt",
+ ExpiresIn: 3600,
+ TokenType: "Bearer",
+ },
+ }
+ handler := &AuthHandler{chatBridgeSvc: stub}
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/chat/bridge/exchange", strings.NewReader(`{"code":" code-123 "}`))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ handler.ExchangeChatBridgeCode(c)
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+ require.Equal(t, " code-123 ", stub.exchangeCode)
+
+ var resp struct {
+ Code int `json:"code"`
+ Data struct {
+ AccessToken string `json:"access_token"`
+ ExpiresIn int `json:"expires_in"`
+ TokenType string `json:"token_type"`
+ } `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
+ require.Equal(t, 0, resp.Code)
+ require.Equal(t, "user-jwt", resp.Data.AccessToken)
+ require.Equal(t, 3600, resp.Data.ExpiresIn)
+ require.Equal(t, "Bearer", resp.Data.TokenType)
+}
diff --git a/backend/internal/handler/auth_current_user_test.go b/backend/internal/handler/auth_current_user_test.go
index cb3e4ba596a..457aba31444 100644
--- a/backend/internal/handler/auth_current_user_test.go
+++ b/backend/internal/handler/auth_current_user_test.go
@@ -29,19 +29,19 @@ func TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields(t *testing.T
AvatarURL: "https://cdn.example.com/linuxdo.png",
AvatarSource: "remote_url",
},
- identities: []service.UserAuthIdentityRecord{
- {
- ProviderType: "linuxdo",
- ProviderKey: "linuxdo",
- ProviderSubject: "linuxdo-subject-31",
- VerifiedAt: &verifiedAt,
- Metadata: map[string]any{
- "username": "linuxdo-handle",
- "avatar_url": "https://cdn.example.com/linuxdo.png",
- },
+ identities: []service.UserAuthIdentityRecord{
+ {
+ ProviderType: "linuxdo",
+ ProviderKey: "linuxdo",
+ ProviderSubject: "linuxdo-subject-31",
+ VerifiedAt: &verifiedAt,
+ Metadata: map[string]any{
+ "username": "linuxdo-handle",
+ "avatar_url": "https://cdn.example.com/linuxdo.png",
},
},
- }
+ },
+ }
handler := &AuthHandler{
userService: service.NewUserService(repo, nil, nil, nil),
diff --git a/backend/internal/handler/auth_email_oauth.go b/backend/internal/handler/auth_email_oauth.go
new file mode 100644
index 00000000000..93e459335be
--- /dev/null
+++ b/backend/internal/handler/auth_email_oauth.go
@@ -0,0 +1,676 @@
+package handler
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/oauth"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/response"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/imroc/req/v3"
+ "github.com/tidwall/gjson"
+)
+
+const (
+ emailOAuthCookiePath = "/api/v1/auth/oauth"
+ emailOAuthStateCookieName = "email_oauth_state"
+ emailOAuthRedirectCookie = "email_oauth_redirect"
+ emailOAuthProviderCookie = "email_oauth_provider"
+ emailOAuthAffiliateCookie = "email_oauth_affiliate"
+ emailOAuthCookieMaxAgeSec = 10 * 60
+ emailOAuthDefaultRedirect = "/dashboard"
+)
+
+type emailOAuthTokenResponse struct {
+ AccessToken string `json:"access_token"`
+ TokenType string `json:"token_type"`
+ Scope string `json:"scope,omitempty"`
+}
+
+type emailOAuthProfile struct {
+ Subject string
+ Email string
+ EmailVerified bool
+ Username string
+ DisplayName string
+ AvatarURL string
+ Metadata map[string]any
+}
+
+func (h *AuthHandler) GitHubOAuthStart(c *gin.Context) { h.emailOAuthStart(c, "github") }
+func (h *AuthHandler) GoogleOAuthStart(c *gin.Context) { h.emailOAuthStart(c, "google") }
+
+func (h *AuthHandler) GitHubOAuthCallback(c *gin.Context) { h.emailOAuthCallback(c, "github") }
+func (h *AuthHandler) GoogleOAuthCallback(c *gin.Context) { h.emailOAuthCallback(c, "google") }
+func (h *AuthHandler) CompleteGitHubOAuthRegistration(c *gin.Context) {
+ h.completeEmailOAuthRegistration(c, "github")
+}
+func (h *AuthHandler) CompleteGoogleOAuthRegistration(c *gin.Context) {
+ h.completeEmailOAuthRegistration(c, "google")
+}
+
+func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) {
+ cfg, err := h.getEmailOAuthConfig(c.Request.Context(), provider)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ state, err := oauth.GenerateState()
+ if err != nil {
+ response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err))
+ return
+ }
+ redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect"))
+ if redirectTo == "" {
+ redirectTo = emailOAuthDefaultRedirect
+ }
+
+ secureCookie := isRequestHTTPS(c)
+ emailOAuthSetCookie(c, emailOAuthStateCookieName, encodeCookieValue(state), secureCookie)
+ emailOAuthSetCookie(c, emailOAuthRedirectCookie, encodeCookieValue(redirectTo), secureCookie)
+ emailOAuthSetCookie(c, emailOAuthProviderCookie, encodeCookieValue(provider), secureCookie)
+ if affCode := strings.TrimSpace(firstNonEmpty(c.Query("aff_code"), c.Query("aff"))); affCode != "" {
+ emailOAuthSetCookie(c, emailOAuthAffiliateCookie, encodeCookieValue(affCode), secureCookie)
+ } else {
+ emailOAuthClearCookie(c, emailOAuthAffiliateCookie, secureCookie)
+ }
+
+ authURL, err := buildEmailOAuthAuthorizeURL(cfg, state)
+ if err != nil {
+ response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
+ return
+ }
+ c.Redirect(http.StatusFound, authURL)
+}
+
+func (h *AuthHandler) emailOAuthCallback(c *gin.Context, provider string) {
+ cfg, cfgErr := h.getEmailOAuthConfig(c.Request.Context(), provider)
+ if cfgErr != nil {
+ response.ErrorFrom(c, cfgErr)
+ return
+ }
+ frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL)
+ if frontendCallback == "" {
+ frontendCallback = "/auth/oauth/callback"
+ }
+ if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" {
+ redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description"))
+ return
+ }
+ code := strings.TrimSpace(c.Query("code"))
+ state := strings.TrimSpace(c.Query("state"))
+ if code == "" || state == "" {
+ redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "")
+ return
+ }
+
+ secureCookie := isRequestHTTPS(c)
+ defer func() {
+ emailOAuthClearCookie(c, emailOAuthStateCookieName, secureCookie)
+ emailOAuthClearCookie(c, emailOAuthRedirectCookie, secureCookie)
+ emailOAuthClearCookie(c, emailOAuthProviderCookie, secureCookie)
+ emailOAuthClearCookie(c, emailOAuthAffiliateCookie, secureCookie)
+ }()
+ expectedState, err := readCookieDecoded(c, emailOAuthStateCookieName)
+ if err != nil || expectedState == "" || expectedState != state {
+ redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "")
+ return
+ }
+ expectedProvider, _ := readCookieDecoded(c, emailOAuthProviderCookie)
+ if !strings.EqualFold(strings.TrimSpace(expectedProvider), provider) {
+ redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth provider", "")
+ return
+ }
+ redirectTo, _ := readCookieDecoded(c, emailOAuthRedirectCookie)
+ redirectTo = sanitizeFrontendRedirectPath(redirectTo)
+ if redirectTo == "" {
+ redirectTo = emailOAuthDefaultRedirect
+ }
+
+ tokenResp, err := exchangeEmailOAuthCode(c.Request.Context(), cfg, code)
+ if err != nil {
+ redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", singleLine(err.Error()))
+ return
+ }
+ profile, err := fetchEmailOAuthProfile(c.Request.Context(), provider, cfg, tokenResp)
+ if err != nil {
+ redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch verified email", singleLine(err.Error()))
+ return
+ }
+ h.emailOAuthCallbackWithProfile(c, provider, cfg, frontendCallback, redirectTo, profile)
+}
+
+func (h *AuthHandler) emailOAuthCallbackWithProfile(
+ c *gin.Context,
+ provider string,
+ cfg config.EmailOAuthProviderConfig,
+ frontendCallback string,
+ redirectTo string,
+ profile *emailOAuthProfile,
+) {
+ input := service.EmailOAuthIdentityInput{
+ ProviderType: provider,
+ ProviderKey: provider,
+ ProviderSubject: profile.Subject,
+ Email: profile.Email,
+ EmailVerified: profile.EmailVerified,
+ Username: profile.Username,
+ DisplayName: profile.DisplayName,
+ AvatarURL: profile.AvatarURL,
+ UpstreamMetadata: profile.Metadata,
+ }
+ affiliateCode := h.emailOAuthAffiliateCode(c)
+ if shouldCreate, err := h.emailOAuthShouldCreatePendingRegistration(c.Request.Context(), input); err != nil {
+ redirectOAuthError(c, frontendCallback, infraerrors.Reason(err), infraerrors.Message(err), "")
+ return
+ } else if shouldCreate {
+ if pendingErr := h.createEmailOAuthRegistrationPendingSession(c, provider, frontendCallback, redirectTo, profile); pendingErr != nil {
+ redirectOAuthError(c, frontendCallback, infraerrors.Reason(pendingErr), infraerrors.Message(pendingErr), "")
+ return
+ }
+ redirectToFrontendCallback(c, frontendCallback)
+ return
+ }
+
+ user, err := h.authService.ResolveVerifiedEmailOAuthWithInvitation(c.Request.Context(), input, "", affiliateCode)
+ if err != nil {
+ if errors.Is(err, service.ErrOAuthInvitationRequired) {
+ if pendingErr := h.createEmailOAuthRegistrationPendingSession(c, provider, frontendCallback, redirectTo, profile); pendingErr != nil {
+ redirectOAuthError(c, frontendCallback, infraerrors.Reason(pendingErr), infraerrors.Message(pendingErr), "")
+ return
+ }
+ redirectToFrontendCallback(c, frontendCallback)
+ return
+ }
+ redirectOAuthError(c, frontendCallback, infraerrors.Reason(err), infraerrors.Message(err), "")
+ return
+ }
+ if err := h.ensureBackendModeAllowsUser(c.Request.Context(), user); err != nil {
+ redirectOAuthError(c, frontendCallback, "login_blocked", infraerrors.Reason(err), infraerrors.Message(err))
+ return
+ }
+ if pendingErr := h.createEmailOAuthLoginPendingSession(c, provider, redirectTo, profile, user); pendingErr != nil {
+ redirectOAuthError(c, frontendCallback, infraerrors.Reason(pendingErr), infraerrors.Message(pendingErr), "")
+ return
+ }
+ redirectToFrontendCallback(c, frontendCallback)
+}
+
+func (h *AuthHandler) emailOAuthShouldCreatePendingRegistration(ctx context.Context, input service.EmailOAuthIdentityInput) (bool, error) {
+ identityUser, err := h.findOAuthIdentityUser(ctx, service.PendingAuthIdentityKey{
+ ProviderType: strings.TrimSpace(input.ProviderType),
+ ProviderKey: strings.TrimSpace(input.ProviderKey),
+ ProviderSubject: strings.TrimSpace(input.ProviderSubject),
+ })
+ if err != nil {
+ return false, err
+ }
+ email := strings.TrimSpace(strings.ToLower(input.Email))
+ if identityUser != nil {
+ if !strings.EqualFold(strings.TrimSpace(identityUser.Email), email) {
+ return false, infraerrors.Conflict("AUTH_IDENTITY_EMAIL_MISMATCH", "oauth identity belongs to a different email")
+ }
+ return false, nil
+ }
+ return true, nil
+}
+
+func (h *AuthHandler) emailOAuthAffiliateCode(c *gin.Context) string {
+ if c == nil {
+ return ""
+ }
+ if code, err := readCookieDecoded(c, emailOAuthAffiliateCookie); err == nil {
+ return strings.TrimSpace(code)
+ }
+ return ""
+}
+
+func (h *AuthHandler) createEmailOAuthLoginPendingSession(
+ c *gin.Context,
+ provider string,
+ redirectTo string,
+ profile *emailOAuthProfile,
+ user *service.User,
+) error {
+ if h == nil || profile == nil || user == nil || user.ID <= 0 {
+ return infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
+ }
+ browserSessionKey, err := generateOAuthPendingBrowserSession()
+ if err != nil {
+ return infraerrors.InternalServer("PENDING_AUTH_SESSION_CREATE_FAILED", "failed to create pending auth session").WithCause(err)
+ }
+ setOAuthPendingBrowserCookie(c, browserSessionKey, isRequestHTTPS(c))
+
+ email := strings.TrimSpace(strings.ToLower(profile.Email))
+ upstreamClaims := map[string]any{
+ "email": email,
+ "email_verified": profile.EmailVerified,
+ "username": strings.TrimSpace(profile.Username),
+ "provider": provider,
+ "provider_key": provider,
+ "provider_subject": strings.TrimSpace(profile.Subject),
+ }
+ if strings.TrimSpace(profile.DisplayName) != "" {
+ upstreamClaims["suggested_display_name"] = strings.TrimSpace(profile.DisplayName)
+ }
+ if strings.TrimSpace(profile.AvatarURL) != "" {
+ upstreamClaims["suggested_avatar_url"] = strings.TrimSpace(profile.AvatarURL)
+ }
+ for key, value := range profile.Metadata {
+ if _, exists := upstreamClaims[key]; !exists {
+ upstreamClaims[key] = value
+ }
+ }
+
+ return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
+ Intent: oauthIntentLogin,
+ Identity: service.PendingAuthIdentityKey{ProviderType: provider, ProviderKey: provider, ProviderSubject: strings.TrimSpace(profile.Subject)},
+ TargetUserID: &user.ID,
+ ResolvedEmail: email,
+ RedirectTo: redirectTo,
+ BrowserSessionKey: browserSessionKey,
+ UpstreamIdentityClaims: upstreamClaims,
+ CompletionResponse: map[string]any{
+ "provider": provider,
+ "redirect": redirectTo,
+ },
+ })
+}
+
+func (h *AuthHandler) createEmailOAuthRegistrationPendingSession(
+ c *gin.Context,
+ provider string,
+ frontendCallback string,
+ redirectTo string,
+ profile *emailOAuthProfile,
+) error {
+ if h == nil || profile == nil {
+ return infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")
+ }
+ browserSessionKey, err := generateOAuthPendingBrowserSession()
+ if err != nil {
+ return infraerrors.InternalServer("PENDING_AUTH_SESSION_CREATE_FAILED", "failed to create pending auth session").WithCause(err)
+ }
+ setOAuthPendingBrowserCookie(c, browserSessionKey, isRequestHTTPS(c))
+
+ email := strings.TrimSpace(strings.ToLower(profile.Email))
+ username := strings.TrimSpace(profile.Username)
+ affiliateCode := h.emailOAuthAffiliateCode(c)
+ upstreamClaims := map[string]any{
+ "email": email,
+ "email_verified": profile.EmailVerified,
+ "username": username,
+ "provider": provider,
+ "provider_key": provider,
+ "provider_subject": strings.TrimSpace(profile.Subject),
+ }
+ if strings.TrimSpace(profile.DisplayName) != "" {
+ upstreamClaims["suggested_display_name"] = strings.TrimSpace(profile.DisplayName)
+ }
+ if strings.TrimSpace(profile.AvatarURL) != "" {
+ upstreamClaims["suggested_avatar_url"] = strings.TrimSpace(profile.AvatarURL)
+ }
+ if affiliateCode != "" {
+ upstreamClaims["aff_code"] = affiliateCode
+ }
+ for key, value := range profile.Metadata {
+ if _, exists := upstreamClaims[key]; !exists {
+ upstreamClaims[key] = value
+ }
+ }
+
+ existingUser, findErr := findUserByNormalizedEmail(c.Request.Context(), h.entClient(), email)
+ if findErr != nil && !errors.Is(findErr, service.ErrUserNotFound) {
+ return findErr
+ }
+
+ invitationRequired := h != nil && h.settingSvc != nil && h.settingSvc.IsInvitationCodeEnabled(c.Request.Context())
+ pendingError := "registration_completion_required"
+ choiceReason := "registration_completion_required"
+ if invitationRequired {
+ pendingError = "invitation_required"
+ choiceReason = "invitation_required"
+ }
+ completionResponse := map[string]any{
+ "step": oauthPendingChoiceStep,
+ "error": pendingError,
+ "choice_reason": choiceReason,
+ "adoption_required": false,
+ "create_account_allowed": true,
+ "existing_account_bindable": false,
+ "force_email_on_signup": true,
+ "invitation_required": invitationRequired,
+ "email": email,
+ "resolved_email": email,
+ "provider": provider,
+ "redirect": redirectTo,
+ }
+ if strings.TrimSpace(frontendCallback) != "" {
+ completionResponse["frontend_callback"] = strings.TrimSpace(frontendCallback)
+ }
+ var targetUserID *int64
+ if existingUser != nil {
+ targetUserID = &existingUser.ID
+ completionResponse["error"] = "existing_account_binding_required"
+ completionResponse["choice_reason"] = "existing_account_binding_required"
+ completionResponse["adoption_required"] = true
+ completionResponse["create_account_allowed"] = false
+ completionResponse["existing_account_bindable"] = true
+ completionResponse["invitation_required"] = false
+ }
+
+ return h.createOAuthPendingSession(c, oauthPendingSessionPayload{
+ Intent: oauthIntentLogin,
+ Identity: service.PendingAuthIdentityKey{ProviderType: provider, ProviderKey: provider, ProviderSubject: strings.TrimSpace(profile.Subject)},
+ TargetUserID: targetUserID,
+ ResolvedEmail: email,
+ RedirectTo: redirectTo,
+ BrowserSessionKey: browserSessionKey,
+ UpstreamIdentityClaims: upstreamClaims,
+ CompletionResponse: completionResponse,
+ })
+}
+
+type completeEmailOAuthRequest struct {
+ Password string `json:"password" binding:"required,min=6"`
+ InvitationCode string `json:"invitation_code,omitempty"`
+ AffCode string `json:"aff_code,omitempty"`
+}
+
+func (h *AuthHandler) completeEmailOAuthRegistration(c *gin.Context, provider string) {
+ var req completeEmailOAuthRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ response.BadRequest(c, "Invalid request: "+err.Error())
+ return
+ }
+
+ _, session, clearCookies, err := readPendingOAuthBrowserSession(c, h)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ if !strings.EqualFold(strings.TrimSpace(session.ProviderType), provider) {
+ response.BadRequest(c, "Pending oauth session provider mismatch")
+ return
+ }
+ if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+
+ affiliateCode := strings.TrimSpace(req.AffCode)
+ if affiliateCode == "" {
+ affiliateCode = pendingSessionStringValue(session.UpstreamIdentityClaims, "aff_code")
+ }
+
+ tokenPair, user, err := h.authService.RegisterVerifiedOAuthEmailAccount(
+ c.Request.Context(),
+ strings.TrimSpace(session.ResolvedEmail),
+ req.Password,
+ strings.TrimSpace(req.InvitationCode),
+ strings.TrimSpace(session.ProviderType),
+ )
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+
+ client := h.entClient()
+ if client == nil {
+ response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready"))
+ return
+ }
+ tx, err := client.Tx(c.Request.Context())
+ if err != nil {
+ response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to consume pending oauth session").WithCause(err))
+ return
+ }
+ defer func() { _ = tx.Rollback() }()
+ txCtx := dbent.NewTxContext(c.Request.Context(), tx)
+ sessionForBinding := *session
+ sessionForBinding.UpstreamIdentityClaims = clonePendingMap(session.UpstreamIdentityClaims)
+ if strings.TrimSpace(req.InvitationCode) != "" {
+ sessionForBinding.UpstreamIdentityClaims["invitation_code"] = strings.TrimSpace(req.InvitationCode)
+ }
+ decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{})
+ if err != nil {
+ _ = tx.Rollback()
+ _ = h.authService.RollbackOAuthEmailAccountCreation(c.Request.Context(), user.ID, strings.TrimSpace(req.InvitationCode))
+ response.ErrorFrom(c, err)
+ return
+ }
+ if err := applyPendingOAuthBinding(txCtx, client, h.authService, h.userService, &sessionForBinding, decision, &user.ID, true, false); err != nil {
+ _ = tx.Rollback()
+ _ = h.authService.RollbackOAuthEmailAccountCreation(c.Request.Context(), user.ID, strings.TrimSpace(req.InvitationCode))
+ respondPendingOAuthBindingApplyError(c, err)
+ return
+ }
+ if err := h.authService.FinalizeOAuthEmailAccount(
+ txCtx,
+ user,
+ strings.TrimSpace(req.InvitationCode),
+ strings.TrimSpace(session.ProviderType),
+ affiliateCode,
+ ); err != nil {
+ _ = tx.Rollback()
+ _ = h.authService.RollbackOAuthEmailAccountCreation(c.Request.Context(), user.ID, strings.TrimSpace(req.InvitationCode))
+ response.ErrorFrom(c, err)
+ return
+ }
+ if err := consumePendingOAuthBrowserSessionTx(c.Request.Context(), tx, session); err != nil {
+ _ = tx.Rollback()
+ _ = h.authService.RollbackOAuthEmailAccountCreation(c.Request.Context(), user.ID, strings.TrimSpace(req.InvitationCode))
+ clearCookies()
+ response.ErrorFrom(c, err)
+ return
+ }
+ if err := tx.Commit(); err != nil {
+ _ = h.authService.RollbackOAuthEmailAccountCreation(c.Request.Context(), user.ID, strings.TrimSpace(req.InvitationCode))
+ response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to consume pending oauth session").WithCause(err))
+ return
+ }
+ h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
+ clearCookies()
+ h.writeOAuthTokenPairResponse(c, tokenPair)
+}
+
+func (h *AuthHandler) getEmailOAuthConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) {
+ if h != nil && h.settingSvc != nil {
+ return h.settingSvc.GetEmailOAuthProviderConfig(ctx, provider)
+ }
+ return config.EmailOAuthProviderConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
+}
+
+func buildEmailOAuthAuthorizeURL(cfg config.EmailOAuthProviderConfig, state string) (string, error) {
+ u, err := url.Parse(cfg.AuthorizeURL)
+ if err != nil {
+ return "", fmt.Errorf("parse authorize_url: %w", err)
+ }
+ q := u.Query()
+ q.Set("response_type", "code")
+ q.Set("client_id", cfg.ClientID)
+ q.Set("redirect_uri", cfg.RedirectURL)
+ q.Set("state", state)
+ if strings.TrimSpace(cfg.Scopes) != "" {
+ q.Set("scope", cfg.Scopes)
+ }
+ u.RawQuery = q.Encode()
+ return u.String(), nil
+}
+
+func exchangeEmailOAuthCode(ctx context.Context, cfg config.EmailOAuthProviderConfig, code string) (*emailOAuthTokenResponse, error) {
+ resp, err := req.C().
+ R().
+ SetContext(ctx).
+ SetHeader("Accept", "application/json").
+ SetFormData(map[string]string{
+ "grant_type": "authorization_code",
+ "client_id": cfg.ClientID,
+ "client_secret": cfg.ClientSecret,
+ "code": code,
+ "redirect_uri": cfg.RedirectURL,
+ }).
+ Post(cfg.TokenURL)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("token endpoint status %d: %s", resp.StatusCode, truncateLogValue(resp.String(), 1024))
+ }
+ var tokenResp emailOAuthTokenResponse
+ if err := json.Unmarshal(resp.Bytes(), &tokenResp); err != nil {
+ return nil, err
+ }
+ if strings.TrimSpace(tokenResp.AccessToken) == "" {
+ return nil, errors.New("missing access_token")
+ }
+ return &tokenResp, nil
+}
+
+func fetchEmailOAuthProfile(ctx context.Context, provider string, cfg config.EmailOAuthProviderConfig, token *emailOAuthTokenResponse) (*emailOAuthProfile, error) {
+ resp, err := req.C().
+ R().
+ SetContext(ctx).
+ SetBearerAuthToken(token.AccessToken).
+ SetHeader("Accept", "application/json").
+ Get(cfg.UserInfoURL)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return nil, fmt.Errorf("userinfo endpoint status %d: %s", resp.StatusCode, truncateLogValue(resp.String(), 1024))
+ }
+ switch strings.ToLower(strings.TrimSpace(provider)) {
+ case "github":
+ return parseGitHubOAuthProfile(ctx, cfg, token, resp.String())
+ case "google":
+ return parseGoogleOAuthProfile(resp.String())
+ default:
+ return nil, errors.New("unsupported oauth provider")
+ }
+}
+
+func parseGitHubOAuthProfile(ctx context.Context, cfg config.EmailOAuthProviderConfig, token *emailOAuthTokenResponse, body string) (*emailOAuthProfile, error) {
+ subject := strings.TrimSpace(gjson.Get(body, "id").String())
+ if subject == "" {
+ return nil, errors.New("github user id is missing")
+ }
+ email := ""
+ emailsURL := strings.TrimSpace(cfg.EmailsURL)
+ if emailsURL == "" {
+ return nil, errors.New("github verified email is missing")
+ }
+ verifiedEmail, err := fetchGitHubPrimaryVerifiedEmail(ctx, emailsURL, token.AccessToken)
+ if err != nil {
+ return nil, err
+ }
+ email = verifiedEmail
+ if email == "" {
+ return nil, errors.New("github verified email is missing")
+ }
+ login := strings.TrimSpace(gjson.Get(body, "login").String())
+ name := strings.TrimSpace(gjson.Get(body, "name").String())
+ return &emailOAuthProfile{
+ Subject: subject,
+ Email: email,
+ EmailVerified: true,
+ Username: firstNonEmpty(login, name, "github_"+subject),
+ DisplayName: firstNonEmpty(name, login),
+ AvatarURL: strings.TrimSpace(gjson.Get(body, "avatar_url").String()),
+ Metadata: map[string]any{
+ "login": login,
+ },
+ }, nil
+}
+
+func fetchGitHubPrimaryVerifiedEmail(ctx context.Context, emailsURL string, accessToken string) (string, error) {
+ resp, err := req.C().
+ R().
+ SetContext(ctx).
+ SetBearerAuthToken(accessToken).
+ SetHeader("Accept", "application/json").
+ Get(emailsURL)
+ if err != nil {
+ return "", err
+ }
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return "", fmt.Errorf("github emails endpoint status %d: %s", resp.StatusCode, truncateLogValue(resp.String(), 1024))
+ }
+ items := gjson.Parse(resp.String()).Array()
+ for _, item := range items {
+ if item.Get("primary").Bool() && item.Get("verified").Bool() {
+ if email := strings.TrimSpace(item.Get("email").String()); email != "" {
+ return email, nil
+ }
+ }
+ }
+ for _, item := range items {
+ if item.Get("verified").Bool() {
+ if email := strings.TrimSpace(item.Get("email").String()); email != "" {
+ return email, nil
+ }
+ }
+ }
+ return "", errors.New("github verified email is missing")
+}
+
+func parseGoogleOAuthProfile(body string) (*emailOAuthProfile, error) {
+ subject := strings.TrimSpace(gjson.Get(body, "sub").String())
+ email := strings.TrimSpace(gjson.Get(body, "email").String())
+ verified := gjson.Get(body, "email_verified").Bool()
+ if subject == "" {
+ return nil, errors.New("google subject is missing")
+ }
+ if email == "" || !verified {
+ return nil, errors.New("google verified email is missing")
+ }
+ name := strings.TrimSpace(gjson.Get(body, "name").String())
+ return &emailOAuthProfile{
+ Subject: subject,
+ Email: email,
+ EmailVerified: true,
+ Username: firstNonEmpty(strings.TrimSpace(gjson.Get(body, "given_name").String()), name, email),
+ DisplayName: name,
+ AvatarURL: strings.TrimSpace(gjson.Get(body, "picture").String()),
+ Metadata: map[string]any{
+ "email_verified": true,
+ },
+ }, nil
+}
+
+func emailOAuthSetCookie(c *gin.Context, name, value string, secure bool) {
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: name,
+ Value: value,
+ Path: emailOAuthCookiePath,
+ MaxAge: emailOAuthCookieMaxAgeSec,
+ HttpOnly: true,
+ Secure: secure,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func emailOAuthClearCookie(c *gin.Context, name string, secure bool) {
+ http.SetCookie(c.Writer, &http.Cookie{
+ Name: name,
+ Value: "",
+ Path: emailOAuthCookiePath,
+ MaxAge: -1,
+ HttpOnly: true,
+ Secure: secure,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
diff --git a/backend/internal/handler/auth_email_oauth_test.go b/backend/internal/handler/auth_email_oauth_test.go
new file mode 100644
index 00000000000..4a2f82e1735
--- /dev/null
+++ b/backend/internal/handler/auth_email_oauth_test.go
@@ -0,0 +1,563 @@
+package handler
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/authidentity"
+ "github.com/Wei-Shaw/sub2api/ent/redeemcode"
+ dbuser "github.com/Wei-Shaw/sub2api/ent/user"
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEmailOAuthCallbackRequiresPendingRegistrationWhenInvitationEnabled(t *testing.T) {
+ handler, client := newOAuthPendingFlowTestHandler(t, true)
+ ctx := context.Background()
+
+ state := "github-oauth-state"
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/callback?code=code-1&state="+url.QueryEscape(state), nil)
+ req.AddCookie(&http.Cookie{Name: emailOAuthStateCookieName, Value: encodeCookieValue(state)})
+ req.AddCookie(&http.Cookie{Name: emailOAuthRedirectCookie, Value: encodeCookieValue("/dashboard")})
+ req.AddCookie(&http.Cookie{Name: emailOAuthProviderCookie, Value: encodeCookieValue("github")})
+ c.Request = req
+
+ profile := &emailOAuthProfile{
+ Subject: "github-123",
+ Email: "fresh@example.com",
+ EmailVerified: true,
+ Username: "fresh",
+ DisplayName: "Fresh User",
+ AvatarURL: "https://cdn.example/fresh.png",
+ Metadata: map[string]any{
+ "login": "fresh",
+ },
+ }
+ handler.emailOAuthCallbackWithProfile(c, "github", config.EmailOAuthProviderConfig{
+ Enabled: true,
+ ClientID: "github-client",
+ ClientSecret: "github-secret",
+ RedirectURL: "https://app.example/api/v1/auth/oauth/github/callback",
+ FrontendRedirectURL: "/auth/oauth/callback",
+ }, "/auth/oauth/callback", "/dashboard", profile)
+
+ require.Equal(t, http.StatusFound, recorder.Code)
+ location := recorder.Header().Get("Location")
+ require.Contains(t, location, "/auth/oauth/callback")
+ require.NotContains(t, location, "access_token=")
+
+ userCount, err := client.User.Query().Where(dbuser.EmailEQ("fresh@example.com")).Count(ctx)
+ require.NoError(t, err)
+ require.Zero(t, userCount)
+
+ session, err := client.PendingAuthSession.Query().Only(ctx)
+ require.NoError(t, err)
+ require.Equal(t, "github", session.ProviderType)
+ require.Equal(t, "github", session.ProviderKey)
+ require.Equal(t, "github-123", session.ProviderSubject)
+ require.Equal(t, "fresh@example.com", session.ResolvedEmail)
+ require.Equal(t, "/dashboard", session.RedirectTo)
+ require.Nil(t, session.TargetUserID)
+
+ completion, ok := readCompletionResponse(session.LocalFlowState)
+ require.True(t, ok)
+ require.Equal(t, oauthPendingChoiceStep, completion["step"])
+ require.Equal(t, "invitation_required", completion["error"])
+ require.Equal(t, true, completion["invitation_required"])
+ require.Equal(t, "fresh@example.com", completion["email"])
+ require.Equal(t, "fresh@example.com", completion["resolved_email"])
+ require.Equal(t, true, completion["create_account_allowed"])
+
+ require.NotEmpty(t, findSetCookieValue(recorder.Result().Cookies(), oauthPendingSessionCookieName))
+ require.NotEmpty(t, findSetCookieValue(recorder.Result().Cookies(), oauthPendingBrowserCookieName))
+}
+
+func TestEmailOAuthCallbackExistingEmailRequiresLocalPasswordBeforeBinding(t *testing.T) {
+ handler, client := newOAuthPendingFlowTestHandler(t, true)
+ ctx := context.Background()
+
+ passwordHash, err := handler.authService.HashPassword("secret-123")
+ require.NoError(t, err)
+ user, err := client.User.Create().
+ SetEmail("existing@example.com").
+ SetUsername("existing").
+ SetPasswordHash(passwordHash).
+ SetRole(service.RoleUser).
+ SetStatus(service.StatusActive).
+ Save(ctx)
+ require.NoError(t, err)
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/google/callback", nil)
+
+ handler.emailOAuthCallbackWithProfile(c, "google", config.EmailOAuthProviderConfig{
+ Enabled: true,
+ ClientID: "google-client",
+ ClientSecret: "google-secret",
+ RedirectURL: "https://app.example/api/v1/auth/oauth/google/callback",
+ FrontendRedirectURL: "/auth/oauth/callback",
+ }, "/auth/oauth/callback", "/dashboard", &emailOAuthProfile{
+ Subject: "google-123",
+ Email: "existing@example.com",
+ EmailVerified: true,
+ Username: "existing",
+ })
+
+ require.Equal(t, http.StatusFound, recorder.Code)
+ location := recorder.Header().Get("Location")
+ require.Equal(t, "/auth/oauth/callback", location)
+ require.NotContains(t, location, "access_token=")
+ require.NotContains(t, location, "refresh_token=")
+
+ session, err := client.PendingAuthSession.Query().Only(ctx)
+ require.NoError(t, err)
+ require.NotNil(t, session.TargetUserID)
+ require.Equal(t, user.ID, *session.TargetUserID)
+ require.Equal(t, "/dashboard", session.RedirectTo)
+
+ sessionCookie := findSetCookieValue(recorder.Result().Cookies(), oauthPendingSessionCookieName)
+ browserCookie := findSetCookieValue(recorder.Result().Cookies(), oauthPendingBrowserCookieName)
+ require.NotEmpty(t, sessionCookie)
+ require.NotEmpty(t, browserCookie)
+
+ exchangeRecorder := httptest.NewRecorder()
+ exchangeContext, _ := gin.CreateTestContext(exchangeRecorder)
+ exchangeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", nil)
+ exchangeRequest.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: sessionCookie})
+ exchangeRequest.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: browserCookie})
+ exchangeContext.Request = exchangeRequest
+
+ handler.ExchangePendingOAuthCompletion(exchangeContext)
+
+ require.Equal(t, http.StatusOK, exchangeRecorder.Code)
+ payload := decodeJSONResponseData(t, exchangeRecorder)
+ require.Equal(t, oauthPendingChoiceStep, payload["step"])
+ require.Equal(t, true, payload["existing_account_bindable"])
+ require.NotContains(t, payload, "access_token")
+ require.NotContains(t, payload, "refresh_token")
+ require.Equal(t, "/dashboard", payload["redirect"])
+
+ identityCount, err := client.AuthIdentity.Query().Where(
+ authidentity.ProviderTypeEQ("google"),
+ authidentity.ProviderSubjectEQ("google-123"),
+ ).Count(ctx)
+ require.NoError(t, err)
+ require.Zero(t, identityCount)
+
+ bindRecorder := httptest.NewRecorder()
+ bindContext, _ := gin.CreateTestContext(bindRecorder)
+ bindRequest := httptest.NewRequest(
+ http.MethodPost,
+ "/api/v1/auth/oauth/pending/bind-login",
+ strings.NewReader(`{"email":"existing@example.com","password":"secret-123"}`),
+ )
+ bindRequest.Header.Set("Content-Type", "application/json")
+ bindRequest.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: sessionCookie})
+ bindRequest.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: browserCookie})
+ bindContext.Request = bindRequest
+
+ handler.BindPendingOAuthLogin(bindContext)
+
+ require.Equal(t, http.StatusOK, bindRecorder.Code)
+ bindPayload := decodeJSONBody(t, bindRecorder)
+ require.NotEmpty(t, bindPayload["access_token"])
+ require.NotEmpty(t, bindPayload["refresh_token"])
+
+ identityCount, err = client.AuthIdentity.Query().Where(
+ authidentity.ProviderTypeEQ("google"),
+ authidentity.ProviderSubjectEQ("google-123"),
+ ).Count(ctx)
+ require.NoError(t, err)
+ require.Equal(t, 1, identityCount)
+}
+
+func TestBindPendingEmailOAuthLoginRejectsResolvedEmailMismatch(t *testing.T) {
+ handler, client := newOAuthPendingFlowTestHandler(t, false)
+ ctx := context.Background()
+
+ passwordHash, err := handler.authService.HashPassword("secret-123")
+ require.NoError(t, err)
+ user, err := client.User.Create().
+ SetEmail("victim@example.com").
+ SetUsername("victim").
+ SetPasswordHash(passwordHash).
+ SetRole(service.RoleUser).
+ SetStatus(service.StatusActive).
+ Save(ctx)
+ require.NoError(t, err)
+
+ session, err := client.PendingAuthSession.Create().
+ SetSessionToken("email-mismatch-session-token").
+ SetIntent("adopt_existing_user_by_email").
+ SetProviderType("google").
+ SetProviderKey("google").
+ SetProviderSubject("google-mismatch-subject").
+ SetTargetUserID(user.ID).
+ SetResolvedEmail("different@example.com").
+ SetBrowserSessionKey("email-mismatch-browser-key").
+ SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
+ Save(ctx)
+ require.NoError(t, err)
+
+ recorder := httptest.NewRecorder()
+ ginCtx, _ := gin.CreateTestContext(recorder)
+ req := httptest.NewRequest(
+ http.MethodPost,
+ "/api/v1/auth/oauth/pending/bind-login",
+ strings.NewReader(`{"email":"victim@example.com","password":"secret-123"}`),
+ )
+ req.Header.Set("Content-Type", "application/json")
+ req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
+ req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue(session.BrowserSessionKey)})
+ ginCtx.Request = req
+
+ handler.BindPendingOAuthLogin(ginCtx)
+
+ require.Equal(t, http.StatusConflict, recorder.Code)
+ require.Contains(t, recorder.Body.String(), "PENDING_AUTH_TARGET_EMAIL_MISMATCH")
+ identityCount, err := client.AuthIdentity.Query().Where(
+ authidentity.ProviderTypeEQ("google"),
+ authidentity.ProviderSubjectEQ("google-mismatch-subject"),
+ ).Count(ctx)
+ require.NoError(t, err)
+ require.Zero(t, identityCount)
+ storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
+ require.NoError(t, err)
+ require.Nil(t, storedSession.ConsumedAt)
+}
+
+func TestResolveVerifiedEmailOAuthRejectsUnboundExistingAccount(t *testing.T) {
+ handler, client := newOAuthPendingFlowTestHandler(t, false)
+ ctx := context.Background()
+
+ _, err := client.User.Create().
+ SetEmail("existing@example.com").
+ SetUsername("existing").
+ SetPasswordHash("hash").
+ SetRole(service.RoleUser).
+ SetStatus(service.StatusActive).
+ Save(ctx)
+ require.NoError(t, err)
+
+ _, err = handler.authService.ResolveVerifiedEmailOAuthWithInvitation(ctx, service.EmailOAuthIdentityInput{
+ ProviderType: "google",
+ ProviderKey: "google",
+ ProviderSubject: "unbound-google-subject",
+ Email: "existing@example.com",
+ EmailVerified: true,
+ }, "", "")
+ require.Error(t, err)
+
+ identityCount, countErr := client.AuthIdentity.Query().Where(
+ authidentity.ProviderTypeEQ("google"),
+ authidentity.ProviderSubjectEQ("unbound-google-subject"),
+ ).Count(ctx)
+ require.NoError(t, countErr)
+ require.Zero(t, identityCount)
+}
+
+func TestEmailOAuthCallbackCreatesPasswordRegistrationSessionForNewEmail(t *testing.T) {
+ affiliateRepo := newOAuthEmailAffiliateRepoStub(map[string]int64{"AFF123": 1001})
+ handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
+ settingValues: map[string]string{
+ service.SettingKeyAffiliateEnabled: "true",
+ },
+ affiliateFactory: func(_ *dbent.Client, settingSvc *service.SettingService) *service.AffiliateService {
+ return service.NewAffiliateService(affiliateRepo, settingSvc, nil, nil)
+ },
+ })
+ ctx := context.Background()
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/callback", nil)
+ req.AddCookie(&http.Cookie{Name: emailOAuthAffiliateCookie, Value: encodeCookieValue("AFF123")})
+ c.Request = req
+
+ handler.emailOAuthCallbackWithProfile(c, "github", config.EmailOAuthProviderConfig{
+ Enabled: true,
+ ClientID: "github-client",
+ ClientSecret: "github-secret",
+ RedirectURL: "https://app.example/api/v1/auth/oauth/github/callback",
+ FrontendRedirectURL: "/auth/oauth/callback",
+ }, "/auth/oauth/callback", "/dashboard", &emailOAuthProfile{
+ Subject: "github-aff-user",
+ Email: "aff-user@example.com",
+ EmailVerified: true,
+ Username: "aff-user",
+ })
+
+ require.Equal(t, http.StatusFound, recorder.Code)
+ require.NotContains(t, recorder.Header().Get("Location"), "access_token=")
+ userCount, err := client.User.Query().Where(dbuser.EmailEQ("aff-user@example.com")).Count(ctx)
+ require.NoError(t, err)
+ require.Zero(t, userCount)
+ require.Empty(t, affiliateRepo.ensureUserIDs)
+ require.Empty(t, affiliateRepo.bindCalls)
+
+ session, err := client.PendingAuthSession.Query().Only(ctx)
+ require.NoError(t, err)
+ require.Equal(t, "aff-user@example.com", session.ResolvedEmail)
+ require.Equal(t, "AFF123", pendingSessionStringValue(session.UpstreamIdentityClaims, "aff_code"))
+
+ completion, ok := readCompletionResponse(session.LocalFlowState)
+ require.True(t, ok)
+ require.Equal(t, oauthPendingChoiceStep, completion["step"])
+ require.Equal(t, "registration_completion_required", completion["error"])
+ require.Equal(t, false, completion["invitation_required"])
+ require.Equal(t, true, completion["create_account_allowed"])
+ require.Equal(t, true, completion["force_email_on_signup"])
+ require.Equal(t, "aff-user@example.com", completion["resolved_email"])
+}
+
+func TestCompleteEmailOAuthRegistrationUsesAffiliateCodeFromPendingSession(t *testing.T) {
+ affiliateRepo := newOAuthEmailAffiliateRepoStub(map[string]int64{"AFF456": 2002})
+ handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
+ invitationEnabled: true,
+ settingValues: map[string]string{
+ service.SettingKeyAffiliateEnabled: "true",
+ },
+ affiliateFactory: func(_ *dbent.Client, settingSvc *service.SettingService) *service.AffiliateService {
+ return service.NewAffiliateService(affiliateRepo, settingSvc, nil, nil)
+ },
+ })
+ ctx := context.Background()
+ invitation, err := client.RedeemCode.Create().
+ SetCode("INVITE456").
+ SetType(service.RedeemTypeInvitation).
+ SetStatus(service.StatusUnused).
+ SetValue(0).
+ Save(ctx)
+ require.NoError(t, err)
+
+ session, err := client.PendingAuthSession.Create().
+ SetSessionToken("email-oauth-aff-session-token").
+ SetIntent(oauthIntentLogin).
+ SetProviderType("google").
+ SetProviderKey("google").
+ SetProviderSubject("google-aff-user").
+ SetResolvedEmail("pending-aff@example.com").
+ SetRedirectTo("/dashboard").
+ SetBrowserSessionKey("browser-aff-key").
+ SetUpstreamIdentityClaims(map[string]any{
+ "email": "pending-aff@example.com",
+ "email_verified": true,
+ "username": "pending-aff",
+ "provider": "google",
+ "provider_key": "google",
+ "provider_subject": "google-aff-user",
+ "aff_code": "AFF456",
+ }).
+ SetLocalFlowState(map[string]any{
+ "step": oauthPendingChoiceStep,
+ "error": "invitation_required",
+ }).
+ SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
+ Save(ctx)
+ require.NoError(t, err)
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/google/complete-registration", strings.NewReader(`{"password":"secret-123","invitation_code":"INVITE456","email":"tampered@example.com"}`))
+ req.Header.Set("Content-Type", "application/json")
+ req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
+ req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-aff-key")})
+ c.Request = req
+
+ handler.completeEmailOAuthRegistration(c, "google")
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+ user, err := client.User.Query().Where(dbuser.EmailEQ("pending-aff@example.com")).Only(ctx)
+ require.NoError(t, err)
+ require.NotEmpty(t, user.PasswordHash)
+ require.NotEqual(t, "secret-123", user.PasswordHash)
+ tamperedCount, err := client.User.Query().Where(dbuser.EmailEQ("tampered@example.com")).Count(ctx)
+ require.NoError(t, err)
+ require.Zero(t, tamperedCount)
+ require.Equal(t, []oauthEmailAffiliateBindCall{{userID: user.ID, inviterID: 2002}}, affiliateRepo.bindCalls)
+ storedInvitation, err := client.RedeemCode.Query().Where(redeemcode.IDEQ(invitation.ID)).Only(ctx)
+ require.NoError(t, err)
+ require.NotNil(t, storedInvitation.UsedBy)
+ require.Equal(t, user.ID, *storedInvitation.UsedBy)
+}
+
+func TestCompleteEmailOAuthRegistrationRequiresPassword(t *testing.T) {
+ handler, client := newOAuthPendingFlowTestHandler(t, false)
+ ctx := context.Background()
+
+ session, err := client.PendingAuthSession.Create().
+ SetSessionToken("email-oauth-password-session-token").
+ SetIntent(oauthIntentLogin).
+ SetProviderType("github").
+ SetProviderKey("github").
+ SetProviderSubject("github-password-user").
+ SetResolvedEmail("password-required@example.com").
+ SetRedirectTo("/dashboard").
+ SetBrowserSessionKey("browser-password-key").
+ SetUpstreamIdentityClaims(map[string]any{
+ "email": "password-required@example.com",
+ "email_verified": true,
+ "username": "password-required",
+ "provider": "github",
+ "provider_key": "github",
+ "provider_subject": "github-password-user",
+ }).
+ SetLocalFlowState(map[string]any{
+ "step": oauthPendingChoiceStep,
+ "error": "registration_completion_required",
+ }).
+ SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
+ Save(ctx)
+ require.NoError(t, err)
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/github/complete-registration", strings.NewReader(`{}`))
+ req.Header.Set("Content-Type", "application/json")
+ req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
+ req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-password-key")})
+ c.Request = req
+
+ handler.completeEmailOAuthRegistration(c, "github")
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code)
+ userCount, err := client.User.Query().Where(dbuser.EmailEQ("password-required@example.com")).Count(ctx)
+ require.NoError(t, err)
+ require.Zero(t, userCount)
+}
+
+func TestParseGitHubOAuthProfileRejectsPublicEmailWhenEmailsEndpointFails(t *testing.T) {
+ emailServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ http.Error(w, "missing scope", http.StatusForbidden)
+ }))
+ t.Cleanup(emailServer.Close)
+
+ profile, err := parseGitHubOAuthProfile(context.Background(), config.EmailOAuthProviderConfig{
+ EmailsURL: emailServer.URL,
+ }, &emailOAuthTokenResponse{AccessToken: "token"}, `{"id":123,"login":"octo","email":"public@example.com"}`)
+
+ require.Error(t, err)
+ require.Nil(t, profile)
+ require.Contains(t, err.Error(), "github emails endpoint status 403")
+}
+
+type oauthEmailAffiliateBindCall struct {
+ userID int64
+ inviterID int64
+}
+
+type oauthEmailAffiliateRepoStub struct {
+ codeOwners map[string]int64
+ ensureUserIDs []int64
+ bindCalls []oauthEmailAffiliateBindCall
+}
+
+func newOAuthEmailAffiliateRepoStub(codeOwners map[string]int64) *oauthEmailAffiliateRepoStub {
+ return &oauthEmailAffiliateRepoStub{codeOwners: codeOwners}
+}
+
+func (r *oauthEmailAffiliateRepoStub) EnsureUserAffiliate(_ context.Context, userID int64) (*service.AffiliateSummary, error) {
+ r.ensureUserIDs = append(r.ensureUserIDs, userID)
+ return &service.AffiliateSummary{UserID: userID, AffCode: "SELF"}, nil
+}
+
+func (r *oauthEmailAffiliateRepoStub) GetAffiliateByCode(_ context.Context, code string) (*service.AffiliateSummary, error) {
+ userID, ok := r.codeOwners[strings.ToUpper(strings.TrimSpace(code))]
+ if !ok {
+ return nil, service.ErrAffiliateProfileNotFound
+ }
+ return &service.AffiliateSummary{UserID: userID, AffCode: strings.ToUpper(strings.TrimSpace(code))}, nil
+}
+
+func (r *oauthEmailAffiliateRepoStub) BindInviter(_ context.Context, userID, inviterID int64) (bool, error) {
+ r.bindCalls = append(r.bindCalls, oauthEmailAffiliateBindCall{userID: userID, inviterID: inviterID})
+ return true, nil
+}
+
+func (r *oauthEmailAffiliateRepoStub) AccrueQuota(context.Context, int64, int64, float64, int, *int64) (bool, error) {
+ panic("unexpected AccrueQuota call")
+}
+
+func (r *oauthEmailAffiliateRepoStub) GetAccruedRebateFromInvitee(context.Context, int64, int64) (float64, error) {
+ panic("unexpected GetAccruedRebateFromInvitee call")
+}
+
+func (r *oauthEmailAffiliateRepoStub) ThawFrozenQuota(context.Context, int64) (float64, error) {
+ panic("unexpected ThawFrozenQuota call")
+}
+
+func (r *oauthEmailAffiliateRepoStub) TransferQuotaToBalance(context.Context, int64) (float64, float64, error) {
+ panic("unexpected TransferQuotaToBalance call")
+}
+
+func (r *oauthEmailAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]service.AffiliateInvitee, error) {
+ panic("unexpected ListInvitees call")
+}
+
+func (r *oauthEmailAffiliateRepoStub) UpdateUserAffCode(context.Context, int64, string) error {
+ panic("unexpected UpdateUserAffCode call")
+}
+
+func (r *oauthEmailAffiliateRepoStub) ResetUserAffCode(context.Context, int64) (string, error) {
+ panic("unexpected ResetUserAffCode call")
+}
+
+func (r *oauthEmailAffiliateRepoStub) SetUserRebateRate(context.Context, int64, *float64) error {
+ panic("unexpected SetUserRebateRate call")
+}
+
+func (r *oauthEmailAffiliateRepoStub) BatchSetUserRebateRate(context.Context, []int64, *float64) error {
+ panic("unexpected BatchSetUserRebateRate call")
+}
+
+func (r *oauthEmailAffiliateRepoStub) ListUsersWithCustomSettings(context.Context, service.AffiliateAdminFilter) ([]service.AffiliateAdminEntry, int64, error) {
+ panic("unexpected ListUsersWithCustomSettings call")
+}
+
+func (r *oauthEmailAffiliateRepoStub) ListAffiliateInviteRecords(context.Context, service.AffiliateRecordFilter) ([]service.AffiliateInviteRecord, int64, error) {
+ panic("unexpected ListAffiliateInviteRecords call")
+}
+
+func (r *oauthEmailAffiliateRepoStub) ListAffiliateRebateRecords(context.Context, service.AffiliateRecordFilter) ([]service.AffiliateRebateRecord, int64, error) {
+ panic("unexpected ListAffiliateRebateRecords call")
+}
+
+func (r *oauthEmailAffiliateRepoStub) ListAffiliateTransferRecords(context.Context, service.AffiliateRecordFilter) ([]service.AffiliateTransferRecord, int64, error) {
+ panic("unexpected ListAffiliateTransferRecords call")
+}
+
+func (r *oauthEmailAffiliateRepoStub) GetAffiliateUserOverview(context.Context, int64) (*service.AffiliateUserOverview, error) {
+ panic("unexpected GetAffiliateUserOverview call")
+}
+
+func (r *oauthEmailAffiliateRepoStub) GetUserSignupIPPrefix(context.Context, int64) (string, error) {
+ return "", nil
+}
+
+func (r *oauthEmailAffiliateRepoStub) HasInviteeFirstOrderRebate(context.Context, int64) (bool, error) {
+ return false, nil
+}
+
+func (r *oauthEmailAffiliateRepoStub) AccrueInviteeFirstOrderQuota(context.Context, int64, float64) (bool, error) {
+ return false, nil
+}
+
+func findSetCookieValue(cookies []*http.Cookie, name string) string {
+ for _, cookie := range cookies {
+ if cookie != nil && strings.EqualFold(cookie.Name, name) && cookie.MaxAge >= 0 {
+ return cookie.Value
+ }
+ }
+ return ""
+}
diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go
index 1f9a66ff371..f54a449bf38 100644
--- a/backend/internal/handler/auth_handler.go
+++ b/backend/internal/handler/auth_handler.go
@@ -3,7 +3,9 @@ package handler
import (
"context"
"log/slog"
+ "net/http"
"strings"
+ "time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
@@ -20,6 +22,7 @@ import (
type AuthHandler struct {
cfg *config.Config
authService *service.AuthService
+ chatBridgeSvc chatBridgeService
userService *service.UserService
settingSvc *service.SettingService
promoService *service.PromoService
@@ -32,6 +35,7 @@ func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userSe
return &AuthHandler{
cfg: cfg,
authService: authService,
+ chatBridgeSvc: service.NewChatBridgeService(authService),
userService: userService,
settingSvc: settingService,
promoService: promoService,
@@ -40,15 +44,45 @@ func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userSe
}
}
+// IssueAdminOpsWSTicket returns a short-lived credential scoped exclusively to
+// the realtime admin Ops WebSocket handshake.
+func (h *AuthHandler) IssueAdminOpsWSTicket(c *gin.Context) {
+ subject, ok := middleware2.GetAuthSubjectFromContext(c)
+ if !ok || subject.UserID <= 0 {
+ response.Error(c, http.StatusUnauthorized, "Unauthorized")
+ return
+ }
+ user, err := h.userService.GetByID(c.Request.Context(), subject.UserID)
+ if err != nil || user == nil || !user.IsActive() || !user.IsAdmin() {
+ response.Error(c, http.StatusForbidden, "Admin access required")
+ return
+ }
+
+ ticket, expiresAt, err := h.authService.GenerateAdminOpsWSTicket(user)
+ if err != nil {
+ slog.Error("failed to issue admin ops websocket ticket", "error", err, "user_id", subject.UserID)
+ response.InternalError(c, "Failed to issue WebSocket ticket")
+ return
+ }
+ c.Header("Cache-Control", "no-store")
+ c.Header("Pragma", "no-cache")
+ response.Success(c, gin.H{
+ "ticket": ticket,
+ "expires_at": expiresAt.Format(time.RFC3339),
+ "expires_in": int(service.AdminOpsWSTicketTTL.Seconds()),
+ })
+}
+
// RegisterRequest represents the registration request payload
type RegisterRequest struct {
- Email string `json:"email" binding:"required,email"`
- Password string `json:"password" binding:"required,min=6"`
- VerifyCode string `json:"verify_code"`
- TurnstileToken string `json:"turnstile_token"`
- PromoCode string `json:"promo_code"` // 注册优惠码
- InvitationCode string `json:"invitation_code"` // 邀请码
- AffCode string `json:"aff_code"` // 邀请返利码
+ Email string `json:"email" binding:"required,email"`
+ Password string `json:"password" binding:"required,min=6"`
+ VerifyCode string `json:"verify_code"`
+ TurnstileToken string `json:"turnstile_token"`
+ PromoCode string `json:"promo_code"` // 注册优惠码
+ InvitationCode string `json:"invitation_code"` // 邀请码
+ AffCode string `json:"aff_code"` // 邀请返利码
+ DeviceFingerprint string `json:"device_fingerprint"` // 浏览器设备指纹 visitorId
}
// SendVerifyCodeRequest 发送验证码请求
@@ -113,13 +147,14 @@ func (h *AuthHandler) respondWithTokenPair(c *gin.Context, user *service.User) {
})
return
}
- response.Success(c, AuthResponse{
- AccessToken: tokenPair.AccessToken,
- RefreshToken: tokenPair.RefreshToken,
- ExpiresIn: tokenPair.ExpiresIn,
- TokenType: "Bearer",
- User: dto.UserFromService(user),
- })
+ payload, err := h.browserSessionTokenPairPayload(c, tokenPair)
+ if err != nil {
+ slog.Error("failed to establish browser session", "error", err, "user_id", user.ID)
+ response.InternalError(c, "Failed to establish browser session")
+ return
+ }
+ payload["user"] = dto.UserFromService(user)
+ response.Success(c, payload)
}
func (h *AuthHandler) ensureBackendModeAllowsUser(ctx context.Context, user *service.User) error {
@@ -165,7 +200,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}
- _, user, err := h.authService.RegisterWithVerification(
+ _, user, err := h.authService.RegisterWithVerificationAndRisk(
c.Request.Context(),
req.Email,
req.Password,
@@ -173,6 +208,11 @@ func (h *AuthHandler) Register(c *gin.Context) {
req.PromoCode,
req.InvitationCode,
req.AffCode,
+ service.RegistrationRiskInput{
+ ClientIP: ip.GetClientIP(c),
+ UserAgent: c.Request.UserAgent(),
+ DeviceFingerprint: req.DeviceFingerprint,
+ },
)
if err != nil {
response.ErrorFrom(c, err)
@@ -310,8 +350,19 @@ func (h *AuthHandler) Login2FA(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
+ consumedSession, err := h.totpService.ConsumeLoginSession(c.Request.Context(), req.TempToken)
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+ if consumedSession == nil || consumedSession.UserID != session.UserID {
+ response.BadRequest(c, "Invalid or expired 2FA session")
+ return
+ }
+ session = consumedSession
- // Get the user (before session deletion so we can check backend mode)
+ // The one-time session is consumed before any token issuance or OAuth bind
+ // side effect. A later transient failure requires a fresh primary login.
user, err := h.userService.GetByID(c.Request.Context(), session.UserID)
if err != nil {
response.ErrorFrom(c, err)
@@ -384,9 +435,6 @@ func (h *AuthHandler) Login2FA(c *gin.Context) {
}
}
- // Delete the login session (only after all checks pass)
- _ = h.totpService.DeleteLoginSession(c.Request.Context(), req.TempToken)
-
if session.PendingOAuthBind == nil {
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
}
@@ -642,13 +690,13 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
// RefreshTokenRequest 刷新Token请求
type RefreshTokenRequest struct {
- RefreshToken string `json:"refresh_token" binding:"required"`
+ RefreshToken string `json:"refresh_token"`
}
// RefreshTokenResponse 刷新Token响应
type RefreshTokenResponse struct {
AccessToken string `json:"access_token"`
- RefreshToken string `json:"refresh_token"`
+ RefreshToken string `json:"refresh_token,omitempty"`
ExpiresIn int `json:"expires_in"` // Access Token有效期(秒)
TokenType string `json:"token_type"`
}
@@ -656,13 +704,30 @@ type RefreshTokenResponse struct {
// RefreshToken 刷新Token
// POST /api/v1/auth/refresh
func (h *AuthHandler) RefreshToken(c *gin.Context) {
- var req RefreshTokenRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
+ var refreshToken string
+ if isBrowserSessionRequest(c) {
+ if err := validateBrowserSessionCSRF(c); err != nil {
+ clearBrowserSessionCookies(c)
+ response.ErrorFrom(c, err)
+ return
+ }
+ var err error
+ refreshToken, err = readBrowserRefreshToken(c)
+ if err != nil {
+ clearBrowserSessionCookies(c)
+ response.Unauthorized(c, "Browser session is missing or invalid")
+ return
+ }
+ } else {
+ var req RefreshTokenRequest
+ if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.RefreshToken) == "" {
+ response.BadRequest(c, "Invalid request: refresh_token is required")
+ return
+ }
+ refreshToken = req.RefreshToken
}
- result, err := h.authService.RefreshTokenPair(c.Request.Context(), req.RefreshToken)
+ result, err := h.authService.RefreshTokenPair(c.Request.Context(), refreshToken)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -674,12 +739,17 @@ func (h *AuthHandler) RefreshToken(c *gin.Context) {
return
}
- response.Success(c, RefreshTokenResponse{
+ payload, err := h.browserSessionTokenPairPayload(c, &service.TokenPair{
AccessToken: result.AccessToken,
RefreshToken: result.RefreshToken,
ExpiresIn: result.ExpiresIn,
- TokenType: "Bearer",
})
+ if err != nil {
+ slog.Error("failed to rotate browser session", "error", err)
+ response.InternalError(c, "Failed to rotate browser session")
+ return
+ }
+ response.Success(c, payload)
}
// LogoutRequest 登出请求
@@ -695,13 +765,27 @@ type LogoutResponse struct {
// Logout 用户登出
// POST /api/v1/auth/logout
func (h *AuthHandler) Logout(c *gin.Context) {
- var req LogoutRequest
- // 允许空请求体(向后兼容)
- _ = c.ShouldBindJSON(&req)
+ var refreshToken string
+ if isBrowserSessionRequest(c) {
+ if cookieToken, err := readBrowserRefreshToken(c); err == nil {
+ if err := validateBrowserSessionCSRF(c); err != nil {
+ clearBrowserSessionCookies(c)
+ response.ErrorFrom(c, err)
+ return
+ }
+ refreshToken = cookieToken
+ }
+ clearBrowserSessionCookies(c)
+ } else {
+ var req LogoutRequest
+ // 允许空请求体(向后兼容)
+ _ = c.ShouldBindJSON(&req)
+ refreshToken = req.RefreshToken
+ }
// 如果提供了Refresh Token,撤销它
- if req.RefreshToken != "" {
- if err := h.authService.RevokeRefreshToken(c.Request.Context(), req.RefreshToken); err != nil {
+ if refreshToken != "" {
+ if err := h.authService.RevokeRefreshToken(c.Request.Context(), refreshToken); err != nil {
slog.Debug("failed to revoke refresh token", "error", err)
// 不影响登出流程
}
diff --git a/backend/internal/handler/auth_linuxdo_oauth.go b/backend/internal/handler/auth_linuxdo_oauth.go
index 7df4abfd451..979e6ab985f 100644
--- a/backend/internal/handler/auth_linuxdo_oauth.go
+++ b/backend/internal/handler/auth_linuxdo_oauth.go
@@ -532,12 +532,7 @@ func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) {
clearOAuthPendingSessionCookie(c, secureCookie)
clearOAuthPendingBrowserCookie(c, secureCookie)
- c.JSON(http.StatusOK, gin.H{
- "access_token": tokenPair.AccessToken,
- "refresh_token": tokenPair.RefreshToken,
- "expires_in": tokenPair.ExpiresIn,
- "token_type": "Bearer",
- })
+ h.writeOAuthTokenPairResponse(c, tokenPair)
}
func (h *AuthHandler) getLinuxDoOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) {
@@ -905,6 +900,13 @@ func sanitizeFrontendRedirectPath(path string) string {
}
func isRequestHTTPS(c *gin.Context) bool {
+ // In release mode, OAuth state/session cookies must fail closed to Secure.
+ // If a reverse proxy is misconfigured and the browser reaches this hop over
+ // plain HTTP, the flow fails instead of emitting reusable OAuth cookies over
+ // an unencrypted connection.
+ if gin.Mode() == gin.ReleaseMode {
+ return true
+ }
if c.Request.TLS != nil {
return true
}
diff --git a/backend/internal/handler/auth_linuxdo_oauth_test.go b/backend/internal/handler/auth_linuxdo_oauth_test.go
index 8b01ab417f8..d597ef7c1b5 100644
--- a/backend/internal/handler/auth_linuxdo_oauth_test.go
+++ b/backend/internal/handler/auth_linuxdo_oauth_test.go
@@ -34,6 +34,24 @@ func TestSanitizeFrontendRedirectPath(t *testing.T) {
require.Equal(t, "", sanitizeFrontendRedirectPath(long))
}
+func TestOAuthCookiesFailClosedToSecureInReleaseMode(t *testing.T) {
+ previousMode := gin.Mode()
+ gin.SetMode(gin.ReleaseMode)
+ t.Cleanup(func() { gin.SetMode(previousMode) })
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodGet, "http://relay.example/api/v1/auth/linuxdo", nil)
+
+ require.True(t, isRequestHTTPS(c), "release-mode OAuth cookies must remain Secure even on a misconfigured HTTP hop")
+ setCookie(c, linuxDoOAuthStateCookieName, "state", linuxDoOAuthCookieMaxAgeSec, isRequestHTTPS(c))
+ cookies := recorder.Result().Cookies()
+ require.Len(t, cookies, 1)
+ require.True(t, cookies[0].Secure)
+ require.True(t, cookies[0].HttpOnly)
+ require.Equal(t, http.SameSiteLaxMode, cookies[0].SameSite)
+}
+
func TestBuildBearerAuthorization(t *testing.T) {
auth, err := buildBearerAuthorization("", "token123")
require.NoError(t, err)
diff --git a/backend/internal/handler/auth_oauth_pending_consume_integration_test.go b/backend/internal/handler/auth_oauth_pending_consume_integration_test.go
new file mode 100644
index 00000000000..67b85f5e2bf
--- /dev/null
+++ b/backend/internal/handler/auth_oauth_pending_consume_integration_test.go
@@ -0,0 +1,70 @@
+//go:build integration
+
+package handler
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ _ "github.com/Wei-Shaw/sub2api/ent/runtime"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ _ "github.com/lib/pq"
+ "github.com/stretchr/testify/require"
+ tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
+)
+
+func TestConsumePendingOAuthBrowserSessionTxRejectsStaleConcurrentConsumer(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
+ defer cancel()
+
+ container, err := tcpostgres.Run(
+ ctx,
+ "postgres:18.1-alpine3.23",
+ tcpostgres.WithDatabase("sub2api_test"),
+ tcpostgres.WithUsername("postgres"),
+ tcpostgres.WithPassword("postgres"),
+ tcpostgres.BasicWaitStrategies(),
+ )
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
+
+ dsn, err := container.ConnectionString(ctx, "sslmode=disable", "TimeZone=UTC")
+ require.NoError(t, err)
+ client, err := dbent.Open("postgres", dsn)
+ require.NoError(t, err)
+ t.Cleanup(func() { require.NoError(t, client.Close()) })
+ require.NoError(t, client.Schema.Create(ctx))
+
+ session, err := client.PendingAuthSession.Create().
+ SetSessionToken("stale-consume-session").
+ SetIntent("login").
+ SetProviderType("oidc").
+ SetProviderKey("issuer").
+ SetProviderSubject("subject").
+ SetBrowserSessionKey("browser-session-key").
+ SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
+ Save(ctx)
+ require.NoError(t, err)
+
+ firstTx, err := client.Tx(ctx)
+ require.NoError(t, err)
+ defer func() { _ = firstTx.Rollback() }()
+ secondTx, err := client.Tx(ctx)
+ require.NoError(t, err)
+ defer func() { _ = secondTx.Rollback() }()
+
+ firstRead, err := firstTx.Client().PendingAuthSession.Get(ctx, session.ID)
+ require.NoError(t, err)
+ secondStaleRead, err := secondTx.Client().PendingAuthSession.Get(ctx, session.ID)
+ require.NoError(t, err)
+ require.Nil(t, firstRead.ConsumedAt)
+ require.Nil(t, secondStaleRead.ConsumedAt)
+
+ require.NoError(t, consumeStoredPendingOAuthBrowserSessionTx(ctx, firstTx, firstRead, "browser-session-key"))
+ require.NoError(t, firstTx.Commit())
+
+ err = consumeStoredPendingOAuthBrowserSessionTx(ctx, secondTx, secondStaleRead, "browser-session-key")
+ require.ErrorIs(t, err, service.ErrPendingAuthSessionConsumed)
+}
diff --git a/backend/internal/handler/auth_oauth_pending_flow.go b/backend/internal/handler/auth_oauth_pending_flow.go
index 490afd0f5d7..6ab125ca311 100644
--- a/backend/internal/handler/auth_oauth_pending_flow.go
+++ b/backend/internal/handler/auth_oauth_pending_flow.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
+ "log/slog"
"net/http"
"net/url"
"strings"
@@ -14,6 +15,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/authidentity"
"github.com/Wei-Shaw/sub2api/ent/authidentitychannel"
"github.com/Wei-Shaw/sub2api/ent/identityadoptiondecision"
+ "github.com/Wei-Shaw/sub2api/ent/pendingauthsession"
"github.com/Wei-Shaw/sub2api/ent/predicate"
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
@@ -500,26 +502,6 @@ func (h *AuthHandler) SendPendingOAuthVerifyCode(c *gin.Context) {
return
}
- client := h.entClient()
- if client == nil {
- response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready"))
- return
- }
-
- email := strings.TrimSpace(strings.ToLower(req.Email))
- if existingUser, err := findUserByNormalizedEmail(c.Request.Context(), client, email); err == nil && existingUser != nil {
- session, err = h.transitionPendingOAuthAccountToChoiceState(c, client, session, existingUser, email)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- c.JSON(http.StatusOK, buildPendingOAuthSessionStatusPayload(session))
- return
- } else if err != nil && !errors.Is(err, service.ErrUserNotFound) {
- response.ErrorFrom(c, err)
- return
- }
-
result, err := h.authService.SendPendingOAuthVerifyCode(c.Request.Context(), req.Email)
if err != nil {
response.ErrorFrom(c, err)
@@ -1203,6 +1185,18 @@ func consumePendingOAuthBrowserSessionTx(
}
return err
}
+ return consumeStoredPendingOAuthBrowserSessionTx(ctx, tx, storedSession, session.BrowserSessionKey)
+}
+
+func consumeStoredPendingOAuthBrowserSessionTx(
+ ctx context.Context,
+ tx *dbent.Tx,
+ storedSession *dbent.PendingAuthSession,
+ browserSessionKey string,
+) error {
+ if tx == nil || storedSession == nil {
+ return service.ErrPendingAuthSessionNotFound
+ }
now := time.Now().UTC()
if storedSession.ConsumedAt != nil {
@@ -1212,16 +1206,44 @@ func consumePendingOAuthBrowserSessionTx(
return service.ErrPendingAuthSessionExpired
}
if strings.TrimSpace(storedSession.BrowserSessionKey) != "" &&
- strings.TrimSpace(storedSession.BrowserSessionKey) != strings.TrimSpace(session.BrowserSessionKey) {
+ strings.TrimSpace(storedSession.BrowserSessionKey) != strings.TrimSpace(browserSessionKey) {
return service.ErrPendingAuthBrowserMismatch
}
- if _, err := tx.Client().PendingAuthSession.UpdateOneID(storedSession.ID).
+ update := tx.Client().PendingAuthSession.UpdateOneID(storedSession.ID).
+ Where(
+ pendingauthsession.ConsumedAtIsNil(),
+ pendingauthsession.ExpiresAtGTE(now),
+ ).
SetConsumedAt(now).
SetCompletionCodeHash("").
- ClearCompletionCodeExpiresAt().
- Save(ctx); err != nil {
- return err
+ ClearCompletionCodeExpiresAt()
+ if expectedBrowserSessionKey := strings.TrimSpace(storedSession.BrowserSessionKey); expectedBrowserSessionKey != "" {
+ update = update.Where(pendingauthsession.BrowserSessionKeyEQ(expectedBrowserSessionKey))
+ }
+ if _, err := update.Save(ctx); err != nil {
+ if !dbent.IsNotFound(err) {
+ return err
+ }
+
+ current, currentErr := tx.Client().PendingAuthSession.Get(ctx, storedSession.ID)
+ if currentErr != nil {
+ if dbent.IsNotFound(currentErr) {
+ return service.ErrPendingAuthSessionNotFound
+ }
+ return currentErr
+ }
+ if current.ConsumedAt != nil {
+ return service.ErrPendingAuthSessionConsumed
+ }
+ if !current.ExpiresAt.IsZero() && now.After(current.ExpiresAt) {
+ return service.ErrPendingAuthSessionExpired
+ }
+ if strings.TrimSpace(current.BrowserSessionKey) != "" &&
+ strings.TrimSpace(current.BrowserSessionKey) != strings.TrimSpace(browserSessionKey) {
+ return service.ErrPendingAuthBrowserMismatch
+ }
+ return service.ErrPendingAuthSessionConsumed
}
return nil
@@ -1524,13 +1546,14 @@ func (h *AuthHandler) transitionPendingOAuthAccountToChoiceState(
return session, nil
}
-func writeOAuthTokenPairResponse(c *gin.Context, tokenPair *service.TokenPair) {
- c.JSON(http.StatusOK, gin.H{
- "access_token": tokenPair.AccessToken,
- "refresh_token": tokenPair.RefreshToken,
- "expires_in": tokenPair.ExpiresIn,
- "token_type": "Bearer",
- })
+func (h *AuthHandler) writeOAuthTokenPairResponse(c *gin.Context, tokenPair *service.TokenPair) {
+ payload, err := h.browserSessionTokenPairPayload(c, tokenPair)
+ if err != nil {
+ slog.Error("failed to establish browser oauth session", "error", err)
+ response.InternalError(c, "Failed to establish browser session")
+ return
+ }
+ c.JSON(http.StatusOK, payload)
}
func (h *AuthHandler) bindPendingOAuthLogin(c *gin.Context, provider string) {
@@ -1559,6 +1582,12 @@ func (h *AuthHandler) bindPendingOAuthLogin(c *gin.Context, provider string) {
response.ErrorFrom(c, infraerrors.Conflict("PENDING_AUTH_TARGET_USER_MISMATCH", "pending oauth session must be completed by the targeted user"))
return
}
+ providerType := strings.ToLower(strings.TrimSpace(session.ProviderType))
+ if (providerType == "github" || providerType == "google") &&
+ !strings.EqualFold(strings.TrimSpace(session.ResolvedEmail), strings.TrimSpace(user.Email)) {
+ response.ErrorFrom(c, infraerrors.Conflict("PENDING_AUTH_TARGET_EMAIL_MISMATCH", "pending oauth session email does not match the targeted user"))
+ return
+ }
if err := h.ensureBackendModeAllowsUser(c.Request.Context(), user); err != nil {
response.ErrorFrom(c, err)
return
@@ -1606,7 +1635,7 @@ func (h *AuthHandler) bindPendingOAuthLogin(c *gin.Context, provider string) {
}
clearCookies()
- writeOAuthTokenPairResponse(c, tokenPair)
+ h.writeOAuthTokenPairResponse(c, tokenPair)
}
func respondPendingOAuthBindingApplyError(c *gin.Context, err error) {
@@ -1645,6 +1674,10 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
}
email := strings.TrimSpace(strings.ToLower(req.Email))
+ if err := h.authService.VerifyOAuthEmailCode(c.Request.Context(), email, req.VerifyCode); err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
existingUser, err := findUserByNormalizedEmail(c.Request.Context(), client, email)
if err != nil {
switch {
@@ -1672,11 +1705,10 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
return
}
- tokenPair, user, err := h.authService.RegisterOAuthEmailAccount(
+ tokenPair, user, err := h.authService.RegisterVerifiedOAuthEmailAccount(
c.Request.Context(),
email,
req.Password,
- strings.TrimSpace(req.VerifyCode),
strings.TrimSpace(req.InvitationCode),
strings.TrimSpace(session.ProviderType),
)
@@ -1793,7 +1825,7 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
clearCookies()
- writeOAuthTokenPairResponse(c, tokenPair)
+ h.writeOAuthTokenPairResponse(c, tokenPair)
}
// ExchangePendingOAuthCompletion redeems a pending OAuth browser session into a frontend-safe payload.
@@ -1935,10 +1967,16 @@ func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) {
return
}
h.authService.RecordSuccessfulLogin(c.Request.Context(), loginUser.ID)
- payload["access_token"] = tokenPair.AccessToken
- payload["refresh_token"] = tokenPair.RefreshToken
- payload["expires_in"] = tokenPair.ExpiresIn
- payload["token_type"] = "Bearer"
+ tokenPayload, err := h.browserSessionTokenPairPayload(c, tokenPair)
+ if err != nil {
+ clearCookies()
+ slog.Error("failed to establish pending oauth browser session", "error", err)
+ response.InternalError(c, "Failed to establish browser session")
+ return
+ }
+ for key, value := range tokenPayload {
+ payload[key] = value
+ }
}
clearCookies()
diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go
index ffe9ff5f533..7c3ccb2228a 100644
--- a/backend/internal/handler/auth_oauth_pending_flow_test.go
+++ b/backend/internal/handler/auth_oauth_pending_flow_test.go
@@ -1123,6 +1123,52 @@ func TestCreateOIDCOAuthAccountExistingEmailReturnsChoicePendingSessionState(t *
require.Zero(t, identityCount)
}
+func TestCreateOIDCOAuthAccountExistingEmailRequiresVerifiedMailboxBeforeChoice(t *testing.T) {
+ handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790")
+ ctx := context.Background()
+
+ existingUser, err := client.User.Create().
+ SetEmail("owner@example.com").
+ SetUsername("owner-user").
+ SetPasswordHash("hash").
+ SetRole(service.RoleUser).
+ SetStatus(service.StatusActive).
+ Save(ctx)
+ require.NoError(t, err)
+
+ session, err := client.PendingAuthSession.Create().
+ SetSessionToken("existing-email-unverified-session-token").
+ SetIntent("login").
+ SetProviderType("oidc").
+ SetProviderKey("https://issuer.example").
+ SetProviderSubject("oidc-existing-unverified-123").
+ SetBrowserSessionKey("existing-email-unverified-browser-session-key").
+ SetRedirectTo("/dashboard").
+ SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
+ Save(ctx)
+ require.NoError(t, err)
+
+ body := bytes.NewBufferString(`{"email":"owner@example.com","password":"secret-123"}`)
+ recorder := httptest.NewRecorder()
+ ginCtx, _ := gin.CreateTestContext(recorder)
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", body)
+ req.Header.Set("Content-Type", "application/json")
+ req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
+ req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("existing-email-unverified-browser-session-key")})
+ ginCtx.Request = req
+
+ handler.CreateOIDCOAuthAccount(ginCtx)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code)
+ payload := decodeJSONBody(t, recorder)
+ require.Equal(t, "EMAIL_VERIFY_REQUIRED", payload["reason"])
+
+ storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
+ require.NoError(t, err)
+ require.Nil(t, storedSession.TargetUserID)
+ require.NotEqual(t, existingUser.ID, 0)
+}
+
func TestCreateOIDCOAuthAccountExistingEmailNormalizesLegacySpacingAndCase(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790")
ctx := context.Background()
@@ -1178,7 +1224,7 @@ func TestCreateOIDCOAuthAccountExistingEmailNormalizesLegacySpacingAndCase(t *te
require.Equal(t, "owner@example.com", storedSession.ResolvedEmail)
}
-func TestSendPendingOAuthVerifyCodeExistingEmailReturnsBindLoginState(t *testing.T) {
+func TestSendPendingOAuthVerifyCodeExistingEmailDoesNotRevealAccountState(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790")
ctx := context.Background()
@@ -1219,20 +1265,18 @@ func TestSendPendingOAuthVerifyCodeExistingEmailReturnsBindLoginState(t *testing
handler.SendPendingOAuthVerifyCode(ginCtx)
- require.Equal(t, http.StatusOK, recorder.Code)
-
- var payload map[string]any
- require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &payload))
- require.Equal(t, "pending_session", payload["auth_result"])
- require.Equal(t, oauthPendingChoiceStep, payload["step"])
- require.Equal(t, "owner@example.com", payload["email"])
+ require.Equal(t, http.StatusTooManyRequests, recorder.Code)
+ payload := decodeJSONBody(t, recorder)
+ require.Equal(t, "VERIFY_CODE_TOO_FREQUENT", payload["reason"])
+ require.NotContains(t, payload, "step")
+ require.NotContains(t, payload, "existing_account_bindable")
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
require.NoError(t, err)
require.Equal(t, oauthIntentLogin, storedSession.Intent)
- require.NotNil(t, storedSession.TargetUserID)
- require.Equal(t, existingUser.ID, *storedSession.TargetUserID)
- require.Equal(t, "owner@example.com", storedSession.ResolvedEmail)
+ require.Nil(t, storedSession.TargetUserID)
+ require.Empty(t, storedSession.ResolvedEmail)
+ _ = existingUser
}
func TestCreateOIDCOAuthAccountBlocksBackendModeBeforeCreatingUser(t *testing.T) {
@@ -2121,6 +2165,8 @@ type oauthPendingFlowTestHandlerOptions struct {
emailCache service.EmailCache
settingValues map[string]string
defaultSubAssigner service.DefaultSubscriptionAssigner
+ affiliateService *service.AffiliateService
+ affiliateFactory func(*dbent.Client, *service.SettingService) *service.AffiliateService
totpCache service.TotpCache
totpEncryptor service.SecretEncryptor
userRepoOptions oauthPendingFlowUserRepoOptions
@@ -2160,6 +2206,21 @@ CREATE TABLE IF NOT EXISTS user_avatars (
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)`)
require.NoError(t, err)
+ _, err = db.Exec(`
+CREATE TABLE IF NOT EXISTS user_affiliates (
+ user_id INTEGER PRIMARY KEY,
+ aff_code TEXT NOT NULL UNIQUE,
+ aff_code_custom BOOLEAN NOT NULL DEFAULT false,
+ aff_rebate_rate_percent REAL NULL,
+ inviter_id INTEGER NULL,
+ aff_count INTEGER NOT NULL DEFAULT 0,
+ aff_quota REAL NOT NULL DEFAULT 0,
+ aff_frozen_quota REAL NOT NULL DEFAULT 0,
+ aff_history_quota REAL NOT NULL DEFAULT 0,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+)`)
+ require.NoError(t, err)
drv := entsql.OpenDB(dialect.SQLite, db)
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
@@ -2177,14 +2238,19 @@ CREATE TABLE IF NOT EXISTS user_avatars (
},
}
settingValues := map[string]string{
- service.SettingKeyRegistrationEnabled: "true",
- service.SettingKeyInvitationCodeEnabled: boolSettingValue(options.invitationEnabled),
- service.SettingKeyEmailVerifyEnabled: boolSettingValue(options.emailVerifyEnabled),
+ service.SettingKeyRegistrationEnabled: "true",
+ service.SettingKeyInvitationCodeEnabled: boolSettingValue(options.invitationEnabled),
+ service.SettingKeyEmailVerifyEnabled: boolSettingValue(options.emailVerifyEnabled),
+ service.SettingKeyRegistrationEmailSuffixWhitelist: "[]",
}
for key, value := range options.settingValues {
settingValues[key] = value
}
settingSvc := service.NewSettingService(&oauthPendingFlowSettingRepoStub{values: settingValues}, cfg)
+ affiliateService := options.affiliateService
+ if affiliateService == nil && options.affiliateFactory != nil {
+ affiliateService = options.affiliateFactory(client, settingSvc)
+ }
userRepo := &oauthPendingFlowUserRepo{
client: client,
options: options.userRepoOptions,
@@ -2210,7 +2276,7 @@ CREATE TABLE IF NOT EXISTS user_avatars (
nil,
nil,
options.defaultSubAssigner,
- nil,
+ affiliateService,
)
userSvc := service.NewUserService(userRepo, nil, nil, nil)
var totpSvc *service.TotpService
@@ -2365,6 +2431,10 @@ func (s *oauthPendingFlowRefreshTokenCacheStub) GetRefreshToken(context.Context,
return nil, service.ErrRefreshTokenNotFound
}
+func (s *oauthPendingFlowRefreshTokenCacheStub) ConsumeRefreshToken(context.Context, string) (*service.RefreshTokenData, error) {
+ return nil, service.ErrRefreshTokenNotFound
+}
+
func (s *oauthPendingFlowRefreshTokenCacheStub) DeleteRefreshToken(context.Context, string) error {
return nil
}
@@ -2474,6 +2544,14 @@ func (r *oauthPendingFlowRedeemCodeRepo) Delete(context.Context, int64) error {
panic("unexpected Delete call")
}
+func (r *oauthPendingFlowRedeemCodeRepo) DeleteIfUnused(context.Context, int64) (bool, error) {
+ panic("unexpected DeleteIfUnused call")
+}
+
+func (r *oauthPendingFlowRedeemCodeRepo) ExpireIfUnused(context.Context, int64) (bool, error) {
+ panic("unexpected ExpireIfUnused call")
+}
+
func (r *oauthPendingFlowRedeemCodeRepo) Use(ctx context.Context, id, userID int64) error {
affected, err := r.client.RedeemCode.Update().
Where(redeemcode.IDEQ(id), redeemcode.StatusEQ(service.StatusUnused)).
@@ -2798,6 +2876,14 @@ func (r *oauthPendingFlowUserRepo) UpdateConcurrency(context.Context, int64, int
panic("unexpected UpdateConcurrency call")
}
+func (r *oauthPendingFlowUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) {
+ panic("unexpected BatchSetConcurrency call")
+}
+
+func (r *oauthPendingFlowUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
+ panic("unexpected BatchAddConcurrency call")
+}
+
func (r *oauthPendingFlowUserRepo) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) {
return map[int64]*time.Time{}, nil
}
@@ -2965,6 +3051,15 @@ func (s *oauthPendingFlowTotpCacheStub) DeleteLoginSession(_ context.Context, te
return nil
}
+func (s *oauthPendingFlowTotpCacheStub) ConsumeLoginSession(_ context.Context, tempToken string) (*service.TotpLoginSession, error) {
+ if s == nil || s.loginSessions == nil {
+ return nil, nil
+ }
+ session := s.loginSessions[tempToken]
+ delete(s.loginSessions, tempToken)
+ return session, nil
+}
+
func (s *oauthPendingFlowTotpCacheStub) IncrementVerifyAttempts(_ context.Context, userID int64) (int, error) {
if s.verifyAttempts == nil {
s.verifyAttempts = map[int64]int{}
@@ -2994,3 +3089,11 @@ func (oauthPendingFlowTotpEncryptorStub) Encrypt(plaintext string) (string, erro
func (oauthPendingFlowTotpEncryptorStub) Decrypt(ciphertext string) (string, error) {
return ciphertext, nil
}
+
+func (oauthPendingFlowTotpEncryptorStub) EncryptForDomain(_ string, plaintext string) (string, error) {
+ return plaintext, nil
+}
+
+func (oauthPendingFlowTotpEncryptorStub) DecryptForDomain(_ string, ciphertext string) (string, error) {
+ return ciphertext, nil
+}
diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go
index 4264002db37..1ec68811ba5 100644
--- a/backend/internal/handler/auth_oidc_oauth.go
+++ b/backend/internal/handler/auth_oidc_oauth.go
@@ -679,12 +679,7 @@ func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) {
clearOAuthPendingSessionCookie(c, secureCookie)
clearOAuthPendingBrowserCookie(c, secureCookie)
- c.JSON(http.StatusOK, gin.H{
- "access_token": tokenPair.AccessToken,
- "refresh_token": tokenPair.RefreshToken,
- "expires_in": tokenPair.ExpiresIn,
- "token_type": "Bearer",
- })
+ h.writeOAuthTokenPairResponse(c, tokenPair)
}
func (h *AuthHandler) getOIDCOAuthConfig(ctx context.Context) (config.OIDCConnectConfig, error) {
diff --git a/backend/internal/handler/auth_wechat_oauth.go b/backend/internal/handler/auth_wechat_oauth.go
index 34e70ed09da..0feaf1b06fb 100644
--- a/backend/internal/handler/auth_wechat_oauth.go
+++ b/backend/internal/handler/auth_wechat_oauth.go
@@ -87,10 +87,11 @@ type wechatOAuthUserInfoResponse struct {
}
type wechatPaymentOAuthContext struct {
- PaymentType string `json:"payment_type"`
- Amount string `json:"amount,omitempty"`
- OrderType string `json:"order_type,omitempty"`
- PlanID int64 `json:"plan_id,omitempty"`
+ PaymentType string `json:"payment_type"`
+ Amount string `json:"amount,omitempty"`
+ OrderType string `json:"order_type,omitempty"`
+ PlanID int64 `json:"plan_id,omitempty"`
+ SubjectToken string `json:"subject_token"`
}
// WeChatOAuthStart starts the WeChat OAuth login flow and stores the short-lived
@@ -333,6 +334,11 @@ func (h *AuthHandler) WeChatPaymentOAuthStart(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
+ subjectToken := strings.TrimSpace(c.Query("subject_token"))
+ if _, err := h.wechatPaymentResumeService().ParseWeChatPaymentOAuthSubjectToken(subjectToken); err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
paymentType := normalizeWeChatPaymentType(c.Query("payment_type"))
if paymentType == "" {
@@ -351,10 +357,11 @@ func (h *AuthHandler) WeChatPaymentOAuthStart(c *gin.Context) {
redirectTo = wechatPaymentOAuthDefaultTo
}
rawContext, err := encodeWeChatPaymentOAuthContext(wechatPaymentOAuthContext{
- PaymentType: paymentType,
- Amount: strings.TrimSpace(c.Query("amount")),
- OrderType: strings.TrimSpace(c.Query("order_type")),
- PlanID: parseWeChatPaymentPlanID(c.Query("plan_id")),
+ PaymentType: paymentType,
+ Amount: strings.TrimSpace(c.Query("amount")),
+ OrderType: strings.TrimSpace(c.Query("order_type")),
+ PlanID: parseWeChatPaymentPlanID(c.Query("plan_id")),
+ SubjectToken: subjectToken,
})
if err != nil {
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_CONTEXT_ENCODE_FAILED", "failed to encode oauth context").WithCause(err))
@@ -376,6 +383,7 @@ func (h *AuthHandler) WeChatPaymentOAuthStart(c *gin.Context) {
return
}
+ c.Header("Referrer-Policy", "no-referrer")
c.Redirect(http.StatusFound, authURL)
}
@@ -422,6 +430,11 @@ func (h *AuthHandler) WeChatPaymentOAuthCallback(c *gin.Context) {
redirectOAuthError(c, frontendCallback, "invalid_context", "invalid oauth context", "")
return
}
+ subjectClaims, err := h.wechatPaymentResumeService().ParseWeChatPaymentOAuthSubjectToken(paymentContext.SubjectToken)
+ if err != nil {
+ redirectOAuthError(c, frontendCallback, "invalid_context", "invalid payment user binding", "")
+ return
+ }
if paymentContext.PaymentType == "" {
paymentContext.PaymentType = payment.TypeWxpay
}
@@ -451,6 +464,7 @@ func (h *AuthHandler) WeChatPaymentOAuthCallback(c *gin.Context) {
}
resumeToken, err := h.wechatPaymentResumeService().CreateWeChatPaymentResumeToken(service.WeChatPaymentResumeClaims{
+ UserID: subjectClaims.UserID,
OpenID: openid,
PaymentType: paymentContext.PaymentType,
Amount: paymentContext.Amount,
@@ -472,7 +486,7 @@ func (h *AuthHandler) WeChatPaymentOAuthCallback(c *gin.Context) {
func (h *AuthHandler) wechatPaymentResumeService() *service.PaymentResumeService {
var legacyKey []byte
- key, err := payment.ProvideEncryptionKey(h.cfg)
+ key, err := payment.ProvideLegacyEncryptionKey(h.cfg)
if err == nil {
legacyKey = []byte(key)
}
@@ -575,12 +589,7 @@ func (h *AuthHandler) CompleteWeChatOAuthRegistration(c *gin.Context) {
clearOAuthPendingSessionCookie(c, secureCookie)
clearOAuthPendingBrowserCookie(c, secureCookie)
- c.JSON(http.StatusOK, gin.H{
- "access_token": tokenPair.AccessToken,
- "refresh_token": tokenPair.RefreshToken,
- "expires_in": tokenPair.ExpiresIn,
- "token_type": "Bearer",
- })
+ h.writeOAuthTokenPairResponse(c, tokenPair)
}
func (h *AuthHandler) createWeChatPendingSession(
diff --git a/backend/internal/handler/auth_wechat_oauth_test.go b/backend/internal/handler/auth_wechat_oauth_test.go
index b3c7786d9a9..8d40c9852e9 100644
--- a/backend/internal/handler/auth_wechat_oauth_test.go
+++ b/backend/internal/handler/auth_wechat_oauth_test.go
@@ -358,6 +358,39 @@ func TestWeChatOAuthCallbackRejectsDisabledExistingIdentityUser(t *testing.T) {
require.Zero(t, count)
}
+func TestWeChatPaymentOAuthStartRequiresSignedUserBinding(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ const signingKey = "0123456789abcdef0123456789abcdef"
+ t.Setenv("PAYMENT_RESUME_SIGNING_KEY", signingKey)
+ handler, client := newWeChatOAuthTestHandlerWithSettings(t, false, wechatOAuthTestSettings("mp", "wx-mp-app", "wx-mp-secret", "/auth/wechat/callback"))
+ defer client.Close()
+
+ unsignedRecorder := httptest.NewRecorder()
+ unsignedContext, _ := gin.CreateTestContext(unsignedRecorder)
+ unsignedContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/wechat/payment/start?payment_type=wxpay", nil)
+ unsignedContext.Request.Host = "api.example.com"
+ handler.WeChatPaymentOAuthStart(unsignedContext)
+ require.Equal(t, http.StatusBadRequest, unsignedRecorder.Code)
+
+ subjectToken, err := service.NewPaymentResumeService([]byte(signingKey)).CreateWeChatPaymentOAuthSubjectToken(17)
+ require.NoError(t, err)
+ signedRecorder := httptest.NewRecorder()
+ signedContext, _ := gin.CreateTestContext(signedRecorder)
+ signedContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/wechat/payment/start?payment_type=wxpay&subject_token="+url.QueryEscape(subjectToken), nil)
+ signedContext.Request.Host = "api.example.com"
+ handler.WeChatPaymentOAuthStart(signedContext)
+
+ require.Equal(t, http.StatusFound, signedRecorder.Code)
+ require.Equal(t, "no-referrer", signedRecorder.Header().Get("Referrer-Policy"))
+ contextCookie := findCookie(signedRecorder.Result().Cookies(), wechatPaymentOAuthContextName)
+ require.NotNil(t, contextCookie)
+ rawContext, err := decodeCookieValue(contextCookie.Value)
+ require.NoError(t, err)
+ paymentContext, err := decodeWeChatPaymentOAuthContext(rawContext)
+ require.NoError(t, err)
+ require.Equal(t, subjectToken, paymentContext.SubjectToken)
+}
+
func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T) {
originalAccessTokenURL := wechatOAuthAccessTokenURL
t.Cleanup(func() {
@@ -379,6 +412,16 @@ func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T)
defer client.Close()
handler.cfg.Totp.EncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
handler.cfg.Totp.EncryptionKeyConfigured = true
+ subjectToken, err := handler.wechatPaymentResumeService().CreateWeChatPaymentOAuthSubjectToken(17)
+ require.NoError(t, err)
+ paymentContext, err := encodeWeChatPaymentOAuthContext(wechatPaymentOAuthContext{
+ PaymentType: payment.TypeWxpay,
+ Amount: "12.5",
+ OrderType: payment.OrderTypeSubscription,
+ PlanID: 7,
+ SubjectToken: subjectToken,
+ })
+ require.NoError(t, err)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
@@ -386,7 +429,7 @@ func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T)
req.Host = "api.example.com"
req.AddCookie(encodedCookie(wechatPaymentOAuthStateName, "state-123"))
req.AddCookie(encodedCookie(wechatPaymentOAuthRedirect, "/purchase?from=wechat"))
- req.AddCookie(encodedCookie(wechatPaymentOAuthContextName, `{"payment_type":"wxpay","amount":"12.5","order_type":"subscription","plan_id":7}`))
+ req.AddCookie(encodedCookie(wechatPaymentOAuthContextName, paymentContext))
req.AddCookie(encodedCookie(wechatPaymentOAuthScope, "snsapi_base"))
c.Request = req
@@ -414,6 +457,8 @@ func TestWeChatPaymentOAuthCallbackRedirectsWithOpaqueResumeToken(t *testing.T)
require.Equal(t, payment.OrderTypeSubscription, claims.OrderType)
require.EqualValues(t, 7, claims.PlanID)
require.Equal(t, "/purchase?from=wechat", claims.RedirectTo)
+ require.EqualValues(t, 17, claims.UserID)
+ require.NotEmpty(t, claims.JTI)
}
func TestWeChatPaymentOAuthCallbackUsesExplicitPaymentResumeSigningKeyWhenMixedKeysConfigured(t *testing.T) {
@@ -441,6 +486,16 @@ func TestWeChatPaymentOAuthCallbackUsesExplicitPaymentResumeSigningKeyWhenMixedK
t.Setenv("PAYMENT_RESUME_SIGNING_KEY", explicitSigningKey)
handler.cfg.Totp.EncryptionKey = legacyKeyHex
handler.cfg.Totp.EncryptionKeyConfigured = true
+ subjectToken, err := handler.wechatPaymentResumeService().CreateWeChatPaymentOAuthSubjectToken(19)
+ require.NoError(t, err)
+ paymentContext, err := encodeWeChatPaymentOAuthContext(wechatPaymentOAuthContext{
+ PaymentType: payment.TypeWxpay,
+ Amount: "18.8",
+ OrderType: payment.OrderTypeSubscription,
+ PlanID: 9,
+ SubjectToken: subjectToken,
+ })
+ require.NoError(t, err)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
@@ -448,7 +503,7 @@ func TestWeChatPaymentOAuthCallbackUsesExplicitPaymentResumeSigningKeyWhenMixedK
req.Host = "api.example.com"
req.AddCookie(encodedCookie(wechatPaymentOAuthStateName, "state-mixed"))
req.AddCookie(encodedCookie(wechatPaymentOAuthRedirect, "/purchase?from=wechat"))
- req.AddCookie(encodedCookie(wechatPaymentOAuthContextName, `{"payment_type":"wxpay","amount":"18.8","order_type":"subscription","plan_id":9}`))
+ req.AddCookie(encodedCookie(wechatPaymentOAuthContextName, paymentContext))
req.AddCookie(encodedCookie(wechatPaymentOAuthScope, "snsapi_base"))
c.Request = req
@@ -472,6 +527,8 @@ func TestWeChatPaymentOAuthCallbackUsesExplicitPaymentResumeSigningKeyWhenMixedK
require.Equal(t, payment.OrderTypeSubscription, claims.OrderType)
require.EqualValues(t, 9, claims.PlanID)
require.Equal(t, "/purchase?from=wechat", claims.RedirectTo)
+ require.EqualValues(t, 19, claims.UserID)
+ require.NotEmpty(t, claims.JTI)
_, err = service.NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef")).ParseWeChatPaymentResumeToken(token)
require.Error(t, err)
@@ -1465,6 +1522,10 @@ func (s *wechatOAuthRefreshTokenCacheStub) GetRefreshToken(context.Context, stri
return nil, service.ErrRefreshTokenNotFound
}
+func (s *wechatOAuthRefreshTokenCacheStub) ConsumeRefreshToken(context.Context, string) (*service.RefreshTokenData, error) {
+ return nil, service.ErrRefreshTokenNotFound
+}
+
func (s *wechatOAuthRefreshTokenCacheStub) DeleteRefreshToken(context.Context, string) error {
return nil
}
diff --git a/backend/internal/handler/content_moderation_helper.go b/backend/internal/handler/content_moderation_helper.go
new file mode 100644
index 00000000000..d9b18131bbb
--- /dev/null
+++ b/backend/internal/handler/content_moderation_helper.go
@@ -0,0 +1,163 @@
+package handler
+
+import (
+ "context"
+ "net/http"
+ "strings"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
+ middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+)
+
+func (h *GatewayHandler) checkContentModeration(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *service.ContentModerationDecision {
+ if h == nil || h.contentModerationService == nil {
+ return nil
+ }
+ return runContentModeration(c, reqLog, h.contentModerationService, apiKey, subject, protocol, model, body)
+}
+
+func contentModerationStatus(decision *service.ContentModerationDecision) int {
+ if decision == nil || decision.StatusCode < 400 || decision.StatusCode > 599 {
+ return http.StatusForbidden
+ }
+ return decision.StatusCode
+}
+
+func contentModerationErrorCode(decision *service.ContentModerationDecision) string {
+ if decision != nil && (decision.StatusCode >= http.StatusInternalServerError || decision.Action == service.ContentModerationActionError) {
+ return "api_error"
+ }
+ return "content_policy_violation"
+}
+
+func (h *OpenAIGatewayHandler) checkContentModeration(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *service.ContentModerationDecision {
+ if h == nil || h.contentModerationService == nil {
+ return nil
+ }
+ return runContentModeration(c, reqLog, h.contentModerationService, apiKey, subject, protocol, model, body)
+}
+
+func (h *OpenAIGatewayHandler) checkImageContentModeration(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, model string, body []byte, safetyIdentifier string) *service.ContentModerationDecision {
+ input := buildContentModerationInput(c, apiKey, subject, service.ContentModerationProtocolOpenAIImages, model, body)
+ input.Stage = service.ContentModerationStageInput
+ input.FailClosed = true
+ input.PolicyRule = "moderation_flagged_input"
+ input.SafetyIdentifier = strings.TrimSpace(safetyIdentifier)
+ if h == nil || h.contentModerationService == nil {
+ return &service.ContentModerationDecision{
+ Allowed: false,
+ Blocked: true,
+ Message: "Image safety review is temporarily unavailable",
+ StatusCode: http.StatusServiceUnavailable,
+ Action: service.ContentModerationActionError,
+ PolicyRule: "moderation_service_unavailable",
+ }
+ }
+ return runContentModerationInput(c, reqLog, h.contentModerationService, input, len(body))
+}
+
+func runContentModeration(c *gin.Context, reqLog *zap.Logger, svc *service.ContentModerationService, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *service.ContentModerationDecision {
+ if svc == nil || c == nil || c.Request == nil {
+ return nil
+ }
+ input := buildContentModerationInput(c, apiKey, subject, protocol, model, body)
+ return runContentModerationInput(c, reqLog, svc, input, len(body))
+}
+
+func runContentModerationInput(c *gin.Context, reqLog *zap.Logger, svc *service.ContentModerationService, input service.ContentModerationCheckInput, bodyBytes int) *service.ContentModerationDecision {
+ if svc == nil || c == nil || c.Request == nil {
+ return nil
+ }
+ if reqLog != nil {
+ reqLog.Info("content_moderation.gateway_check_start",
+ zap.String("request_id", input.RequestID),
+ zap.Int64("user_id", input.UserID),
+ zap.Int64("api_key_id", input.APIKeyID),
+ zap.String("api_key_name", input.APIKeyName),
+ zap.Int64p("group_id", input.GroupID),
+ zap.String("group_name", input.GroupName),
+ zap.String("endpoint", input.Endpoint),
+ zap.String("provider", input.Provider),
+ zap.String("protocol", input.Protocol),
+ zap.String("model", input.Model),
+ zap.String("stage", input.Stage),
+ zap.Bool("fail_closed", input.FailClosed),
+ zap.String("policy_rule", input.PolicyRule),
+ zap.Int("body_bytes", bodyBytes),
+ )
+ }
+ decision, err := svc.Check(c.Request.Context(), input)
+ if err != nil {
+ if reqLog != nil {
+ reqLog.Warn("content_moderation.check_failed", zap.Error(err))
+ }
+ return nil
+ }
+ if reqLog != nil && decision != nil {
+ reqLog.Info("content_moderation.gateway_check_done",
+ zap.String("request_id", input.RequestID),
+ zap.Bool("allowed", decision.Allowed),
+ zap.Bool("blocked", decision.Blocked),
+ zap.Bool("flagged", decision.Flagged),
+ zap.String("action", decision.Action),
+ zap.String("policy_rule", decision.PolicyRule),
+ zap.Int("status_code", decision.StatusCode),
+ zap.String("highest_category", decision.HighestCategory),
+ zap.Float64("highest_score", decision.HighestScore),
+ )
+ }
+ return decision
+}
+
+func buildContentModerationInput(c *gin.Context, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) service.ContentModerationCheckInput {
+ input := service.ContentModerationCheckInput{
+ RequestID: contentModerationRequestID(c.Request.Context()),
+ UserID: subject.UserID,
+ Endpoint: GetInboundEndpoint(c),
+ Provider: contentModerationProvider(apiKey),
+ Model: strings.TrimSpace(model),
+ Protocol: protocol,
+ Body: body,
+ }
+ if forcedPlatform, ok := middleware2.GetForcePlatformFromContext(c); ok {
+ input.Provider = strings.TrimSpace(forcedPlatform)
+ }
+ if apiKey != nil {
+ input.APIKeyID = apiKey.ID
+ input.APIKeyName = apiKey.Name
+ if apiKey.User != nil {
+ input.UserEmail = apiKey.User.Email
+ }
+ if apiKey.GroupID != nil {
+ groupID := *apiKey.GroupID
+ input.GroupID = &groupID
+ }
+ if apiKey.Group != nil {
+ input.GroupName = apiKey.Group.Name
+ }
+ }
+ if input.Endpoint == "" && c.Request != nil && c.Request.URL != nil {
+ input.Endpoint = c.Request.URL.Path
+ }
+ return input
+}
+
+func contentModerationProvider(apiKey *service.APIKey) string {
+ if apiKey == nil || apiKey.Group == nil {
+ return ""
+ }
+ return strings.TrimSpace(apiKey.Group.Platform)
+}
+
+func contentModerationRequestID(ctx context.Context) string {
+ if ctx == nil {
+ return ""
+ }
+ if requestID, ok := ctx.Value(ctxkey.RequestID).(string); ok {
+ return strings.TrimSpace(requestID)
+ }
+ return ""
+}
diff --git a/backend/internal/handler/cursor_gateway_handler.go b/backend/internal/handler/cursor_gateway_handler.go
new file mode 100644
index 00000000000..f65a744d995
--- /dev/null
+++ b/backend/internal/handler/cursor_gateway_handler.go
@@ -0,0 +1,998 @@
+package handler
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/domain"
+ pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/ip"
+ middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/tidwall/gjson"
+ "go.uber.org/zap"
+ "golang.org/x/sync/semaphore"
+ "golang.org/x/sync/singleflight"
+)
+
+const (
+ cursorDefaultModel = "cursor-auto"
+ cursorSidecarAPIKeyHeader = "X-Cursor-Sidecar-Key"
+ cursorSidecarAccountRefHeader = "X-Cursor-Account-Ref"
+ cursorSidecarAccountIDHeader = "X-Cursor-Account-ID"
+ cursorSidecarOriginalPathHeader = "X-Cursor-Original-Path"
+ cursorSidecarClientRequestID = "X-Request-ID"
+ cursorMaxSidecarResponseBytes = int64(16 * 1024 * 1024)
+ cursorDefaultRequestTimeout = 90 * time.Second
+ cursorSidecarLimiterAcquireWeight = int64(1)
+ cursorModelsCacheStatusHeader = "X-Cursor-Models-Cache"
+ cursorModelsCacheAgeHeader = "X-Cursor-Models-Cache-Age"
+ cursorDefaultModelsCacheTTL = 5 * time.Minute
+ cursorDefaultModelsStaleTTL = 24 * time.Hour
+)
+
+var cursorSidecarLimiters sync.Map // map[int]*semaphore.Weighted
+var cursorModelsListCache = newCursorModelsListCache()
+
+type cursorSidecarListResponse struct {
+ StatusCode int
+ ContentType string
+ Header http.Header
+ Body []byte
+}
+
+type cursorModelsListCacheEntry struct {
+ cursorSidecarListResponse
+ StoredAt time.Time
+ FreshUntil time.Time
+ StaleUntil time.Time
+}
+
+type cursorModelsCache struct {
+ mu sync.Mutex
+ entries map[string]cursorModelsListCacheEntry
+ sf singleflight.Group
+}
+
+func newCursorModelsListCache() *cursorModelsCache {
+ return &cursorModelsCache{entries: make(map[string]cursorModelsListCacheEntry)}
+}
+
+func (c *cursorModelsCache) getFresh(key string, now time.Time) (cursorModelsListCacheEntry, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ entry, ok := c.entries[key]
+ if !ok || !entry.FreshUntil.After(now) {
+ return cursorModelsListCacheEntry{}, false
+ }
+ return cloneCursorModelsCacheEntry(entry), true
+}
+
+func (c *cursorModelsCache) getStale(key string, now time.Time) (cursorModelsListCacheEntry, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ entry, ok := c.entries[key]
+ if !ok || !entry.StaleUntil.After(now) {
+ return cursorModelsListCacheEntry{}, false
+ }
+ return cloneCursorModelsCacheEntry(entry), true
+}
+
+func (c *cursorModelsCache) set(key string, result cursorSidecarListResponse, freshTTL, staleTTL time.Duration) {
+ if freshTTL <= 0 {
+ freshTTL = cursorDefaultModelsCacheTTL
+ }
+ if staleTTL <= 0 {
+ staleTTL = cursorDefaultModelsStaleTTL
+ }
+ now := time.Now()
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.entries[key] = cursorModelsListCacheEntry{
+ cursorSidecarListResponse: cloneCursorSidecarListResponse(result),
+ StoredAt: now,
+ FreshUntil: now.Add(freshTTL),
+ StaleUntil: now.Add(staleTTL),
+ }
+}
+
+func (c *cursorModelsCache) resetForTest() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.entries = make(map[string]cursorModelsListCacheEntry)
+ c.sf = singleflight.Group{}
+}
+
+func cloneCursorModelsCacheEntry(entry cursorModelsListCacheEntry) cursorModelsListCacheEntry {
+ entry.cursorSidecarListResponse = cloneCursorSidecarListResponse(entry.cursorSidecarListResponse)
+ return entry
+}
+
+func cloneCursorSidecarListResponse(result cursorSidecarListResponse) cursorSidecarListResponse {
+ return cursorSidecarListResponse{
+ StatusCode: result.StatusCode,
+ ContentType: result.ContentType,
+ Header: result.Header.Clone(),
+ Body: append([]byte(nil), result.Body...),
+ }
+}
+
+func (h *GatewayHandler) CursorModels(c *gin.Context) {
+ h.forwardCursorSidecarList(c, "/v1/models")
+}
+
+func (h *GatewayHandler) CursorMessages(c *gin.Context) {
+ h.handleCursorSidecarPost(c, "/v1/messages", true)
+}
+
+func (h *GatewayHandler) CursorCountTokens(c *gin.Context) {
+ h.handleCursorSidecarPost(c, "/v1/messages/count_tokens", false)
+}
+
+func (h *GatewayHandler) CursorResponses(c *gin.Context) {
+ h.handleCursorSidecarPost(c, "/v1/responses", true)
+}
+
+func (h *GatewayHandler) CursorChatCompletions(c *gin.Context) {
+ h.handleCursorSidecarPost(c, "/v1/chat/completions", true)
+}
+
+type cursorSidecarRequest struct {
+ Method string
+ Path string
+ Model string
+ UpstreamBody []byte
+ RequestBody []byte
+ Stream bool
+ RecordUsage bool
+ RejectUnmeteredStream bool
+ Parsed *service.ParsedRequest
+ Mapping service.ChannelMappingResult
+}
+
+func (h *GatewayHandler) handleCursorSidecarPost(c *gin.Context, sidecarPath string, recordUsage bool) {
+ body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
+ if err != nil {
+ if maxErr, ok := extractMaxBytesError(err); ok {
+ h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
+ return
+ }
+ h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
+ return
+ }
+ if len(body) == 0 {
+ h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
+ return
+ }
+ if !gjson.ValidBytes(body) {
+ h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
+ return
+ }
+
+ model := strings.TrimSpace(gjson.GetBytes(body, "model").String())
+ if model == "" {
+ model = cursorDefaultModel
+ }
+ stream := gjson.GetBytes(body, "stream").Bool()
+ parsed, err := service.ParseGatewayRequest(body, domain.PlatformAnthropic)
+ if err != nil {
+ parsed = &service.ParsedRequest{Body: body, Model: model, Stream: stream}
+ }
+ parsed.Model = model
+ parsed.Stream = stream
+ parsed.SessionContext = &service.SessionContext{
+ ClientIP: ip.GetClientIP(c),
+ UserAgent: c.GetHeader("User-Agent"),
+ }
+
+ upstreamBody := body
+ mapping := service.ChannelMappingResult{MappedModel: model}
+ if apiKey, ok := middleware2.GetAPIKeyFromContext(c); ok && h != nil && h.gatewayService != nil {
+ mapping, _ = h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, model)
+ if mapping.Mapped && strings.TrimSpace(mapping.MappedModel) != "" && mapping.MappedModel != model {
+ upstreamBody = h.gatewayService.ReplaceModelInBody(body, mapping.MappedModel)
+ }
+ }
+
+ h.handleCursorSidecar(c, cursorSidecarRequest{
+ Method: http.MethodPost,
+ Path: sidecarPath,
+ Model: model,
+ UpstreamBody: upstreamBody,
+ RequestBody: body,
+ Stream: stream,
+ RecordUsage: recordUsage,
+ Parsed: parsed,
+ Mapping: mapping,
+ })
+}
+
+func (h *GatewayHandler) handleCursorSidecar(c *gin.Context, req cursorSidecarRequest) {
+ requestStart := time.Now()
+ streamStarted := false
+
+ apiKey, ok := middleware2.GetAPIKeyFromContext(c)
+ if !ok {
+ h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
+ return
+ }
+ if apiKey.Group == nil || apiKey.Group.Platform != service.PlatformCursor {
+ h.errorResponse(c, http.StatusForbidden, "authentication_error", "Cursor endpoint requires an API key assigned to a cursor group")
+ return
+ }
+ if h == nil || h.gatewayService == nil {
+ h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Cursor bridge is not configured")
+ return
+ }
+
+ subject, hasSubject := middleware2.GetAuthSubjectFromContext(c)
+ if !hasSubject && req.RecordUsage {
+ h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
+ return
+ }
+ reqLog := requestLogger(
+ c,
+ "handler.gateway.cursor",
+ zap.Int64("api_key_id", apiKey.ID),
+ zap.Any("group_id", apiKey.GroupID),
+ zap.String("model", req.Model),
+ zap.Bool("stream", req.Stream),
+ )
+
+ if req.RecordUsage {
+ setOpsRequestContext(c, req.Model, req.Stream, req.RequestBody)
+ setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(req.Stream, false)))
+ } else {
+ setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(false, false)))
+ }
+ if req.RecordUsage && h.blockSidecarContentModeration(c, reqLog, apiKey, subject, req.Path, req.Model, req.RequestBody) {
+ return
+ }
+ if h.errorPassthroughService != nil {
+ service.BindErrorPassthroughService(c, h.errorPassthroughService)
+ }
+
+ subscription, _ := middleware2.GetSubscriptionFromContext(c)
+ if req.RecordUsage {
+ billingRequestCtx, err := service.PrepareUsageBillingRequestContext(c.Request.Context())
+ if err != nil {
+ h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "billing_service_error", "Billing service temporarily unavailable", streamStarted)
+ return
+ }
+ c.Request = c.Request.WithContext(billingRequestCtx)
+ if subscription != nil && subscription.IsWalletMode() && req.Stream {
+ h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "billing_service_error", "Wallet billing for Cursor streaming is unavailable", streamStarted)
+ return
+ }
+ req.RejectUnmeteredStream = subscription != nil && subscription.IsWalletMode()
+ userRelease, ok := h.acquireCursorUserSlot(c, subject, req.Stream, &streamStarted, reqLog)
+ if !ok {
+ return
+ }
+ if userRelease != nil {
+ defer userRelease()
+ }
+
+ if h.billingCacheService != nil {
+ if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription); err != nil {
+ reqLog.Info("gateway.cursor.billing_eligibility_check_failed", zap.Error(err))
+ status, code, message, retryAfter := billingErrorDetails(err)
+ if retryAfter > 0 {
+ c.Header("Retry-After", strconv.Itoa(retryAfter))
+ }
+ h.handleStreamingAwareError(c, status, code, message, streamStarted)
+ return
+ }
+ }
+ }
+
+ sessionHash := ""
+ if req.Parsed != nil {
+ req.Parsed.GroupID = apiKey.GroupID
+ if req.Parsed.SessionContext == nil {
+ req.Parsed.SessionContext = &service.SessionContext{}
+ }
+ req.Parsed.SessionContext.APIKeyID = apiKey.ID
+ sessionHash = h.gatewayService.GenerateSessionHash(req.Parsed)
+ }
+ if sessionHash != "" {
+ sessionHash = "cursor:" + sessionHash
+ }
+
+ fs := NewFailoverState(h.maxAccountSwitches, false)
+ for {
+ selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionHash, req.Model, fs.FailedAccountIDs, "", subject.UserID)
+ if err != nil {
+ if len(fs.FailedAccountIDs) == 0 {
+ reqLog.Warn("gateway.cursor.select_account_no_available", zap.Error(err))
+ h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available Cursor accounts", streamStarted)
+ return
+ }
+ action := fs.HandleSelectionExhausted(c.Request.Context())
+ switch action {
+ case FailoverContinue:
+ continue
+ case FailoverCanceled:
+ return
+ default:
+ if fs.LastFailoverErr != nil {
+ h.handleFailoverExhausted(c, fs.LastFailoverErr, service.PlatformCursor, streamStarted)
+ } else {
+ h.handleFailoverExhaustedSimple(c, http.StatusBadGateway, streamStarted)
+ }
+ return
+ }
+ }
+
+ account := selection.Account
+ if account == nil || account.Platform != service.PlatformCursor {
+ if selection.Acquired && selection.ReleaseFunc != nil {
+ selection.ReleaseFunc()
+ }
+ h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Selected account is not a Cursor account", streamStarted)
+ return
+ }
+ setOpsSelectedAccount(c, account.ID, account.Platform)
+
+ accountRelease, acquired := h.acquireCursorAccountSlot(c, selection, req.Stream, &streamStarted, reqLog)
+ if !acquired {
+ return
+ }
+
+ requestCtx := c.Request.Context()
+ if fs.SwitchCount > 0 {
+ requestCtx = service.WithAccountSwitchCount(requestCtx, fs.SwitchCount, h.metadataBridgeEnabled())
+ }
+ var usageBillingIdentity *service.GatewayUsageBillingIdentity
+ if req.RecordUsage && subscription != nil && subscription.IsWalletMode() {
+ usageBillingIdentity, err = h.gatewayService.PrepareGatewayWalletUsageBillingAdmission(
+ requestCtx, apiKey, apiKey.User, account, subscription, req.Parsed,
+ )
+ if err != nil {
+ if accountRelease != nil {
+ accountRelease()
+ }
+ status := http.StatusServiceUnavailable
+ code := "billing_service_error"
+ message := "Billing service temporarily unavailable"
+ switch {
+ case errors.Is(err, service.ErrWalletInsufficient):
+ status, code, message, _ = billingErrorDetails(err)
+ case errors.Is(err, service.ErrUsageBillingRequestConflict),
+ errors.Is(err, service.ErrUsageBillingAdmissionFinalized):
+ status = http.StatusConflict
+ code = "billing_request_conflict"
+ message = "Billing request identity conflict"
+ }
+ h.handleStreamingAwareError(c, status, code, message, streamStarted)
+ return
+ }
+ }
+ markBillingAttemptFailed := func() {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingAttemptFailed(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.cursor.billing_attempt_fail_mark_failed", zap.Error(lifecycleErr))
+ }
+ }
+ markBillingOrphaned := func() {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingOrphaned(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.cursor.billing_attempt_orphan_mark_failed", zap.Error(lifecycleErr))
+ }
+ }
+ abandonBilling := func() {
+ if lifecycleErr := h.gatewayService.AbandonGatewayUsageBilling(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.cursor.billing_attempt_abandon_failed", zap.Error(lifecycleErr))
+ }
+ }
+
+ writerSizeBeforeForward := c.Writer.Size()
+ result, err := h.forwardCursorSidecar(c, account, req)
+ if accountRelease != nil {
+ accountRelease()
+ }
+
+ deliveryFailed := err != nil && result != nil && errors.Is(err, errSidecarClientDelivery)
+ if err != nil && !deliveryFailed {
+ var failoverErr *service.UpstreamFailoverError
+ if errors.As(err, &failoverErr) {
+ if c.Writer.Size() != writerSizeBeforeForward {
+ markBillingOrphaned()
+ h.handleFailoverExhausted(c, failoverErr, service.PlatformCursor, true)
+ return
+ }
+ markBillingAttemptFailed()
+ action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, failoverErr)
+ switch action {
+ case FailoverContinue:
+ continue
+ case FailoverExhausted:
+ abandonBilling()
+ h.handleFailoverExhausted(c, fs.LastFailoverErr, service.PlatformCursor, streamStarted)
+ return
+ case FailoverCanceled:
+ abandonBilling()
+ return
+ }
+ }
+ markBillingOrphaned()
+ h.ensureForwardErrorResponse(c, streamStarted)
+ reqLog.Error("gateway.cursor.forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
+ return
+ }
+ if result != nil {
+ result.UsageBillingIdentity = usageBillingIdentity
+ }
+
+ if req.RecordUsage {
+ userAgent := c.GetHeader("User-Agent")
+ clientIP := ip.GetClientIP(c)
+ requestPayloadHash := service.HashUsageRequestPayload(req.RequestBody)
+ inboundEndpoint := GetInboundEndpoint(c)
+ upstreamEndpoint := req.Path
+ mappingFields := req.Mapping.ToUsageFields(req.Model, result.UpstreamModel)
+
+ h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
+ if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
+ Result: result,
+ ParsedRequest: req.Parsed,
+ APIKey: apiKey,
+ User: apiKey.User,
+ Account: account,
+ Subscription: subscription,
+ InboundEndpoint: inboundEndpoint,
+ UpstreamEndpoint: upstreamEndpoint,
+ UserAgent: userAgent,
+ IPAddress: clientIP,
+ RequestPayloadHash: requestPayloadHash,
+ ForceCacheBilling: fs.ForceCacheBilling,
+ APIKeyService: h.apiKeyService,
+ ChannelUsageFields: mappingFields,
+ }); err != nil {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingOrphaned(ctx, result.UsageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.cursor.billing_result_orphan_mark_failed", zap.Error(lifecycleErr))
+ }
+ reqLog.Error("gateway.cursor.record_usage_failed", zap.Int64("account_id", account.ID), zap.Error(err))
+ }
+ })
+ }
+ if deliveryFailed {
+ reqLog.Warn("gateway.cursor.client_delivery_failed_after_metered_upstream_completion", zap.Error(err))
+ }
+
+ service.SetOpsLatencyMs(c, service.OpsUpstreamLatencyMsKey, time.Since(requestStart).Milliseconds())
+ return
+ }
+}
+
+func (h *GatewayHandler) acquireCursorUserSlot(c *gin.Context, subject middleware2.AuthSubject, stream bool, streamStarted *bool, reqLog *zap.Logger) (func(), bool) {
+ if h.concurrencyHelper == nil || h.concurrencyHelper.concurrencyService == nil {
+ return nil, true
+ }
+ maxWait := service.CalculateMaxWait(subject.Concurrency)
+ canWait, err := h.concurrencyHelper.IncrementWaitCount(c.Request.Context(), subject.UserID, maxWait)
+ waitCounted := false
+ if err != nil {
+ reqLog.Warn("gateway.cursor.user_wait_counter_increment_failed", zap.Error(err))
+ } else if !canWait {
+ h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", *streamStarted)
+ return nil, false
+ }
+ if err == nil && canWait {
+ waitCounted = true
+ }
+ defer func() {
+ if waitCounted {
+ h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID)
+ }
+ }()
+
+ userRelease, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, subject.UserID, subject.Concurrency, stream, streamStarted)
+ if err != nil {
+ reqLog.Warn("gateway.cursor.user_slot_acquire_failed", zap.Error(err))
+ h.handleConcurrencyError(c, err, "user", *streamStarted)
+ return nil, false
+ }
+ if waitCounted {
+ h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID)
+ waitCounted = false
+ }
+ return wrapReleaseOnDone(c.Request.Context(), userRelease), true
+}
+
+func (h *GatewayHandler) acquireCursorAccountSlot(c *gin.Context, selection *service.AccountSelectionResult, stream bool, streamStarted *bool, reqLog *zap.Logger) (func(), bool) {
+ if selection == nil || selection.Account == nil {
+ h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available Cursor accounts", *streamStarted)
+ return nil, false
+ }
+ if selection.Acquired {
+ return wrapReleaseOnDone(c.Request.Context(), selection.ReleaseFunc), true
+ }
+ if h.concurrencyHelper == nil || h.concurrencyHelper.concurrencyService == nil {
+ return nil, true
+ }
+ if selection.WaitPlan == nil {
+ h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available Cursor accounts", *streamStarted)
+ return nil, false
+ }
+
+ account := selection.Account
+ accountWaitCounted := false
+ canWait, err := h.concurrencyHelper.IncrementAccountWaitCount(c.Request.Context(), account.ID, selection.WaitPlan.MaxWaiting)
+ if err != nil {
+ reqLog.Warn("gateway.cursor.account_wait_counter_increment_failed", zap.Int64("account_id", account.ID), zap.Error(err))
+ } else if !canWait {
+ h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", *streamStarted)
+ return nil, false
+ }
+ if err == nil && canWait {
+ accountWaitCounted = true
+ }
+ releaseWait := func() {
+ if accountWaitCounted {
+ h.concurrencyHelper.DecrementAccountWaitCount(c.Request.Context(), account.ID)
+ accountWaitCounted = false
+ }
+ }
+
+ accountRelease, err := h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout(
+ c,
+ account.ID,
+ selection.WaitPlan.MaxConcurrency,
+ selection.WaitPlan.Timeout,
+ stream,
+ streamStarted,
+ )
+ if err != nil {
+ releaseWait()
+ reqLog.Warn("gateway.cursor.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
+ h.handleConcurrencyError(c, err, "account", *streamStarted)
+ return nil, false
+ }
+ releaseWait()
+ return wrapReleaseOnDone(c.Request.Context(), accountRelease), true
+}
+
+func (h *GatewayHandler) forwardCursorSidecar(c *gin.Context, account *service.Account, req cursorSidecarRequest) (*service.ForwardResult, error) {
+ cfg := h.cursorConfig()
+ baseURL, err := validateConfiguredCursorSidecarURL(cfg.SidecarURL)
+ if err != nil {
+ h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Cursor sidecar is not configured")
+ return nil, err
+ }
+
+ releaseSidecarSlot, err := acquireCursorSidecarSlot(c.Request.Context(), cfg.MaxConcurrency)
+ if err != nil {
+ h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Cursor sidecar is busy, please retry later", false)
+ return nil, err
+ }
+ defer releaseSidecarSlot()
+
+ sidecarURL, err := joinCursorSidecarURL(baseURL, req.Path)
+ if err != nil {
+ h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Cursor sidecar URL is invalid")
+ return nil, err
+ }
+
+ start := time.Now()
+ ctx, cancel := context.WithTimeout(sidecarUpstreamContext(c.Request.Context(), req.RecordUsage), cfg.requestTimeout())
+ defer cancel()
+
+ var bodyReader io.Reader = http.NoBody
+ if req.Method != http.MethodGet {
+ bodyReader = bytes.NewReader(req.UpstreamBody)
+ }
+ sidecarReq, err := http.NewRequestWithContext(ctx, req.Method, sidecarURL, bodyReader)
+ if err != nil {
+ return nil, err
+ }
+ h.applyCursorSidecarHeaders(c, sidecarReq, account)
+
+ httpClient := &http.Client{Timeout: cfg.requestTimeout()}
+ resp, err := httpClient.Do(sidecarReq)
+ if err != nil {
+ service.SetOpsUpstreamError(c, http.StatusServiceUnavailable, err.Error(), "")
+ return nil, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if cursorShouldFailoverStatus(resp.StatusCode) {
+ body, _ := readCursorSidecarBody(resp.Body)
+ service.SetOpsUpstreamError(c, resp.StatusCode, service.ExtractUpstreamErrorMessage(body), "")
+ return nil, &service.UpstreamFailoverError{
+ StatusCode: resp.StatusCode,
+ ResponseBody: body,
+ ResponseHeaders: resp.Header.Clone(),
+ }
+ }
+
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+ body, _ := readCursorSidecarBody(resp.Body)
+ upstreamMsg := service.ExtractUpstreamErrorMessage(body)
+ service.SetOpsUpstreamError(c, resp.StatusCode, upstreamMsg, "")
+ h.handleStreamingAwareError(c, resp.StatusCode, "upstream_error", "Upstream request failed", false)
+ return nil, fmt.Errorf("cursor sidecar upstream error: %d", resp.StatusCode)
+ }
+
+ isStreamingResponse := req.Stream || isCursorStreamingContentType(resp.Header.Get("Content-Type"))
+ if isStreamingResponse && req.RejectUnmeteredStream {
+ return nil, errors.New("wallet billing for Cursor streaming is unavailable")
+ }
+ if isStreamingResponse {
+ body, usage, err := readMeteredSidecarStream(
+ resp.Body,
+ cursorMaxSidecarResponseBytes,
+ extractCursorUsage,
+ req.RecordUsage,
+ )
+ if err != nil {
+ return nil, err
+ }
+ result := &service.ForwardResult{
+ RequestID: resp.Header.Get("X-Request-ID"),
+ Usage: usage,
+ Model: req.Model,
+ UpstreamModel: resolvedCursorUpstreamModel(req),
+ Stream: true,
+ Duration: time.Since(start),
+ }
+ copyCursorSidecarHeaders(c.Writer.Header(), resp.Header)
+ c.Status(resp.StatusCode)
+ if err := writeBufferedSidecarStream(c.Writer, body); err != nil {
+ return result, err
+ }
+ if flusher, ok := c.Writer.(http.Flusher); ok {
+ flusher.Flush()
+ }
+ return result, nil
+ }
+
+ copyCursorSidecarHeaders(c.Writer.Header(), resp.Header)
+ c.Status(resp.StatusCode)
+ body, err := readCursorSidecarBody(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := c.Writer.Write(body); err != nil {
+ return nil, err
+ }
+
+ return &service.ForwardResult{
+ RequestID: resp.Header.Get("X-Request-ID"),
+ Usage: extractCursorUsage(body),
+ Model: req.Model,
+ UpstreamModel: resolvedCursorUpstreamModel(req),
+ Stream: false,
+ Duration: time.Since(start),
+ }, nil
+}
+
+func (h *GatewayHandler) forwardCursorSidecarList(c *gin.Context, sidecarPath string) {
+ cfg := h.cursorConfig()
+ baseURL, err := validateConfiguredCursorSidecarURL(cfg.SidecarURL)
+ if err != nil {
+ h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Cursor sidecar is not configured")
+ return
+ }
+
+ if sidecarPath == "/v1/models" {
+ h.forwardCursorSidecarModelsList(c, cfg, baseURL, sidecarPath)
+ return
+ }
+
+ result, err := h.fetchCursorSidecarList(c, cfg, baseURL, sidecarPath)
+ if err != nil {
+ h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Cursor sidecar request failed")
+ return
+ }
+ h.writeCursorSidecarListResponse(c, result, "")
+}
+
+func (h *GatewayHandler) forwardCursorSidecarModelsList(c *gin.Context, cfg cursorRuntimeConfig, baseURL, sidecarPath string) {
+ cacheKey := cursorModelsListCacheKey(baseURL, sidecarPath, cfg.SidecarAPIKey)
+ now := time.Now()
+ if entry, ok := cursorModelsListCache.getFresh(cacheKey, now); ok {
+ c.Header(cursorModelsCacheAgeHeader, strconv.Itoa(int(now.Sub(entry.StoredAt).Seconds())))
+ h.writeCursorSidecarListResponse(c, entry.cursorSidecarListResponse, "hit")
+ return
+ }
+
+ value, err, _ := cursorModelsListCache.sf.Do(cacheKey, func() (any, error) {
+ result, fetchErr := h.fetchCursorSidecarList(c, cfg, baseURL, sidecarPath)
+ if fetchErr != nil {
+ return nil, fetchErr
+ }
+ return result, nil
+ })
+ if err != nil {
+ if entry, ok := cursorModelsListCache.getStale(cacheKey, time.Now()); ok {
+ c.Header(cursorModelsCacheAgeHeader, strconv.Itoa(int(time.Since(entry.StoredAt).Seconds())))
+ h.writeCursorSidecarListResponse(c, entry.cursorSidecarListResponse, "stale")
+ return
+ }
+ service.SetOpsUpstreamError(c, http.StatusServiceUnavailable, err.Error(), "")
+ h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Cursor sidecar request failed")
+ return
+ }
+
+ result, ok := value.(cursorSidecarListResponse)
+ if !ok {
+ h.errorResponse(c, http.StatusBadGateway, "api_error", "Cursor sidecar response is invalid")
+ return
+ }
+ if result.StatusCode >= http.StatusInternalServerError {
+ if entry, ok := cursorModelsListCache.getStale(cacheKey, time.Now()); ok {
+ c.Header(cursorModelsCacheAgeHeader, strconv.Itoa(int(time.Since(entry.StoredAt).Seconds())))
+ h.writeCursorSidecarListResponse(c, entry.cursorSidecarListResponse, "stale")
+ return
+ }
+ }
+ if result.StatusCode >= http.StatusOK && result.StatusCode < http.StatusMultipleChoices && gjson.ValidBytes(result.Body) {
+ cursorModelsListCache.set(cacheKey, result, cursorModelsFreshTTL(), cursorModelsStaleTTL())
+ c.Header(cursorModelsCacheAgeHeader, "0")
+ h.writeCursorSidecarListResponse(c, result, "miss")
+ return
+ }
+ h.writeCursorSidecarListResponse(c, result, "miss")
+}
+
+func (h *GatewayHandler) fetchCursorSidecarList(c *gin.Context, cfg cursorRuntimeConfig, baseURL, sidecarPath string) (cursorSidecarListResponse, error) {
+ sidecarURL, err := joinCursorSidecarURL(baseURL, sidecarPath)
+ if err != nil {
+ return cursorSidecarListResponse{}, err
+ }
+ reqCtx, cancel := context.WithTimeout(c.Request.Context(), cfg.requestTimeout())
+ defer cancel()
+ req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, sidecarURL, http.NoBody)
+ if err != nil {
+ return cursorSidecarListResponse{}, err
+ }
+ h.applyCursorSidecarHeaders(c, req, nil)
+
+ resp, err := (&http.Client{Timeout: cfg.requestTimeout()}).Do(req)
+ if err != nil {
+ return cursorSidecarListResponse{}, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ body, err := readCursorSidecarBody(resp.Body)
+ if err != nil {
+ return cursorSidecarListResponse{}, err
+ }
+ return cursorSidecarListResponse{
+ StatusCode: resp.StatusCode,
+ ContentType: resp.Header.Get("Content-Type"),
+ Header: resp.Header.Clone(),
+ Body: append([]byte(nil), body...),
+ }, nil
+}
+
+func (h *GatewayHandler) writeCursorSidecarListResponse(c *gin.Context, result cursorSidecarListResponse, cacheStatus string) {
+ copyCursorSidecarHeaders(c.Writer.Header(), result.Header)
+ if cacheStatus != "" {
+ c.Header(cursorModelsCacheStatusHeader, cacheStatus)
+ }
+ contentType := result.ContentType
+ if strings.TrimSpace(contentType) == "" {
+ contentType = "application/json"
+ }
+ c.Data(result.StatusCode, contentType, result.Body)
+}
+
+func (h *GatewayHandler) applyCursorSidecarHeaders(c *gin.Context, req *http.Request, account *service.Account) {
+ req.Header.Set("Content-Type", "application/json")
+ cfg := h.cursorConfig()
+ if cfg.SidecarAPIKey != "" {
+ req.Header.Set(cursorSidecarAPIKeyHeader, cfg.SidecarAPIKey)
+ req.Header.Set("Authorization", "Bearer "+cfg.SidecarAPIKey)
+ req.Header.Set("x-api-key", cfg.SidecarAPIKey)
+ }
+ if account != nil {
+ req.Header.Set(cursorSidecarAccountIDHeader, strconv.FormatInt(account.ID, 10))
+ req.Header.Set(cursorSidecarAccountRefHeader, service.CursorSidecarAccountRef(account))
+ }
+ req.Header.Set(cursorSidecarOriginalPathHeader, c.Request.URL.Path)
+ if rid := c.GetHeader(cursorSidecarClientRequestID); strings.TrimSpace(rid) != "" {
+ req.Header.Set(cursorSidecarClientRequestID, rid)
+ }
+ if ua := c.GetHeader("User-Agent"); strings.TrimSpace(ua) != "" {
+ req.Header.Set("User-Agent", ua)
+ }
+}
+
+func resolvedCursorUpstreamModel(req cursorSidecarRequest) string {
+ if req.Mapping.Mapped && strings.TrimSpace(req.Mapping.MappedModel) != "" && req.Mapping.MappedModel != req.Model {
+ return req.Mapping.MappedModel
+ }
+ return ""
+}
+
+type cursorRuntimeConfig struct {
+ SidecarURL string
+ SidecarAPIKey string
+ MaxConcurrency int
+ RequestTimeoutSeconds int
+}
+
+func (h *GatewayHandler) cursorConfig() cursorRuntimeConfig {
+ cfg := cursorRuntimeConfig{
+ MaxConcurrency: 1,
+ RequestTimeoutSeconds: int(cursorDefaultRequestTimeout / time.Second),
+ }
+ if h != nil && h.cfg != nil {
+ cfg.SidecarURL = h.cfg.Cursor.SidecarURL
+ cfg.SidecarAPIKey = h.cfg.Cursor.SidecarAPIKey
+ cfg.MaxConcurrency = h.cfg.Cursor.MaxConcurrency
+ cfg.RequestTimeoutSeconds = h.cfg.Cursor.RequestTimeoutSeconds
+ }
+ if sidecarURL := strings.TrimSpace(os.Getenv("CURSOR_SIDECAR_URL")); sidecarURL != "" {
+ cfg.SidecarURL = sidecarURL
+ }
+ if sidecarAPIKey := strings.TrimSpace(os.Getenv("CURSOR_SIDECAR_API_KEY")); sidecarAPIKey != "" {
+ cfg.SidecarAPIKey = sidecarAPIKey
+ }
+ if maxConcurrency := cursorEnvPositiveInt("CURSOR_MAX_CONCURRENCY"); maxConcurrency > 0 {
+ cfg.MaxConcurrency = maxConcurrency
+ }
+ if timeoutSeconds := cursorEnvPositiveInt("CURSOR_REQUEST_TIMEOUT_SECONDS"); timeoutSeconds > 0 {
+ cfg.RequestTimeoutSeconds = timeoutSeconds
+ }
+ return cfg
+}
+
+func cursorEnvPositiveInt(key string) int {
+ raw := strings.TrimSpace(os.Getenv(key))
+ if raw == "" {
+ return 0
+ }
+ value, err := strconv.Atoi(raw)
+ if err != nil || value <= 0 {
+ return 0
+ }
+ return value
+}
+
+func cursorModelsFreshTTL() time.Duration {
+ if seconds := cursorEnvPositiveInt("CURSOR_MODELS_CACHE_TTL_SECONDS"); seconds > 0 {
+ return time.Duration(seconds) * time.Second
+ }
+ return cursorDefaultModelsCacheTTL
+}
+
+func cursorModelsStaleTTL() time.Duration {
+ if seconds := cursorEnvPositiveInt("CURSOR_MODELS_STALE_TTL_SECONDS"); seconds > 0 {
+ return time.Duration(seconds) * time.Second
+ }
+ return cursorDefaultModelsStaleTTL
+}
+
+func cursorModelsListCacheKey(baseURL, sidecarPath, sidecarAPIKey string) string {
+ sum := sha256.Sum256([]byte(sidecarAPIKey))
+ return fmt.Sprintf("%s|%s|%x", baseURL, sidecarPath, sum[:8])
+}
+
+func (c cursorRuntimeConfig) requestTimeout() time.Duration {
+ if c.RequestTimeoutSeconds <= 0 {
+ return cursorDefaultRequestTimeout
+ }
+ return time.Duration(c.RequestTimeoutSeconds) * time.Second
+}
+
+func validateConfiguredCursorSidecarURL(raw string) (string, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", errors.New("cursor sidecar url is empty")
+ }
+ if err := config.ValidateAbsoluteHTTPURL(raw); err != nil {
+ return "", err
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return "", err
+ }
+ if u.User != nil || u.RawQuery != "" || u.ForceQuery {
+ return "", errors.New("cursor sidecar url must not include userinfo or query")
+ }
+ return strings.TrimRight(raw, "/"), nil
+}
+
+func joinCursorSidecarURL(baseURL, path string) (string, error) {
+ u, err := url.Parse(baseURL)
+ if err != nil {
+ return "", err
+ }
+ if strings.TrimSpace(path) == "" || !strings.HasPrefix(path, "/") {
+ return "", errors.New("cursor sidecar path must be absolute")
+ }
+ u.Path = strings.TrimRight(u.Path, "/") + path
+ u.RawQuery = ""
+ u.Fragment = ""
+ return u.String(), nil
+}
+
+func acquireCursorSidecarSlot(ctx context.Context, maxConcurrency int) (func(), error) {
+ if maxConcurrency <= 0 {
+ maxConcurrency = 1
+ }
+ raw, _ := cursorSidecarLimiters.LoadOrStore(maxConcurrency, semaphore.NewWeighted(int64(maxConcurrency)))
+ limiter, ok := raw.(*semaphore.Weighted)
+ if !ok {
+ return nil, errors.New("cursor sidecar limiter has invalid type")
+ }
+ if err := limiter.Acquire(ctx, cursorSidecarLimiterAcquireWeight); err != nil {
+ return nil, err
+ }
+ return func() {
+ limiter.Release(cursorSidecarLimiterAcquireWeight)
+ }, nil
+}
+
+func readCursorSidecarBody(body io.Reader) ([]byte, error) {
+ limited := io.LimitReader(body, cursorMaxSidecarResponseBytes+1)
+ data, err := io.ReadAll(limited)
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(data)) > cursorMaxSidecarResponseBytes {
+ return nil, fmt.Errorf("cursor sidecar response exceeds %d bytes", cursorMaxSidecarResponseBytes)
+ }
+ return data, nil
+}
+
+func extractCursorUsage(body []byte) service.ClaudeUsage {
+ return extractCommonSidecarUsage(body)
+}
+
+func copyCursorSidecarHeaders(dst, src http.Header) {
+ for key, values := range src {
+ if !shouldCopyCursorSidecarHeader(key) {
+ continue
+ }
+ for _, value := range values {
+ dst.Add(key, value)
+ }
+ }
+}
+
+func shouldCopyCursorSidecarHeader(key string) bool {
+ switch strings.ToLower(strings.TrimSpace(key)) {
+ case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
+ "te", "trailer", "transfer-encoding", "upgrade", "content-length",
+ "authorization", "set-cookie", strings.ToLower(cursorSidecarAPIKeyHeader):
+ return false
+ default:
+ return true
+ }
+}
+
+func isCursorStreamingContentType(contentType string) bool {
+ contentType = strings.ToLower(contentType)
+ return strings.Contains(contentType, "text/event-stream") || strings.Contains(contentType, "application/x-ndjson")
+}
+
+func cursorShouldFailoverStatus(statusCode int) bool {
+ switch statusCode {
+ case http.StatusUnauthorized,
+ http.StatusForbidden,
+ http.StatusTooManyRequests,
+ http.StatusInternalServerError,
+ http.StatusBadGateway,
+ http.StatusServiceUnavailable,
+ http.StatusGatewayTimeout,
+ 529:
+ return true
+ default:
+ return false
+ }
+}
diff --git a/backend/internal/handler/cursor_gateway_handler_models_cache_test.go b/backend/internal/handler/cursor_gateway_handler_models_cache_test.go
new file mode 100644
index 00000000000..58286d5767e
--- /dev/null
+++ b/backend/internal/handler/cursor_gateway_handler_models_cache_test.go
@@ -0,0 +1,68 @@
+package handler
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCursorModelsUsesFreshCacheAndStaleOnSidecarFailure(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ cursorModelsListCache.resetForTest()
+ t.Cleanup(func() { cursorModelsListCache.resetForTest() })
+ t.Setenv("CURSOR_MODELS_CACHE_TTL_SECONDS", "1")
+ t.Setenv("CURSOR_MODELS_STALE_TTL_SECONDS", "60")
+ t.Setenv("CURSOR_SIDECAR_URL", "")
+ t.Setenv("CURSOR_SIDECAR_API_KEY", "")
+
+ calls := 0
+ sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls++
+ require.Equal(t, "/v1/models", r.URL.Path)
+ w.Header().Set("Content-Type", "application/json")
+ if calls == 1 {
+ _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"cursor-live","object":"model"}]}`))
+ return
+ }
+ http.Error(w, `{"error":"sidecar down"}`, http.StatusServiceUnavailable)
+ }))
+ defer sidecar.Close()
+
+ h := &GatewayHandler{cfg: &config.Config{Cursor: config.CursorConfig{
+ SidecarURL: sidecar.URL,
+ SidecarAPIKey: "sidecar-key",
+ RequestTimeoutSeconds: 3,
+ }}}
+
+ first := performCursorModelsRequest(h)
+ require.Equal(t, http.StatusOK, first.Code)
+ require.Equal(t, "miss", first.Header().Get(cursorModelsCacheStatusHeader))
+ require.Contains(t, first.Body.String(), "cursor-live")
+ require.Equal(t, 1, calls)
+
+ second := performCursorModelsRequest(h)
+ require.Equal(t, http.StatusOK, second.Code)
+ require.Equal(t, "hit", second.Header().Get(cursorModelsCacheStatusHeader))
+ require.Contains(t, second.Body.String(), "cursor-live")
+ require.Equal(t, 1, calls)
+
+ time.Sleep(1100 * time.Millisecond)
+ third := performCursorModelsRequest(h)
+ require.Equal(t, http.StatusOK, third.Code)
+ require.Equal(t, "stale", third.Header().Get(cursorModelsCacheStatusHeader))
+ require.Contains(t, third.Body.String(), "cursor-live")
+ require.Equal(t, 2, calls)
+}
+
+func performCursorModelsRequest(h *GatewayHandler) *httptest.ResponseRecorder {
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodGet, "/cursor/v1/models", nil)
+ h.CursorModels(c)
+ return rec
+}
diff --git a/backend/internal/handler/cursor_gateway_handler_test.go b/backend/internal/handler/cursor_gateway_handler_test.go
new file mode 100644
index 00000000000..49add5c1ab1
--- /dev/null
+++ b/backend/internal/handler/cursor_gateway_handler_test.go
@@ -0,0 +1,190 @@
+package handler
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateConfiguredCursorSidecarURLRejectsSecretsAndQueries(t *testing.T) {
+ _, err := validateConfiguredCursorSidecarURL("http://user:pass@127.0.0.1:8788")
+ require.Error(t, err)
+
+ _, err = validateConfiguredCursorSidecarURL("http://127.0.0.1:8788?token=secret")
+ require.Error(t, err)
+
+ got, err := validateConfiguredCursorSidecarURL("http://127.0.0.1:8788/")
+ require.NoError(t, err)
+ require.Equal(t, "http://127.0.0.1:8788", got)
+}
+
+func TestJoinCursorSidecarURLPreservesBasePath(t *testing.T) {
+ got, err := joinCursorSidecarURL("http://sidecar.local/internal", "/v1/messages")
+ require.NoError(t, err)
+ require.Equal(t, "http://sidecar.local/internal/v1/messages", got)
+}
+
+func TestExtractCursorUsageSupportsCommonSchemas(t *testing.T) {
+ anthropicUsage := extractCursorUsage([]byte(`{"usage":{"input_tokens":10,"output_tokens":20,"cache_read_input_tokens":3}}`))
+ require.Equal(t, 10, anthropicUsage.InputTokens)
+ require.Equal(t, 20, anthropicUsage.OutputTokens)
+ require.Equal(t, 3, anthropicUsage.CacheReadInputTokens)
+
+ openAIUsage := extractCursorUsage([]byte(`{"usage":{"prompt_tokens":11,"completion_tokens":21}}`))
+ require.Equal(t, 11, openAIUsage.InputTokens)
+ require.Equal(t, 21, openAIUsage.OutputTokens)
+
+ geminiUsage := extractCursorUsage([]byte(`{"usage":{"promptTokenCount":12,"candidatesTokenCount":22}}`))
+ require.Equal(t, 12, geminiUsage.InputTokens)
+ require.Equal(t, 22, geminiUsage.OutputTokens)
+}
+
+func TestCopyCursorSidecarHeadersDropsSensitiveHopByHopHeaders(t *testing.T) {
+ src := http.Header{}
+ src.Set("Content-Type", "application/json")
+ src.Set("Authorization", "Bearer secret")
+ src.Set("X-Cursor-Sidecar-Key", "cursor-secret")
+ src.Set("Connection", "keep-alive")
+ src.Set("Retry-After", "5")
+
+ dst := http.Header{}
+ copyCursorSidecarHeaders(dst, src)
+
+ require.Equal(t, "application/json", dst.Get("Content-Type"))
+ require.Equal(t, "5", dst.Get("Retry-After"))
+ require.Empty(t, dst.Get("Authorization"))
+ require.Empty(t, dst.Get("X-Cursor-Sidecar-Key"))
+ require.Empty(t, dst.Get("Connection"))
+}
+
+func TestApplyCursorSidecarHeadersAddsInternalAndBearerAuth(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodPost, "/cursor/v1/responses", nil)
+
+ h := &GatewayHandler{cfg: &config.Config{}}
+ h.cfg.Cursor.SidecarAPIKey = "sidecar-test-key"
+
+ req := httptest.NewRequest(http.MethodPost, "http://sidecar.local/v1/responses", nil)
+ h.applyCursorSidecarHeaders(c, req, nil)
+
+ require.Equal(t, "sidecar-test-key", req.Header.Get("X-Cursor-Sidecar-Key"))
+ require.Equal(t, "Bearer sidecar-test-key", req.Header.Get("Authorization"))
+ require.Equal(t, "sidecar-test-key", req.Header.Get("x-api-key"))
+}
+
+func TestForwardCursorSidecarWalletStreamFailsClosedWithoutDeliveringBody(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: unmetered\n\n"))
+ }))
+ defer sidecar.Close()
+
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodPost, "/cursor/v1/messages", nil)
+ h := &GatewayHandler{cfg: &config.Config{}}
+ h.cfg.Cursor.SidecarURL = sidecar.URL
+ account := &service.Account{ID: 1, Platform: service.PlatformCursor}
+
+ result, err := h.forwardCursorSidecar(c, account, cursorSidecarRequest{
+ Method: http.MethodPost,
+ Path: "/v1/messages",
+ UpstreamBody: []byte(`{}`),
+ RejectUnmeteredStream: true,
+ })
+
+ require.Nil(t, result)
+ require.EqualError(t, err, "wallet billing for Cursor streaming is unavailable")
+ require.Empty(t, w.Body.String())
+}
+
+func TestForwardCursorSidecarNonWalletStreamExtractsTerminalUsage(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ streamBody := "{\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n" +
+ "{\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":11,\"output_tokens\":6}}}\n"
+ sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/x-ndjson")
+ _, _ = w.Write([]byte(streamBody))
+ }))
+ defer sidecar.Close()
+
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodPost, "/cursor/v1/messages", nil)
+ h := &GatewayHandler{cfg: &config.Config{}}
+ h.cfg.Cursor.SidecarURL = sidecar.URL
+ account := &service.Account{ID: 1, Platform: service.PlatformCursor}
+
+ result, err := h.forwardCursorSidecar(c, account, cursorSidecarRequest{
+ Method: http.MethodPost,
+ Path: "/v1/messages",
+ UpstreamBody: []byte(`{}`),
+ RecordUsage: true,
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.True(t, result.Stream)
+ require.Equal(t, 11, result.Usage.InputTokens)
+ require.Equal(t, 6, result.Usage.OutputTokens)
+ require.Equal(t, streamBody, w.Body.String())
+}
+
+func TestForwardCursorSidecarNonWalletStreamRejectsMissingUsageBeforeDelivery(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/x-ndjson")
+ _, _ = w.Write([]byte("{\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n"))
+ }))
+ defer sidecar.Close()
+
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodPost, "/cursor/v1/messages", nil)
+ h := &GatewayHandler{cfg: &config.Config{}}
+ h.cfg.Cursor.SidecarURL = sidecar.URL
+ account := &service.Account{ID: 1, Platform: service.PlatformCursor}
+
+ result, err := h.forwardCursorSidecar(c, account, cursorSidecarRequest{
+ Method: http.MethodPost, Path: "/v1/messages", UpstreamBody: []byte(`{}`), RecordUsage: true,
+ })
+ require.Nil(t, result)
+ require.ErrorContains(t, err, "completed without usage")
+ require.Empty(t, w.Body.String())
+}
+
+func TestForwardCursorSidecarRedactsNonFailoverUpstreamErrorBody(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ const upstreamSecret = "cursor-upstream-private-token"
+ sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusTeapot)
+ _, _ = w.Write([]byte(`{"error":{"message":"internal endpoint http://10.0.0.8:8788 token ` + upstreamSecret + `"}}`))
+ }))
+ defer sidecar.Close()
+
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodPost, "/cursor/v1/messages", nil)
+ h := &GatewayHandler{cfg: &config.Config{}}
+ h.cfg.Cursor.SidecarURL = sidecar.URL
+ account := &service.Account{ID: 1, Platform: service.PlatformCursor}
+
+ result, err := h.forwardCursorSidecar(c, account, cursorSidecarRequest{
+ Method: http.MethodPost, Path: "/v1/messages", UpstreamBody: []byte(`{}`),
+ })
+
+ require.Nil(t, result)
+ require.EqualError(t, err, "cursor sidecar upstream error: 418")
+ require.NotContains(t, w.Body.String(), upstreamSecret)
+ require.NotContains(t, w.Body.String(), "10.0.0.8")
+ require.Contains(t, w.Body.String(), "Upstream request failed")
+}
diff --git a/backend/internal/handler/dto/api_key_masking_security_test.go b/backend/internal/handler/dto/api_key_masking_security_test.go
new file mode 100644
index 00000000000..ce5d439992e
--- /dev/null
+++ b/backend/internal/handler/dto/api_key_masking_security_test.go
@@ -0,0 +1,46 @@
+package dto
+
+import (
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIKeyFromServiceMaskedDoesNotExposeReusableSecret(t *testing.T) {
+ raw := "sk-super-secret-api-key"
+ out := APIKeyFromServiceMasked(&service.APIKey{
+ ID: 7,
+ UserID: 9,
+ Key: raw,
+ KeyPrefix: "sk-sup",
+ Name: "customer key",
+ })
+ require.NotNil(t, out)
+ require.NotEqual(t, raw, out.Key)
+ require.NotContains(t, out.Key, "secret")
+ require.Equal(t, "sk-sup••••", out.Key)
+}
+
+func TestAdminSubscriptionMapperMasksNestedWalletKey(t *testing.T) {
+ raw := "sk-wallet-secret"
+ out := UserSubscriptionFromServiceAdmin(&service.UserSubscription{
+ WalletUniversalKey: &service.APIKey{Key: raw, KeyPrefix: "sk-wall"},
+ })
+ require.NotNil(t, out)
+ require.NotNil(t, out.WalletUniversalKey)
+ require.NotEqual(t, raw, out.WalletUniversalKey.Key)
+}
+
+func TestDefaultNestedMappersDoNotExposeAPIKeyPlaintext(t *testing.T) {
+ key := APIKeyFromService(&service.APIKey{ID: 9, Key: "sk-default-mapper-secret", KeyPrefix: "sk-defau"})
+ require.Equal(t, "sk-default-mapper-secret", key.Key, "one-time create mapper remains explicit")
+
+ user := UserFromService(&service.User{APIKeys: []service.APIKey{{ID: 9, Key: "sk-default-mapper-secret", KeyPrefix: "sk-defau"}}})
+ require.Len(t, user.APIKeys, 1)
+ require.NotContains(t, user.APIKeys[0].Key, "secret")
+
+ usage := UsageLogFromService(&service.UsageLog{APIKey: &service.APIKey{ID: 9, Key: "sk-default-mapper-secret", KeyPrefix: "sk-defau"}})
+ require.NotNil(t, usage.APIKey)
+ require.NotContains(t, usage.APIKey.Key, "secret")
+}
diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go
index f7503c2ea15..d4686c46cf7 100644
--- a/backend/internal/handler/dto/mappers.go
+++ b/backend/internal/handler/dto/mappers.go
@@ -3,6 +3,7 @@ package dto
import (
"strconv"
+ "strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -42,7 +43,7 @@ func UserFromService(u *service.User) *User {
out.APIKeys = make([]APIKey, 0, len(u.APIKeys))
for i := range u.APIKeys {
k := u.APIKeys[i]
- out.APIKeys = append(out.APIKeys, *APIKeyFromService(&k))
+ out.APIKeys = append(out.APIKeys, *APIKeyFromServiceMasked(&k))
}
}
if len(u.Subscriptions) > 0 {
@@ -65,6 +66,14 @@ func UserFromServiceAdmin(u *service.User) *AdminUser {
if base == nil {
return nil
}
+ if len(u.APIKeys) > 0 {
+ base.APIKeys = make([]APIKey, 0, len(u.APIKeys))
+ for i := range u.APIKeys {
+ if key := APIKeyFromServiceMasked(&u.APIKeys[i]); key != nil {
+ base.APIKeys = append(base.APIKeys, *key)
+ }
+ }
+ }
return &AdminUser{
User: *base,
Notes: u.Notes,
@@ -82,6 +91,7 @@ func APIKeyFromService(k *service.APIKey) *APIKey {
UserID: k.UserID,
Key: k.Key,
Name: k.Name,
+ Purpose: k.Purpose,
GroupID: k.GroupID,
Status: k.Status,
IPWhitelist: k.IPWhitelist,
@@ -119,6 +129,26 @@ func APIKeyFromService(k *service.APIKey) *APIKey {
return out
}
+// APIKeyFromServiceMasked is the only mapper admin endpoints should use.
+// Administrators can manage ownership and policy without receiving a
+// customer's reusable credential in their browser or idempotency records.
+func APIKeyFromServiceMasked(k *service.APIKey) *APIKey {
+ out := APIKeyFromService(k)
+ if out == nil {
+ return nil
+ }
+ prefix := strings.TrimSpace(k.KeyPrefix)
+ if prefix == "" && strings.TrimSpace(k.Key) != "" {
+ prefix = service.APIKeyPrefixForStorage(k.Key)
+ }
+ if prefix == "" {
+ out.Key = ""
+ } else {
+ out.Key = prefix + "••••"
+ }
+ return out
+}
+
func GroupFromServiceShallow(g *service.Group) *Group {
if g == nil {
return nil
@@ -176,6 +206,9 @@ func groupFromServiceBase(g *service.Group) Group {
DailyLimitUSD: g.DailyLimitUSD,
WeeklyLimitUSD: g.WeeklyLimitUSD,
MonthlyLimitUSD: g.MonthlyLimitUSD,
+ AllowImageGeneration: g.AllowImageGeneration,
+ ImageRateIndependent: g.ImageRateIndependent,
+ ImageRateMultiplier: g.ImageRateMultiplier,
ImagePrice1K: g.ImagePrice1K,
ImagePrice2K: g.ImagePrice2K,
ImagePrice4K: g.ImagePrice4K,
@@ -559,17 +592,13 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
// 普通用户 DTO:严禁包含管理员字段(例如 account_rate_multiplier、ip_address、account)。
requestType := l.EffectiveRequestType()
stream, openAIWSMode := service.ApplyLegacyRequestFields(requestType, l.Stream, l.OpenAIWSMode)
- requestedModel := l.RequestedModel
- if requestedModel == "" {
- requestedModel = l.Model
- }
return UsageLog{
ID: l.ID,
UserID: l.UserID,
APIKeyID: l.APIKeyID,
AccountID: l.AccountID,
RequestID: l.RequestID,
- Model: requestedModel,
+ Model: visibleUsageLogModel(l),
ServiceTier: l.ServiceTier,
ReasoningEffort: l.ReasoningEffort,
InboundEndpoint: l.InboundEndpoint,
@@ -603,12 +632,65 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
BillingMode: l.BillingMode,
CreatedAt: l.CreatedAt,
User: UserFromServiceShallow(l.User),
- APIKey: APIKeyFromService(l.APIKey),
+ APIKey: APIKeyFromServiceMasked(l.APIKey),
Group: GroupFromServiceShallow(l.Group),
Subscription: UserSubscriptionFromService(l.Subscription),
}
}
+func visibleUsageLogModel(l *service.UsageLog) string {
+ if l.UpstreamModel != nil {
+ if upstream := strings.TrimSpace(*l.UpstreamModel); upstream != "" {
+ return upstream
+ }
+ }
+ if model := strings.TrimSpace(l.Model); model != "" {
+ return model
+ }
+ return strings.TrimSpace(l.RequestedModel)
+}
+
+func requestedUsageLogModel(l *service.UsageLog) string {
+ if requested := strings.TrimSpace(l.RequestedModel); requested != "" {
+ return requested
+ }
+ return strings.TrimSpace(l.Model)
+}
+
+func usageLogModelFamily(model string) string {
+ normalized := strings.ToLower(strings.TrimSpace(model))
+ if slash := strings.LastIndex(normalized, "/"); slash >= 0 {
+ normalized = strings.TrimSpace(normalized[slash+1:])
+ }
+ switch {
+ case strings.HasPrefix(normalized, "gpt-"):
+ return "gpt"
+ case strings.HasPrefix(normalized, "claude-"):
+ return "claude"
+ case normalized == "opus", normalized == "sonnet", normalized == "haiku", normalized == "default":
+ return "claude"
+ default:
+ return "other"
+ }
+}
+
+func usageLogCompatMode(l *service.UsageLog) string {
+ requestedFamily := usageLogModelFamily(requestedUsageLogModel(l))
+ executedFamily := usageLogModelFamily(visibleUsageLogModel(l))
+ billingFamily := ""
+ if l.BillingModel != nil {
+ billingFamily = usageLogModelFamily(*l.BillingModel)
+ }
+ billingMatchesGPT := billingFamily == "gpt"
+ if requestedFamily == "gpt" && executedFamily == "gpt" && billingMatchesGPT {
+ return "native_gpt"
+ }
+ if requestedFamily == "claude" && executedFamily == "gpt" && billingMatchesGPT {
+ return "legacy_claude_alias"
+ }
+ return "other"
+}
+
// UsageLogFromService converts a service UsageLog to DTO for regular users.
// It excludes Account details and IP address - users should not see these.
func UsageLogFromService(l *service.UsageLog) *UsageLog {
@@ -627,6 +709,12 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog {
}
return &AdminUsageLog{
UsageLog: usageLogFromServiceUser(l),
+ RequestedModel: requestedUsageLogModel(l),
+ BillingModel: l.BillingModel,
+ PricingSource: l.PricingSource,
+ PricingRevision: l.PricingRevision,
+ PricingHash: l.PricingHash,
+ CompatMode: usageLogCompatMode(l),
UpstreamModel: l.UpstreamModel,
ChannelID: l.ChannelID,
ModelMappingChain: l.ModelMappingChain,
@@ -703,12 +791,38 @@ func UserSubscriptionFromServiceAdmin(sub *service.UserSubscription) *AdminUserS
if sub == nil {
return nil
}
+ var walletGroupKeys []APIKey
+ var createdCount *int
+ if len(sub.WalletGroupKeys) > 0 {
+ walletGroupKeys = make([]APIKey, 0, len(sub.WalletGroupKeys))
+ for i := range sub.WalletGroupKeys {
+ if dtoKey := APIKeyFromServiceMasked(&sub.WalletGroupKeys[i]); dtoKey != nil {
+ walletGroupKeys = append(walletGroupKeys, *dtoKey)
+ }
+ }
+ c := sub.WalletGroupKeysCreatedCount
+ createdCount = &c
+ }
+
+ // 单 key 模式(5/14 反转决策):钱包激活/topup 时建 1 把通用 key 走 model_router 路由。
+ var walletUniversalKey *APIKey
+ var walletUniversalKeyCreated *bool
+ if sub.WalletUniversalKey != nil {
+ walletUniversalKey = APIKeyFromServiceMasked(sub.WalletUniversalKey)
+ c := sub.WalletUniversalKeyCreated
+ walletUniversalKeyCreated = &c
+ }
+
return &AdminUserSubscription{
- UserSubscription: userSubscriptionFromServiceBase(sub),
- AssignedBy: sub.AssignedBy,
- AssignedAt: sub.AssignedAt,
- Notes: sub.Notes,
- AssignedByUser: UserFromServiceShallow(sub.AssignedByUser),
+ UserSubscription: userSubscriptionFromServiceBase(sub),
+ AssignedBy: sub.AssignedBy,
+ AssignedAt: sub.AssignedAt,
+ Notes: sub.Notes,
+ AssignedByUser: UserFromServiceShallow(sub.AssignedByUser),
+ WalletGroupKeys: walletGroupKeys,
+ WalletGroupKeysCreatedCount: createdCount,
+ WalletUniversalKey: walletUniversalKey,
+ WalletUniversalKeyCreated: walletUniversalKeyCreated,
}
}
@@ -726,6 +840,9 @@ func userSubscriptionFromServiceBase(sub *service.UserSubscription) UserSubscrip
DailyUsageUSD: sub.DailyUsageUSD,
WeeklyUsageUSD: sub.WeeklyUsageUSD,
MonthlyUsageUSD: sub.MonthlyUsageUSD,
+ WalletBalanceUSD: sub.WalletBalanceUSD,
+ WalletInitialUSD: sub.WalletInitialUSD,
+ LockedRates: cloneLockedRatesDTO(sub.LockedRates),
CreatedAt: sub.CreatedAt,
UpdatedAt: sub.UpdatedAt,
User: UserFromServiceShallow(sub.User),
@@ -733,6 +850,17 @@ func userSubscriptionFromServiceBase(sub *service.UserSubscription) UserSubscrip
}
}
+func cloneLockedRatesDTO(in map[string]float64) map[string]float64 {
+ if len(in) == 0 {
+ return nil
+ }
+ out := make(map[string]float64, len(in))
+ for k, v := range in {
+ out[k] = v
+ }
+ return out
+}
+
func BulkAssignResultFromService(r *service.BulkAssignResult) *BulkAssignResult {
if r == nil {
return nil
diff --git a/backend/internal/handler/dto/mappers_usage_test.go b/backend/internal/handler/dto/mappers_usage_test.go
index c2635e339a5..6ce742cc1d5 100644
--- a/backend/internal/handler/dto/mappers_usage_test.go
+++ b/backend/internal/handler/dto/mappers_usage_test.go
@@ -2,6 +2,7 @@ package dto
import (
"encoding/json"
+ "strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -107,22 +108,22 @@ func TestUsageLogFromService_IncludesServiceTierForUserAndAdmin(t *testing.T) {
require.InDelta(t, 1.5, *adminDTO.AccountRateMultiplier, 1e-12)
}
-func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *testing.T) {
+func TestUsageLogFromService_UsesUpstreamModelAndKeepsUpstreamAdminOnly(t *testing.T) {
t.Parallel()
- upstreamModel := "claude-sonnet-4-20250514"
+ upstreamModel := "gpt-5.4"
log := &service.UsageLog{
RequestID: "req_4",
- Model: upstreamModel,
- RequestedModel: "claude-sonnet-4",
+ Model: "claude-sonnet-4-6",
+ RequestedModel: "claude-sonnet-4-6",
UpstreamModel: &upstreamModel,
}
userDTO := UsageLogFromService(log)
adminDTO := UsageLogFromServiceAdmin(log)
- require.Equal(t, "claude-sonnet-4", userDTO.Model)
- require.Equal(t, "claude-sonnet-4", adminDTO.Model)
+ require.Equal(t, "gpt-5.4", userDTO.Model)
+ require.Equal(t, "gpt-5.4", adminDTO.Model)
userJSON, err := json.Marshal(userDTO)
require.NoError(t, err)
@@ -130,22 +131,163 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test
adminJSON, err := json.Marshal(adminDTO)
require.NoError(t, err)
- require.Contains(t, string(adminJSON), `"upstream_model":"claude-sonnet-4-20250514"`)
+ require.Contains(t, string(adminJSON), `"upstream_model":"gpt-5.4"`)
}
-func TestUsageLogFromService_FallsBackToLegacyModelWhenRequestedModelMissing(t *testing.T) {
+func TestUsageLogFromService_FallsBackToStoredModelWhenUpstreamMissing(t *testing.T) {
t.Parallel()
log := &service.UsageLog{
- RequestID: "req_legacy",
- Model: "claude-3",
+ RequestID: "req_model_fallback",
+ Model: "gpt-5.4",
+ RequestedModel: "claude-sonnet-4-6",
}
userDTO := UsageLogFromService(log)
adminDTO := UsageLogFromServiceAdmin(log)
- require.Equal(t, "claude-3", userDTO.Model)
- require.Equal(t, "claude-3", adminDTO.Model)
+ require.Equal(t, "gpt-5.4", userDTO.Model)
+ require.Equal(t, "gpt-5.4", adminDTO.Model)
+}
+
+func TestUsageLogFromServiceAdmin_IncludesBillingIdentityWithoutLeakingUserAuditFields(t *testing.T) {
+ t.Parallel()
+
+ upstreamModel := "gpt-5.4"
+ billingModel := "gpt-5.4"
+ log := &service.UsageLog{
+ RequestID: "req_billing_identity",
+ Model: "gpt-5.4",
+ RequestedModel: "claude-sonnet-4-6",
+ UpstreamModel: &upstreamModel,
+ BillingModel: &billingModel,
+ }
+
+ userJSON, err := json.Marshal(UsageLogFromService(log))
+ require.NoError(t, err)
+ require.NotContains(t, string(userJSON), "requested_model")
+ require.NotContains(t, string(userJSON), "billing_model")
+ require.NotContains(t, string(userJSON), "compat_mode")
+
+ adminJSON, err := json.Marshal(UsageLogFromServiceAdmin(log))
+ require.NoError(t, err)
+ for _, expected := range []string{
+ `"model":"gpt-5.4"`,
+ `"requested_model":"claude-sonnet-4-6"`,
+ `"upstream_model":"gpt-5.4"`,
+ `"billing_model":"gpt-5.4"`,
+ `"compat_mode":"legacy_claude_alias"`,
+ } {
+ require.Contains(t, string(adminJSON), expected)
+ }
+}
+
+func TestUsageLogMapperPricingEvidenceIsAdminOnly(t *testing.T) {
+ t.Parallel()
+
+ source := service.PricingSourceBuiltinGPT56
+ revision := service.GPT56PricingRevision
+ hash := strings.Repeat("b", 64)
+ log := &service.UsageLog{
+ RequestID: "req-pricing-evidence", Model: "gpt-5.6-terra",
+ PricingSource: &source, PricingRevision: &revision, PricingHash: &hash,
+ }
+
+ userJSON, err := json.Marshal(UsageLogFromService(log))
+ require.NoError(t, err)
+ for _, field := range []string{"pricing_source", "pricing_revision", "pricing_hash"} {
+ require.NotContains(t, string(userJSON), field)
+ }
+
+ adminJSON, err := json.Marshal(UsageLogFromServiceAdmin(log))
+ require.NoError(t, err)
+ require.Contains(t, string(adminJSON), `"pricing_source":"builtin_gpt56"`)
+ require.Contains(t, string(adminJSON), `"pricing_revision":"gpt56-policy-v1"`)
+ require.Contains(t, string(adminJSON), `"pricing_hash":"`+hash+`"`)
+}
+
+func TestUsageLogFromServiceAdmin_DerivesCompatModeFromStoredIdentity(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ requestedModel string
+ executedModel string
+ billingModel *string
+ want string
+ }{
+ {
+ name: "native GPT",
+ requestedModel: "gpt-5.6-terra",
+ executedModel: "gpt-5.6-terra",
+ billingModel: stringPtr("gpt-5.6-terra"),
+ want: "native_gpt",
+ },
+ {
+ name: "legacy Claude alias",
+ requestedModel: "claude-sonnet-4-6",
+ executedModel: "gpt-5.4",
+ billingModel: stringPtr("gpt-5.4"),
+ want: "legacy_claude_alias",
+ },
+ {
+ name: "bare opus alias",
+ requestedModel: "opus",
+ executedModel: "gpt-5.6-sol",
+ billingModel: stringPtr("gpt-5.6-sol"),
+ want: "legacy_claude_alias",
+ },
+ {
+ name: "bare sonnet alias",
+ requestedModel: "sonnet",
+ executedModel: "gpt-5.6-terra",
+ billingModel: stringPtr("gpt-5.6-terra"),
+ want: "legacy_claude_alias",
+ },
+ {
+ name: "bare haiku alias",
+ requestedModel: "haiku",
+ executedModel: "gpt-5.6-luna",
+ billingModel: stringPtr("gpt-5.6-luna"),
+ want: "legacy_claude_alias",
+ },
+ {
+ name: "bare default alias",
+ requestedModel: "default",
+ executedModel: "gpt-5.4",
+ billingModel: stringPtr("gpt-5.4"),
+ want: "legacy_claude_alias",
+ },
+ {
+ name: "historical audit missing",
+ requestedModel: "claude-sonnet-4-6",
+ executedModel: "gpt-5.4",
+ want: "other",
+ },
+ {
+ name: "other provider",
+ requestedModel: "gemini-3-pro",
+ executedModel: "gemini-3-pro",
+ billingModel: stringPtr("gemini-3-pro"),
+ want: "other",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tt := tt
+ dto := UsageLogFromServiceAdmin(&service.UsageLog{
+ Model: tt.executedModel,
+ RequestedModel: tt.requestedModel,
+ BillingModel: tt.billingModel,
+ })
+ require.Equal(t, tt.want, dto.CompatMode)
+ })
+ }
+}
+
+func stringPtr(value string) *string {
+ return &value
}
func f64Ptr(value float64) *float64 {
diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go
index 492be170bee..b70efdc3cce 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -11,6 +11,7 @@ type CustomMenuItem struct {
Label string `json:"label"`
IconSVG string `json:"icon_svg"`
URL string `json:"url"`
+ PageSlug string `json:"page_slug,omitempty"`
Visibility string `json:"visibility"` // "user" or "admin"
SortOrder int `json:"sort_order"`
}
@@ -22,17 +23,30 @@ type CustomEndpoint struct {
Description string `json:"description"`
}
+// AdminUISettings is the lightweight payload needed by shared admin chrome.
+type AdminUISettings struct {
+ OpsMonitoringEnabled bool `json:"ops_monitoring_enabled"`
+ OpsRealtimeMonitoringEnabled bool `json:"ops_realtime_monitoring_enabled"`
+ OpsQueryModeDefault string `json:"ops_query_mode_default"`
+ CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
+ PaymentEnabled bool `json:"payment_enabled"`
+}
+
// SystemSettings represents the admin settings API response payload.
type SystemSettings struct {
- RegistrationEnabled bool `json:"registration_enabled"`
- EmailVerifyEnabled bool `json:"email_verify_enabled"`
- RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
- PromoCodeEnabled bool `json:"promo_code_enabled"`
- PasswordResetEnabled bool `json:"password_reset_enabled"`
- FrontendURL string `json:"frontend_url"`
- InvitationCodeEnabled bool `json:"invitation_code_enabled"`
- TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
- TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
+ RegistrationEnabled bool `json:"registration_enabled"`
+ EmailVerifyEnabled bool `json:"email_verify_enabled"`
+ RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
+ PromoCodeEnabled bool `json:"promo_code_enabled"`
+ PasswordResetEnabled bool `json:"password_reset_enabled"`
+ FrontendURL string `json:"frontend_url"`
+ InvitationCodeEnabled bool `json:"invitation_code_enabled"`
+ TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
+ TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
+ LoginAgreementEnabled bool `json:"login_agreement_enabled"`
+ LoginAgreementMode string `json:"login_agreement_mode"`
+ LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
+ LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
@@ -91,6 +105,17 @@ type SystemSettings struct {
OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"`
OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_path"`
+ GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
+ GitHubOAuthClientID string `json:"github_oauth_client_id"`
+ GitHubOAuthClientSecretConfigured bool `json:"github_oauth_client_secret_configured"`
+ GitHubOAuthRedirectURL string `json:"github_oauth_redirect_url"`
+ GitHubOAuthFrontendRedirectURL string `json:"github_oauth_frontend_redirect_url"`
+ GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
+ GoogleOAuthClientID string `json:"google_oauth_client_id"`
+ GoogleOAuthClientSecretConfigured bool `json:"google_oauth_client_secret_configured"`
+ GoogleOAuthRedirectURL string `json:"google_oauth_redirect_url"`
+ GoogleOAuthFrontendRedirectURL string `json:"google_oauth_frontend_redirect_url"`
+
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
@@ -197,6 +222,9 @@ type SystemSettings struct {
// Available Channels feature switch (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
+ // 风控中心功能开关
+ RiskControlEnabled bool `json:"risk_control_enabled"`
+
// Affiliate (邀请返利) feature switch
AffiliateEnabled bool `json:"affiliate_enabled"`
@@ -210,45 +238,52 @@ type DefaultSubscriptionSetting struct {
}
type PublicSettings struct {
- RegistrationEnabled bool `json:"registration_enabled"`
- EmailVerifyEnabled bool `json:"email_verify_enabled"`
- ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"`
- RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
- PromoCodeEnabled bool `json:"promo_code_enabled"`
- PasswordResetEnabled bool `json:"password_reset_enabled"`
- InvitationCodeEnabled bool `json:"invitation_code_enabled"`
- TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
- TurnstileEnabled bool `json:"turnstile_enabled"`
- TurnstileSiteKey string `json:"turnstile_site_key"`
- SiteName string `json:"site_name"`
- SiteLogo string `json:"site_logo"`
- SiteSubtitle string `json:"site_subtitle"`
- APIBaseURL string `json:"api_base_url"`
- ContactInfo string `json:"contact_info"`
- DocURL string `json:"doc_url"`
- HomeContent string `json:"home_content"`
- HideCcsImportButton bool `json:"hide_ccs_import_button"`
- PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
- PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
- TableDefaultPageSize int `json:"table_default_page_size"`
- TablePageSizeOptions []int `json:"table_page_size_options"`
- CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
- CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
- LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
- WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
- WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
- WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
- WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
- OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
- OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
- SoraClientEnabled bool `json:"sora_client_enabled"`
- BackendModeEnabled bool `json:"backend_mode_enabled"`
- PaymentEnabled bool `json:"payment_enabled"`
- Version string `json:"version"`
- BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
- AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
- BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
- BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
+ RegistrationEnabled bool `json:"registration_enabled"`
+ EmailVerifyEnabled bool `json:"email_verify_enabled"`
+ ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"`
+ RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
+ PromoCodeEnabled bool `json:"promo_code_enabled"`
+ PasswordResetEnabled bool `json:"password_reset_enabled"`
+ InvitationCodeEnabled bool `json:"invitation_code_enabled"`
+ TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
+ LoginAgreementEnabled bool `json:"login_agreement_enabled"`
+ LoginAgreementMode string `json:"login_agreement_mode"`
+ LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
+ LoginAgreementRevision string `json:"login_agreement_revision"`
+ LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
+ TurnstileEnabled bool `json:"turnstile_enabled"`
+ TurnstileSiteKey string `json:"turnstile_site_key"`
+ SiteName string `json:"site_name"`
+ SiteLogo string `json:"site_logo"`
+ SiteSubtitle string `json:"site_subtitle"`
+ APIBaseURL string `json:"api_base_url"`
+ ContactInfo string `json:"contact_info"`
+ DocURL string `json:"doc_url"`
+ HomeContent string `json:"home_content"`
+ HideCcsImportButton bool `json:"hide_ccs_import_button"`
+ PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
+ PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
+ TableDefaultPageSize int `json:"table_default_page_size"`
+ TablePageSizeOptions []int `json:"table_page_size_options"`
+ CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
+ CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
+ LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
+ WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
+ WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
+ WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
+ WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
+ OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
+ OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
+ GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
+ GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
+ SoraClientEnabled bool `json:"sora_client_enabled"`
+ BackendModeEnabled bool `json:"backend_mode_enabled"`
+ PaymentEnabled bool `json:"payment_enabled"`
+ Version string `json:"version"`
+ BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
+ AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
+ BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
+ BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
@@ -256,6 +291,14 @@ type PublicSettings struct {
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
AffiliateEnabled bool `json:"affiliate_enabled"`
+
+ RiskControlEnabled bool `json:"risk_control_enabled"`
+}
+
+type LoginAgreementDocument struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ ContentMD string `json:"content_md"`
}
// OverloadCooldownSettings 529过载冷却配置 DTO
@@ -264,6 +307,12 @@ type OverloadCooldownSettings struct {
CooldownMinutes int `json:"cooldown_minutes"`
}
+// RateLimit429CooldownSettings 429默认回避配置 DTO
+type RateLimit429CooldownSettings struct {
+ Enabled bool `json:"enabled"`
+ CooldownSeconds int `json:"cooldown_seconds"`
+}
+
// StreamTimeoutSettings 流超时处理配置 DTO
type StreamTimeoutSettings struct {
Enabled bool `json:"enabled"`
diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go
index 5cc2f8e4dd4..827fb28466e 100644
--- a/backend/internal/handler/dto/types.go
+++ b/backend/internal/handler/dto/types.go
@@ -50,6 +50,7 @@ type APIKey struct {
UserID int64 `json:"user_id"`
Key string `json:"key"`
Name string `json:"name"`
+ Purpose string `json:"purpose"`
GroupID *int64 `json:"group_id"`
Status string `json:"status"`
IPWhitelist []string `json:"ip_whitelist"`
@@ -94,9 +95,12 @@ type Group struct {
MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
// 图片生成计费配置(仅 antigravity 平台使用)
- ImagePrice1K *float64 `json:"image_price_1k"`
- ImagePrice2K *float64 `json:"image_price_2k"`
- ImagePrice4K *float64 `json:"image_price_4k"`
+ AllowImageGeneration bool `json:"allow_image_generation"`
+ ImageRateIndependent bool `json:"image_rate_independent"`
+ ImageRateMultiplier float64 `json:"image_rate_multiplier"`
+ ImagePrice1K *float64 `json:"image_price_1k"`
+ ImagePrice2K *float64 `json:"image_price_2k"`
+ ImagePrice4K *float64 `json:"image_price_4k"`
// Claude Code 客户端限制
ClaudeCodeOnly bool `json:"claude_code_only"`
@@ -422,6 +426,13 @@ type UsageLog struct {
type AdminUsageLog struct {
UsageLog
+ RequestedModel string `json:"requested_model"`
+ BillingModel *string `json:"billing_model,omitempty"`
+ PricingSource *string `json:"pricing_source,omitempty"`
+ PricingRevision *string `json:"pricing_revision,omitempty"`
+ PricingHash *string `json:"pricing_hash,omitempty"`
+ CompatMode string `json:"compat_mode"`
+
// UpstreamModel is the actual model sent to the upstream provider after mapping.
// Omitted when no mapping was applied (requested model was used as-is).
UpstreamModel *string `json:"upstream_model,omitempty"`
@@ -488,9 +499,10 @@ type Setting struct {
}
type UserSubscription struct {
- ID int64 `json:"id"`
- UserID int64 `json:"user_id"`
- GroupID int64 `json:"group_id"`
+ ID int64 `json:"id"`
+ UserID int64 `json:"user_id"`
+ // GroupID 钱包模式 (v4) 下为 nil;老的单 group 订阅 (v3) 下必填。
+ GroupID *int64 `json:"group_id"`
StartsAt time.Time `json:"starts_at"`
ExpiresAt time.Time `json:"expires_at"`
@@ -504,6 +516,11 @@ type UserSubscription struct {
WeeklyUsageUSD float64 `json:"weekly_usage_usd"`
MonthlyUsageUSD float64 `json:"monthly_usage_usd"`
+ // WalletBalanceUSD / WalletInitialUSD 钱包模式 (v4) 字段;nil = 老 group 订阅。
+ WalletBalanceUSD *float64 `json:"wallet_balance_usd,omitempty"`
+ WalletInitialUSD *float64 `json:"wallet_initial_usd,omitempty"`
+ LockedRates map[string]float64 `json:"locked_rates,omitempty"`
+
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -521,6 +538,16 @@ type AdminUserSubscription struct {
Notes string `json:"notes"`
AssignedByUser *User `json:"assigned_by_user,omitempty"`
+
+ // 钱包激活返回的 N 把分组 key(命名 "钱包-{group.Name}")。
+ // 5/14 反转决策后不再写入(多 key 已废弃),字段保留作历史兼容。
+ WalletGroupKeys []APIKey `json:"wallet_group_keys,omitempty"`
+ WalletGroupKeysCreatedCount *int `json:"wallet_group_keys_created_count,omitempty"`
+
+ // 钱包激活返回的 1 把通用 key(命名 "钱包通用 key(自动路由)")。
+ // 5/14 反转决策后激活流程走单 key 路径,多 key 字段降为兼容用。
+ WalletUniversalKey *APIKey `json:"wallet_universal_key,omitempty"`
+ WalletUniversalKeyCreated *bool `json:"wallet_universal_key_created,omitempty"`
}
type BulkAssignResult struct {
diff --git a/backend/internal/handler/gateway_compat_wallet_admission_contract_test.go b/backend/internal/handler/gateway_compat_wallet_admission_contract_test.go
new file mode 100644
index 00000000000..fd1ae0e8579
--- /dev/null
+++ b/backend/internal/handler/gateway_compat_wallet_admission_contract_test.go
@@ -0,0 +1,82 @@
+package handler
+
+import (
+ "os"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestGatewayCompatibilityEndpointsFenceWalletUsageBeforeTransport(t *testing.T) {
+ for _, path := range []string{
+ "gateway_handler_responses.go",
+ "gateway_handler_chat_completions.go",
+ } {
+ t.Run(path, func(t *testing.T) {
+ raw, err := os.ReadFile(path)
+ require.NoError(t, err)
+ source := string(raw)
+ for _, required := range []string{
+ "PrepareUsageBillingRequestContext",
+ "PrepareGatewayWalletUsageBillingAdmission",
+ "MarkGatewayUsageBillingAttemptFailed",
+ "MarkGatewayUsageBillingOrphaned",
+ "AbandonGatewayUsageBilling",
+ "result.UsageBillingIdentity = usageBillingIdentity",
+ "submitGatewayUsageRecordTask",
+ "RequestPayloadHash: requestPayloadHash",
+ } {
+ require.Contains(t, source, required)
+ }
+ })
+ }
+}
+
+func TestGeminiGatewayEndpointsFenceWalletUsageBeforeTransport(t *testing.T) {
+ for _, path := range []string{
+ "gemini_v1beta_handler.go",
+ "gateway_handler.go",
+ } {
+ t.Run(path, func(t *testing.T) {
+ raw, err := os.ReadFile(path)
+ require.NoError(t, err)
+ source := string(raw)
+ for _, required := range []string{
+ "PrepareUsageBillingRequestContext",
+ "PrepareGatewayWalletUsageBillingAdmission",
+ "MarkGatewayUsageBillingAttemptFailed",
+ "MarkGatewayUsageBillingOrphaned",
+ "AbandonGatewayUsageBilling",
+ "result.UsageBillingIdentity = usageBillingIdentity",
+ } {
+ require.Contains(t, source, required)
+ }
+ })
+ }
+}
+
+func TestSidecarGatewayEndpointsFenceWalletUsageBeforeTransport(t *testing.T) {
+ for _, path := range []string{
+ "kiro_gateway_handler.go",
+ "cursor_gateway_handler.go",
+ } {
+ t.Run(path, func(t *testing.T) {
+ raw, err := os.ReadFile(path)
+ require.NoError(t, err)
+ source := string(raw)
+ for _, required := range []string{
+ "PrepareUsageBillingRequestContext",
+ "PrepareGatewayWalletUsageBillingAdmission",
+ "MarkGatewayUsageBillingAttemptFailed",
+ "MarkGatewayUsageBillingOrphaned",
+ "AbandonGatewayUsageBilling",
+ "result.UsageBillingIdentity = usageBillingIdentity",
+ "RejectUnmeteredStream",
+ "billing_service_error",
+ "billing_request_conflict",
+ } {
+ require.Contains(t, source, required)
+ }
+ })
+ }
+}
diff --git a/backend/internal/handler/gateway_error_disclosure_security_test.go b/backend/internal/handler/gateway_error_disclosure_security_test.go
new file mode 100644
index 00000000000..d5df07f8191
--- /dev/null
+++ b/backend/internal/handler/gateway_error_disclosure_security_test.go
@@ -0,0 +1,78 @@
+package handler
+
+import (
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGatewayPublicErrorResponsesDoNotExposeInternalErrors(t *testing.T) {
+ files := []string{
+ "gateway_handler.go",
+ "gateway_handler_chat_completions.go",
+ "gateway_handler_responses.go",
+ "gemini_v1beta_handler.go",
+ "kiro_gateway_handler.go",
+ "cursor_gateway_handler.go",
+ }
+ publicWriters := map[string]struct{}{
+ "googleError": {},
+ "handleStreamingAwareError": {},
+ "responsesErrorResponse": {},
+ "chatCompletionsErrorResponse": {},
+ }
+
+ for _, filename := range files {
+ fset := token.NewFileSet()
+ file, err := parser.ParseFile(fset, filename, nil, 0)
+ if !assert.NoError(t, err, filename) {
+ continue
+ }
+ ast.Inspect(file, func(node ast.Node) bool {
+ call, ok := node.(*ast.CallExpr)
+ if !ok || !isPublicGatewayErrorWriter(call.Fun, publicWriters) {
+ return true
+ }
+ for _, arg := range call.Args {
+ if containsErrorMethodCall(arg) {
+ pos := fset.Position(arg.Pos())
+ t.Errorf("%s:%d exposes err.Error() through a public gateway response", filename, pos.Line)
+ }
+ }
+ return true
+ })
+ }
+}
+
+func isPublicGatewayErrorWriter(expr ast.Expr, names map[string]struct{}) bool {
+ switch fn := expr.(type) {
+ case *ast.Ident:
+ _, ok := names[fn.Name]
+ return ok
+ case *ast.SelectorExpr:
+ _, ok := names[fn.Sel.Name]
+ return ok
+ default:
+ return false
+ }
+}
+
+func containsErrorMethodCall(expr ast.Expr) bool {
+ found := false
+ ast.Inspect(expr, func(node ast.Node) bool {
+ call, ok := node.(*ast.CallExpr)
+ if !ok {
+ return true
+ }
+ selector, ok := call.Fun.(*ast.SelectorExpr)
+ if ok && selector.Sel.Name == "Error" && len(call.Args) == 0 {
+ found = true
+ return false
+ }
+ return true
+ })
+ return found
+}
diff --git a/backend/internal/handler/gateway_gpt56_access_boundary.go b/backend/internal/handler/gateway_gpt56_access_boundary.go
new file mode 100644
index 00000000000..82ab15fa4b6
--- /dev/null
+++ b/backend/internal/handler/gateway_gpt56_access_boundary.go
@@ -0,0 +1,43 @@
+package handler
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+func genericGatewayGPT56AccessError(model string) (int, string, string, bool) {
+ normalized, isFamily := service.NormalizeOpenAIGPT56PreviewModel(model)
+ if !isFamily {
+ return 0, "", "", false
+ }
+ if normalized == "" {
+ return http.StatusBadRequest, "invalid_request_error",
+ "GPT-5.6 requires an exact supported tier: gpt-5.6-sol, gpt-5.6-terra, or gpt-5.6-luna", true
+ }
+ return http.StatusForbidden, "model_not_available",
+ fmt.Sprintf("GPT-5.6 model %s is not available for this API key group", normalized), true
+}
+
+func genericGatewayGPT56MappedAccessError(mapping service.ChannelMappingResult) (int, string, string, bool) {
+ if !mapping.Mapped {
+ return 0, "", "", false
+ }
+ return genericGatewayGPT56AccessError(mapping.MappedModel)
+}
+
+func genericGatewayGPT56AccountAccessError(account *service.Account, requestedModel string, mapping service.ChannelMappingResult) (int, string, string, bool) {
+ if account == nil {
+ return 0, "", "", false
+ }
+ model := requestedModel
+ if mapping.Mapped {
+ model = mapping.MappedModel
+ }
+ mappedModel, matched := account.ResolveMappedModel(model)
+ if !matched {
+ return 0, "", "", false
+ }
+ return genericGatewayGPT56AccessError(mappedModel)
+}
diff --git a/backend/internal/handler/gateway_gpt56_access_boundary_test.go b/backend/internal/handler/gateway_gpt56_access_boundary_test.go
new file mode 100644
index 00000000000..853d47388da
--- /dev/null
+++ b/backend/internal/handler/gateway_gpt56_access_boundary_test.go
@@ -0,0 +1,173 @@
+package handler
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+func TestGenericGatewayRejectsGPT56BeforeAccountScheduling(t *testing.T) {
+ tests := []struct {
+ name string
+ path string
+ body string
+ run func(*GatewayHandler, *gin.Context)
+ wantStatus int
+ wantType string
+ }{
+ {
+ name: "responses exact tier",
+ path: "/v1/responses",
+ body: `{"model":"gpt-5.6-terra","input":"hello"}`,
+ run: func(h *GatewayHandler, c *gin.Context) { h.Responses(c) },
+ wantStatus: http.StatusForbidden,
+ wantType: "model_not_available",
+ },
+ {
+ name: "messages exact tier",
+ path: "/v1/messages",
+ body: `{"model":"gpt-5.6-terra","max_tokens":8,"messages":[{"role":"user","content":"hello"}]}`,
+ run: func(h *GatewayHandler, c *gin.Context) { h.Messages(c) },
+ wantStatus: http.StatusForbidden,
+ wantType: "model_not_available",
+ },
+ {
+ name: "responses bare family",
+ path: "/v1/responses",
+ body: `{"model":"gpt-5.6","input":"hello"}`,
+ run: func(h *GatewayHandler, c *gin.Context) { h.Responses(c) },
+ wantStatus: http.StatusBadRequest,
+ wantType: "invalid_request_error",
+ },
+ {
+ name: "chat completions exact tier",
+ path: "/v1/chat/completions",
+ body: `{"model":"gpt-5.6-sol","messages":[{"role":"user","content":"hello"}]}`,
+ run: func(h *GatewayHandler, c *gin.Context) { h.ChatCompletions(c) },
+ wantStatus: http.StatusForbidden,
+ wantType: "model_not_available",
+ },
+ {
+ name: "count tokens exact tier",
+ path: "/v1/messages/count_tokens",
+ body: `{"model":"gpt-5.6-luna","messages":[{"role":"user","content":"hello"}]}`,
+ run: func(h *GatewayHandler, c *gin.Context) { h.CountTokens(c) },
+ wantStatus: http.StatusForbidden,
+ wantType: "model_not_available",
+ },
+ {
+ name: "count tokens bare family",
+ path: "/v1/messages/count_tokens",
+ body: `{"model":"gpt-5.6","messages":[{"role":"user","content":"hello"}]}`,
+ run: func(h *GatewayHandler, c *gin.Context) { h.CountTokens(c) },
+ wantStatus: http.StatusBadRequest,
+ wantType: "invalid_request_error",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, tt.path, strings.NewReader(tt.body))
+ groupID := int64(22)
+ apiKey := &service.APIKey{
+ ID: 607, UserID: 142, GroupID: &groupID, Status: service.StatusActive,
+ User: &service.User{ID: 142, Concurrency: 1},
+ Group: &service.Group{ID: groupID, Platform: service.PlatformAnthropic},
+ }
+ c.Set(string(middleware.ContextKeyAPIKey), apiKey)
+ c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 1})
+
+ tt.run(&GatewayHandler{}, c)
+
+ require.Equal(t, tt.wantStatus, recorder.Code, recorder.Body.String())
+ actualType := gjson.GetBytes(recorder.Body.Bytes(), "error.type").String()
+ if actualType == "" {
+ actualType = gjson.GetBytes(recorder.Body.Bytes(), "error.code").String()
+ }
+ require.Equal(t, tt.wantType, actualType)
+ require.Contains(t, gjson.GetBytes(recorder.Body.Bytes(), "error.message").String(), "GPT-5.6")
+ })
+ }
+}
+
+func TestGenericGatewayGPT56MappedAccessError(t *testing.T) {
+ tests := []struct {
+ name string
+ mapping service.ChannelMappingResult
+ wantStatus int
+ wantType string
+ wantReject bool
+ }{
+ {
+ name: "mapped exact tier",
+ mapping: service.ChannelMappingResult{Mapped: true, MappedModel: "gpt-5.6-terra"},
+ wantStatus: http.StatusForbidden,
+ wantType: "model_not_available",
+ wantReject: true,
+ },
+ {
+ name: "mapped bare family",
+ mapping: service.ChannelMappingResult{Mapped: true, MappedModel: "gpt-5.6"},
+ wantStatus: http.StatusBadRequest,
+ wantType: "invalid_request_error",
+ wantReject: true,
+ },
+ {
+ name: "mapped non gpt56",
+ mapping: service.ChannelMappingResult{Mapped: true, MappedModel: "gpt-5.4"},
+ wantReject: false,
+ },
+ {
+ name: "unmapped gpt56 target is ignored",
+ mapping: service.ChannelMappingResult{Mapped: false, MappedModel: "gpt-5.6-sol"},
+ wantReject: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ status, errType, _, reject := genericGatewayGPT56MappedAccessError(tt.mapping)
+ require.Equal(t, tt.wantReject, reject)
+ require.Equal(t, tt.wantStatus, status)
+ require.Equal(t, tt.wantType, errType)
+ })
+ }
+}
+
+func TestGenericGatewayGPT56AccountAccessError(t *testing.T) {
+ account := &service.Account{
+ Platform: service.PlatformAnthropic,
+ Credentials: map[string]any{
+ "model_mapping": map[string]any{
+ "claude-sonnet-4-6": "gpt-5.6-sol",
+ "claude-opus-*": "gpt-5.6-terra",
+ "claude-haiku-4-5": "claude-haiku-4-5-20251001",
+ },
+ },
+ }
+
+ status, errType, _, reject := genericGatewayGPT56AccountAccessError(account, "claude-sonnet-4-6", service.ChannelMappingResult{})
+ require.True(t, reject)
+ require.Equal(t, http.StatusForbidden, status)
+ require.Equal(t, "model_not_available", errType)
+
+ status, errType, _, reject = genericGatewayGPT56AccountAccessError(account, "claude-opus-4-6", service.ChannelMappingResult{})
+ require.True(t, reject)
+ require.Equal(t, http.StatusForbidden, status)
+ require.Equal(t, "model_not_available", errType)
+
+ _, _, _, reject = genericGatewayGPT56AccountAccessError(account, "claude-haiku-4-5", service.ChannelMappingResult{})
+ require.False(t, reject)
+
+ _, _, _, reject = genericGatewayGPT56AccountAccessError(account, "claude-sonnet-4-6", service.ChannelMappingResult{Mapped: true, MappedModel: "claude-haiku-4-5"})
+ require.False(t, reject)
+}
diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go
index 7b082b07ff6..5fabdea54ac 100644
--- a/backend/internal/handler/gateway_handler.go
+++ b/backend/internal/handler/gateway_handler.go
@@ -15,6 +15,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
pkgerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
@@ -37,6 +38,7 @@ var gatewayCompatibilityMetricsLogCounter atomic.Uint64
// GatewayHandler handles API gateway requests
type GatewayHandler struct {
gatewayService *service.GatewayService
+ modelsProvider gatewayModelsProvider
geminiCompatService *service.GeminiMessagesCompatService
antigravityGatewayService *service.AntigravityGatewayService
userService *service.UserService
@@ -45,6 +47,7 @@ type GatewayHandler struct {
apiKeyService *service.APIKeyService
usageRecordWorkerPool *service.UsageRecordWorkerPool
errorPassthroughService *service.ErrorPassthroughService
+ contentModerationService *service.ContentModerationService
concurrencyHelper *ConcurrencyHelper
userMsgQueueHelper *UserMsgQueueHelper
maxAccountSwitches int
@@ -65,6 +68,7 @@ func NewGatewayHandler(
apiKeyService *service.APIKeyService,
usageRecordWorkerPool *service.UsageRecordWorkerPool,
errorPassthroughService *service.ErrorPassthroughService,
+ contentModerationService *service.ContentModerationService,
userMsgQueueService *service.UserMessageQueueService,
cfg *config.Config,
settingService *service.SettingService,
@@ -90,6 +94,7 @@ func NewGatewayHandler(
return &GatewayHandler{
gatewayService: gatewayService,
+ modelsProvider: gatewayService,
geminiCompatService: geminiCompatService,
antigravityGatewayService: antigravityGatewayService,
userService: userService,
@@ -98,6 +103,7 @@ func NewGatewayHandler(
apiKeyService: apiKeyService,
usageRecordWorkerPool: usageRecordWorkerPool,
errorPassthroughService: errorPassthroughService,
+ contentModerationService: contentModerationService,
concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatClaude, pingInterval),
userMsgQueueHelper: umqHelper,
maxAccountSwitches: maxAccountSwitches,
@@ -107,6 +113,10 @@ func NewGatewayHandler(
}
}
+type gatewayModelsProvider interface {
+ GetAvailableModels(ctx context.Context, groupID *int64, platform string) []string
+}
+
// Messages handles Claude API compatible messages endpoint
// POST /v1/messages
func (h *GatewayHandler) Messages(c *gin.Context) {
@@ -147,6 +157,27 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
return
}
+ // Strip placeholder empty-thinking blocks emitted by buggy CC clients before
+ // forwarding upstream. Anthropic /v1/messages rejects {"type":"thinking",
+ // "thinking":""} with a 400 schema error which surfaces to customers as a
+ // mid-task abort. Real signed thinking continuations are preserved. See
+ // apicompat/sanitize.go for the full rationale.
+ if cleaned, removed, sanErr := apicompat.SanitizeAnthropicRequestBody(body); removed > 0 {
+ body = cleaned
+ reqLog.Info("sanitized empty thinking blocks before upstream forward",
+ zap.Int("removed", removed),
+ zap.String("path", "anthropic_native"),
+ )
+ } else if errors.Is(sanErr, apicompat.ErrAnthropicSanitizeBodyTooLarge) {
+ h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", "Request body exceeds the compatibility sanitization limit")
+ return
+ } else if sanErr != nil {
+ reqLog.Warn("sanitize anthropic body parse error (non-fatal, forwarding unchanged)",
+ zap.Error(sanErr),
+ zap.String("path", "anthropic_native"),
+ )
+ }
+
setOpsRequestContext(c, "", false, body)
parsedReq, err := service.ParseGatewayRequest(body, domain.PlatformAnthropic)
@@ -154,12 +185,34 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
return
}
+ billingRequestCtx, err := service.PrepareUsageBillingRequestContext(c.Request.Context())
+ if err != nil {
+ h.errorResponse(c, http.StatusServiceUnavailable, "billing_service_error", "Billing service temporarily unavailable")
+ return
+ }
+ c.Request = c.Request.WithContext(billingRequestCtx)
reqModel := parsedReq.Model
reqStream := parsedReq.Stream
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
+ setOpsRequestContext(c, reqModel, reqStream, body)
+ setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
+
+ // 验证 model 必填,并在进入非 OpenAI 调度前封住 GPT-5.6。
+ if reqModel == "" {
+ h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required")
+ return
+ }
+ if status, errType, message, reject := genericGatewayGPT56AccessError(reqModel); reject {
+ h.errorResponse(c, status, errType, message)
+ return
+ }
// 解析渠道级模型映射
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
+ if status, errType, message, reject := genericGatewayGPT56MappedAccessError(channelMapping); reject {
+ h.errorResponse(c, status, errType, message)
+ return
+ }
// 设置 max_tokens=1 + haiku 探测请求标识到 context 中
// 必须在 SetClaudeCodeClientContext 之前设置,因为 ClaudeCodeValidator 需要读取此标识进行绕过判断
@@ -180,12 +233,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 在请求上下文中记录 thinking 状态,供 Antigravity 最终模型 key 推导/模型维度限流使用
c.Request = c.Request.WithContext(service.WithThinkingEnabled(c.Request.Context(), parsedReq.ThinkingEnabled, h.metadataBridgeEnabled()))
- setOpsRequestContext(c, reqModel, reqStream, body)
- setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
-
- // 验证 model 必填
- if reqModel == "" {
- h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required")
+ if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && decision.Blocked {
+ h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
return
}
@@ -323,7 +372,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
zap.String("platform", platform),
zap.Error(err),
)
- h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
+ h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted)
return
}
action := fs.HandleSelectionExhausted(c.Request.Context())
@@ -345,6 +394,13 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
account := selection.Account
setOpsSelectedAccount(c, account.ID, account.Platform)
+ if status, errType, message, reject := genericGatewayGPT56AccountAccessError(account, reqModel, channelMapping); reject {
+ if selection.Acquired && selection.ReleaseFunc != nil {
+ selection.ReleaseFunc()
+ }
+ h.errorResponse(c, status, errType, message)
+ return
+ }
// 检查请求拦截(预热请求、SUGGESTION MODE等)
if account.IsInterceptWarmupEnabled() {
@@ -425,6 +481,40 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
if fs.SwitchCount > 0 {
requestCtx = service.WithAccountSwitchCount(requestCtx, fs.SwitchCount, h.metadataBridgeEnabled())
}
+ var usageBillingIdentity *service.GatewayUsageBillingIdentity
+ if subscription != nil && subscription.IsWalletMode() {
+ usageBillingIdentity, err = h.gatewayService.PrepareGatewayWalletUsageBillingAdmission(
+ requestCtx, apiKey, apiKey.User, account, subscription, parsedReq,
+ )
+ if err != nil {
+ if accountReleaseFunc != nil {
+ accountReleaseFunc()
+ }
+ status := http.StatusServiceUnavailable
+ code := "billing_service_error"
+ message := "Billing service temporarily unavailable"
+ if errors.Is(err, service.ErrWalletInsufficient) {
+ status, code, message, _ = billingErrorDetails(err)
+ }
+ h.handleStreamingAwareError(c, status, code, message, streamStarted)
+ return
+ }
+ }
+ markBillingAttemptFailed := func() {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingAttemptFailed(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.billing_attempt_fail_mark_failed", zap.Error(lifecycleErr))
+ }
+ }
+ markBillingOrphaned := func() {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingOrphaned(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.billing_attempt_orphan_mark_failed", zap.Error(lifecycleErr))
+ }
+ }
+ abandonBilling := func() {
+ if lifecycleErr := h.gatewayService.AbandonGatewayUsageBilling(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.billing_attempt_abandon_failed", zap.Error(lifecycleErr))
+ }
+ }
// 记录 Forward 前已写入字节数,Forward 后若增加则说明 SSE 内容已发,禁止 failover
writerSizeBeforeForward := c.Writer.Size()
if account.Platform == service.PlatformAntigravity {
@@ -440,20 +530,25 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
if errors.As(err, &failoverErr) {
// 流式内容已写入客户端,无法撤销,禁止 failover 以防止流拼接腐化
if c.Writer.Size() != writerSizeBeforeForward {
+ markBillingOrphaned()
h.handleFailoverExhausted(c, failoverErr, service.PlatformGemini, true)
return
}
+ markBillingAttemptFailed()
action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, failoverErr)
switch action {
case FailoverContinue:
continue
case FailoverExhausted:
+ abandonBilling()
h.handleFailoverExhausted(c, fs.LastFailoverErr, service.PlatformGemini, streamStarted)
return
case FailoverCanceled:
+ abandonBilling()
return
}
}
+ markBillingOrphaned()
wroteFallback := h.ensureForwardErrorResponse(c, streamStarted)
forwardFailedFields := []zap.Field{
zap.Int64("account_id", account.ID),
@@ -475,7 +570,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
reqLog.Error("gateway.forward_failed", forwardFailedFields...)
return
}
-
+ if result != nil {
+ result.UsageBillingIdentity = usageBillingIdentity
+ }
// RPM 计数递增(Forward 成功后)
// 注意:TOCTOU 竞态是已知且可接受的设计权衡,与 WindowCost 一致的 soft-limit 模式。
// 在高并发下可能短暂超出 RPM 限制,但不会导致请求失败。
@@ -497,7 +594,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
- h.submitUsageRecordTask(func(ctx context.Context) {
+ h.submitGatewayUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
ParsedRequest: parsedReq,
@@ -514,6 +611,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
APIKeyService: h.apiKeyService,
ChannelUsageFields: channelMapping.ToUsageFields(reqModel, result.UpstreamModel),
}); err != nil {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingOrphaned(ctx, result.UsageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.billing_result_orphan_mark_failed", zap.Error(lifecycleErr))
+ }
logger.L().With(
zap.String("component", "handler.gateway.messages"),
zap.Int64("user_id", subject.UserID),
@@ -565,7 +665,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
zap.Bool("fallback_used", fallbackUsed),
zap.Error(err),
)
- h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
+ h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted)
return
}
action := fs.HandleSelectionExhausted(c.Request.Context())
@@ -587,6 +687,13 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
account := selection.Account
setOpsSelectedAccount(c, account.ID, account.Platform)
+ if status, errType, message, reject := genericGatewayGPT56AccountAccessError(account, reqModel, channelMapping); reject {
+ if selection.Acquired && selection.ReleaseFunc != nil {
+ selection.ReleaseFunc()
+ }
+ h.errorResponse(c, status, errType, message)
+ return
+ }
// [DEBUG-STICKY] 打印账号选择结果
reqLog.Info("sticky.account_selected",
@@ -741,9 +848,65 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
if fs.SwitchCount > 0 {
requestCtx = service.WithAccountSwitchCount(requestCtx, fs.SwitchCount, h.metadataBridgeEnabled())
}
+ var usageBillingIdentity *service.GatewayUsageBillingIdentity
+ if currentSubscription != nil && currentSubscription.IsWalletMode() {
+ usageBillingIdentity, err = h.gatewayService.PrepareGatewayWalletUsageBillingAdmission(
+ requestCtx, currentAPIKey, currentAPIKey.User, account, currentSubscription, parsedReq,
+ )
+ if err != nil {
+ if queueRelease != nil {
+ queueRelease()
+ }
+ parsedReq.OnUpstreamAccepted = nil
+ if accountReleaseFunc != nil {
+ accountReleaseFunc()
+ }
+ status := http.StatusServiceUnavailable
+ code := "billing_service_error"
+ message := "Billing service temporarily unavailable"
+ switch {
+ case errors.Is(err, service.ErrWalletInsufficient):
+ status, code, message, _ = billingErrorDetails(err)
+ case errors.Is(err, service.ErrUsageBillingRequestConflict),
+ errors.Is(err, service.ErrUsageBillingAdmissionFinalized):
+ status = http.StatusConflict
+ code = "billing_request_conflict"
+ message = "Billing request identity conflict"
+ }
+ h.handleStreamingAwareError(c, status, code, message, streamStarted)
+ return
+ }
+ }
+ markBillingAttemptFailed := func() {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingAttemptFailed(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.billing_attempt_fail_mark_failed", zap.Error(lifecycleErr))
+ }
+ }
+ markBillingOrphaned := func() {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingOrphaned(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.billing_attempt_orphan_mark_failed", zap.Error(lifecycleErr))
+ }
+ }
+ abandonBilling := func() {
+ if lifecycleErr := h.gatewayService.AbandonGatewayUsageBilling(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.billing_attempt_abandon_failed", zap.Error(lifecycleErr))
+ }
+ }
// 记录 Forward 前已写入字节数,Forward 后若增加则说明 SSE 内容已发,禁止 failover
writerSizeBeforeForward := c.Writer.Size()
- if account.Platform == service.PlatformAntigravity && account.Type != service.AccountTypeAPIKey {
+ if account.Platform == service.PlatformKiro {
+ result, err = h.forwardKiroSidecar(c, account, kiroSidecarRequest{
+ Method: http.MethodPost,
+ Path: "/v1/messages",
+ Model: reqModel,
+ UpstreamBody: body,
+ RequestBody: body,
+ Stream: reqStream,
+ RecordUsage: true,
+ Parsed: parsedReq,
+ Mapping: channelMapping,
+ })
+ } else if account.Platform == service.PlatformAntigravity && account.Type != service.AccountTypeAPIKey {
result, err = h.antigravityGatewayService.Forward(requestCtx, c, account, body, hasBoundSession)
} else {
result, err = h.gatewayService.Forward(requestCtx, c, account, parsedReq)
@@ -763,12 +926,15 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// Beta policy block: return 400 immediately, no failover
var betaBlockedErr *service.BetaBlockedError
if errors.As(err, &betaBlockedErr) {
+ markBillingAttemptFailed()
+ abandonBilling()
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", betaBlockedErr.Message)
return
}
var promptTooLongErr *service.PromptTooLongError
if errors.As(err, &promptTooLongErr) {
+ markBillingAttemptFailed()
reqLog.Warn("gateway.prompt_too_long_from_antigravity",
zap.Any("current_group_id", currentAPIKey.GroupID),
zap.Any("fallback_group_id", fallbackGroupID),
@@ -779,6 +945,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
if err != nil {
reqLog.Warn("gateway.resolve_fallback_group_failed", zap.Int64("fallback_group_id", *fallbackGroupID), zap.Error(err))
_ = h.antigravityGatewayService.WriteMappedClaudeError(c, account, promptTooLongErr.StatusCode, promptTooLongErr.RequestID, promptTooLongErr.Body)
+ abandonBilling()
return
}
if fallbackGroup.Platform != service.PlatformAnthropic ||
@@ -790,6 +957,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
zap.String("fallback_subscription_type", fallbackGroup.SubscriptionType),
)
_ = h.antigravityGatewayService.WriteMappedClaudeError(c, account, promptTooLongErr.StatusCode, promptTooLongErr.RequestID, promptTooLongErr.Body)
+ abandonBilling()
return
}
fallbackAPIKey := cloneAPIKeyWithGroup(apiKey, fallbackGroup)
@@ -799,6 +967,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.handleStreamingAwareError(c, status, code, message, streamStarted)
+ abandonBilling()
return
}
// 兜底重试按"直接请求兜底分组"处理:清除强制平台,允许按分组平台调度
@@ -811,26 +980,32 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
break
}
_ = h.antigravityGatewayService.WriteMappedClaudeError(c, account, promptTooLongErr.StatusCode, promptTooLongErr.RequestID, promptTooLongErr.Body)
+ abandonBilling()
return
}
var failoverErr *service.UpstreamFailoverError
if errors.As(err, &failoverErr) {
// 流式内容已写入客户端,无法撤销,禁止 failover 以防止流拼接腐化
if c.Writer.Size() != writerSizeBeforeForward {
+ markBillingOrphaned()
h.handleFailoverExhausted(c, failoverErr, account.Platform, true)
return
}
+ markBillingAttemptFailed()
action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, failoverErr)
switch action {
case FailoverContinue:
continue
case FailoverExhausted:
+ abandonBilling()
h.handleFailoverExhausted(c, fs.LastFailoverErr, account.Platform, streamStarted)
return
case FailoverCanceled:
+ abandonBilling()
return
}
}
+ markBillingOrphaned()
wroteFallback := h.ensureForwardErrorResponse(c, streamStarted)
forwardFailedFields := []zap.Field{
zap.Int64("account_id", account.ID),
@@ -852,6 +1027,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
reqLog.Error("gateway.forward_failed", forwardFailedFields...)
return
}
+ if result != nil {
+ result.UsageBillingIdentity = usageBillingIdentity
+ }
// RPM 计数递增(Forward 成功后)
// 注意:TOCTOU 竞态是已知且可接受的设计权衡,与 WindowCost 一致的 soft-limit 模式。
@@ -885,7 +1063,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
- h.submitUsageRecordTask(func(ctx context.Context) {
+ h.submitGatewayUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
ParsedRequest: parsedReq,
@@ -902,6 +1080,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
APIKeyService: h.apiKeyService,
ChannelUsageFields: channelMapping.ToUsageFields(reqModel, result.UpstreamModel),
}); err != nil {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingOrphaned(ctx, result.UsageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.billing_result_orphan_mark_failed", zap.Error(lifecycleErr))
+ }
logger.L().With(
zap.String("component", "handler.gateway.messages"),
zap.Int64("user_id", subject.UserID),
@@ -938,8 +1119,32 @@ func (h *GatewayHandler) Models(c *gin.Context) {
platform = forcedPlatform
}
+ if shouldHideGatewayModelListForClient(c.GetHeader("User-Agent")) {
+ c.JSON(http.StatusOK, gin.H{
+ "object": "list",
+ "data": []any{},
+ })
+ return
+ }
+
+ if visibility, ok := middleware2.GetWalletModelVisibilityFromContext(c); ok &&
+ apiKey != nil && apiKey.User != nil && visibility.APIKeyID == apiKey.ID &&
+ apiKey.IsWalletUniversal() && service.IsWalletUniversalKeyName(apiKey.Name) {
+ h.writeWalletVisibleModels(c, apiKey.User, visibility)
+ return
+ }
+
+ provider := h.modelsProvider
+ if provider == nil && h.gatewayService != nil {
+ provider = h.gatewayService
+ }
+ if provider == nil {
+ h.errorResponse(c, http.StatusInternalServerError, "api_error", "Model service is not configured")
+ return
+ }
+
// Get available models from account configurations (without platform filter)
- availableModels := h.gatewayService.GetAvailableModels(c.Request.Context(), groupID, "")
+ availableModels := provider.GetAvailableModels(c.Request.Context(), groupID, "")
if len(availableModels) > 0 {
// Build model list from whitelist
@@ -974,6 +1179,83 @@ func (h *GatewayHandler) Models(c *gin.Context) {
})
}
+func (h *GatewayHandler) writeWalletVisibleModels(c *gin.Context, user *service.User, visibility middleware2.WalletModelVisibility) {
+ provider := h.modelsProvider
+ if provider == nil && h.gatewayService != nil {
+ provider = h.gatewayService
+ }
+ if provider == nil {
+ h.errorResponse(c, http.StatusInternalServerError, "api_error", "Model service is not configured")
+ return
+ }
+
+ models := make([]any, 0)
+ seenGroups := make(map[int64]struct{}, len(visibility.Groups))
+ seenModels := make(map[string]struct{})
+ appendModel := func(modelID string, model any) {
+ if _, duplicate := seenModels[modelID]; duplicate {
+ return
+ }
+ seenModels[modelID] = struct{}{}
+ models = append(models, model)
+ }
+
+ for i := range visibility.Groups {
+ group := &visibility.Groups[i]
+ if _, duplicate := seenGroups[group.ID]; duplicate || !service.CanUseWalletGroup(user, group) {
+ continue
+ }
+ seenGroups[group.ID] = struct{}{}
+ availableModels := provider.GetAvailableModels(c.Request.Context(), &group.ID, "")
+ if len(availableModels) > 0 {
+ for _, modelID := range availableModels {
+ if routedGroupName, ok := service.WalletModelRouteGroupName(visibility.Routes, modelID); !ok || routedGroupName != group.Name {
+ continue
+ }
+ appendModel(modelID, claude.Model{
+ ID: modelID, Type: "model", DisplayName: modelID,
+ CreatedAt: "2024-01-01T00:00:00Z",
+ })
+ }
+ continue
+ }
+
+ switch group.Platform {
+ case service.PlatformOpenAI:
+ for _, model := range openai.DefaultModels {
+ if routedGroupName, ok := service.WalletModelRouteGroupName(visibility.Routes, model.ID); ok && routedGroupName == group.Name {
+ appendModel(model.ID, model)
+ }
+ }
+ case service.PlatformAnthropic:
+ for _, model := range claude.DefaultModels {
+ if routedGroupName, ok := service.WalletModelRouteGroupName(visibility.Routes, model.ID); ok && routedGroupName == group.Name {
+ appendModel(model.ID, model)
+ }
+ }
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "object": "list",
+ "data": models,
+ })
+}
+
+func shouldHideGatewayModelListForClient(userAgent string) bool {
+ normalized := strings.ToLower(strings.TrimSpace(userAgent))
+ compact := strings.NewReplacer("-", "", "_", "", " ", "").Replace(normalized)
+ if compact == "claudecode" ||
+ compact == "claudecli" ||
+ strings.HasPrefix(compact, "claudecode/") ||
+ strings.HasPrefix(compact, "claudecli/") ||
+ strings.HasPrefix(compact, "claudecode(") ||
+ strings.HasPrefix(compact, "claudecli(") {
+ return true
+ }
+ return service.NewClaudeCodeValidator().ValidateUserAgent(userAgent)
+}
+
// AntigravityModels 返回 Antigravity 支持的全部模型
// GET /antigravity/models
func (h *GatewayHandler) AntigravityModels(c *gin.Context) {
@@ -1181,36 +1463,57 @@ func (h *GatewayHandler) usageQuotaLimited(c *gin.Context, ctx context.Context,
if modelStats != nil {
resp["model_stats"] = modelStats
}
+ subscription, _ := middleware2.GetSubscriptionFromContext(c)
+ if rateCtx := h.usageRateContext(apiKey, subscription); rateCtx != nil {
+ resp["rate_context"] = rateCtx
+ }
c.JSON(http.StatusOK, resp)
}
// usageUnrestricted 处理 unrestricted 模式的响应(向后兼容)
func (h *GatewayHandler) usageUnrestricted(c *gin.Context, ctx context.Context, apiKey *service.APIKey, subject middleware2.AuthSubject, usageData gin.H, modelStats any) {
+ subscription, hasSubscription := middleware2.GetSubscriptionFromContext(c)
+ isSubscriptionBilling, effectiveGroup := service.EffectiveBillingContext(apiKey.Group, subscription)
+
// 订阅模式
- if apiKey.Group != nil && apiKey.Group.IsSubscriptionType() {
+ if hasSubscription && isSubscriptionBilling {
resp := gin.H{
"mode": "unrestricted",
"isValid": true,
- "planName": apiKey.Group.Name,
+ "planName": usagePlanName(apiKey.Group, effectiveGroup, subscription),
"unit": "USD",
}
-
- // 订阅信息可能不在 context 中(/v1/usage 路径跳过了中间件的计费检查)
- subscription, ok := middleware2.GetSubscriptionFromContext(c)
- if ok {
- remaining := h.calculateSubscriptionRemaining(apiKey.Group, subscription)
+ if subscription.IsWalletMode() {
+ remaining := 0.0
+ if subscription.WalletBalanceUSD != nil {
+ remaining = *subscription.WalletBalanceUSD
+ }
+ resp["remaining"] = remaining
+ resp["balance"] = remaining
+ resp["subscription"] = gin.H{
+ "wallet_balance_usd": subscription.WalletBalanceUSD,
+ "wallet_initial_usd": subscription.WalletInitialUSD,
+ "locked_rates": subscription.LockedRates,
+ "expires_at": subscription.ExpiresAt,
+ }
+ } else if effectiveGroup != nil {
+ remaining := h.calculateSubscriptionRemaining(effectiveGroup, subscription)
resp["remaining"] = remaining
resp["subscription"] = gin.H{
"daily_usage_usd": subscription.DailyUsageUSD,
"weekly_usage_usd": subscription.WeeklyUsageUSD,
"monthly_usage_usd": subscription.MonthlyUsageUSD,
- "daily_limit_usd": apiKey.Group.DailyLimitUSD,
- "weekly_limit_usd": apiKey.Group.WeeklyLimitUSD,
- "monthly_limit_usd": apiKey.Group.MonthlyLimitUSD,
+ "daily_limit_usd": effectiveGroup.DailyLimitUSD,
+ "weekly_limit_usd": effectiveGroup.WeeklyLimitUSD,
+ "monthly_limit_usd": effectiveGroup.MonthlyLimitUSD,
+ "locked_rates": subscription.LockedRates,
"expires_at": subscription.ExpiresAt,
}
}
+ if rateCtx := h.usageRateContext(apiKey, subscription); rateCtx != nil {
+ resp["rate_context"] = rateCtx
+ }
if usageData != nil {
resp["usage"] = usageData
@@ -1222,6 +1525,26 @@ func (h *GatewayHandler) usageUnrestricted(c *gin.Context, ctx context.Context,
return
}
+ if apiKey.Group != nil && apiKey.Group.IsSubscriptionType() {
+ resp := gin.H{
+ "mode": "unrestricted",
+ "isValid": true,
+ "planName": apiKey.Group.Name,
+ "unit": "USD",
+ }
+ if rateCtx := h.usageRateContext(apiKey, nil); rateCtx != nil {
+ resp["rate_context"] = rateCtx
+ }
+ if usageData != nil {
+ resp["usage"] = usageData
+ }
+ if modelStats != nil {
+ resp["model_stats"] = modelStats
+ }
+ c.JSON(http.StatusOK, resp)
+ return
+ }
+
// 余额模式
latestUser, err := h.userService.GetByID(ctx, subject.UserID)
if err != nil {
@@ -1243,9 +1566,48 @@ func (h *GatewayHandler) usageUnrestricted(c *gin.Context, ctx context.Context,
if modelStats != nil {
resp["model_stats"] = modelStats
}
+ if rateCtx := h.usageRateContext(apiKey, nil); rateCtx != nil {
+ resp["rate_context"] = rateCtx
+ }
c.JSON(http.StatusOK, resp)
}
+func (h *GatewayHandler) usageRateContext(apiKey *service.APIKey, subscription *service.UserSubscription) gin.H {
+ if apiKey == nil {
+ return nil
+ }
+ systemDefault := 1.0
+ if h != nil && h.cfg != nil {
+ systemDefault = h.cfg.Default.RateMultiplier
+ }
+ resolution := service.ResolveSubscriptionDisplayRateMultiplier(apiKey.GroupID, apiKey.Group, subscription, systemDefault)
+ out := gin.H{
+ "effective_rate_multiplier": resolution.Multiplier,
+ "rate_source": resolution.Source,
+ }
+ if subscription != nil && len(subscription.LockedRates) > 0 {
+ out["locked_rates"] = subscription.LockedRates
+ }
+ if apiKey.Group != nil {
+ out["group_id"] = apiKey.Group.ID
+ out["group_rate_multiplier"] = apiKey.Group.RateMultiplier
+ }
+ return out
+}
+
+func usagePlanName(calledGroup, effectiveGroup *service.Group, subscription *service.UserSubscription) string {
+ if subscription != nil && subscription.IsWalletMode() {
+ return "钱包余额"
+ }
+ if effectiveGroup != nil && effectiveGroup.Name != "" {
+ return effectiveGroup.Name
+ }
+ if calledGroup != nil && calledGroup.Name != "" {
+ return calledGroup.Name
+ }
+ return "订阅"
+}
+
// calculateSubscriptionRemaining 计算订阅剩余可用额度
// 逻辑:
// 1. 如果日/周/月任一限额达到100%,返回0
@@ -1314,11 +1676,7 @@ func (h *GatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *se
respCode = *rule.ResponseCode
}
- // 确定响应消息
- msg := service.ExtractUpstreamErrorMessage(responseBody)
- if !rule.PassthroughBody && rule.CustomMessage != nil {
- msg = *rule.CustomMessage
- }
+ msg := service.ErrorPassthroughClientMessage(rule, "Upstream request failed")
if rule.SkipMonitoring {
c.Set(service.OpsSkipPassthroughKey, true)
@@ -1486,6 +1844,27 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
return
}
+ // Strip placeholder empty-thinking blocks emitted by buggy CC clients before
+ // forwarding upstream. Anthropic /v1/messages rejects {"type":"thinking",
+ // "thinking":""} with a 400 schema error which surfaces to customers as a
+ // mid-task abort. Real signed thinking continuations are preserved. See
+ // apicompat/sanitize.go for the full rationale.
+ if cleaned, removed, sanErr := apicompat.SanitizeAnthropicRequestBody(body); removed > 0 {
+ body = cleaned
+ reqLog.Info("sanitized empty thinking blocks before upstream forward",
+ zap.Int("removed", removed),
+ zap.String("path", "anthropic_native"),
+ )
+ } else if errors.Is(sanErr, apicompat.ErrAnthropicSanitizeBodyTooLarge) {
+ h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", "Request body exceeds the compatibility sanitization limit")
+ return
+ } else if sanErr != nil {
+ reqLog.Warn("sanitize anthropic body parse error (non-fatal, forwarding unchanged)",
+ zap.Error(sanErr),
+ zap.String("path", "anthropic_native"),
+ )
+ }
+
setOpsRequestContext(c, "", false, body)
parsedReq, err := service.ParseGatewayRequest(body, domain.PlatformAnthropic)
@@ -1507,6 +1886,10 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
setOpsRequestContext(c, parsedReq.Model, parsedReq.Stream, body)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(parsedReq.Stream, false)))
+ if status, errType, message, reject := genericGatewayGPT56AccessError(parsedReq.Model); reject {
+ h.errorResponse(c, status, errType, message)
+ return
+ }
// 获取订阅信息(可能为nil)
subscription, _ := middleware2.GetSubscriptionFromContext(c)
@@ -1538,8 +1921,29 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
return
}
setOpsSelectedAccount(c, account.ID, account.Platform)
+ if status, errType, message, reject := genericGatewayGPT56AccountAccessError(account, parsedReq.Model, service.ChannelMappingResult{}); reject {
+ h.errorResponse(c, status, errType, message)
+ return
+ }
// 转发请求(不记录使用量)
+ if account.Platform == service.PlatformKiro {
+ _, err := h.forwardKiroSidecar(c, account, kiroSidecarRequest{
+ Method: http.MethodPost,
+ Path: "/v1/messages/count_tokens",
+ Model: parsedReq.Model,
+ UpstreamBody: body,
+ RequestBody: body,
+ Stream: false,
+ RecordUsage: false,
+ Parsed: parsedReq,
+ Mapping: service.ChannelMappingResult{MappedModel: parsedReq.Model},
+ })
+ if err != nil {
+ reqLog.Error("gateway.count_tokens_kiro_forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
+ }
+ return
+ }
if err := h.gatewayService.ForwardCountTokens(c.Request.Context(), c, account, parsedReq); err != nil {
reqLog.Error("gateway.count_tokens_forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
// 错误响应已在 ForwardCountTokens 中处理
@@ -1787,6 +2191,18 @@ func billingErrorDetails(err error) (status int, code, message string, retryAfte
retrySeconds := 60 - int(time.Now().Unix()%60)
return http.StatusTooManyRequests, "rate_limit_exceeded", msg, retrySeconds
}
+ // 钱包模式 (v4) 余额耗尽 → HTTP 402 Payment Required,让 SDK / 前端
+ // 区分「续费」与一般 billing 错误。HTTP 边界统一 +1
+ // wallet_insufficient_total,无论上游 pre-check 还是 billing 后置阶段命中。
+ if errors.Is(err, service.ErrWalletInsufficient) {
+ service.IncWalletInsufficientTotal()
+ msg := pkgerrors.Message(err)
+ return http.StatusPaymentRequired, "wallet_insufficient", msg, 0
+ }
+ if errors.Is(err, service.ErrTrialPaymentBindingRequired) {
+ msg := pkgerrors.Message(err)
+ return http.StatusPaymentRequired, "trial_payment_binding_required", msg, 0
+ }
msg := pkgerrors.Message(err)
if msg == "" {
logger.L().With(
@@ -1822,17 +2238,10 @@ func (h *GatewayHandler) maybeLogCompatibilityFallbackMetrics(reqLog *zap.Logger
)
}
-func (h *GatewayHandler) submitUsageRecordTask(task service.UsageRecordTask) {
+func (h *GatewayHandler) submitUsageRecordTask(requestCtx context.Context, task service.UsageRecordTask) {
if task == nil {
return
}
- if h.usageRecordWorkerPool != nil {
- h.usageRecordWorkerPool.Submit(task)
- return
- }
- // 回退路径:worker 池未注入时同步执行,避免退回到无界 goroutine 模式。
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
defer func() {
if recovered := recover(); recovered != nil {
logger.L().With(
@@ -1841,7 +2250,34 @@ func (h *GatewayHandler) submitUsageRecordTask(task service.UsageRecordTask) {
).Error("gateway.usage_record_task_panic_recovered")
}
}()
- task(ctx)
+ billingCtx := context.Background()
+ if requestCtx != nil {
+ billingCtx = context.WithoutCancel(requestCtx)
+ }
+ task(billingCtx)
+}
+
+func (h *GatewayHandler) submitGatewayUsageRecordTask(
+ requestCtx context.Context,
+ result *service.ForwardResult,
+ task service.UsageRecordTask,
+) {
+ if task == nil {
+ return
+ }
+ h.submitUsageRecordTask(requestCtx, func(ctx context.Context) {
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ if result != nil && h != nil && h.gatewayService != nil {
+ if err := h.gatewayService.MarkGatewayUsageBillingOrphaned(ctx, result.UsageBillingIdentity); err != nil {
+ logger.L().Error("gateway.billing_result_orphan_mark_failed", zap.Error(err))
+ }
+ }
+ panic(recovered)
+ }
+ }()
+ task(ctx)
+ })
}
// getUserMsgQueueMode 获取当前请求的 UMQ 模式
diff --git a/backend/internal/handler/gateway_handler_billing_error_test.go b/backend/internal/handler/gateway_handler_billing_error_test.go
index e8a88802903..8270a1d1a41 100644
--- a/backend/internal/handler/gateway_handler_billing_error_test.go
+++ b/backend/internal/handler/gateway_handler_billing_error_test.go
@@ -52,3 +52,20 @@ func TestBillingErrorDetails_UnknownErrorFallsBackTo403(t *testing.T) {
require.Equal(t, "billing_error", code)
require.NotEmpty(t, msg)
}
+
+// 钱包模式 (v4) 余额耗尽必须 → 402,前端据此跳「续费」流程。
+func TestBillingErrorDetails_WalletInsufficientMapsTo402(t *testing.T) {
+ status, code, msg, retryAfter := billingErrorDetails(service.ErrWalletInsufficient)
+ require.Equal(t, http.StatusPaymentRequired, status)
+ require.Equal(t, "wallet_insufficient", code)
+ require.NotEmpty(t, msg)
+ require.Equal(t, 0, retryAfter)
+}
+
+func TestBillingErrorDetails_TrialPaymentBindingRequiredMapsTo402(t *testing.T) {
+ status, code, msg, retryAfter := billingErrorDetails(service.ErrTrialPaymentBindingRequired)
+ require.Equal(t, http.StatusPaymentRequired, status)
+ require.Equal(t, "trial_payment_binding_required", code)
+ require.NotEmpty(t, msg)
+ require.Equal(t, 0, retryAfter)
+}
diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go
index 4290e54bfbb..b8744ddf772 100644
--- a/backend/internal/handler/gateway_handler_chat_completions.go
+++ b/backend/internal/handler/gateway_handler_chat_completions.go
@@ -80,9 +80,17 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream, body)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
+ if status, errType, message, reject := genericGatewayGPT56AccessError(reqModel); reject {
+ h.chatCompletionsErrorResponse(c, status, errType, message)
+ return
+ }
// 解析渠道级模型映射
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
+ if status, errType, message, reject := genericGatewayGPT56MappedAccessError(channelMapping); reject {
+ h.chatCompletionsErrorResponse(c, status, errType, message)
+ return
+ }
// Claude Code only restriction
if apiKey.Group != nil && apiKey.Group.ClaudeCodeOnly {
@@ -91,12 +99,23 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
return
}
+ if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, reqModel, body); decision != nil && decision.Blocked {
+ h.chatCompletionsErrorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
+ return
+ }
+
// Error passthrough binding
if h.errorPassthroughService != nil {
service.BindErrorPassthroughService(c, h.errorPassthroughService)
}
subscription, _ := middleware2.GetSubscriptionFromContext(c)
+ billingRequestCtx, err := service.PrepareUsageBillingRequestContext(c.Request.Context())
+ if err != nil {
+ h.chatCompletionsErrorResponse(c, http.StatusServiceUnavailable, "billing_service_error", "Billing service temporarily unavailable")
+ return
+ }
+ c.Request = c.Request.WithContext(billingRequestCtx)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
@@ -164,7 +183,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
- h.chatCompletionsErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error())
+ h.chatCompletionsErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts")
return
}
action := fs.HandleSelectionExhausted(c.Request.Context())
@@ -184,6 +203,13 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
}
account := selection.Account
setOpsSelectedAccount(c, account.ID, account.Platform)
+ if status, errType, message, reject := genericGatewayGPT56AccountAccessError(account, reqModel, channelMapping); reject {
+ if selection.Acquired && selection.ReleaseFunc != nil {
+ selection.ReleaseFunc()
+ }
+ h.chatCompletionsErrorResponse(c, status, errType, message)
+ return
+ }
// 4. Acquire account concurrency slot
accountReleaseFunc := selection.ReleaseFunc
@@ -214,7 +240,69 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
if channelMapping.Mapped {
forwardBody = h.gatewayService.ReplaceModelInBody(body, channelMapping.MappedModel)
}
- result, err := h.gatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, forwardBody, parsedReq)
+ billingParsedReq := *parsedReq
+ billingParsedReq.Body = forwardBody
+ if channelMapping.Mapped {
+ billingParsedReq.Model = channelMapping.MappedModel
+ }
+ requestCtx := c.Request.Context()
+ if fs.SwitchCount > 0 {
+ requestCtx = service.WithAccountSwitchCount(requestCtx, fs.SwitchCount, h.metadataBridgeEnabled())
+ }
+ var usageBillingIdentity *service.GatewayUsageBillingIdentity
+ if subscription != nil && subscription.IsWalletMode() {
+ usageBillingIdentity, err = h.gatewayService.PrepareGatewayWalletUsageBillingAdmission(
+ requestCtx, apiKey, apiKey.User, account, subscription, &billingParsedReq,
+ )
+ if err != nil {
+ if accountReleaseFunc != nil {
+ accountReleaseFunc()
+ }
+ status := http.StatusServiceUnavailable
+ code := "billing_service_error"
+ message := "Billing service temporarily unavailable"
+ switch {
+ case errors.Is(err, service.ErrWalletInsufficient):
+ status, code, message, _ = billingErrorDetails(err)
+ case errors.Is(err, service.ErrUsageBillingRequestConflict),
+ errors.Is(err, service.ErrUsageBillingAdmissionFinalized):
+ status, code, message = http.StatusConflict, "billing_request_conflict", "Billing request identity conflict"
+ }
+ h.chatCompletionsErrorResponse(c, status, code, message)
+ return
+ }
+ }
+ markBillingAttemptFailed := func() {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingAttemptFailed(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.cc.billing_attempt_fail_mark_failed", zap.Error(lifecycleErr))
+ }
+ }
+ markBillingOrphaned := func() {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingOrphaned(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.cc.billing_attempt_orphan_mark_failed", zap.Error(lifecycleErr))
+ }
+ }
+ abandonBilling := func() {
+ if lifecycleErr := h.gatewayService.AbandonGatewayUsageBilling(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.cc.billing_attempt_abandon_failed", zap.Error(lifecycleErr))
+ }
+ }
+ var result *service.ForwardResult
+ if account.Platform == service.PlatformKiro {
+ result, err = h.forwardKiroSidecar(c, account, kiroSidecarRequest{
+ Method: http.MethodPost,
+ Path: "/v1/chat/completions",
+ Model: reqModel,
+ UpstreamBody: forwardBody,
+ RequestBody: body,
+ Stream: reqStream,
+ RecordUsage: true,
+ Parsed: parsedReq,
+ Mapping: channelMapping,
+ })
+ } else {
+ result, err = h.gatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, forwardBody, parsedReq)
+ }
if accountReleaseFunc != nil {
accountReleaseFunc()
@@ -224,20 +312,25 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
var failoverErr *service.UpstreamFailoverError
if errors.As(err, &failoverErr) {
if c.Writer.Size() != writerSizeBeforeForward {
+ markBillingOrphaned()
h.handleCCFailoverExhausted(c, failoverErr, true)
return
}
+ markBillingAttemptFailed()
action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, failoverErr)
switch action {
case FailoverContinue:
continue
case FailoverExhausted:
+ abandonBilling()
h.handleCCFailoverExhausted(c, fs.LastFailoverErr, streamStarted)
return
case FailoverCanceled:
+ abandonBilling()
return
}
}
+ markBillingOrphaned()
h.ensureForwardErrorResponse(c, streamStarted)
reqLog.Error("gateway.cc.forward_failed",
zap.Int64("account_id", account.ID),
@@ -245,15 +338,18 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
)
return
}
+ if result != nil {
+ result.UsageBillingIdentity = usageBillingIdentity
+ }
// 6. Record usage
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
- requestPayloadHash := service.HashUsageRequestPayload(body)
+ requestPayloadHash := service.HashUsageRequestPayload(forwardBody)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
- h.submitUsageRecordTask(func(ctx context.Context) {
+ h.submitGatewayUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
APIKey: apiKey,
@@ -268,6 +364,9 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
APIKeyService: h.apiKeyService,
ChannelUsageFields: channelMapping.ToUsageFields(reqModel, result.UpstreamModel),
}); err != nil {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingOrphaned(ctx, result.UsageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.cc.billing_result_orphan_mark_failed", zap.Error(lifecycleErr))
+ }
reqLog.Error("gateway.cc.record_usage_failed",
zap.Int64("account_id", account.ID),
zap.Error(err),
diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go
index 683cf2b7719..0adc9402082 100644
--- a/backend/internal/handler/gateway_handler_responses.go
+++ b/backend/internal/handler/gateway_handler_responses.go
@@ -80,9 +80,17 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream, body)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
+ if status, errType, message, reject := genericGatewayGPT56AccessError(reqModel); reject {
+ h.responsesErrorResponse(c, status, errType, message)
+ return
+ }
// 解析渠道级模型映射
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
+ if status, errType, message, reject := genericGatewayGPT56MappedAccessError(channelMapping); reject {
+ h.responsesErrorResponse(c, status, errType, message)
+ return
+ }
// Claude Code only restriction:
// /v1/responses is never a Claude Code endpoint.
@@ -96,12 +104,23 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
return
}
+ if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && decision.Blocked {
+ h.responsesErrorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
+ return
+ }
+
// Error passthrough binding
if h.errorPassthroughService != nil {
service.BindErrorPassthroughService(c, h.errorPassthroughService)
}
subscription, _ := middleware2.GetSubscriptionFromContext(c)
+ billingRequestCtx, err := service.PrepareUsageBillingRequestContext(c.Request.Context())
+ if err != nil {
+ h.responsesErrorResponse(c, http.StatusServiceUnavailable, "billing_service_error", "Billing service temporarily unavailable")
+ return
+ }
+ c.Request = c.Request.WithContext(billingRequestCtx)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
@@ -169,7 +188,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
- h.responsesErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error())
+ h.responsesErrorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts")
return
}
action := fs.HandleSelectionExhausted(c.Request.Context())
@@ -189,6 +208,13 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
}
account := selection.Account
setOpsSelectedAccount(c, account.ID, account.Platform)
+ if status, errType, message, reject := genericGatewayGPT56AccountAccessError(account, reqModel, channelMapping); reject {
+ if selection.Acquired && selection.ReleaseFunc != nil {
+ selection.ReleaseFunc()
+ }
+ h.responsesErrorResponse(c, status, errType, message)
+ return
+ }
// 4. Acquire account concurrency slot
accountReleaseFunc := selection.ReleaseFunc
@@ -219,7 +245,69 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
if channelMapping.Mapped {
forwardBody = h.gatewayService.ReplaceModelInBody(body, channelMapping.MappedModel)
}
- result, err := h.gatewayService.ForwardAsResponses(c.Request.Context(), c, account, forwardBody, parsedReq)
+ billingParsedReq := *parsedReq
+ billingParsedReq.Body = forwardBody
+ if channelMapping.Mapped {
+ billingParsedReq.Model = channelMapping.MappedModel
+ }
+ requestCtx := c.Request.Context()
+ if fs.SwitchCount > 0 {
+ requestCtx = service.WithAccountSwitchCount(requestCtx, fs.SwitchCount, h.metadataBridgeEnabled())
+ }
+ var usageBillingIdentity *service.GatewayUsageBillingIdentity
+ if subscription != nil && subscription.IsWalletMode() {
+ usageBillingIdentity, err = h.gatewayService.PrepareGatewayWalletUsageBillingAdmission(
+ requestCtx, apiKey, apiKey.User, account, subscription, &billingParsedReq,
+ )
+ if err != nil {
+ if accountReleaseFunc != nil {
+ accountReleaseFunc()
+ }
+ status := http.StatusServiceUnavailable
+ code := "billing_service_error"
+ message := "Billing service temporarily unavailable"
+ switch {
+ case errors.Is(err, service.ErrWalletInsufficient):
+ status, code, message, _ = billingErrorDetails(err)
+ case errors.Is(err, service.ErrUsageBillingRequestConflict),
+ errors.Is(err, service.ErrUsageBillingAdmissionFinalized):
+ status, code, message = http.StatusConflict, "billing_request_conflict", "Billing request identity conflict"
+ }
+ h.responsesErrorResponse(c, status, code, message)
+ return
+ }
+ }
+ markBillingAttemptFailed := func() {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingAttemptFailed(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.responses.billing_attempt_fail_mark_failed", zap.Error(lifecycleErr))
+ }
+ }
+ markBillingOrphaned := func() {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingOrphaned(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.responses.billing_attempt_orphan_mark_failed", zap.Error(lifecycleErr))
+ }
+ }
+ abandonBilling := func() {
+ if lifecycleErr := h.gatewayService.AbandonGatewayUsageBilling(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.responses.billing_attempt_abandon_failed", zap.Error(lifecycleErr))
+ }
+ }
+ var result *service.ForwardResult
+ if account.Platform == service.PlatformKiro {
+ result, err = h.forwardKiroSidecar(c, account, kiroSidecarRequest{
+ Method: http.MethodPost,
+ Path: "/v1/responses",
+ Model: reqModel,
+ UpstreamBody: forwardBody,
+ RequestBody: body,
+ Stream: reqStream,
+ RecordUsage: true,
+ Parsed: parsedReq,
+ Mapping: channelMapping,
+ })
+ } else {
+ result, err = h.gatewayService.ForwardAsResponses(c.Request.Context(), c, account, forwardBody, parsedReq)
+ }
if accountReleaseFunc != nil {
accountReleaseFunc()
@@ -230,20 +318,25 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
if errors.As(err, &failoverErr) {
// Can't failover if streaming content already sent
if c.Writer.Size() != writerSizeBeforeForward {
+ markBillingOrphaned()
h.handleResponsesFailoverExhausted(c, failoverErr, true)
return
}
+ markBillingAttemptFailed()
action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, failoverErr)
switch action {
case FailoverContinue:
continue
case FailoverExhausted:
+ abandonBilling()
h.handleResponsesFailoverExhausted(c, fs.LastFailoverErr, streamStarted)
return
case FailoverCanceled:
+ abandonBilling()
return
}
}
+ markBillingOrphaned()
h.ensureForwardErrorResponse(c, streamStarted)
reqLog.Error("gateway.responses.forward_failed",
zap.Int64("account_id", account.ID),
@@ -251,15 +344,18 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
)
return
}
+ if result != nil {
+ result.UsageBillingIdentity = usageBillingIdentity
+ }
// 6. Record usage
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
- requestPayloadHash := service.HashUsageRequestPayload(body)
+ requestPayloadHash := service.HashUsageRequestPayload(forwardBody)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
- h.submitUsageRecordTask(func(ctx context.Context) {
+ h.submitGatewayUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
APIKey: apiKey,
@@ -274,6 +370,9 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
APIKeyService: h.apiKeyService,
ChannelUsageFields: channelMapping.ToUsageFields(reqModel, result.UpstreamModel),
}); err != nil {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingOrphaned(ctx, result.UsageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.responses.billing_result_orphan_mark_failed", zap.Error(lifecycleErr))
+ }
reqLog.Error("gateway.responses.record_usage_failed",
zap.Int64("account_id", account.ID),
zap.Error(err),
diff --git a/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go b/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go
index 57554cf976b..70727f4c52d 100644
--- a/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go
+++ b/backend/internal/handler/gateway_handler_warmup_intercept_unit_test.go
@@ -152,6 +152,7 @@ func newTestGatewayHandler(t *testing.T, group *service.Group, accounts []*servi
nil, // usageBillingRepo
nil, // userRepo
nil, // userSubRepo
+ nil, // walletRepo
nil, // userGroupRateRepo
nil, // cache (disable sticky)
nil, // cfg
@@ -160,7 +161,6 @@ func newTestGatewayHandler(t *testing.T, group *service.Group, accounts []*servi
nil, // billingService
nil, // rateLimitService
nil, // billingCacheService
- nil, // identityService
nil, // httpUpstream
nil, // deferredService
nil, // claudeTokenProvider
@@ -172,6 +172,9 @@ func newTestGatewayHandler(t *testing.T, group *service.Group, accounts []*servi
nil, // channelService
nil, // resolver
nil, // balanceNotifyService
+ nil, // usageBillingOutboxRepo
+ nil, // usageBillingOutboxWorker
+ nil, // usageBillingAdmissionRepo
)
// RunModeSimple:跳过计费检查,避免引入 repo/cache 依赖。
diff --git a/backend/internal/handler/gateway_helper.go b/backend/internal/handler/gateway_helper.go
index 09e6c09baf8..a0a493d5092 100644
--- a/backend/internal/handler/gateway_helper.go
+++ b/backend/internal/handler/gateway_helper.go
@@ -20,7 +20,8 @@ var claudeCodeValidator = service.NewClaudeCodeValidator()
const claudeCodeParsedRequestContextKey = "claude_code_parsed_request"
-// SetClaudeCodeClientContext 检查请求是否来自 Claude Code 客户端,并设置到 context 中
+// SetClaudeCodeClientContext records a downstream-controlled compatibility
+// shape for routing/UX only. It is not provider-client authentication.
// 返回更新后的 context
func SetClaudeCodeClientContext(c *gin.Context, body []byte, parsedReq *service.ParsedRequest) {
if c == nil || c.Request == nil {
diff --git a/backend/internal/handler/gateway_models_wallet_visibility_test.go b/backend/internal/handler/gateway_models_wallet_visibility_test.go
new file mode 100644
index 00000000000..177ba45959f
--- /dev/null
+++ b/backend/internal/handler/gateway_models_wallet_visibility_test.go
@@ -0,0 +1,126 @@
+package handler
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+type walletModelsProviderStub struct {
+ byGroup map[int64][]string
+ calls []int64
+}
+
+func (s *walletModelsProviderStub) GetAvailableModels(_ context.Context, groupID *int64, _ string) []string {
+ if groupID == nil {
+ return nil
+ }
+ s.calls = append(s.calls, *groupID)
+ return append([]string(nil), s.byGroup[*groupID]...)
+}
+
+func TestGatewayModelsUniversalWalletMergesAuthorizedGroupsAndDeduplicates(t *testing.T) {
+ openAI := service.Group{
+ ID: 3, Name: service.WalletDefaultOpenAIGroupName, Platform: service.PlatformOpenAI,
+ Status: service.StatusActive, Hydrated: true, SubscriptionType: service.SubscriptionTypeStandard,
+ }
+ vip := service.Group{
+ ID: 22, Name: service.WalletDefaultVIPGroupName, Platform: service.PlatformAnthropic,
+ Status: service.StatusActive, Hydrated: true, IsExclusive: true, SubscriptionType: service.SubscriptionTypeStandard,
+ }
+ provider := &walletModelsProviderStub{byGroup: map[int64][]string{
+ openAI.ID: {"gpt-5.6-high", "claude-sonnet-4-6"},
+ vip.ID: {"claude-sonnet-4-6", "claude-sonnet-4-6", "fable5", "gpt-5.6-high"},
+ }}
+ h := &GatewayHandler{modelsProvider: provider}
+ user := &service.User{ID: 91, AllowedGroups: []int64{vip.ID}}
+
+ ids := performWalletModelsHandlerRequest(t, h, user, middleware.WalletModelVisibility{
+ APIKeyID: 930,
+ Groups: []service.Group{openAI, vip, vip},
+ Routes: service.DefaultModelRoutes(),
+ })
+
+ require.Equal(t, []int64{openAI.ID, vip.ID}, provider.calls, "duplicate route groups must be fetched once")
+ require.Equal(t, map[string]int{
+ "gpt-5.6-high": 1,
+ "claude-sonnet-4-6": 1,
+ "fable5": 1,
+ }, countModelIDs(ids))
+}
+
+func TestGatewayModelsUniversalWalletDoesNotLeakVIPModelsWithoutGrant(t *testing.T) {
+ openAI := service.Group{
+ ID: 3, Name: service.WalletDefaultOpenAIGroupName, Platform: service.PlatformOpenAI,
+ Status: service.StatusActive, Hydrated: true, SubscriptionType: service.SubscriptionTypeStandard,
+ }
+ provider := &walletModelsProviderStub{byGroup: map[int64][]string{
+ openAI.ID: {"gpt-5.6-high", "claude-opus-4-7", "fable5"},
+ }}
+ h := &GatewayHandler{modelsProvider: provider}
+ user := &service.User{ID: 92}
+
+ ids := performWalletModelsHandlerRequest(t, h, user, middleware.WalletModelVisibility{
+ APIKeyID: 930,
+ Groups: []service.Group{openAI},
+ Routes: service.DefaultModelRoutes(),
+ })
+
+ require.Equal(t, []string{"gpt-5.6-high"}, ids)
+}
+
+func TestGatewayModelsReturnsControlledErrorWhenModelServiceIsMissing(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
+
+ (&GatewayHandler{}).Models(c)
+
+ require.Equal(t, http.StatusInternalServerError, w.Code, w.Body.String())
+}
+
+func performWalletModelsHandlerRequest(t *testing.T, h *GatewayHandler, user *service.User, visibility middleware.WalletModelVisibility) []string {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
+ groupID := int64(3)
+ c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
+ ID: visibility.APIKeyID, Name: service.WalletUniversalAPIKeyName, Purpose: service.APIKeyPurposeWalletUniversal,
+ GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI},
+ UserID: user.ID, User: user,
+ })
+ c.Set(string(middleware.ContextKeyWalletModelVisibility), visibility)
+
+ h.Models(c)
+
+ require.Equal(t, http.StatusOK, w.Code, w.Body.String())
+ var response struct {
+ Data []struct {
+ ID string `json:"id"`
+ } `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
+ ids := make([]string, 0, len(response.Data))
+ for _, model := range response.Data {
+ ids = append(ids, model.ID)
+ }
+ return ids
+}
+
+func countModelIDs(ids []string) map[string]int {
+ counts := make(map[string]int, len(ids))
+ for _, id := range ids {
+ counts[id]++
+ }
+ return counts
+}
diff --git a/backend/internal/handler/gateway_sanitize_resource_limit_test.go b/backend/internal/handler/gateway_sanitize_resource_limit_test.go
new file mode 100644
index 00000000000..f7f78b95f08
--- /dev/null
+++ b/backend/internal/handler/gateway_sanitize_resource_limit_test.go
@@ -0,0 +1,53 @@
+package handler
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+func TestGatewayAnthropicEndpointsRejectOversizedSanitizationSlowPath(t *testing.T) {
+ body := make([]byte, 0, 16<<20+256)
+ body = append(body, `{"model":"claude-test","max_tokens":8,"thinking":"","messages":[],"padding":"`...)
+ body = append(body, bytes.Repeat([]byte{'x'}, 16<<20)...)
+ body = append(body, `"}`...)
+
+ tests := []struct {
+ name string
+ path string
+ run func(*GatewayHandler, *gin.Context)
+ }{
+ {name: "messages", path: "/v1/messages", run: func(h *GatewayHandler, c *gin.Context) { h.Messages(c) }},
+ {name: "count tokens", path: "/v1/messages/count_tokens", run: func(h *GatewayHandler, c *gin.Context) { h.CountTokens(c) }},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, tt.path, bytes.NewReader(body))
+ groupID := int64(22)
+ apiKey := &service.APIKey{
+ ID: 607,
+ UserID: 142,
+ GroupID: &groupID,
+ Status: service.StatusActive,
+ }
+ c.Set(string(middleware.ContextKeyAPIKey), apiKey)
+ c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID})
+
+ tt.run(&GatewayHandler{}, c)
+
+ require.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code)
+ require.Equal(t, "invalid_request_error", gjson.GetBytes(recorder.Body.Bytes(), "error.type").String())
+ require.Contains(t, gjson.GetBytes(recorder.Body.Bytes(), "error.message").String(), "compatibility sanitization limit")
+ })
+ }
+}
diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go
index 2a34e3f079d..e05e50c9dc7 100644
--- a/backend/internal/handler/gemini_v1beta_handler.go
+++ b/backend/internal/handler/gemini_v1beta_handler.go
@@ -61,13 +61,13 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
c.JSON(http.StatusOK, gemini.FallbackModelsList())
return
}
- googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error())
+ googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts")
return
}
res, err := h.geminiCompatService.ForwardAIStudioGET(c.Request.Context(), account, "/v1beta/models")
if err != nil {
- googleError(c, http.StatusBadGateway, err.Error())
+ googleError(c, http.StatusBadGateway, "Upstream request failed")
return
}
if shouldFallbackGeminiModels(res) {
@@ -113,13 +113,13 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
c.JSON(http.StatusOK, gemini.FallbackModel(modelName))
return
}
- googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error())
+ googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts")
return
}
res, err := h.geminiCompatService.ForwardAIStudioGET(c.Request.Context(), account, "/v1beta/models/"+modelName)
if err != nil {
- googleError(c, http.StatusBadGateway, err.Error())
+ googleError(c, http.StatusBadGateway, "Upstream request failed")
return
}
if shouldFallbackGeminiModel(modelName, res) {
@@ -161,7 +161,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
modelName, action, err := parseGeminiModelAction(strings.TrimPrefix(c.Param("modelAction"), "/"))
if err != nil {
- googleError(c, http.StatusNotFound, err.Error())
+ googleError(c, http.StatusNotFound, "Invalid model action path")
return
}
@@ -185,6 +185,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
setOpsRequestContext(c, modelName, stream, body)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(stream, false)))
+ if decision := h.checkContentModeration(c, reqLog, apiKey, authSubject, service.ContentModerationProtocolGemini, modelName, body); decision != nil && decision.Blocked {
+ googleError(c, contentModerationStatus(decision), decision.Message)
+ return
+ }
+
// 解析渠道级模型映射
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, modelName)
reqModel := modelName // 保存映射前的原始模型名
@@ -194,6 +199,12 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
// Get subscription (may be nil)
subscription, _ := middleware.GetSubscriptionFromContext(c)
+ billingRequestCtx, err := service.PrepareUsageBillingRequestContext(c.Request.Context())
+ if err != nil {
+ googleError(c, http.StatusServiceUnavailable, "Billing service temporarily unavailable")
+ return
+ }
+ c.Request = c.Request.WithContext(billingRequestCtx)
// For Gemini native API, do not send Claude-style ping frames.
geminiConcurrency := NewConcurrencyHelper(h.concurrencyHelper.concurrencyService, SSEPingFormatNone, 0)
@@ -226,7 +237,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
userReleaseFunc, err := geminiConcurrency.AcquireUserSlotWithWait(c, authSubject.UserID, authSubject.Concurrency, stream, &streamStarted)
if err != nil {
reqLog.Warn("gemini.user_slot_acquire_failed", zap.Error(err))
- googleError(c, http.StatusTooManyRequests, err.Error())
+ googleError(c, http.StatusTooManyRequests, "Concurrency limit exceeded, please retry later")
return
}
if waitCounted {
@@ -367,7 +378,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
- googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts: "+err.Error())
+ googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts")
return
}
action := fs.HandleSelectionExhausted(c.Request.Context())
@@ -448,7 +459,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
)
if err != nil {
reqLog.Warn("gemini.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
- googleError(c, http.StatusTooManyRequests, err.Error())
+ googleError(c, http.StatusTooManyRequests, "Concurrency limit exceeded, please retry later")
return
}
if accountWaitCounted {
@@ -468,6 +479,41 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
if fs.SwitchCount > 0 {
requestCtx = service.WithAccountSwitchCount(requestCtx, fs.SwitchCount, h.metadataBridgeEnabled())
}
+ var usageBillingIdentity *service.GatewayUsageBillingIdentity
+ if subscription != nil && subscription.IsWalletMode() {
+ usageBillingIdentity, err = h.gatewayService.PrepareGatewayWalletUsageBillingAdmission(
+ requestCtx, apiKey, apiKey.User, account, subscription, &service.ParsedRequest{
+ Body: body, Model: modelName, GroupID: apiKey.GroupID,
+ },
+ )
+ if err != nil {
+ if accountReleaseFunc != nil {
+ accountReleaseFunc()
+ }
+ status := http.StatusServiceUnavailable
+ message := "Billing service temporarily unavailable"
+ if errors.Is(err, service.ErrWalletInsufficient) {
+ status, _, message, _ = billingErrorDetails(err)
+ }
+ googleError(c, status, message)
+ return
+ }
+ }
+ markBillingAttemptFailed := func() {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingAttemptFailed(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gemini.billing_attempt_fail_mark_failed", zap.Error(lifecycleErr))
+ }
+ }
+ markBillingOrphaned := func() {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingOrphaned(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gemini.billing_attempt_orphan_mark_failed", zap.Error(lifecycleErr))
+ }
+ }
+ abandonBilling := func() {
+ if lifecycleErr := h.gatewayService.AbandonGatewayUsageBilling(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gemini.billing_attempt_abandon_failed", zap.Error(lifecycleErr))
+ }
+ }
if account.Platform == service.PlatformAntigravity && account.Type != service.AccountTypeAPIKey {
result, err = h.antigravityGatewayService.ForwardGemini(requestCtx, c, account, modelName, action, stream, body, hasBoundSession)
} else {
@@ -479,21 +525,28 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
if err != nil {
var failoverErr *service.UpstreamFailoverError
if errors.As(err, &failoverErr) {
+ markBillingAttemptFailed()
failoverAction := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, failoverErr)
switch failoverAction {
case FailoverContinue:
continue
case FailoverExhausted:
+ abandonBilling()
h.handleGeminiFailoverExhausted(c, fs.LastFailoverErr)
return
case FailoverCanceled:
+ abandonBilling()
return
}
}
+ markBillingOrphaned()
// ForwardNative already wrote the response
reqLog.Error("gemini.forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
return
}
+ if result != nil {
+ result.UsageBillingIdentity = usageBillingIdentity
+ }
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context)
userAgent := c.GetHeader("User-Agent")
@@ -518,7 +571,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
- h.submitUsageRecordTask(func(ctx context.Context) {
+ h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{
Result: result,
APIKey: apiKey,
@@ -536,6 +589,9 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
APIKeyService: h.apiKeyService,
ChannelUsageFields: channelMapping.ToUsageFields(reqModel, result.UpstreamModel),
}); err != nil {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingOrphaned(ctx, result.UsageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gemini.billing_result_orphan_mark_failed", zap.Error(lifecycleErr))
+ }
logger.L().With(
zap.String("component", "handler.gemini_v1beta.models"),
zap.Int64("user_id", authSubject.UserID),
@@ -591,11 +647,7 @@ func (h *GatewayHandler) handleGeminiFailoverExhausted(c *gin.Context, failoverE
respCode = *rule.ResponseCode
}
- // 确定响应消息
- msg := service.ExtractUpstreamErrorMessage(responseBody)
- if !rule.PassthroughBody && rule.CustomMessage != nil {
- msg = *rule.CustomMessage
- }
+ msg := service.ErrorPassthroughClientMessage(rule, "Upstream request failed")
if rule.SkipMonitoring {
c.Set(service.OpsSkipPassthroughKey, true)
diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go
index 13e3ac8869a..308b219921e 100644
--- a/backend/internal/handler/handler.go
+++ b/backend/internal/handler/handler.go
@@ -33,6 +33,7 @@ type AdminHandlers struct {
Channel *admin.ChannelHandler
ChannelMonitor *admin.ChannelMonitorHandler
ChannelMonitorTemplate *admin.ChannelMonitorRequestTemplateHandler
+ ContentModeration *admin.ContentModerationHandler
Payment *admin.PaymentHandler
Affiliate *admin.AffiliateHandler
}
diff --git a/backend/internal/handler/idempotency_helper.go b/backend/internal/handler/idempotency_helper.go
index bca63b6bea4..4602f4159e2 100644
--- a/backend/internal/handler/idempotency_helper.go
+++ b/backend/internal/handler/idempotency_helper.go
@@ -28,7 +28,8 @@ func executeUserIdempotentJSON(
response.ErrorFrom(c, err)
return
}
- response.Success(c, data)
+ publicData, _ := service.SplitIdempotencySensitiveResponse(data)
+ response.Success(c, publicData)
return
}
diff --git a/backend/internal/handler/image_concurrency_limiter.go b/backend/internal/handler/image_concurrency_limiter.go
new file mode 100644
index 00000000000..6e7cbb676c7
--- /dev/null
+++ b/backend/internal/handler/image_concurrency_limiter.go
@@ -0,0 +1,126 @@
+package handler
+
+import (
+ "context"
+ "sync"
+ "time"
+)
+
+type imageConcurrencyLimiter struct {
+ mu sync.Mutex
+ notify chan struct{}
+ limit int
+ active int
+ waiting int
+ enabled bool
+}
+
+func (l *imageConcurrencyLimiter) TryAcquire(enabled bool, limit int) (func(), bool) {
+ return l.acquire(context.Background(), enabled, limit, false, 0, 0)
+}
+
+func (l *imageConcurrencyLimiter) Acquire(ctx context.Context, enabled bool, limit int, wait bool, timeout time.Duration, maxWaiting int) (func(), bool) {
+ return l.acquire(ctx, enabled, limit, wait, timeout, maxWaiting)
+}
+
+func (l *imageConcurrencyLimiter) acquire(ctx context.Context, enabled bool, limit int, wait bool, timeout time.Duration, maxWaiting int) (func(), bool) {
+ if !enabled || limit <= 0 {
+ return nil, true
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if wait {
+ if timeout <= 0 {
+ return nil, false
+ }
+ waitCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ ctx = waitCtx
+ }
+ if maxWaiting < 0 {
+ maxWaiting = 0
+ }
+ for {
+ release, acquired, waitRelease, notify := l.tryAcquireLocked(enabled, limit, wait, maxWaiting)
+ if acquired {
+ return release, acquired
+ }
+ if !wait || notify == nil {
+ return nil, false
+ }
+ if !l.waitForSlot(ctx, notify) {
+ if waitRelease != nil {
+ waitRelease()
+ }
+ return nil, false
+ }
+ if waitRelease != nil {
+ waitRelease()
+ }
+ }
+}
+
+func (l *imageConcurrencyLimiter) tryAcquireLocked(enabled bool, limit int, wait bool, maxWaiting int) (func(), bool, func(), <-chan struct{}) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ if l.notify == nil {
+ l.notify = make(chan struct{})
+ }
+ if l.enabled != enabled || l.limit != limit {
+ l.enabled = enabled
+ l.limit = limit
+ }
+ if l.active < l.limit {
+ l.active++
+ return l.releaseFunc(), true, nil, nil
+ }
+ if !wait {
+ return nil, false, nil, nil
+ }
+ if maxWaiting > 0 && l.waiting >= maxWaiting {
+ return nil, false, nil, nil
+ }
+ l.waiting++
+ return nil, false, l.waiterReleaseFunc(), l.notify
+}
+
+func (l *imageConcurrencyLimiter) waitForSlot(ctx context.Context, notify <-chan struct{}) bool {
+ select {
+ case <-notify:
+ return true
+ case <-ctx.Done():
+ return false
+ }
+}
+
+func (l *imageConcurrencyLimiter) releaseFunc() func() {
+ var once sync.Once
+ return func() {
+ once.Do(func() {
+ l.mu.Lock()
+ if l.active > 0 {
+ l.active--
+ }
+ if l.notify != nil {
+ close(l.notify)
+ l.notify = make(chan struct{})
+ }
+ l.mu.Unlock()
+ })
+ }
+}
+
+func (l *imageConcurrencyLimiter) waiterReleaseFunc() func() {
+ var once sync.Once
+ return func() {
+ once.Do(func() {
+ l.mu.Lock()
+ if l.waiting > 0 {
+ l.waiting--
+ }
+ l.mu.Unlock()
+ })
+ }
+}
diff --git a/backend/internal/handler/image_concurrency_limiter_test.go b/backend/internal/handler/image_concurrency_limiter_test.go
new file mode 100644
index 00000000000..20147f16119
--- /dev/null
+++ b/backend/internal/handler/image_concurrency_limiter_test.go
@@ -0,0 +1,230 @@
+package handler
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+func TestImageConcurrencyLimiter_DefaultDisabledAllowsRequests(t *testing.T) {
+ limiter := &imageConcurrencyLimiter{}
+
+ release, acquired := limiter.TryAcquire(false, 1)
+
+ require.True(t, acquired)
+ require.Nil(t, release)
+}
+
+func TestImageConcurrencyLimiter_RejectsWhenLimitReachedAndAllowsAfterRelease(t *testing.T) {
+ limiter := &imageConcurrencyLimiter{}
+
+ release, acquired := limiter.TryAcquire(true, 1)
+ require.True(t, acquired)
+ require.NotNil(t, release)
+
+ secondRelease, secondAcquired := limiter.TryAcquire(true, 1)
+ require.False(t, secondAcquired)
+ require.Nil(t, secondRelease)
+
+ release()
+ thirdRelease, thirdAcquired := limiter.TryAcquire(true, 1)
+ require.True(t, thirdAcquired)
+ require.NotNil(t, thirdRelease)
+ thirdRelease()
+}
+
+func TestImageConcurrencyLimiter_WaitsUntilSlotReleased(t *testing.T) {
+ limiter := &imageConcurrencyLimiter{}
+ release, acquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1)
+ require.True(t, acquired)
+ require.NotNil(t, release)
+
+ acquiredCh := make(chan func(), 1)
+ go func() {
+ waitRelease, waitAcquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1)
+ require.True(t, waitAcquired)
+ acquiredCh <- waitRelease
+ }()
+
+ time.Sleep(20 * time.Millisecond)
+ release()
+
+ select {
+ case waitRelease := <-acquiredCh:
+ require.NotNil(t, waitRelease)
+ waitRelease()
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for image concurrency slot")
+ }
+}
+
+func TestImageConcurrencyLimiter_WaitTimesOut(t *testing.T) {
+ limiter := &imageConcurrencyLimiter{}
+ release, acquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1)
+ require.True(t, acquired)
+ require.NotNil(t, release)
+ defer release()
+
+ waitRelease, waitAcquired := limiter.Acquire(context.Background(), true, 1, true, 10*time.Millisecond, 1)
+
+ require.False(t, waitAcquired)
+ require.Nil(t, waitRelease)
+}
+
+func TestImageConcurrencyLimiter_MaxWaitingRequestsRejectsOverflow(t *testing.T) {
+ limiter := &imageConcurrencyLimiter{}
+ release, acquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1)
+ require.True(t, acquired)
+ require.NotNil(t, release)
+ defer release()
+
+ waitingStarted := make(chan struct{})
+ waitingDone := make(chan struct{})
+ go func() {
+ close(waitingStarted)
+ waitRelease, waitAcquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1)
+ if waitAcquired && waitRelease != nil {
+ waitRelease()
+ }
+ close(waitingDone)
+ }()
+ <-waitingStarted
+ time.Sleep(20 * time.Millisecond)
+
+ overflowRelease, overflowAcquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1)
+
+ require.False(t, overflowAcquired)
+ require.Nil(t, overflowRelease)
+ release()
+ <-waitingDone
+}
+
+func TestOpenAIGatewayHandlerAcquireImageGenerationSlot_Returns429WhenFull(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil)
+
+ h := &OpenAIGatewayHandler{
+ cfg: &config.Config{
+ Gateway: config.GatewayConfig{
+ ImageConcurrency: config.ImageConcurrencyConfig{
+ Enabled: true,
+ MaxConcurrentRequests: 1,
+ OverflowMode: config.ImageConcurrencyOverflowModeReject,
+ },
+ },
+ },
+ imageLimiter: &imageConcurrencyLimiter{},
+ }
+ release, acquired := h.acquireImageGenerationSlot(c, false)
+ require.True(t, acquired)
+ require.NotNil(t, release)
+ defer release()
+
+ blockedRelease, blocked := h.acquireImageGenerationSlot(c, false)
+
+ require.False(t, blocked)
+ require.Nil(t, blockedRelease)
+ require.Equal(t, http.StatusTooManyRequests, rec.Code)
+ require.Equal(t, "rate_limit_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
+ require.Contains(t, rec.Body.String(), "Image generation concurrency limit exceeded")
+}
+
+func TestOpenAIGatewayHandlerResponses_ImageIntentRejectedByImageConcurrency(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ body := `{"model":"gpt-5.4","input":"draw","tools":[{"type":"image_generation"}]}`
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
+ groupID := int64(1)
+ c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
+ ID: 10,
+ GroupID: &groupID,
+ Group: &service.Group{
+ ID: groupID,
+ AllowImageGeneration: true,
+ },
+ User: &service.User{ID: 20},
+ })
+ c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 20, Concurrency: 1})
+
+ h := &OpenAIGatewayHandler{
+ gatewayService: &service.OpenAIGatewayService{},
+ billingCacheService: &service.BillingCacheService{},
+ apiKeyService: &service.APIKeyService{},
+ concurrencyHelper: &ConcurrencyHelper{concurrencyService: service.NewConcurrencyService(&helperConcurrencyCacheStub{userSeq: []bool{true}})},
+ errorPassthroughService: nil,
+ cfg: &config.Config{Gateway: config.GatewayConfig{ImageConcurrency: config.ImageConcurrencyConfig{
+ Enabled: true,
+ MaxConcurrentRequests: 1,
+ OverflowMode: config.ImageConcurrencyOverflowModeReject,
+ }}},
+ imageLimiter: &imageConcurrencyLimiter{},
+ }
+ release, acquired := h.acquireImageGenerationSlot(c, false)
+ require.True(t, acquired)
+ require.NotNil(t, release)
+ defer release()
+ rec.Body.Reset()
+ rec.Code = 0
+
+ h.Responses(c)
+
+ require.Equal(t, http.StatusTooManyRequests, rec.Code)
+ require.Equal(t, "rate_limit_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
+ require.Contains(t, rec.Body.String(), "Image generation concurrency limit exceeded")
+}
+
+func TestOpenAIGatewayHandlerResponses_TextOnlyNotRejectedByImageConcurrency(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ body := `{"model":"gpt-5.4","input":"write code"}`
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
+ groupID := int64(1)
+ c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
+ ID: 10,
+ GroupID: &groupID,
+ Group: &service.Group{
+ ID: groupID,
+ AllowImageGeneration: true,
+ },
+ User: &service.User{ID: 20},
+ })
+ c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 20, Concurrency: 1})
+
+ h := &OpenAIGatewayHandler{
+ gatewayService: &service.OpenAIGatewayService{},
+ billingCacheService: service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}),
+ apiKeyService: &service.APIKeyService{},
+ concurrencyHelper: &ConcurrencyHelper{concurrencyService: service.NewConcurrencyService(&helperConcurrencyCacheStub{userSeq: []bool{true}})},
+ cfg: &config.Config{Gateway: config.GatewayConfig{ImageConcurrency: config.ImageConcurrencyConfig{
+ Enabled: true,
+ MaxConcurrentRequests: 1,
+ OverflowMode: config.ImageConcurrencyOverflowModeReject,
+ }}},
+ imageLimiter: &imageConcurrencyLimiter{},
+ }
+ release, acquired := h.acquireImageGenerationSlot(c, false)
+ require.True(t, acquired)
+ require.NotNil(t, release)
+ defer release()
+ rec.Body.Reset()
+ rec.Code = 0
+
+ h.Responses(c)
+
+ require.NotEqual(t, http.StatusTooManyRequests, rec.Code)
+ require.NotContains(t, rec.Body.String(), "Image generation concurrency limit exceeded")
+}
diff --git a/backend/internal/handler/kiro_gateway_handler.go b/backend/internal/handler/kiro_gateway_handler.go
new file mode 100644
index 00000000000..63164c3c717
--- /dev/null
+++ b/backend/internal/handler/kiro_gateway_handler.go
@@ -0,0 +1,779 @@
+package handler
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/domain"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
+ pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/ip"
+ middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/tidwall/gjson"
+ "go.uber.org/zap"
+ "golang.org/x/sync/semaphore"
+)
+
+const (
+ kiroDefaultModel = "claude-sonnet-4-6"
+ kiroSidecarAPIKeyHeader = "X-Kiro-API-Key"
+ kiroSidecarAccountIDHeader = "X-Kiro-Account-ID"
+ kiroSidecarOriginalPathHeader = "X-Kiro-Original-Path"
+ kiroSidecarClientRequestID = "X-Request-ID"
+ kiroMaxSidecarResponseBytes = int64(16 * 1024 * 1024)
+ kiroDefaultRequestTimeout = 90 * time.Second
+ kiroSidecarLimiterAcquireWeight = int64(1)
+)
+
+var kiroSidecarLimiters sync.Map // map[int]*semaphore.Weighted
+
+func (h *GatewayHandler) KiroModels(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{
+ "data": claude.LatestClaudeCodeModels,
+ "object": "list",
+ })
+}
+
+func (h *GatewayHandler) KiroMessages(c *gin.Context) {
+ h.handleKiroSidecarPost(c, "/v1/messages", true)
+}
+
+func (h *GatewayHandler) KiroCountTokens(c *gin.Context) {
+ h.handleKiroSidecarPost(c, "/v1/messages/count_tokens", false)
+}
+
+func (h *GatewayHandler) KiroResponses(c *gin.Context) {
+ h.handleKiroSidecarPost(c, "/v1/responses", true)
+}
+
+func (h *GatewayHandler) KiroChatCompletions(c *gin.Context) {
+ h.handleKiroSidecarPost(c, "/v1/chat/completions", true)
+}
+
+type kiroSidecarRequest struct {
+ Method string
+ Path string
+ Model string
+ UpstreamBody []byte
+ RequestBody []byte
+ Stream bool
+ RecordUsage bool
+ RejectUnmeteredStream bool
+ Parsed *service.ParsedRequest
+ Mapping service.ChannelMappingResult
+}
+
+func (h *GatewayHandler) handleKiroSidecarPost(c *gin.Context, sidecarPath string, recordUsage bool) {
+ body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
+ if err != nil {
+ if maxErr, ok := extractMaxBytesError(err); ok {
+ h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
+ return
+ }
+ h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
+ return
+ }
+ if len(body) == 0 {
+ h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
+ return
+ }
+ if !gjson.ValidBytes(body) {
+ h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
+ return
+ }
+
+ model := strings.TrimSpace(gjson.GetBytes(body, "model").String())
+ if model == "" {
+ h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required")
+ return
+ }
+ stream := gjson.GetBytes(body, "stream").Bool()
+ parsed, err := service.ParseGatewayRequest(body, domain.PlatformAnthropic)
+ if err != nil {
+ parsed = &service.ParsedRequest{Body: body, Model: model, Stream: stream}
+ }
+ parsed.Model = model
+ parsed.Stream = stream
+ parsed.SessionContext = &service.SessionContext{
+ ClientIP: ip.GetClientIP(c),
+ UserAgent: c.GetHeader("User-Agent"),
+ }
+
+ upstreamBody := body
+ mapping := service.ChannelMappingResult{MappedModel: model}
+ if apiKey, ok := middleware2.GetAPIKeyFromContext(c); ok && h != nil && h.gatewayService != nil {
+ mapping, _ = h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, model)
+ if mapping.Mapped && strings.TrimSpace(mapping.MappedModel) != "" && mapping.MappedModel != model {
+ upstreamBody = h.gatewayService.ReplaceModelInBody(body, mapping.MappedModel)
+ }
+ }
+
+ h.handleKiroSidecar(c, kiroSidecarRequest{
+ Method: http.MethodPost,
+ Path: sidecarPath,
+ Model: model,
+ UpstreamBody: upstreamBody,
+ RequestBody: body,
+ Stream: stream,
+ RecordUsage: recordUsage,
+ Parsed: parsed,
+ Mapping: mapping,
+ })
+}
+
+func (h *GatewayHandler) handleKiroSidecar(c *gin.Context, req kiroSidecarRequest) {
+ requestStart := time.Now()
+ streamStarted := false
+
+ apiKey, ok := middleware2.GetAPIKeyFromContext(c)
+ if !ok {
+ h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
+ return
+ }
+ if apiKey.Group == nil || apiKey.Group.Platform != service.PlatformKiro {
+ h.errorResponse(c, http.StatusForbidden, "authentication_error", "Kiro endpoint requires an API key assigned to a kiro group")
+ return
+ }
+ if h == nil || h.gatewayService == nil {
+ h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Kiro bridge is not configured")
+ return
+ }
+
+ subject, hasSubject := middleware2.GetAuthSubjectFromContext(c)
+ if !hasSubject && req.RecordUsage {
+ h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
+ return
+ }
+ reqLog := requestLogger(
+ c,
+ "handler.gateway.kiro",
+ zap.Int64("api_key_id", apiKey.ID),
+ zap.Any("group_id", apiKey.GroupID),
+ zap.String("model", req.Model),
+ zap.Bool("stream", req.Stream),
+ )
+
+ if req.RecordUsage {
+ setOpsRequestContext(c, req.Model, req.Stream, req.RequestBody)
+ setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(req.Stream, false)))
+ } else {
+ setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(false, false)))
+ }
+ if req.RecordUsage && h.blockSidecarContentModeration(c, reqLog, apiKey, subject, req.Path, req.Model, req.RequestBody) {
+ return
+ }
+ if h.errorPassthroughService != nil {
+ service.BindErrorPassthroughService(c, h.errorPassthroughService)
+ }
+
+ subscription, _ := middleware2.GetSubscriptionFromContext(c)
+ if req.RecordUsage {
+ billingRequestCtx, err := service.PrepareUsageBillingRequestContext(c.Request.Context())
+ if err != nil {
+ h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "billing_service_error", "Billing service temporarily unavailable", streamStarted)
+ return
+ }
+ c.Request = c.Request.WithContext(billingRequestCtx)
+ if subscription != nil && subscription.IsWalletMode() && req.Stream {
+ h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "billing_service_error", "Wallet billing for Kiro streaming is unavailable", streamStarted)
+ return
+ }
+ req.RejectUnmeteredStream = subscription != nil && subscription.IsWalletMode()
+ userRelease, ok := h.acquireKiroUserSlot(c, subject, req.Stream, &streamStarted, reqLog)
+ if !ok {
+ return
+ }
+ if userRelease != nil {
+ defer userRelease()
+ }
+
+ if h.billingCacheService != nil {
+ if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription); err != nil {
+ reqLog.Info("gateway.kiro.billing_eligibility_check_failed", zap.Error(err))
+ status, code, message, retryAfter := billingErrorDetails(err)
+ if retryAfter > 0 {
+ c.Header("Retry-After", strconv.Itoa(retryAfter))
+ }
+ h.handleStreamingAwareError(c, status, code, message, streamStarted)
+ return
+ }
+ }
+ }
+
+ sessionHash := ""
+ if req.Parsed != nil {
+ req.Parsed.GroupID = apiKey.GroupID
+ if req.Parsed.SessionContext == nil {
+ req.Parsed.SessionContext = &service.SessionContext{}
+ }
+ req.Parsed.SessionContext.APIKeyID = apiKey.ID
+ sessionHash = h.gatewayService.GenerateSessionHash(req.Parsed)
+ }
+ if sessionHash != "" {
+ sessionHash = "kiro:" + sessionHash
+ }
+
+ fs := NewFailoverState(h.maxAccountSwitches, false)
+ for {
+ selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionHash, req.Model, fs.FailedAccountIDs, "", subject.UserID)
+ if err != nil {
+ if len(fs.FailedAccountIDs) == 0 {
+ reqLog.Warn("gateway.kiro.select_account_no_available", zap.Error(err))
+ h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available Kiro accounts", streamStarted)
+ return
+ }
+ action := fs.HandleSelectionExhausted(c.Request.Context())
+ switch action {
+ case FailoverContinue:
+ continue
+ case FailoverCanceled:
+ return
+ default:
+ if fs.LastFailoverErr != nil {
+ h.handleFailoverExhausted(c, fs.LastFailoverErr, service.PlatformKiro, streamStarted)
+ } else {
+ h.handleFailoverExhaustedSimple(c, http.StatusBadGateway, streamStarted)
+ }
+ return
+ }
+ }
+
+ account := selection.Account
+ if account == nil || account.Platform != service.PlatformKiro {
+ if selection.Acquired && selection.ReleaseFunc != nil {
+ selection.ReleaseFunc()
+ }
+ h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Selected account is not a Kiro account", streamStarted)
+ return
+ }
+ setOpsSelectedAccount(c, account.ID, account.Platform)
+
+ accountRelease, acquired := h.acquireKiroAccountSlot(c, selection, req.Stream, &streamStarted, reqLog)
+ if !acquired {
+ return
+ }
+
+ requestCtx := c.Request.Context()
+ if fs.SwitchCount > 0 {
+ requestCtx = service.WithAccountSwitchCount(requestCtx, fs.SwitchCount, h.metadataBridgeEnabled())
+ }
+ var usageBillingIdentity *service.GatewayUsageBillingIdentity
+ if req.RecordUsage && subscription != nil && subscription.IsWalletMode() {
+ usageBillingIdentity, err = h.gatewayService.PrepareGatewayWalletUsageBillingAdmission(
+ requestCtx, apiKey, apiKey.User, account, subscription, req.Parsed,
+ )
+ if err != nil {
+ if accountRelease != nil {
+ accountRelease()
+ }
+ status := http.StatusServiceUnavailable
+ code := "billing_service_error"
+ message := "Billing service temporarily unavailable"
+ switch {
+ case errors.Is(err, service.ErrWalletInsufficient):
+ status, code, message, _ = billingErrorDetails(err)
+ case errors.Is(err, service.ErrUsageBillingRequestConflict),
+ errors.Is(err, service.ErrUsageBillingAdmissionFinalized):
+ status = http.StatusConflict
+ code = "billing_request_conflict"
+ message = "Billing request identity conflict"
+ }
+ h.handleStreamingAwareError(c, status, code, message, streamStarted)
+ return
+ }
+ }
+ markBillingAttemptFailed := func() {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingAttemptFailed(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.kiro.billing_attempt_fail_mark_failed", zap.Error(lifecycleErr))
+ }
+ }
+ markBillingOrphaned := func() {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingOrphaned(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.kiro.billing_attempt_orphan_mark_failed", zap.Error(lifecycleErr))
+ }
+ }
+ abandonBilling := func() {
+ if lifecycleErr := h.gatewayService.AbandonGatewayUsageBilling(requestCtx, usageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.kiro.billing_attempt_abandon_failed", zap.Error(lifecycleErr))
+ }
+ }
+
+ writerSizeBeforeForward := c.Writer.Size()
+ result, err := h.forwardKiroSidecar(c, account, req)
+ if accountRelease != nil {
+ accountRelease()
+ }
+
+ deliveryFailed := err != nil && result != nil && errors.Is(err, errSidecarClientDelivery)
+ if err != nil && !deliveryFailed {
+ var failoverErr *service.UpstreamFailoverError
+ if errors.As(err, &failoverErr) {
+ if c.Writer.Size() != writerSizeBeforeForward {
+ markBillingOrphaned()
+ h.handleFailoverExhausted(c, failoverErr, service.PlatformKiro, true)
+ return
+ }
+ markBillingAttemptFailed()
+ action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, failoverErr)
+ switch action {
+ case FailoverContinue:
+ continue
+ case FailoverExhausted:
+ abandonBilling()
+ h.handleFailoverExhausted(c, fs.LastFailoverErr, service.PlatformKiro, streamStarted)
+ return
+ case FailoverCanceled:
+ abandonBilling()
+ return
+ }
+ }
+ markBillingOrphaned()
+ h.ensureForwardErrorResponse(c, streamStarted)
+ reqLog.Error("gateway.kiro.forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
+ return
+ }
+ if result != nil {
+ result.UsageBillingIdentity = usageBillingIdentity
+ }
+
+ if req.RecordUsage {
+ userAgent := c.GetHeader("User-Agent")
+ clientIP := ip.GetClientIP(c)
+ requestPayloadHash := service.HashUsageRequestPayload(req.RequestBody)
+ inboundEndpoint := GetInboundEndpoint(c)
+ upstreamEndpoint := req.Path
+ mappingFields := req.Mapping.ToUsageFields(req.Model, result.UpstreamModel)
+
+ h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
+ if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
+ Result: result,
+ ParsedRequest: req.Parsed,
+ APIKey: apiKey,
+ User: apiKey.User,
+ Account: account,
+ Subscription: subscription,
+ InboundEndpoint: inboundEndpoint,
+ UpstreamEndpoint: upstreamEndpoint,
+ UserAgent: userAgent,
+ IPAddress: clientIP,
+ RequestPayloadHash: requestPayloadHash,
+ ForceCacheBilling: fs.ForceCacheBilling,
+ APIKeyService: h.apiKeyService,
+ ChannelUsageFields: mappingFields,
+ }); err != nil {
+ if lifecycleErr := h.gatewayService.MarkGatewayUsageBillingOrphaned(ctx, result.UsageBillingIdentity); lifecycleErr != nil {
+ reqLog.Error("gateway.kiro.billing_result_orphan_mark_failed", zap.Error(lifecycleErr))
+ }
+ reqLog.Error("gateway.kiro.record_usage_failed", zap.Int64("account_id", account.ID), zap.Error(err))
+ }
+ })
+ }
+ if deliveryFailed {
+ reqLog.Warn("gateway.kiro.client_delivery_failed_after_metered_upstream_completion", zap.Error(err))
+ }
+
+ service.SetOpsLatencyMs(c, service.OpsUpstreamLatencyMsKey, time.Since(requestStart).Milliseconds())
+ return
+ }
+}
+
+func (h *GatewayHandler) acquireKiroUserSlot(c *gin.Context, subject middleware2.AuthSubject, stream bool, streamStarted *bool, reqLog *zap.Logger) (func(), bool) {
+ if h.concurrencyHelper == nil || h.concurrencyHelper.concurrencyService == nil {
+ return nil, true
+ }
+ maxWait := service.CalculateMaxWait(subject.Concurrency)
+ canWait, err := h.concurrencyHelper.IncrementWaitCount(c.Request.Context(), subject.UserID, maxWait)
+ waitCounted := false
+ if err != nil {
+ reqLog.Warn("gateway.kiro.user_wait_counter_increment_failed", zap.Error(err))
+ } else if !canWait {
+ h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", *streamStarted)
+ return nil, false
+ }
+ if err == nil && canWait {
+ waitCounted = true
+ }
+ defer func() {
+ if waitCounted {
+ h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID)
+ }
+ }()
+
+ userRelease, err := h.concurrencyHelper.AcquireUserSlotWithWait(c, subject.UserID, subject.Concurrency, stream, streamStarted)
+ if err != nil {
+ reqLog.Warn("gateway.kiro.user_slot_acquire_failed", zap.Error(err))
+ h.handleConcurrencyError(c, err, "user", *streamStarted)
+ return nil, false
+ }
+ if waitCounted {
+ h.concurrencyHelper.DecrementWaitCount(c.Request.Context(), subject.UserID)
+ waitCounted = false
+ }
+ return wrapReleaseOnDone(c.Request.Context(), userRelease), true
+}
+
+func (h *GatewayHandler) acquireKiroAccountSlot(c *gin.Context, selection *service.AccountSelectionResult, stream bool, streamStarted *bool, reqLog *zap.Logger) (func(), bool) {
+ if selection == nil || selection.Account == nil {
+ h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available Kiro accounts", *streamStarted)
+ return nil, false
+ }
+ if selection.Acquired {
+ return wrapReleaseOnDone(c.Request.Context(), selection.ReleaseFunc), true
+ }
+ if h.concurrencyHelper == nil || h.concurrencyHelper.concurrencyService == nil {
+ return nil, true
+ }
+ if selection.WaitPlan == nil {
+ h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available Kiro accounts", *streamStarted)
+ return nil, false
+ }
+
+ account := selection.Account
+ accountWaitCounted := false
+ canWait, err := h.concurrencyHelper.IncrementAccountWaitCount(c.Request.Context(), account.ID, selection.WaitPlan.MaxWaiting)
+ if err != nil {
+ reqLog.Warn("gateway.kiro.account_wait_counter_increment_failed", zap.Int64("account_id", account.ID), zap.Error(err))
+ } else if !canWait {
+ h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", *streamStarted)
+ return nil, false
+ }
+ if err == nil && canWait {
+ accountWaitCounted = true
+ }
+ releaseWait := func() {
+ if accountWaitCounted {
+ h.concurrencyHelper.DecrementAccountWaitCount(c.Request.Context(), account.ID)
+ accountWaitCounted = false
+ }
+ }
+
+ accountRelease, err := h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout(
+ c,
+ account.ID,
+ selection.WaitPlan.MaxConcurrency,
+ selection.WaitPlan.Timeout,
+ stream,
+ streamStarted,
+ )
+ if err != nil {
+ releaseWait()
+ reqLog.Warn("gateway.kiro.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
+ h.handleConcurrencyError(c, err, "account", *streamStarted)
+ return nil, false
+ }
+ releaseWait()
+ return wrapReleaseOnDone(c.Request.Context(), accountRelease), true
+}
+
+func (h *GatewayHandler) forwardKiroSidecar(c *gin.Context, account *service.Account, req kiroSidecarRequest) (*service.ForwardResult, error) {
+ cfg := h.kiroConfig()
+ baseURL, err := validateConfiguredKiroSidecarURL(cfg.SidecarURL)
+ if err != nil {
+ h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Kiro sidecar is not configured")
+ return nil, err
+ }
+ accountKey := strings.TrimSpace(account.GetCredential("api_key"))
+ if accountKey == "" {
+ h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Selected Kiro account has no API key")
+ return nil, fmt.Errorf("kiro account %d missing api_key", account.ID)
+ }
+
+ releaseSidecarSlot, err := acquireKiroSidecarSlot(c.Request.Context(), cfg.MaxConcurrency)
+ if err != nil {
+ h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Kiro sidecar is busy, please retry later", false)
+ return nil, err
+ }
+ defer releaseSidecarSlot()
+
+ sidecarURL, err := joinKiroSidecarURL(baseURL, req.Path)
+ if err != nil {
+ h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Kiro sidecar URL is invalid")
+ return nil, err
+ }
+
+ start := time.Now()
+ ctx, cancel := context.WithTimeout(sidecarUpstreamContext(c.Request.Context(), req.RecordUsage), cfg.requestTimeout())
+ defer cancel()
+
+ var bodyReader io.Reader = http.NoBody
+ if req.Method != http.MethodGet {
+ bodyReader = bytes.NewReader(req.UpstreamBody)
+ }
+ sidecarReq, err := http.NewRequestWithContext(ctx, req.Method, sidecarURL, bodyReader)
+ if err != nil {
+ return nil, err
+ }
+ sidecarReq.Header.Set("Content-Type", "application/json")
+ sidecarReq.Header.Set(kiroSidecarAPIKeyHeader, accountKey)
+ sidecarReq.Header.Set(kiroSidecarAccountIDHeader, strconv.FormatInt(account.ID, 10))
+ sidecarReq.Header.Set(kiroSidecarOriginalPathHeader, c.Request.URL.Path)
+ if rid := c.GetHeader(kiroSidecarClientRequestID); strings.TrimSpace(rid) != "" {
+ sidecarReq.Header.Set(kiroSidecarClientRequestID, rid)
+ }
+ if ua := c.GetHeader("User-Agent"); strings.TrimSpace(ua) != "" {
+ sidecarReq.Header.Set("User-Agent", ua)
+ }
+
+ httpClient := &http.Client{Timeout: cfg.requestTimeout()}
+ resp, err := httpClient.Do(sidecarReq)
+ if err != nil {
+ service.SetOpsUpstreamError(c, http.StatusServiceUnavailable, err.Error(), "")
+ return nil, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if kiroShouldFailoverStatus(resp.StatusCode) {
+ body, _ := readKiroSidecarBody(resp.Body)
+ service.SetOpsUpstreamError(c, resp.StatusCode, service.ExtractUpstreamErrorMessage(body), "")
+ return nil, &service.UpstreamFailoverError{
+ StatusCode: resp.StatusCode,
+ ResponseBody: body,
+ ResponseHeaders: resp.Header.Clone(),
+ }
+ }
+
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+ body, _ := readKiroSidecarBody(resp.Body)
+ upstreamMsg := service.ExtractUpstreamErrorMessage(body)
+ service.SetOpsUpstreamError(c, resp.StatusCode, upstreamMsg, "")
+ h.handleStreamingAwareError(c, resp.StatusCode, "upstream_error", "Upstream request failed", false)
+ return nil, fmt.Errorf("kiro sidecar upstream error: %d", resp.StatusCode)
+ }
+
+ isStreamingResponse := req.Stream || isKiroStreamingContentType(resp.Header.Get("Content-Type"))
+ if isStreamingResponse && req.RejectUnmeteredStream {
+ return nil, errors.New("wallet billing for Kiro streaming is unavailable")
+ }
+ if isStreamingResponse {
+ body, usage, err := readMeteredSidecarStream(
+ resp.Body,
+ kiroMaxSidecarResponseBytes,
+ extractKiroUsage,
+ req.RecordUsage,
+ )
+ if err != nil {
+ return nil, err
+ }
+ result := &service.ForwardResult{
+ RequestID: resp.Header.Get("X-Request-ID"),
+ Usage: usage,
+ Model: req.Model,
+ UpstreamModel: resolvedKiroUpstreamModel(req),
+ Stream: true,
+ Duration: time.Since(start),
+ }
+ copyKiroSidecarHeaders(c.Writer.Header(), resp.Header)
+ c.Status(resp.StatusCode)
+ if err := writeBufferedSidecarStream(c.Writer, body); err != nil {
+ return result, err
+ }
+ if flusher, ok := c.Writer.(http.Flusher); ok {
+ flusher.Flush()
+ }
+ return result, nil
+ }
+
+ copyKiroSidecarHeaders(c.Writer.Header(), resp.Header)
+ c.Status(resp.StatusCode)
+ body, err := readKiroSidecarBody(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := c.Writer.Write(body); err != nil {
+ return nil, err
+ }
+
+ return &service.ForwardResult{
+ RequestID: resp.Header.Get("X-Request-ID"),
+ Usage: extractKiroUsage(body),
+ Model: req.Model,
+ UpstreamModel: resolvedKiroUpstreamModel(req),
+ Stream: false,
+ Duration: time.Since(start),
+ }, nil
+}
+
+func resolvedKiroUpstreamModel(req kiroSidecarRequest) string {
+ if req.Mapping.Mapped && strings.TrimSpace(req.Mapping.MappedModel) != "" && req.Mapping.MappedModel != req.Model {
+ return req.Mapping.MappedModel
+ }
+ return ""
+}
+
+type kiroRuntimeConfig struct {
+ SidecarURL string
+ MaxConcurrency int
+ RequestTimeoutSeconds int
+}
+
+func (h *GatewayHandler) kiroConfig() kiroRuntimeConfig {
+ cfg := kiroRuntimeConfig{
+ MaxConcurrency: 1,
+ RequestTimeoutSeconds: int(kiroDefaultRequestTimeout / time.Second),
+ }
+ if h != nil && h.cfg != nil {
+ cfg.SidecarURL = h.cfg.Kiro.SidecarURL
+ cfg.MaxConcurrency = h.cfg.Kiro.MaxConcurrency
+ cfg.RequestTimeoutSeconds = h.cfg.Kiro.RequestTimeoutSeconds
+ }
+ if sidecarURL := strings.TrimSpace(os.Getenv("KIRO_SIDECAR_URL")); sidecarURL != "" {
+ cfg.SidecarURL = sidecarURL
+ }
+ if maxConcurrency := envPositiveInt("KIRO_MAX_CONCURRENCY"); maxConcurrency > 0 {
+ cfg.MaxConcurrency = maxConcurrency
+ }
+ if timeoutSeconds := envPositiveInt("KIRO_REQUEST_TIMEOUT_SECONDS"); timeoutSeconds > 0 {
+ cfg.RequestTimeoutSeconds = timeoutSeconds
+ }
+ return cfg
+}
+
+func (c kiroRuntimeConfig) requestTimeout() time.Duration {
+ if c.RequestTimeoutSeconds <= 0 {
+ return kiroDefaultRequestTimeout
+ }
+ return time.Duration(c.RequestTimeoutSeconds) * time.Second
+}
+
+func envPositiveInt(key string) int {
+ raw := strings.TrimSpace(os.Getenv(key))
+ if raw == "" {
+ return 0
+ }
+ value, err := strconv.Atoi(raw)
+ if err != nil || value <= 0 {
+ return 0
+ }
+ return value
+}
+
+func validateConfiguredKiroSidecarURL(raw string) (string, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", errors.New("kiro sidecar url is empty")
+ }
+ if err := config.ValidateAbsoluteHTTPURL(raw); err != nil {
+ return "", err
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return "", err
+ }
+ if u.User != nil || u.RawQuery != "" || u.ForceQuery {
+ return "", errors.New("kiro sidecar url must not include userinfo or query")
+ }
+ return strings.TrimRight(raw, "/"), nil
+}
+
+func joinKiroSidecarURL(baseURL, path string) (string, error) {
+ u, err := url.Parse(baseURL)
+ if err != nil {
+ return "", err
+ }
+ if strings.TrimSpace(path) == "" || !strings.HasPrefix(path, "/") {
+ return "", errors.New("kiro sidecar path must be absolute")
+ }
+ u.Path = strings.TrimRight(u.Path, "/") + path
+ u.RawQuery = ""
+ u.Fragment = ""
+ return u.String(), nil
+}
+
+func acquireKiroSidecarSlot(ctx context.Context, maxConcurrency int) (func(), error) {
+ if maxConcurrency <= 0 {
+ maxConcurrency = 1
+ }
+ raw, _ := kiroSidecarLimiters.LoadOrStore(maxConcurrency, semaphore.NewWeighted(int64(maxConcurrency)))
+ limiter, ok := raw.(*semaphore.Weighted)
+ if !ok {
+ return nil, errors.New("kiro sidecar limiter has invalid type")
+ }
+ if err := limiter.Acquire(ctx, kiroSidecarLimiterAcquireWeight); err != nil {
+ return nil, err
+ }
+ return func() {
+ limiter.Release(kiroSidecarLimiterAcquireWeight)
+ }, nil
+}
+
+func readKiroSidecarBody(body io.Reader) ([]byte, error) {
+ limited := io.LimitReader(body, kiroMaxSidecarResponseBytes+1)
+ data, err := io.ReadAll(limited)
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(data)) > kiroMaxSidecarResponseBytes {
+ return nil, fmt.Errorf("kiro sidecar response exceeds %d bytes", kiroMaxSidecarResponseBytes)
+ }
+ return data, nil
+}
+
+func extractKiroUsage(body []byte) service.ClaudeUsage {
+ return extractCommonSidecarUsage(body)
+}
+
+func firstKiroInt(body []byte, paths ...string) int {
+ for _, path := range paths {
+ r := gjson.GetBytes(body, path)
+ if r.Exists() && r.Type == gjson.Number {
+ return int(r.Int())
+ }
+ }
+ return 0
+}
+
+func copyKiroSidecarHeaders(dst, src http.Header) {
+ for key, values := range src {
+ if !shouldCopyKiroSidecarHeader(key) {
+ continue
+ }
+ for _, value := range values {
+ dst.Add(key, value)
+ }
+ }
+}
+
+func shouldCopyKiroSidecarHeader(key string) bool {
+ switch strings.ToLower(strings.TrimSpace(key)) {
+ case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
+ "te", "trailer", "transfer-encoding", "upgrade", "content-length",
+ "authorization", "set-cookie", strings.ToLower(kiroSidecarAPIKeyHeader):
+ return false
+ default:
+ return true
+ }
+}
+
+func isKiroStreamingContentType(contentType string) bool {
+ contentType = strings.ToLower(contentType)
+ return strings.Contains(contentType, "text/event-stream") || strings.Contains(contentType, "application/x-ndjson")
+}
+
+func kiroShouldFailoverStatus(statusCode int) bool {
+ switch statusCode {
+ case http.StatusUnauthorized,
+ http.StatusForbidden,
+ http.StatusTooManyRequests,
+ http.StatusInternalServerError,
+ http.StatusBadGateway,
+ http.StatusServiceUnavailable,
+ http.StatusGatewayTimeout,
+ 529:
+ return true
+ default:
+ return false
+ }
+}
diff --git a/backend/internal/handler/kiro_gateway_handler_test.go b/backend/internal/handler/kiro_gateway_handler_test.go
new file mode 100644
index 00000000000..2f61e27a1b9
--- /dev/null
+++ b/backend/internal/handler/kiro_gateway_handler_test.go
@@ -0,0 +1,182 @@
+package handler
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateConfiguredKiroSidecarURLRejectsSecretsAndQueries(t *testing.T) {
+ _, err := validateConfiguredKiroSidecarURL("http://user:pass@127.0.0.1:8787")
+ require.Error(t, err)
+
+ _, err = validateConfiguredKiroSidecarURL("http://127.0.0.1:8787?token=secret")
+ require.Error(t, err)
+
+ got, err := validateConfiguredKiroSidecarURL("http://127.0.0.1:8787/")
+ require.NoError(t, err)
+ require.Equal(t, "http://127.0.0.1:8787", got)
+}
+
+func TestJoinKiroSidecarURLPreservesBasePath(t *testing.T) {
+ got, err := joinKiroSidecarURL("http://sidecar.local/internal", "/v1/messages")
+ require.NoError(t, err)
+ require.Equal(t, "http://sidecar.local/internal/v1/messages", got)
+}
+
+func TestExtractKiroUsageSupportsCommonSchemas(t *testing.T) {
+ anthropicUsage := extractKiroUsage([]byte(`{"usage":{"input_tokens":10,"output_tokens":20,"cache_read_input_tokens":3}}`))
+ require.Equal(t, 10, anthropicUsage.InputTokens)
+ require.Equal(t, 20, anthropicUsage.OutputTokens)
+ require.Equal(t, 3, anthropicUsage.CacheReadInputTokens)
+
+ openAIUsage := extractKiroUsage([]byte(`{"usage":{"prompt_tokens":11,"completion_tokens":21}}`))
+ require.Equal(t, 11, openAIUsage.InputTokens)
+ require.Equal(t, 21, openAIUsage.OutputTokens)
+
+ geminiUsage := extractKiroUsage([]byte(`{"usage":{"promptTokenCount":12,"candidatesTokenCount":22}}`))
+ require.Equal(t, 12, geminiUsage.InputTokens)
+ require.Equal(t, 22, geminiUsage.OutputTokens)
+}
+
+func TestCopyKiroSidecarHeadersDropsSensitiveHopByHopHeaders(t *testing.T) {
+ src := http.Header{}
+ src.Set("Content-Type", "application/json")
+ src.Set("Authorization", "Bearer secret")
+ src.Set("X-Kiro-API-Key", "kiro-secret")
+ src.Set("Connection", "keep-alive")
+ src.Set("Retry-After", "5")
+
+ dst := http.Header{}
+ copyKiroSidecarHeaders(dst, src)
+
+ require.Equal(t, "application/json", dst.Get("Content-Type"))
+ require.Equal(t, "5", dst.Get("Retry-After"))
+ require.Empty(t, dst.Get("Authorization"))
+ require.Empty(t, dst.Get("X-Kiro-API-Key"))
+ require.Empty(t, dst.Get("Connection"))
+}
+
+func TestForwardKiroSidecarWalletStreamFailsClosedWithoutDeliveringBody(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: unmetered\n\n"))
+ }))
+ defer sidecar.Close()
+
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodPost, "/kiro/v1/messages", nil)
+ h := &GatewayHandler{cfg: &config.Config{}}
+ h.cfg.Kiro.SidecarURL = sidecar.URL
+ account := &service.Account{
+ ID: 1,
+ Platform: service.PlatformKiro,
+ Credentials: map[string]any{"api_key": "test-key"},
+ }
+
+ result, err := h.forwardKiroSidecar(c, account, kiroSidecarRequest{
+ Method: http.MethodPost,
+ Path: "/v1/messages",
+ UpstreamBody: []byte(`{}`),
+ RejectUnmeteredStream: true,
+ })
+
+ require.Nil(t, result)
+ require.EqualError(t, err, "wallet billing for Kiro streaming is unavailable")
+ require.Empty(t, w.Body.String())
+}
+
+func TestForwardKiroSidecarNonWalletStreamExtractsTerminalUsage(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ streamBody := "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":10,\"cache_read_input_tokens\":2}}}\n\n" +
+ "data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":7}}\n\n"
+ sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte(streamBody))
+ }))
+ defer sidecar.Close()
+
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodPost, "/kiro/v1/messages", nil)
+ h := &GatewayHandler{cfg: &config.Config{}}
+ h.cfg.Kiro.SidecarURL = sidecar.URL
+ account := &service.Account{
+ ID: 1,
+ Platform: service.PlatformKiro,
+ Credentials: map[string]any{"api_key": "test-key"},
+ }
+
+ result, err := h.forwardKiroSidecar(c, account, kiroSidecarRequest{
+ Method: http.MethodPost,
+ Path: "/v1/messages",
+ UpstreamBody: []byte(`{}`),
+ RecordUsage: true,
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.True(t, result.Stream)
+ require.Equal(t, 10, result.Usage.InputTokens)
+ require.Equal(t, 7, result.Usage.OutputTokens)
+ require.Equal(t, 2, result.Usage.CacheReadInputTokens)
+ require.Equal(t, streamBody, w.Body.String())
+}
+
+func TestForwardKiroSidecarNonWalletStreamRejectsMissingUsageBeforeDelivery(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ _, _ = w.Write([]byte("data: {\"type\":\"content_block_delta\"}\n\n"))
+ }))
+ defer sidecar.Close()
+
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodPost, "/kiro/v1/messages", nil)
+ h := &GatewayHandler{cfg: &config.Config{}}
+ h.cfg.Kiro.SidecarURL = sidecar.URL
+ account := &service.Account{ID: 1, Platform: service.PlatformKiro, Credentials: map[string]any{"api_key": "test-key"}}
+
+ result, err := h.forwardKiroSidecar(c, account, kiroSidecarRequest{
+ Method: http.MethodPost, Path: "/v1/messages", UpstreamBody: []byte(`{}`), RecordUsage: true,
+ })
+ require.Nil(t, result)
+ require.ErrorContains(t, err, "completed without usage")
+ require.Empty(t, w.Body.String())
+}
+
+func TestForwardKiroSidecarRedactsNonFailoverUpstreamErrorBody(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ const upstreamSecret = "sk-upstream-kiro-secret"
+ sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusTeapot)
+ _, _ = w.Write([]byte(`{"error":{"message":"internal endpoint http://10.0.0.7:8787 token ` + upstreamSecret + `"}}`))
+ }))
+ defer sidecar.Close()
+
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodPost, "/kiro/v1/messages", nil)
+ h := &GatewayHandler{cfg: &config.Config{}}
+ h.cfg.Kiro.SidecarURL = sidecar.URL
+ account := &service.Account{ID: 1, Platform: service.PlatformKiro, Credentials: map[string]any{"api_key": "test-key"}}
+
+ result, err := h.forwardKiroSidecar(c, account, kiroSidecarRequest{
+ Method: http.MethodPost, Path: "/v1/messages", UpstreamBody: []byte(`{}`),
+ })
+
+ require.Nil(t, result)
+ require.EqualError(t, err, "kiro sidecar upstream error: 418")
+ require.NotContains(t, w.Body.String(), upstreamSecret)
+ require.NotContains(t, w.Body.String(), "10.0.0.7")
+ require.Contains(t, w.Body.String(), "Upstream request failed")
+}
diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go
index f395970a2cd..93bef569ed8 100644
--- a/backend/internal/handler/openai_chat_completions.go
+++ b/backend/internal/handler/openai_chat_completions.go
@@ -10,6 +10,7 @@ import (
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -80,14 +81,35 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream, body)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
+ if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, reqModel, body); decision != nil && decision.Blocked {
+ h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
+ return
+ }
+
// 解析渠道级模型映射
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
+ routingModel, routingValid := resolveOpenAIAccountRoutingModel(reqModel, "", channelMapping)
+ if !routingValid {
+ h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Invalid GPT-5.6 preview model mapping")
+ return
+ }
if h.errorPassthroughService != nil {
service.BindErrorPassthroughService(c, h.errorPassthroughService)
}
subscription, _ := middleware2.GetSubscriptionFromContext(c)
+ billingCtx, err := service.PrepareUsageBillingRequestContext(c.Request.Context())
+ if err != nil {
+ h.errorResponse(c, http.StatusServiceUnavailable, "billing_service_error", "Billing service temporarily unavailable")
+ return
+ }
+ c.Request = c.Request.WithContext(billingCtx)
+ usageBillingAdmission := &service.OpenAIUsageBillingAdmissionInput{
+ APIKey: apiKey, User: apiKey.User, Subscription: subscription,
+ RequestBody: append([]byte(nil), body...), RequestPayloadHash: service.HashUsageRequestPayload(body),
+ }
+ defer h.finalizeOpenAIUsageBillingLifecycle(c.Request.Context(), usageBillingAdmission, reqLog)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
routingStart := time.Now()
@@ -120,14 +142,13 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
var lastFailoverErr *service.UpstreamFailoverError
for {
- c.Set("openai_chat_completions_fallback_model", "")
reqLog.Debug("openai_chat_completions.account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs)))
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithScheduler(
c.Request.Context(),
apiKey.GroupID,
"",
sessionHash,
- reqModel,
+ routingModel,
failedAccountIDs,
service.OpenAIUpstreamTransportAny,
false,
@@ -137,33 +158,13 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
zap.Error(err),
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
+ if status, errType, message, ok := openAIGPT56AvailabilityError(routingModel, err, lastFailoverErr); ok {
+ h.handleStreamingAwareError(c, status, errType, message, streamStarted)
+ return
+ }
if len(failedAccountIDs) == 0 {
- defaultModel := ""
- if apiKey.Group != nil {
- defaultModel = apiKey.Group.DefaultMappedModel
- }
- if defaultModel != "" && defaultModel != reqModel {
- reqLog.Info("openai_chat_completions.fallback_to_default_model",
- zap.String("default_mapped_model", defaultModel),
- )
- selection, scheduleDecision, err = h.gatewayService.SelectAccountWithScheduler(
- c.Request.Context(),
- apiKey.GroupID,
- "",
- sessionHash,
- defaultModel,
- failedAccountIDs,
- service.OpenAIUpstreamTransportAny,
- false,
- )
- if err == nil && selection != nil {
- c.Set("openai_chat_completions_fallback_model", defaultModel)
- }
- }
- if err != nil {
- h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted)
- return
- }
+ h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted)
+ return
} else {
if lastFailoverErr != nil {
h.handleFailoverExhausted(c, lastFailoverErr, streamStarted)
@@ -174,10 +175,25 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
}
}
if selection == nil || selection.Account == nil {
+ if status, errType, message, ok := openAIGPT56AvailabilityError(routingModel, nil, lastFailoverErr); ok {
+ h.handleStreamingAwareError(c, status, errType, message, streamStarted)
+ return
+ }
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted)
return
}
account := selection.Account
+ if !openAISelectedAccountSupportsRoutingModel(account, routingModel) {
+ if selection.ReleaseFunc != nil {
+ selection.ReleaseFunc()
+ }
+ failedAccountIDs[account.ID] = struct{}{}
+ reqLog.Warn("openai_chat_completions.account_rejected_after_routing_check",
+ zap.Int64("account_id", account.ID),
+ zap.String("routing_model", routingModel),
+ )
+ continue
+ }
sessionHash = ensureOpenAIPoolModeSessionHash(sessionHash, account)
reqLog.Debug("openai_chat_completions.account_selected", zap.Int64("account_id", account.ID), zap.String("account_name", account.Name))
_ = scheduleDecision
@@ -191,12 +207,19 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds())
forwardStart := time.Now()
- defaultMappedModel := resolveOpenAIForwardDefaultMappedModel(apiKey, c.GetString("openai_chat_completions_fallback_model"))
forwardBody := body
if channelMapping.Mapped {
forwardBody = h.gatewayService.ReplaceModelInBody(body, channelMapping.MappedModel)
}
- result, err := h.gatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, forwardBody, promptCacheKey, defaultMappedModel)
+ result, err := h.gatewayService.ForwardAsChatCompletionsWithOptions(c.Request.Context(), c, account, forwardBody, promptCacheKey, "", service.OpenAIForwardOptions{
+ RequestedModel: reqModel,
+ ChannelMapping: channelMapping,
+ GroupID: apiKey.GroupID,
+ ImagePriceConfig: openAIImagePriceConfig(apiKey.Group),
+ RequirePricingPreflight: true,
+ RequireBillingAdmission: true,
+ UsageBilling: usageBillingAdmission,
+ })
forwardDurationMs := time.Since(forwardStart).Milliseconds()
if accountReleaseFunc != nil {
@@ -212,53 +235,71 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs))
}
if err != nil {
- var failoverErr *service.UpstreamFailoverError
- if errors.As(err, &failoverErr) {
- h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
- // Pool mode: retry on the same account
- if failoverErr.RetryableOnSameAccount {
- retryLimit := account.GetPoolModeRetryCount()
- if sameAccountRetryCount[account.ID] < retryLimit {
- sameAccountRetryCount[account.ID]++
- reqLog.Warn("openai_chat_completions.pool_mode_same_account_retry",
- zap.Int64("account_id", account.ID),
- zap.Int("upstream_status", failoverErr.StatusCode),
- zap.Int("retry_limit", retryLimit),
- zap.Int("retry_count", sameAccountRetryCount[account.ID]),
- )
- select {
- case <-c.Request.Context().Done():
- return
- case <-time.After(sameAccountRetryDelay):
+ if h.handleOpenAIUsageBillingAdmissionError(c, err, streamStarted, false) {
+ return
+ }
+ if result != nil && result.ImageCount > 0 {
+ reqLog.Warn("openai_chat_completions.forward_partial_error_with_image_result",
+ zap.Int64("account_id", account.ID),
+ zap.Int("image_count", result.ImageCount),
+ zap.Error(err),
+ )
+ } else {
+ var failoverErr *service.UpstreamFailoverError
+ if errors.As(err, &failoverErr) {
+ h.markOpenAIUsageBillingAttemptFailed(c.Request.Context(), usageBillingAdmission, reqLog)
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
+ // Pool mode: retry on the same account
+ if failoverErr.RetryableOnSameAccount {
+ retryLimit := account.GetPoolModeRetryCount()
+ if sameAccountRetryCount[account.ID] < retryLimit {
+ sameAccountRetryCount[account.ID]++
+ reqLog.Warn("openai_chat_completions.pool_mode_same_account_retry",
+ zap.Int64("account_id", account.ID),
+ zap.Int("upstream_status", failoverErr.StatusCode),
+ zap.Int("retry_limit", retryLimit),
+ zap.Int("retry_count", sameAccountRetryCount[account.ID]),
+ )
+ select {
+ case <-c.Request.Context().Done():
+ return
+ case <-time.After(sameAccountRetryDelay):
+ }
+ continue
}
- continue
}
+ h.gatewayService.RecordOpenAIAccountSwitch()
+ failedAccountIDs[account.ID] = struct{}{}
+ lastFailoverErr = failoverErr
+ if switchCount >= maxAccountSwitches {
+ h.handleFailoverExhausted(c, failoverErr, streamStarted)
+ return
+ }
+ switchCount++
+ reqLog.Warn("openai_chat_completions.upstream_failover_switching",
+ zap.Int64("account_id", account.ID),
+ zap.Int("upstream_status", failoverErr.StatusCode),
+ zap.Int("switch_count", switchCount),
+ zap.Int("max_switches", maxAccountSwitches),
+ )
+ continue
}
- h.gatewayService.RecordOpenAIAccountSwitch()
- failedAccountIDs[account.ID] = struct{}{}
- lastFailoverErr = failoverErr
- if switchCount >= maxAccountSwitches {
- h.handleFailoverExhausted(c, failoverErr, streamStarted)
+ if errors.Is(err, service.ErrOpenAIBillingPreflight) || errors.Is(err, service.ErrOpenAIPricingUnavailable) {
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
+ h.handleStreamingAwareError(c, http.StatusBadRequest, "invalid_request_error", "OpenAI billing preflight failed", streamStarted)
return
}
- switchCount++
- reqLog.Warn("openai_chat_completions.upstream_failover_switching",
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
+ wroteFallback := h.ensureForwardErrorResponse(c, streamStarted)
+ reqLog.Warn("openai_chat_completions.forward_failed",
zap.Int64("account_id", account.ID),
- zap.Int("upstream_status", failoverErr.StatusCode),
- zap.Int("switch_count", switchCount),
- zap.Int("max_switches", maxAccountSwitches),
+ zap.Bool("fallback_error_response_written", wroteFallback),
+ zap.Error(err),
)
- continue
+ return
}
- h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
- wroteFallback := h.ensureForwardErrorResponse(c, streamStarted)
- reqLog.Warn("openai_chat_completions.forward_failed",
- zap.Int64("account_id", account.ID),
- zap.Bool("fallback_error_response_written", wroteFallback),
- zap.Error(err),
- )
- return
}
+ h.gatewayService.MarkOpenAIUsageBillingAccepted(usageBillingAdmission)
if result != nil {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs)
} else {
@@ -267,21 +308,25 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
+ inboundEndpoint := GetInboundEndpoint(c)
+ upstreamEndpoint := resolveRawCCUpstreamEndpoint(c, account)
- h.submitUsageRecordTask(func(ctx context.Context) {
+ h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
- InboundEndpoint: GetInboundEndpoint(c),
- UpstreamEndpoint: GetUpstreamEndpoint(c, account.Platform),
+ InboundEndpoint: inboundEndpoint,
+ UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
+ RequestPayloadHash: service.HashUsageRequestPayload(body),
APIKeyService: h.apiKeyService,
ChannelUsageFields: channelMapping.ToUsageFields(reqModel, result.UpstreamModel),
}); err != nil {
+ h.markOpenAIUsageBillingResultOrphaned(ctx, result, reqLog)
logger.L().With(
zap.String("component", "handler.openai_gateway.chat_completions"),
zap.Int64("user_id", subject.UserID),
@@ -299,3 +344,16 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
return
}
}
+
+// resolveRawCCUpstreamEndpoint returns the actual upstream endpoint for
+// OpenAI Chat Completions requests. For APIKey accounts whose upstream
+// has been probed to not support the Responses API, the request is
+// forwarded directly to /v1/chat/completions — not through the default
+// CC→Responses conversion path.
+func resolveRawCCUpstreamEndpoint(c *gin.Context, account *service.Account) string {
+ if account != nil && account.Type == service.AccountTypeAPIKey &&
+ !openai_compat.ShouldUseResponsesAPI(account.Extra) {
+ return "/v1/chat/completions"
+ }
+ return GetUpstreamEndpoint(c, account.Platform)
+}
diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go
index 7676ffa32d5..b7323d2b251 100644
--- a/backend/internal/handler/openai_gateway_handler.go
+++ b/backend/internal/handler/openai_gateway_handler.go
@@ -9,9 +9,12 @@ import (
"runtime/debug"
"strconv"
"strings"
+ "sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
@@ -27,31 +30,125 @@ import (
// OpenAIGatewayHandler handles OpenAI API gateway requests
type OpenAIGatewayHandler struct {
- gatewayService *service.OpenAIGatewayService
- billingCacheService *service.BillingCacheService
- apiKeyService *service.APIKeyService
- usageRecordWorkerPool *service.UsageRecordWorkerPool
- errorPassthroughService *service.ErrorPassthroughService
- concurrencyHelper *ConcurrencyHelper
- maxAccountSwitches int
- cfg *config.Config
+ gatewayService *service.OpenAIGatewayService
+ billingCacheService *service.BillingCacheService
+ apiKeyService *service.APIKeyService
+ usageRecordWorkerPool *service.UsageRecordWorkerPool
+ errorPassthroughService *service.ErrorPassthroughService
+ contentModerationService *service.ContentModerationService
+ concurrencyHelper *ConcurrencyHelper
+ imageLimiter *imageConcurrencyLimiter
+ maxAccountSwitches int
+ cfg *config.Config
}
-func resolveOpenAIForwardDefaultMappedModel(apiKey *service.APIKey, fallbackModel string) string {
- if fallbackModel = strings.TrimSpace(fallbackModel); fallbackModel != "" {
- return fallbackModel
- }
+func resolveOpenAIMessagesDispatchMappedModel(apiKey *service.APIKey, requestedModel string) string {
if apiKey == nil || apiKey.Group == nil {
return ""
}
- return strings.TrimSpace(apiKey.Group.DefaultMappedModel)
+ return strings.TrimSpace(apiKey.Group.ResolveMessagesDispatchModel(requestedModel))
}
-func resolveOpenAIMessagesDispatchMappedModel(apiKey *service.APIKey, requestedModel string) string {
- if apiKey == nil || apiKey.Group == nil {
- return ""
+func openAIWSBillingConnectionID(ctx context.Context, sessionHash string) string {
+ base := strings.TrimSpace(sessionHash)
+ if ctx != nil {
+ if clientRequestID, _ := ctx.Value(ctxkey.ClientRequestID).(string); strings.TrimSpace(clientRequestID) != "" {
+ base = "client:" + strings.TrimSpace(clientRequestID)
+ } else if requestID, _ := ctx.Value(ctxkey.RequestID).(string); strings.TrimSpace(requestID) != "" {
+ base = "local:" + strings.TrimSpace(requestID)
+ }
}
- return strings.TrimSpace(apiKey.Group.ResolveMessagesDispatchModel(requestedModel))
+ if base == "" {
+ base = "generated:" + uuid.NewString()
+ }
+ return "ws:" + service.HashUsageRequestPayload([]byte(base))
+}
+
+func deriveOpenAIWSBillingRequestID(connectionID string, turn int) string {
+ if turn < 1 {
+ turn = 1
+ }
+ return fmt.Sprintf("%s:turn:%d", strings.TrimSpace(connectionID), turn)
+}
+
+// resolveOpenAIAccountRoutingModel returns the final model whose account
+// entitlement must be checked. Channel mapping is the last routing decision,
+// so it takes precedence over the Messages dispatch default and client model.
+func resolveOpenAIAccountRoutingModel(requestedModel, preferredMappedModel string, channelMapping service.ChannelMappingResult) (string, bool) {
+ candidate := strings.TrimSpace(requestedModel)
+ if preferred := strings.TrimSpace(preferredMappedModel); preferred != "" {
+ candidate = preferred
+ }
+ if channelMapping.Mapped {
+ candidate = strings.TrimSpace(channelMapping.MappedModel)
+ }
+ if candidate == "" || !service.ValidateOpenAIGPT56ModelTransition(requestedModel, candidate) {
+ return "", false
+ }
+ normalized := ""
+ if previewTier, isPreviewFamily := service.NormalizeOpenAIGPT56PreviewModel(candidate); isPreviewFamily {
+ normalized = previewTier
+ } else {
+ normalized = strings.TrimSpace(service.NormalizeOpenAICompatRequestedModel(candidate))
+ }
+ if normalized == "" || !service.ValidateOpenAIGPT56ModelTransition(candidate, normalized) {
+ return "", false
+ }
+ return normalized, true
+}
+
+func openAISelectedAccountSupportsRoutingModel(account *service.Account, routingModel string) bool {
+ if account == nil {
+ return false
+ }
+ routingModel = strings.TrimSpace(routingModel)
+ return routingModel == "" || account.IsModelSupported(routingModel)
+}
+
+func openAIGPT56AvailabilityError(
+ model string,
+ selectionErr error,
+ lastFailoverErr *service.UpstreamFailoverError,
+) (status int, errType, message string, ok bool) {
+ normalized, isFamily := service.NormalizeOpenAIGPT56PreviewModel(model)
+ if !isFamily || normalized == "" {
+ return 0, "", "", false
+ }
+ if selectionErr != nil &&
+ !errors.Is(selectionErr, service.ErrNoAvailableAccounts) &&
+ !errors.Is(selectionErr, service.ErrNoAvailableOpenAIAccounts) {
+ return 0, "", "", false
+ }
+ if lastFailoverErr != nil && lastFailoverErr.StatusCode != http.StatusForbidden {
+ return 0, "", "", false
+ }
+ return http.StatusForbidden,
+ "model_not_available",
+ fmt.Sprintf("Model %s is not available for this API key or group", normalized),
+ true
+}
+
+func openAIWSChannelMappingKeepsCanonicalModel(requestedModel string, channelMapping service.ChannelMappingResult) bool {
+ if !channelMapping.Mapped {
+ return true
+ }
+ requestedModel = strings.TrimSpace(requestedModel)
+ mappedModel := strings.TrimSpace(channelMapping.MappedModel)
+ _, requestedIsPreview := service.NormalizeOpenAIGPT56PreviewModel(requestedModel)
+ _, mappedIsPreview := service.NormalizeOpenAIGPT56PreviewModel(mappedModel)
+ if requestedIsPreview || mappedIsPreview {
+ return requestedModel != "" && requestedModel == mappedModel
+ }
+ if service.OpenAIWSSameCanonicalModel(requestedModel, mappedModel) {
+ return true
+ }
+ stripOpenAIPrefix := func(model string) string {
+ if strings.HasPrefix(strings.ToLower(model), "openai/") {
+ return strings.TrimSpace(model[len("openai/"):])
+ }
+ return model
+ }
+ return service.OpenAIWSSameCanonicalModel(stripOpenAIPrefix(requestedModel), stripOpenAIPrefix(mappedModel))
}
// NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler
@@ -62,6 +159,7 @@ func NewOpenAIGatewayHandler(
apiKeyService *service.APIKeyService,
usageRecordWorkerPool *service.UsageRecordWorkerPool,
errorPassthroughService *service.ErrorPassthroughService,
+ contentModerationService *service.ContentModerationService,
cfg *config.Config,
) *OpenAIGatewayHandler {
pingInterval := time.Duration(0)
@@ -73,14 +171,16 @@ func NewOpenAIGatewayHandler(
}
}
return &OpenAIGatewayHandler{
- gatewayService: gatewayService,
- billingCacheService: billingCacheService,
- apiKeyService: apiKeyService,
- usageRecordWorkerPool: usageRecordWorkerPool,
- errorPassthroughService: errorPassthroughService,
- concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatComment, pingInterval),
- maxAccountSwitches: maxAccountSwitches,
- cfg: cfg,
+ gatewayService: gatewayService,
+ billingCacheService: billingCacheService,
+ apiKeyService: apiKeyService,
+ usageRecordWorkerPool: usageRecordWorkerPool,
+ errorPassthroughService: errorPassthroughService,
+ contentModerationService: contentModerationService,
+ concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatComment, pingInterval),
+ imageLimiter: &imageConcurrencyLimiter{},
+ maxAccountSwitches: maxAccountSwitches,
+ cfg: cfg,
}
}
@@ -197,8 +297,35 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream, body)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
+ if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && decision.Blocked {
+ h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
+ return
+ }
+
+ imageIntent := service.IsImageGenerationIntent("/v1/responses", reqModel, body)
+ if imageIntent && !service.GroupAllowsImageGeneration(apiKey.Group) {
+ h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage())
+ return
+ }
+ var imageReleaseFunc func()
+ if imageIntent {
+ var imageAcquired bool
+ imageReleaseFunc, imageAcquired = h.acquireImageGenerationSlot(c, streamStarted)
+ if !imageAcquired {
+ return
+ }
+ if imageReleaseFunc != nil {
+ defer imageReleaseFunc()
+ }
+ }
+
// 解析渠道级模型映射
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
+ routingModel, routingValid := resolveOpenAIAccountRoutingModel(reqModel, "", channelMapping)
+ if !routingValid {
+ h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Invalid GPT-5.6 preview model mapping")
+ return
+ }
// 提前校验 function_call_output 是否具备可关联上下文,避免上游 400。
if !h.validateFunctionCallOutputRequest(c, body, reqLog) {
@@ -212,6 +339,17 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
// Get subscription info (may be nil)
subscription, _ := middleware2.GetSubscriptionFromContext(c)
+ billingCtx, err := service.PrepareUsageBillingRequestContext(c.Request.Context())
+ if err != nil {
+ h.errorResponse(c, http.StatusServiceUnavailable, "billing_service_error", "Billing service temporarily unavailable")
+ return
+ }
+ c.Request = c.Request.WithContext(billingCtx)
+ usageBillingAdmission := &service.OpenAIUsageBillingAdmissionInput{
+ APIKey: apiKey, User: apiKey.User, Subscription: subscription,
+ RequestBody: append([]byte(nil), body...), RequestPayloadHash: service.HashUsageRequestPayload(body),
+ }
+ defer h.finalizeOpenAIUsageBillingLifecycle(c.Request.Context(), usageBillingAdmission, reqLog)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
routingStart := time.Now()
@@ -254,7 +392,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
apiKey.GroupID,
previousResponseID,
sessionHash,
- reqModel,
+ routingModel,
failedAccountIDs,
service.OpenAIUpstreamTransportAny,
requireCompact,
@@ -269,9 +407,17 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "compact_not_supported", "No available OpenAI accounts support /responses/compact", streamStarted)
return
}
+ if status, errType, message, ok := openAIGPT56AvailabilityError(routingModel, err, lastFailoverErr); ok {
+ h.handleStreamingAwareError(c, status, errType, message, streamStarted)
+ return
+ }
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted)
return
}
+ if status, errType, message, ok := openAIGPT56AvailabilityError(routingModel, err, lastFailoverErr); ok {
+ h.handleStreamingAwareError(c, status, errType, message, streamStarted)
+ return
+ }
if lastFailoverErr != nil {
h.handleFailoverExhausted(c, lastFailoverErr, streamStarted)
} else {
@@ -280,6 +426,10 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
return
}
if selection == nil || selection.Account == nil {
+ if status, errType, message, ok := openAIGPT56AvailabilityError(routingModel, nil, lastFailoverErr); ok {
+ h.handleStreamingAwareError(c, status, errType, message, streamStarted)
+ return
+ }
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted)
return
}
@@ -296,6 +446,17 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
zap.Float64("load_skew", scheduleDecision.LoadSkew),
)
account := selection.Account
+ if !openAISelectedAccountSupportsRoutingModel(account, routingModel) {
+ if selection.ReleaseFunc != nil {
+ selection.ReleaseFunc()
+ }
+ failedAccountIDs[account.ID] = struct{}{}
+ reqLog.Warn("openai.account_rejected_after_routing_check",
+ zap.Int64("account_id", account.ID),
+ zap.String("routing_model", routingModel),
+ )
+ continue
+ }
sessionHash = ensureOpenAIPoolModeSessionHash(sessionHash, account)
reqLog.Debug("openai.account_selected", zap.Int64("account_id", account.ID), zap.String("account_name", account.Name))
setOpsSelectedAccount(c, account.ID, account.Platform)
@@ -313,7 +474,15 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
if channelMapping.Mapped {
forwardBody = h.gatewayService.ReplaceModelInBody(body, channelMapping.MappedModel)
}
- result, err := h.gatewayService.Forward(c.Request.Context(), c, account, forwardBody)
+ result, err := h.gatewayService.ForwardWithOptions(c.Request.Context(), c, account, forwardBody, service.OpenAIForwardOptions{
+ RequestedModel: reqModel,
+ ChannelMapping: channelMapping,
+ GroupID: apiKey.GroupID,
+ ImagePriceConfig: openAIImagePriceConfig(apiKey.Group),
+ RequirePricingPreflight: true,
+ RequireBillingAdmission: true,
+ UsageBilling: usageBillingAdmission,
+ })
forwardDurationMs := time.Since(forwardStart).Milliseconds()
if accountReleaseFunc != nil {
accountReleaseFunc()
@@ -328,58 +497,76 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs))
}
if err != nil {
- var failoverErr *service.UpstreamFailoverError
- if errors.As(err, &failoverErr) {
- h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
- // 池模式:同账号重试
- if failoverErr.RetryableOnSameAccount {
- retryLimit := account.GetPoolModeRetryCount()
- if sameAccountRetryCount[account.ID] < retryLimit {
- sameAccountRetryCount[account.ID]++
- reqLog.Warn("openai.pool_mode_same_account_retry",
- zap.Int64("account_id", account.ID),
- zap.Int("upstream_status", failoverErr.StatusCode),
- zap.Int("retry_limit", retryLimit),
- zap.Int("retry_count", sameAccountRetryCount[account.ID]),
- )
- select {
- case <-c.Request.Context().Done():
- return
- case <-time.After(sameAccountRetryDelay):
+ if h.handleOpenAIUsageBillingAdmissionError(c, err, streamStarted, false) {
+ return
+ }
+ if result != nil && result.ImageCount > 0 {
+ reqLog.Warn("openai.forward_partial_error_with_image_result",
+ zap.Int64("account_id", account.ID),
+ zap.Int("image_count", result.ImageCount),
+ zap.Error(err),
+ )
+ } else {
+ var failoverErr *service.UpstreamFailoverError
+ if errors.As(err, &failoverErr) {
+ h.markOpenAIUsageBillingAttemptFailed(c.Request.Context(), usageBillingAdmission, reqLog)
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
+ // 池模式:同账号重试
+ if failoverErr.RetryableOnSameAccount {
+ retryLimit := account.GetPoolModeRetryCount()
+ if sameAccountRetryCount[account.ID] < retryLimit {
+ sameAccountRetryCount[account.ID]++
+ reqLog.Warn("openai.pool_mode_same_account_retry",
+ zap.Int64("account_id", account.ID),
+ zap.Int("upstream_status", failoverErr.StatusCode),
+ zap.Int("retry_limit", retryLimit),
+ zap.Int("retry_count", sameAccountRetryCount[account.ID]),
+ )
+ select {
+ case <-c.Request.Context().Done():
+ return
+ case <-time.After(sameAccountRetryDelay):
+ }
+ continue
}
- continue
}
+ h.gatewayService.RecordOpenAIAccountSwitch()
+ failedAccountIDs[account.ID] = struct{}{}
+ lastFailoverErr = failoverErr
+ if switchCount >= maxAccountSwitches {
+ h.handleFailoverExhausted(c, failoverErr, streamStarted)
+ return
+ }
+ switchCount++
+ reqLog.Warn("openai.upstream_failover_switching",
+ zap.Int64("account_id", account.ID),
+ zap.Int("upstream_status", failoverErr.StatusCode),
+ zap.Int("switch_count", switchCount),
+ zap.Int("max_switches", maxAccountSwitches),
+ )
+ continue
}
- h.gatewayService.RecordOpenAIAccountSwitch()
- failedAccountIDs[account.ID] = struct{}{}
- lastFailoverErr = failoverErr
- if switchCount >= maxAccountSwitches {
- h.handleFailoverExhausted(c, failoverErr, streamStarted)
+ if errors.Is(err, service.ErrOpenAIBillingPreflight) || errors.Is(err, service.ErrOpenAIPricingUnavailable) {
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
+ h.handleStreamingAwareError(c, http.StatusBadRequest, "invalid_request_error", "OpenAI billing preflight failed", streamStarted)
return
}
- switchCount++
- reqLog.Warn("openai.upstream_failover_switching",
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
+ wroteFallback := h.ensureForwardErrorResponse(c, streamStarted)
+ fields := []zap.Field{
zap.Int64("account_id", account.ID),
- zap.Int("upstream_status", failoverErr.StatusCode),
- zap.Int("switch_count", switchCount),
- zap.Int("max_switches", maxAccountSwitches),
- )
- continue
- }
- h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
- wroteFallback := h.ensureForwardErrorResponse(c, streamStarted)
- fields := []zap.Field{
- zap.Int64("account_id", account.ID),
- zap.Bool("fallback_error_response_written", wroteFallback),
- zap.Error(err),
- }
- if shouldLogOpenAIForwardFailureAsWarn(c, wroteFallback) {
- reqLog.Warn("openai.forward_failed", fields...)
+ zap.Bool("fallback_error_response_written", wroteFallback),
+ zap.Error(err),
+ }
+ if shouldLogOpenAIForwardFailureAsWarn(c, wroteFallback) {
+ reqLog.Warn("openai.forward_failed", fields...)
+ return
+ }
+ reqLog.Error("openai.forward_failed", fields...)
return
}
- reqLog.Error("openai.forward_failed", fields...)
- return
}
+ h.gatewayService.MarkOpenAIUsageBillingAccepted(usageBillingAdmission)
if result != nil {
if account.Type == service.AccountTypeOAuth {
h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders)
@@ -393,23 +580,26 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
+ inboundEndpoint := GetInboundEndpoint(c)
+ upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
- h.submitUsageRecordTask(func(ctx context.Context) {
+ h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
- InboundEndpoint: GetInboundEndpoint(c),
- UpstreamEndpoint: GetUpstreamEndpoint(c, account.Platform),
+ InboundEndpoint: inboundEndpoint,
+ UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
ChannelUsageFields: channelMapping.ToUsageFields(reqModel, result.UpstreamModel),
}); err != nil {
+ h.markOpenAIUsageBillingResultOrphaned(ctx, result, reqLog)
logger.L().With(
zap.String("component", "handler.openai_gateway.responses"),
zap.Int64("user_id", subject.UserID),
@@ -560,18 +750,50 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
return
}
+ // Strip placeholder empty-thinking blocks before any further processing.
+ // On the OpenAI translation path the existing Anthropic→Responses converter
+ // already discards thinking content, but doing the scrub here keeps the
+ // ops_error_logs body sample, claude-code-validator, and any failover that
+ // pivots back to a native Anthropic upstream all working off the same clean
+ // payload. See apicompat/sanitize.go.
+ if cleaned, removed, sanErr := apicompat.SanitizeAnthropicRequestBody(body); removed > 0 {
+ body = cleaned
+ reqLog.Info("sanitized empty thinking blocks before upstream forward",
+ zap.Int("removed", removed),
+ zap.String("path", "openai_translation"),
+ )
+ } else if sanErr != nil {
+ reqLog.Warn("sanitize anthropic body parse error (non-fatal, forwarding unchanged)",
+ zap.Error(sanErr),
+ zap.String("path", "openai_translation"),
+ )
+ }
+
if !gjson.ValidBytes(body) {
h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
return
}
+ contextRisk := analyzeOpenAIMessagesContextRisk(body)
+ longContextGuardrailInjected := false
+ if service.NewClaudeCodeValidator().ValidateUserAgent(c.GetHeader("User-Agent")) {
+ guardedBody, injected, guardErr := applyOpenAIMessagesContextGuardrail(body, contextRisk)
+ if guardErr != nil {
+ reqLog.Warn("openai_messages.long_context_guardrail_skipped", zap.Error(guardErr))
+ } else if injected {
+ body = guardedBody
+ longContextGuardrailInjected = true
+ c.Header("X-HFC-Long-Context-Guardrail", "file-navigation")
+ reqLog.Info("openai_messages.long_context_guardrail_injected", contextRisk.zapFields(0, true)...)
+ }
+ }
+
modelResult := gjson.GetBytes(body, "model")
if !modelResult.Exists() || modelResult.Type != gjson.String || modelResult.String() == "" {
h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required")
return
}
reqModel := modelResult.String()
- routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel)
preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel)
reqStream := gjson.GetBytes(body, "stream").Bool()
@@ -580,8 +802,18 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream, body)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
+ if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && decision.Blocked {
+ h.anthropicErrorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
+ return
+ }
+
// 解析渠道级模型映射
channelMappingMsg, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
+ routingModel, routingValid := resolveOpenAIAccountRoutingModel(reqModel, preferredMappedModel, channelMappingMsg)
+ if !routingValid {
+ h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Invalid GPT-5.6 preview model mapping")
+ return
+ }
// 绑定错误透传服务,允许 service 层在非 failover 错误场景复用规则。
if h.errorPassthroughService != nil {
@@ -589,6 +821,17 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
}
subscription, _ := middleware2.GetSubscriptionFromContext(c)
+ billingCtx, err := service.PrepareUsageBillingRequestContext(c.Request.Context())
+ if err != nil {
+ h.anthropicErrorResponse(c, http.StatusServiceUnavailable, "api_error", "Billing service temporarily unavailable")
+ return
+ }
+ c.Request = c.Request.WithContext(billingCtx)
+ usageBillingAdmission := &service.OpenAIUsageBillingAdmissionInput{
+ APIKey: apiKey, User: apiKey.User, Subscription: subscription,
+ RequestBody: append([]byte(nil), body...), RequestPayloadHash: service.HashUsageRequestPayload(body),
+ }
+ defer h.finalizeOpenAIUsageBillingLifecycle(c.Request.Context(), usageBillingAdmission, reqLog)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
routingStart := time.Now()
@@ -613,21 +856,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
sessionHash := h.gatewayService.GenerateSessionHash(c, body)
promptCacheKey := h.gatewayService.ExtractSessionID(c, body)
-
- // Anthropic 格式的请求在 metadata.user_id 中携带 session 标识,
- // 而非 OpenAI 的 session_id/conversation_id headers。
- // 从中派生 sessionHash(sticky session)和 promptCacheKey(upstream cache)。
- if sessionHash == "" || promptCacheKey == "" {
- if userID := strings.TrimSpace(gjson.GetBytes(body, "metadata.user_id").String()); userID != "" {
- seed := reqModel + "-" + userID
- if promptCacheKey == "" {
- promptCacheKey = service.GenerateSessionUUID(seed)
- }
- if sessionHash == "" {
- sessionHash = service.DeriveSessionHashFromSeed(seed)
- }
- }
- }
+ sessionHash, promptCacheKey = resolveOpenAIMessagesMetadataSession(sessionHash, promptCacheKey, reqModel, body)
maxAccountSwitches := h.maxAccountSwitches
switchCount := 0
@@ -638,9 +867,6 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
for {
currentRoutingModel := routingModel
- if effectiveMappedModel != "" {
- currentRoutingModel = effectiveMappedModel
- }
reqLog.Debug("openai_messages.account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs)))
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithScheduler(
c.Request.Context(),
@@ -653,10 +879,18 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
false,
)
if err != nil {
+ if isClientCanceledAccountSelectError(c, err) {
+ reqLog.Info("openai_messages.account_select_aborted_client_canceled", zap.Error(err))
+ return
+ }
reqLog.Warn("openai_messages.account_select_failed",
zap.Error(err),
zap.Int("excluded_account_count", len(failedAccountIDs)),
)
+ if status, errType, message, ok := openAIGPT56AvailabilityError(currentRoutingModel, err, lastFailoverErr); ok {
+ h.anthropicStreamingAwareError(c, status, errType, message, streamStarted)
+ return
+ }
if len(failedAccountIDs) == 0 {
if err != nil {
h.anthropicStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable", streamStarted)
@@ -672,10 +906,29 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
}
}
if selection == nil || selection.Account == nil {
+ if isClientCanceledAccountSelectError(c, nil) {
+ reqLog.Info("openai_messages.account_select_empty_client_canceled")
+ return
+ }
+ if status, errType, message, ok := openAIGPT56AvailabilityError(currentRoutingModel, nil, lastFailoverErr); ok {
+ h.anthropicStreamingAwareError(c, status, errType, message, streamStarted)
+ return
+ }
h.anthropicStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", streamStarted)
return
}
account := selection.Account
+ if !openAISelectedAccountSupportsRoutingModel(account, currentRoutingModel) {
+ if selection.ReleaseFunc != nil {
+ selection.ReleaseFunc()
+ }
+ failedAccountIDs[account.ID] = struct{}{}
+ reqLog.Warn("openai_messages.account_rejected_after_routing_check",
+ zap.Int64("account_id", account.ID),
+ zap.String("routing_model", currentRoutingModel),
+ )
+ continue
+ }
sessionHash = ensureOpenAIPoolModeSessionHash(sessionHash, account)
reqLog.Debug("openai_messages.account_selected", zap.Int64("account_id", account.ID), zap.String("account_name", account.Name))
_ = scheduleDecision
@@ -695,7 +948,15 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
if channelMappingMsg.Mapped {
forwardBody = h.gatewayService.ReplaceModelInBody(body, channelMappingMsg.MappedModel)
}
- result, err := h.gatewayService.ForwardAsAnthropic(c.Request.Context(), c, account, forwardBody, promptCacheKey, defaultMappedModel)
+ result, err := h.gatewayService.ForwardAsAnthropicWithOptions(c.Request.Context(), c, account, forwardBody, promptCacheKey, defaultMappedModel, service.OpenAIForwardOptions{
+ RequestedModel: reqModel,
+ ChannelMapping: channelMappingMsg,
+ GroupID: apiKey.GroupID,
+ ImagePriceConfig: openAIImagePriceConfig(apiKey.Group),
+ RequirePricingPreflight: true,
+ RequireBillingAdmission: true,
+ UsageBilling: usageBillingAdmission,
+ })
forwardDurationMs := time.Since(forwardStart).Milliseconds()
if accountReleaseFunc != nil {
@@ -711,78 +972,107 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs))
}
if err != nil {
- var failoverErr *service.UpstreamFailoverError
- if errors.As(err, &failoverErr) {
- h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
- // 池模式:同账号重试
- if failoverErr.RetryableOnSameAccount {
- retryLimit := account.GetPoolModeRetryCount()
- if sameAccountRetryCount[account.ID] < retryLimit {
- sameAccountRetryCount[account.ID]++
- reqLog.Warn("openai_messages.pool_mode_same_account_retry",
- zap.Int64("account_id", account.ID),
- zap.Int("upstream_status", failoverErr.StatusCode),
- zap.Int("retry_limit", retryLimit),
- zap.Int("retry_count", sameAccountRetryCount[account.ID]),
- )
- select {
- case <-c.Request.Context().Done():
- return
- case <-time.After(sameAccountRetryDelay):
+ if h.handleOpenAIUsageBillingAdmissionError(c, err, streamStarted, true) {
+ return
+ }
+ if result != nil && result.ImageCount > 0 {
+ reqLog.Warn("openai_messages.forward_partial_error_with_image_result",
+ zap.Int64("account_id", account.ID),
+ zap.Int("image_count", result.ImageCount),
+ zap.Error(err),
+ )
+ } else {
+ var failoverErr *service.UpstreamFailoverError
+ if errors.As(err, &failoverErr) {
+ h.markOpenAIUsageBillingAttemptFailed(c.Request.Context(), usageBillingAdmission, reqLog)
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
+ // 池模式:同账号重试
+ if failoverErr.RetryableOnSameAccount {
+ retryLimit := account.GetPoolModeRetryCount()
+ if sameAccountRetryCount[account.ID] < retryLimit {
+ sameAccountRetryCount[account.ID]++
+ reqLog.Warn("openai_messages.pool_mode_same_account_retry",
+ zap.Int64("account_id", account.ID),
+ zap.Int("upstream_status", failoverErr.StatusCode),
+ zap.Int("retry_limit", retryLimit),
+ zap.Int("retry_count", sameAccountRetryCount[account.ID]),
+ )
+ select {
+ case <-c.Request.Context().Done():
+ return
+ case <-time.After(sameAccountRetryDelay):
+ }
+ continue
}
- continue
}
+ h.gatewayService.RecordOpenAIAccountSwitch()
+ failedAccountIDs[account.ID] = struct{}{}
+ lastFailoverErr = failoverErr
+ if switchCount >= maxAccountSwitches {
+ h.handleAnthropicFailoverExhausted(c, failoverErr, streamStarted)
+ return
+ }
+ switchCount++
+ reqLog.Warn("openai_messages.upstream_failover_switching",
+ zap.Int64("account_id", account.ID),
+ zap.Int("upstream_status", failoverErr.StatusCode),
+ zap.Int("switch_count", switchCount),
+ zap.Int("max_switches", maxAccountSwitches),
+ )
+ continue
}
- h.gatewayService.RecordOpenAIAccountSwitch()
- failedAccountIDs[account.ID] = struct{}{}
- lastFailoverErr = failoverErr
- if switchCount >= maxAccountSwitches {
- h.handleAnthropicFailoverExhausted(c, failoverErr, streamStarted)
+ if errors.Is(err, service.ErrOpenAIBillingPreflight) || errors.Is(err, service.ErrOpenAIPricingUnavailable) {
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
+ h.anthropicStreamingAwareError(c, http.StatusBadRequest, "invalid_request_error", "OpenAI billing preflight failed", streamStarted)
return
}
- switchCount++
- reqLog.Warn("openai_messages.upstream_failover_switching",
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
+ wroteFallback := h.ensureAnthropicErrorResponse(c, streamStarted)
+ reqLog.Warn("openai_messages.forward_failed",
zap.Int64("account_id", account.ID),
- zap.Int("upstream_status", failoverErr.StatusCode),
- zap.Int("switch_count", switchCount),
- zap.Int("max_switches", maxAccountSwitches),
+ zap.Bool("fallback_error_response_written", wroteFallback),
+ zap.Error(err),
)
- continue
+ return
}
- h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
- wroteFallback := h.ensureAnthropicErrorResponse(c, streamStarted)
- reqLog.Warn("openai_messages.forward_failed",
- zap.Int64("account_id", account.ID),
- zap.Bool("fallback_error_response_written", wroteFallback),
- zap.Error(err),
- )
- return
}
+ h.gatewayService.MarkOpenAIUsageBillingAccepted(usageBillingAdmission)
if result != nil {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs)
} else {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil)
}
+ promptTotalTokens := result.Usage.InputTokens + result.Usage.CacheCreationInputTokens + result.Usage.CacheReadInputTokens
+ if contextRisk.shouldLog(promptTotalTokens, longContextGuardrailInjected) {
+ reqLog.Warn("openai_messages.long_context_file_navigation_risk", contextRisk.zapFields(promptTotalTokens, longContextGuardrailInjected)...)
+ }
+
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
+ inboundEndpoint := GetInboundEndpoint(c)
+ upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
- h.submitUsageRecordTask(func(ctx context.Context) {
+ dispatchMappedModel := effectiveMappedModel
+ h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
+ usageFields := channelMappingMsg.ToUsageFields(reqModel, result.UpstreamModel)
+ usageFields = preserveOpenAIMessagesDispatchSub2BillingSource(usageFields, reqModel, dispatchMappedModel)
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
- InboundEndpoint: GetInboundEndpoint(c),
- UpstreamEndpoint: GetUpstreamEndpoint(c, account.Platform),
+ InboundEndpoint: inboundEndpoint,
+ UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
- ChannelUsageFields: channelMappingMsg.ToUsageFields(reqModel, result.UpstreamModel),
+ ChannelUsageFields: usageFields,
}); err != nil {
+ h.markOpenAIUsageBillingResultOrphaned(ctx, result, reqLog)
logger.L().With(
zap.String("component", "handler.openai_gateway.messages"),
zap.Int64("user_id", subject.UserID),
@@ -801,6 +1091,20 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
}
}
+func resolveOpenAIMessagesMetadataSession(sessionHash, promptCacheKey, reqModel string, body []byte) (string, string) {
+ // Anthropic metadata.user_id 只作为账号粘性信号。上游 GPT/Codex 缓存键
+ // 交给 ForwardAsAnthropic 从 cache_control 或完整消息 digest 派生,避免
+ // 固定 metadata key 压住后续 turn 的缓存滚动。
+ if sessionHash != "" {
+ return sessionHash, promptCacheKey
+ }
+ if userID := strings.TrimSpace(gjson.GetBytes(body, "metadata.user_id").String()); userID != "" {
+ seed := reqModel + "-" + userID
+ sessionHash = service.DeriveSessionHashFromSeed(seed)
+ }
+ return sessionHash, promptCacheKey
+}
+
// anthropicErrorResponse writes an error in Anthropic Messages API format.
func (h *OpenAIGatewayHandler) anthropicErrorResponse(c *gin.Context, status int, errType, message string) {
c.JSON(status, gin.H{
@@ -833,6 +1137,16 @@ func (h *OpenAIGatewayHandler) anthropicStreamingAwareError(c *gin.Context, stat
h.anthropicErrorResponse(c, status, errType, message)
}
+func isClientCanceledAccountSelectError(c *gin.Context, err error) bool {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return true
+ }
+ if c == nil || c.Request == nil {
+ return false
+ }
+ return c.Request.Context().Err() != nil
+}
+
// handleAnthropicFailoverExhausted maps upstream failover errors to Anthropic format.
func (h *OpenAIGatewayHandler) handleAnthropicFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError, streamStarted bool) {
status, errType, errMsg := h.mapUpstreamError(failoverErr.StatusCode)
@@ -1024,6 +1338,16 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot(
return wrapReleaseOnDone(ctx, accountReleaseFunc), true
}
+const openAIWSIngressMaxMessageBytes int64 = 8 * 1024 * 1024
+
+func openAIWSIngressReadLimit(cfg *config.Config) int64 {
+ limit := openAIWSIngressMaxMessageBytes
+ if cfg != nil && cfg.Gateway.MaxBodySize > 0 && cfg.Gateway.MaxBodySize < limit {
+ limit = cfg.Gateway.MaxBodySize
+ }
+ return limit
+}
+
// ResponsesWebSocket handles OpenAI Responses API WebSocket ingress endpoint
// GET /openai/v1/responses (Upgrade: websocket)
func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
@@ -1056,11 +1380,41 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
return
}
reqLog.Info("openai.websocket_ingress_started")
+ ctx := c.Request.Context()
+ var currentUserRelease func()
+ var currentAccountRelease func()
+ releaseTurnSlots := func() {
+ if currentAccountRelease != nil {
+ currentAccountRelease()
+ currentAccountRelease = nil
+ }
+ if currentUserRelease != nil {
+ currentUserRelease()
+ currentUserRelease = nil
+ }
+ }
+ defer releaseTurnSlots()
+
+ // Reserve the tenant slot before accepting or decompressing any WebSocket
+ // frame. This keeps pre-admission sockets inside the same distributed
+ // concurrency budget as normal gateway work.
+ userReleaseFunc, userAcquired, err := h.concurrencyHelper.TryAcquireUserSlot(ctx, subject.UserID, subject.Concurrency)
+ if err != nil {
+ reqLog.Warn("openai.websocket_user_slot_acquire_failed", zap.Error(err))
+ h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Concurrency service temporarily unavailable")
+ return
+ }
+ if !userAcquired {
+ h.errorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Too many concurrent requests, please retry later")
+ return
+ }
+ currentUserRelease = wrapReleaseOnDone(ctx, userReleaseFunc)
+
clientIP := ip.GetClientIP(c)
userAgent := strings.TrimSpace(c.GetHeader("User-Agent"))
wsConn, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{
- CompressionMode: coderws.CompressionContextTakeover,
+ CompressionMode: coderws.CompressionNoContextTakeover,
})
if err != nil {
reqLog.Warn("openai.websocket_accept_failed",
@@ -1077,9 +1431,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
defer func() {
_ = wsConn.CloseNow()
}()
- wsConn.SetReadLimit(16 * 1024 * 1024)
+ wsConn.SetReadLimit(openAIWSIngressReadLimit(h.cfg))
- ctx := c.Request.Context()
readCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
msgType, firstMessage, err := wsConn.Read(readCtx)
cancel()
@@ -1124,35 +1477,28 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
setOpsRequestContext(c, reqModel, true, firstMessage)
setOpsEndpointContext(c, "", int16(service.RequestTypeWSV2))
- // 解析渠道级模型映射
- channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel)
+ if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, firstMessage); decision != nil && decision.Blocked {
+ writeContentModerationWSError(ctx, wsConn, decision)
+ closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, decision.Message)
+ return
+ }
- var currentUserRelease func()
- var currentAccountRelease func()
- releaseTurnSlots := func() {
- if currentAccountRelease != nil {
- currentAccountRelease()
- currentAccountRelease = nil
- }
- if currentUserRelease != nil {
- currentUserRelease()
- currentUserRelease = nil
- }
+ if service.IsImageGenerationIntent("/v1/responses", reqModel, firstMessage) && !service.GroupAllowsImageGeneration(apiKey.Group) {
+ closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, service.ImageGenerationPermissionMessage())
+ return
}
- // 必须尽早注册,确保任何 early return 都能释放已获取的并发槽位。
- defer releaseTurnSlots()
- userReleaseFunc, userAcquired, err := h.concurrencyHelper.TryAcquireUserSlot(ctx, subject.UserID, subject.Concurrency)
- if err != nil {
- reqLog.Warn("openai.websocket_user_slot_acquire_failed", zap.Error(err))
- closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "failed to acquire user concurrency slot")
+ // 解析渠道级模型映射
+ channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel)
+ if !openAIWSChannelMappingKeepsCanonicalModel(reqModel, channelMappingWS) {
+ closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "websocket channel model mapping cannot change the persistent connection model")
return
}
- if !userAcquired {
- closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "too many concurrent requests, please retry later")
+ routingModel, routingValid := resolveOpenAIAccountRoutingModel(reqModel, "", channelMappingWS)
+ if !routingValid {
+ closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "invalid GPT-5.6 preview model mapping")
return
}
- currentUserRelease = wrapReleaseOnDone(ctx, userReleaseFunc)
subscription, _ := middleware2.GetSubscriptionFromContext(c)
if err := h.billingCacheService.CheckBillingEligibility(ctx, apiKey.User, apiKey, apiKey.Group, subscription); err != nil {
@@ -1166,27 +1512,47 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
firstMessage,
openAIWSIngressFallbackSessionSeed(subject.UserID, apiKey.ID, apiKey.GroupID),
)
+ wsBillingConnectionID := openAIWSBillingConnectionID(ctx, sessionHash)
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithScheduler(
ctx,
apiKey.GroupID,
previousResponseID,
sessionHash,
- reqModel,
+ routingModel,
nil,
service.OpenAIUpstreamTransportResponsesWebsocketV2,
false,
)
if err != nil {
reqLog.Warn("openai.websocket_account_select_failed", zap.Error(err))
+ if _, _, message, ok := openAIGPT56AvailabilityError(routingModel, err, nil); ok {
+ closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, message)
+ return
+ }
closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "no available account")
return
}
if selection == nil || selection.Account == nil {
+ if _, _, message, ok := openAIGPT56AvailabilityError(routingModel, nil, nil); ok {
+ closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, message)
+ return
+ }
closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "no available account")
return
}
account := selection.Account
+ if !openAISelectedAccountSupportsRoutingModel(account, routingModel) {
+ if selection.ReleaseFunc != nil {
+ selection.ReleaseFunc()
+ }
+ reqLog.Warn("openai.websocket_account_rejected_after_routing_check",
+ zap.Int64("account_id", account.ID),
+ zap.String("routing_model", routingModel),
+ )
+ closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "no available account")
+ return
+ }
accountMaxConcurrency := account.Concurrency
if selection.WaitPlan != nil && selection.WaitPlan.MaxConcurrency > 0 {
accountMaxConcurrency = selection.WaitPlan.MaxConcurrency
@@ -1218,13 +1584,61 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
reqLog.Warn("openai.websocket_bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err))
}
+ wsPreflightOptions := service.OpenAIForwardOptions{
+ RequestedModel: reqModel,
+ ChannelMapping: channelMappingWS,
+ GroupID: apiKey.GroupID,
+ ImagePriceConfig: openAIImagePriceConfig(apiKey.Group),
+ RequirePricingPreflight: true,
+ RequireBillingAdmission: true,
+ }
+ firstTurnCtx := context.WithValue(ctx, ctxkey.UsageBillingRequestID, deriveOpenAIWSBillingRequestID(wsBillingConnectionID, 1))
+ firstTurnCtx, err = service.PrepareUsageBillingRequestContext(firstTurnCtx)
+ if err != nil {
+ reqLog.Warn("openai.websocket_billing_context_failed", zap.Error(err))
+ closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "Billing service temporarily unavailable")
+ return
+ }
+ firstTurnBillingIdentity, err := h.gatewayService.ResolveOpenAIWSBillingIdentityForPayload(firstTurnCtx, account, wsPreflightOptions, firstMessage)
+ if err != nil {
+ reqLog.Warn("openai.websocket_billing_preflight_failed", zap.Int64("account_id", account.ID), zap.Error(err))
+ closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "OpenAI billing preflight failed")
+ return
+ }
+ firstTurnPayloadHash := service.HashUsageRequestPayload(firstMessage)
+ _, firstTurnAdmission, admissionErr := h.gatewayService.PrepareOpenAIUsageBillingAdmission(firstTurnCtx, account, &service.OpenAIUsageBillingAdmissionInput{
+ APIKey: apiKey, User: apiKey.User, Subscription: subscription,
+ RequestBody: append([]byte(nil), firstMessage...), RequestPayloadHash: firstTurnPayloadHash,
+ BillingIdentity: firstTurnBillingIdentity,
+ })
+ if admissionErr == nil {
+ admissionErr = h.gatewayService.DispatchUsageBillingRequest(firstTurnCtx, firstTurnAdmission)
+ }
+ if admissionErr != nil {
+ reqLog.Warn("openai.websocket_billing_admission_failed", zap.Int64("account_id", account.ID), zap.Error(admissionErr))
+ closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "OpenAI billing admission failed")
+ return
+ }
+ if firstTurnAdmission != nil {
+ firstTurnBillingIdentity.AdmissionAttemptID = firstTurnAdmission.AttemptID
+ firstTurnBillingIdentity.AdmissionRef = firstTurnAdmission.Ref()
+ }
+
token, _, err := h.gatewayService.GetAccessToken(ctx, account)
if err != nil {
+ if firstTurnBillingIdentity != nil && firstTurnBillingIdentity.AdmissionRef.Validate() == nil {
+ lifecycleInput := &service.OpenAIUsageBillingAdmissionInput{AdmissionRef: firstTurnBillingIdentity.AdmissionRef}
+ if lifecycleErr := h.gatewayService.MarkOpenAIUsageBillingAttemptFailed(firstTurnCtx, lifecycleInput); lifecycleErr != nil {
+ reqLog.Error("openai.websocket_billing_attempt_fail_mark_failed", zap.Error(lifecycleErr))
+ }
+ if lifecycleErr := h.gatewayService.FinalizeOpenAIUsageBillingRequest(firstTurnCtx, lifecycleInput); lifecycleErr != nil {
+ reqLog.Error("openai.websocket_billing_lifecycle_finalize_failed", zap.Error(lifecycleErr))
+ }
+ }
reqLog.Warn("openai.websocket_get_access_token_failed", zap.Int64("account_id", account.ID), zap.Error(err))
closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "failed to get access token")
return
}
-
reqLog.Debug("openai.websocket_account_selected",
zap.Int64("account_id", account.ID),
zap.String("account_name", account.Name),
@@ -1232,7 +1646,97 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
zap.Int("candidate_count", scheduleDecision.CandidateCount),
)
+ type turnBillingState struct {
+ Context context.Context
+ Identity *service.ResolvedOpenAIBillingIdentity
+ PayloadHash string
+ }
+ turnBillingIdentities := map[int]turnBillingState{1: {
+ Context: firstTurnCtx, Identity: firstTurnBillingIdentity, PayloadHash: firstTurnPayloadHash,
+ }}
+ var turnBillingIdentitiesMu sync.Mutex
+ finalizeTurnBilling := func(state turnBillingState, definitelyRejected bool) {
+ if state.Context == nil || state.Identity == nil || state.Identity.AdmissionRef.Validate() != nil {
+ return
+ }
+ lifecycleInput := &service.OpenAIUsageBillingAdmissionInput{AdmissionRef: state.Identity.AdmissionRef}
+ if definitelyRejected {
+ if lifecycleErr := h.gatewayService.MarkOpenAIUsageBillingAttemptFailed(state.Context, lifecycleInput); lifecycleErr != nil {
+ reqLog.Error("openai.websocket_billing_attempt_fail_mark_failed", zap.Error(lifecycleErr))
+ return
+ }
+ }
+ if lifecycleErr := h.gatewayService.FinalizeOpenAIUsageBillingRequest(state.Context, lifecycleInput); lifecycleErr != nil {
+ reqLog.Error("openai.websocket_billing_lifecycle_finalize_failed", zap.Error(lifecycleErr))
+ }
+ }
+ defer func() {
+ turnBillingIdentitiesMu.Lock()
+ remaining := make([]turnBillingState, 0, len(turnBillingIdentities))
+ for turn, state := range turnBillingIdentities {
+ remaining = append(remaining, state)
+ delete(turnBillingIdentities, turn)
+ }
+ turnBillingIdentitiesMu.Unlock()
+ for _, state := range remaining {
+ finalizeTurnBilling(state, false)
+ }
+ }()
hooks := &service.OpenAIWSIngressHooks{
+ InitialRequestModel: reqModel,
+ BeforeRequest: func(turn int, payload []byte, originalModel string) error {
+ if !gjson.ValidBytes(payload) {
+ return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", errors.New("invalid json"))
+ }
+ if turn == 1 {
+ return nil
+ }
+ turnCtx := context.WithValue(ctx, ctxkey.UsageBillingRequestID, deriveOpenAIWSBillingRequestID(wsBillingConnectionID, turn))
+ turnCtx, preflightErr := service.PrepareUsageBillingRequestContext(turnCtx)
+ if preflightErr != nil {
+ return service.NewOpenAIWSClientCloseError(coderws.StatusInternalError, "Billing service temporarily unavailable", preflightErr)
+ }
+ turnIdentity, preflightErr := h.gatewayService.ResolveOpenAIWSBillingIdentityForPayload(turnCtx, account, wsPreflightOptions, payload)
+ if preflightErr != nil {
+ return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "OpenAI billing preflight failed", preflightErr)
+ }
+ payloadHash := service.HashUsageRequestPayload(payload)
+ _, turnAdmission, admissionErr := h.gatewayService.PrepareOpenAIUsageBillingAdmission(turnCtx, account, &service.OpenAIUsageBillingAdmissionInput{
+ APIKey: apiKey, User: apiKey.User, Subscription: subscription,
+ RequestBody: append([]byte(nil), payload...), RequestPayloadHash: payloadHash,
+ BillingIdentity: turnIdentity,
+ })
+ if admissionErr == nil {
+ admissionErr = h.gatewayService.DispatchUsageBillingRequest(turnCtx, turnAdmission)
+ }
+ if admissionErr != nil {
+ return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "OpenAI billing admission failed", admissionErr)
+ }
+ if turnAdmission != nil {
+ turnIdentity.AdmissionAttemptID = turnAdmission.AttemptID
+ turnIdentity.AdmissionRef = turnAdmission.Ref()
+ }
+ turnBillingIdentitiesMu.Lock()
+ turnBillingIdentities[turn] = turnBillingState{Context: turnCtx, Identity: turnIdentity, PayloadHash: payloadHash}
+ turnBillingIdentitiesMu.Unlock()
+ model := strings.TrimSpace(originalModel)
+ if model == "" {
+ model = strings.TrimSpace(gjson.GetBytes(payload, "model").String())
+ }
+ if model == "" {
+ model = reqModel
+ }
+ if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, model, payload); decision != nil && decision.Blocked {
+ turnBillingIdentitiesMu.Lock()
+ blockedBilling := turnBillingIdentities[turn]
+ delete(turnBillingIdentities, turn)
+ turnBillingIdentitiesMu.Unlock()
+ finalizeTurnBilling(blockedBilling, true)
+ writeContentModerationWSError(ctx, wsConn, decision)
+ return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, decision.Message, nil)
+ }
+ return nil
+ },
BeforeTurn: func(turn int) error {
if turn == 1 {
return nil
@@ -1266,28 +1770,54 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
},
AfterTurn: func(turn int, result *service.OpenAIForwardResult, turnErr error) {
releaseTurnSlots()
- if turnErr != nil || result == nil {
+ turnBillingIdentitiesMu.Lock()
+ turnBilling := turnBillingIdentities[turn]
+ delete(turnBillingIdentities, turn)
+ turnBillingIdentitiesMu.Unlock()
+ if turnErr != nil {
+ if result == nil || result.ImageCount <= 0 {
+ finalizeTurnBilling(turnBilling, false)
+ return
+ }
+ reqLog.Warn("openai.websocket_partial_error_with_image_result",
+ zap.Int64("account_id", account.ID),
+ zap.Int("image_count", result.ImageCount),
+ zap.Error(turnErr),
+ )
+ }
+ if result == nil {
+ finalizeTurnBilling(turnBilling, false)
return
}
+ if turnBilling.Context == nil || turnBilling.Identity == nil || turnBilling.PayloadHash == "" {
+ reqLog.Error("openai.websocket_missing_turn_billing_admission", zap.Int("turn", turn))
+ finalizeTurnBilling(turnBilling, false)
+ return
+ }
+ result.RequestID = deriveOpenAIWSBillingRequestID(wsBillingConnectionID, turn)
+ service.AttachOpenAIBillingIdentity(result, turnBilling.Identity)
if account.Type == service.AccountTypeOAuth {
h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(ctx, account.ID, result.ResponseHeaders)
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs)
- h.submitUsageRecordTask(func(taskCtx context.Context) {
+ inboundEndpoint := GetInboundEndpoint(c)
+ upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
+ h.submitOpenAIUsageRecordTask(turnBilling.Context, result, func(taskCtx context.Context) {
if err := h.gatewayService.RecordUsage(taskCtx, &service.OpenAIRecordUsageInput{
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
- InboundEndpoint: GetInboundEndpoint(c),
- UpstreamEndpoint: GetUpstreamEndpoint(c, account.Platform),
+ InboundEndpoint: inboundEndpoint,
+ UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
- RequestPayloadHash: service.HashUsageRequestPayload(firstMessage),
+ RequestPayloadHash: turnBilling.PayloadHash,
APIKeyService: h.apiKeyService,
ChannelUsageFields: channelMappingWS.ToUsageFields(reqModel, result.UpstreamModel),
}); err != nil {
+ h.markOpenAIUsageBillingResultOrphaned(taskCtx, result, reqLog)
reqLog.Error("openai.websocket_record_usage_failed",
zap.Int64("account_id", account.ID),
zap.String("request_id", result.RequestID),
@@ -1324,6 +1854,17 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
reqLog.Info("openai.websocket_ingress_closed", zap.Int64("account_id", account.ID))
}
+func openAIImagePriceConfig(group *service.Group) *service.ImagePriceConfig {
+ if group == nil {
+ return nil
+ }
+ return &service.ImagePriceConfig{
+ Price1K: group.ImagePrice1K,
+ Price2K: group.ImagePrice2K,
+ Price4K: group.ImagePrice4K,
+ }
+}
+
func (h *OpenAIGatewayHandler) recoverResponsesPanic(c *gin.Context, streamStarted *bool) {
recovered := recover()
if recovered == nil {
@@ -1363,6 +1904,14 @@ func (h *OpenAIGatewayHandler) recoverAnthropicMessagesPanic(c *gin.Context, str
}
}
+func preserveOpenAIMessagesDispatchSub2BillingSource(fields service.ChannelUsageFields, reqModel, mappedModel string) service.ChannelUsageFields {
+ // Billing must follow Sub2API's native channel/upstream basis. Claude
+ // compatibility model names are kept in ChannelUsageFields for audit, but
+ // they must not force requested-model billing when the request is mapped to
+ // a GPT/OpenAI upstream model.
+ return fields
+}
+
func (h *OpenAIGatewayHandler) ensureResponsesDependencies(c *gin.Context, reqLog *zap.Logger) bool {
missing := h.missingResponsesDependencies()
if len(missing) == 0 {
@@ -1427,26 +1976,75 @@ func getContextInt64(c *gin.Context, key string) (int64, bool) {
}
}
-func (h *OpenAIGatewayHandler) submitUsageRecordTask(task service.UsageRecordTask) {
+func (h *OpenAIGatewayHandler) submitUsageRecordTask(requestCtx context.Context, task service.UsageRecordTask) {
+ h.runDurableUsageRecordTask(requestCtx, task)
+}
+
+func (h *OpenAIGatewayHandler) submitOpenAIUsageRecordTask(requestCtx context.Context, result *service.OpenAIForwardResult, task service.UsageRecordTask) {
if task == nil {
return
}
- if h.usageRecordWorkerPool != nil {
- h.usageRecordWorkerPool.Submit(task)
+ h.runDurableUsageRecordTask(requestCtx, func(ctx context.Context) {
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ if err := h.gatewayService.MarkOpenAIUsageBillingResultOrphaned(ctx, result); err != nil {
+ logger.L().Error("openai.billing_result_orphan_mark_failed", zap.Error(err))
+ }
+ panic(recovered)
+ }
+ }()
+ task(ctx)
+ })
+}
+
+func (h *OpenAIGatewayHandler) submitMandatoryUsageRecordTask(requestCtx context.Context, task service.UsageRecordTask) {
+ h.runDurableUsageRecordTask(requestCtx, task)
+}
+
+func (h *OpenAIGatewayHandler) runDurableUsageRecordTask(requestCtx context.Context, task service.UsageRecordTask) {
+ if task == nil {
return
}
- // 回退路径:worker 池未注入时同步执行,避免退回到无界 goroutine 模式。
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
+ // Standard-mode RecordUsage now performs the durable outbox insert. It must
+ // finish before this request goroutine can disappear; the in-memory worker
+ // pool remains available only to non-OpenAI/native usage paths.
defer func() {
if recovered := recover(); recovered != nil {
logger.L().With(
- zap.String("component", "handler.openai_gateway.responses"),
+ zap.String("component", "handler.openai_gateway.usage"),
zap.Any("panic", recovered),
).Error("openai.usage_record_task_panic_recovered")
}
}()
- task(ctx)
+ // RecordUsage persists the durable outbox event synchronously. Database
+ // slowness therefore applies backpressure instead of expiring this task and
+ // erasing an already successful billable use.
+ billingCtx := context.Background()
+ if requestCtx != nil {
+ billingCtx = context.WithoutCancel(requestCtx)
+ }
+ task(billingCtx)
+}
+
+func (h *OpenAIGatewayHandler) acquireImageGenerationSlot(c *gin.Context, streamStarted bool) (func(), bool) {
+ if h == nil || h.cfg == nil || h.imageLimiter == nil {
+ return nil, true
+ }
+ imageConcurrency := h.cfg.Gateway.ImageConcurrency
+ wait := strings.TrimSpace(imageConcurrency.OverflowMode) == config.ImageConcurrencyOverflowModeWait
+ release, acquired := h.imageLimiter.Acquire(
+ c.Request.Context(),
+ imageConcurrency.Enabled,
+ imageConcurrency.MaxConcurrentRequests,
+ wait,
+ time.Duration(imageConcurrency.WaitTimeoutSeconds)*time.Second,
+ imageConcurrency.MaxWaitingRequests,
+ )
+ if acquired {
+ return release, true
+ }
+ h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Image generation concurrency limit exceeded, please retry later", streamStarted)
+ return nil, false
}
// handleConcurrencyError handles concurrency-related errors with proper 429 response
@@ -1468,11 +2066,7 @@ func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverE
respCode = *rule.ResponseCode
}
- // 确定响应消息
- msg := service.ExtractUpstreamErrorMessage(responseBody)
- if !rule.PassthroughBody && rule.CustomMessage != nil {
- msg = *rule.CustomMessage
- }
+ msg := service.ErrorPassthroughClientMessage(rule, "Upstream request failed")
if rule.SkipMonitoring {
c.Set(service.OpsSkipPassthroughKey, true)
@@ -1565,6 +2159,67 @@ func (h *OpenAIGatewayHandler) errorResponse(c *gin.Context, status int, errType
})
}
+func (h *OpenAIGatewayHandler) handleOpenAIUsageBillingAdmissionError(c *gin.Context, err error, streamStarted, anthropic bool) bool {
+ if err == nil {
+ return false
+ }
+ status := http.StatusServiceUnavailable
+ code := "billing_service_error"
+ message := "Billing service temporarily unavailable"
+ switch {
+ case errors.Is(err, service.ErrWalletInsufficient):
+ status, code, message, _ = billingErrorDetails(err)
+ case errors.Is(err, service.ErrUsageBillingRequestConflict), errors.Is(err, service.ErrUsageBillingAdmissionFinalized):
+ status = http.StatusConflict
+ code = "billing_request_conflict"
+ message = "Billing request identity conflict"
+ case errors.Is(err, service.ErrUsageBillingLifecycleContractInvalid),
+ errors.Is(err, service.ErrUsageBillingAdmissionInvalid),
+ errors.Is(err, service.ErrUsageBillingAdmissionMissing),
+ errors.Is(err, service.ErrUsageBillingAdmissionLeaseLost),
+ errors.Is(err, service.ErrUsageBillingOutboxUnavailable),
+ errors.Is(err, service.ErrUsageBillingOutboxAdmissionRetryable):
+ default:
+ return false
+ }
+ if anthropic {
+ h.anthropicStreamingAwareError(c, status, code, message, streamStarted)
+ } else {
+ h.handleStreamingAwareError(c, status, code, message, streamStarted)
+ }
+ return true
+}
+
+func (h *OpenAIGatewayHandler) markOpenAIUsageBillingAttemptFailed(
+ ctx context.Context,
+ input *service.OpenAIUsageBillingAdmissionInput,
+ reqLog *zap.Logger,
+) {
+ if err := h.gatewayService.MarkOpenAIUsageBillingAttemptFailed(ctx, input); err != nil && reqLog != nil {
+ reqLog.Error("openai.billing_attempt_fail_mark_failed", zap.Error(err))
+ }
+}
+
+func (h *OpenAIGatewayHandler) finalizeOpenAIUsageBillingLifecycle(
+ ctx context.Context,
+ input *service.OpenAIUsageBillingAdmissionInput,
+ reqLog *zap.Logger,
+) {
+ if err := h.gatewayService.FinalizeOpenAIUsageBillingRequest(ctx, input); err != nil && reqLog != nil {
+ reqLog.Error("openai.billing_lifecycle_finalize_failed", zap.Error(err))
+ }
+}
+
+func (h *OpenAIGatewayHandler) markOpenAIUsageBillingResultOrphaned(
+ ctx context.Context,
+ result *service.OpenAIForwardResult,
+ reqLog *zap.Logger,
+) {
+ if err := h.gatewayService.MarkOpenAIUsageBillingResultOrphaned(ctx, result); err != nil && reqLog != nil {
+ reqLog.Error("openai.billing_result_orphan_mark_failed", zap.Error(err))
+ }
+}
+
func setOpenAIClientTransportHTTP(c *gin.Context) {
service.SetOpenAIClientTransport(c, service.OpenAIClientTransportHTTP)
}
@@ -1611,6 +2266,34 @@ func closeOpenAIClientWS(conn *coderws.Conn, status coderws.StatusCode, reason s
_ = conn.CloseNow()
}
+func writeContentModerationWSError(ctx context.Context, conn *coderws.Conn, decision *service.ContentModerationDecision) {
+ if conn == nil || decision == nil {
+ return
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ message := strings.TrimSpace(decision.Message)
+ if message == "" {
+ message = "content moderation blocked this request"
+ }
+ payload, err := json.Marshal(gin.H{
+ "event_id": "evt_content_moderation_blocked",
+ "type": "error",
+ "error": gin.H{
+ "type": "invalid_request_error",
+ "code": contentModerationErrorCode(decision),
+ "message": message,
+ },
+ })
+ if err != nil {
+ payload = []byte(`{"event_id":"evt_content_moderation_blocked","type":"error","error":{"type":"invalid_request_error","code":"content_policy_violation","message":"content moderation blocked this request"}}`)
+ }
+ writeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
+ defer cancel()
+ _ = conn.Write(writeCtx, coderws.MessageText, payload)
+}
+
func summarizeWSCloseErrorForLog(err error) (string, string) {
if err == nil {
return "-", "-"
diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go
index 8ecee59ae79..c4315b32ff3 100644
--- a/backend/internal/handler/openai_gateway_handler_test.go
+++ b/backend/internal/handler/openai_gateway_handler_test.go
@@ -7,10 +7,14 @@ import (
"net/http"
"net/http/httptest"
"strings"
+ "sync/atomic"
"testing"
"time"
+ "github.com/Wei-Shaw/sub2api/internal/config"
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
coderws "github.com/coder/websocket"
@@ -91,6 +95,24 @@ func TestOpenAIHandleStreamingAwareError_JSONEscaping(t *testing.T) {
}
}
+func TestResolveOpenAIMessagesMetadataSession_DoesNotDerivePromptCacheKey(t *testing.T) {
+ body := []byte(`{"model":"claude-sonnet-4-5","metadata":{"user_id":"claude-code-session"},"messages":[{"role":"user","content":"hello"}]}`)
+
+ sessionHash, promptCacheKey := resolveOpenAIMessagesMetadataSession("", "", "claude-sonnet-4-5", body)
+
+ require.NotEmpty(t, sessionHash)
+ require.Empty(t, promptCacheKey)
+}
+
+func TestResolveOpenAIMessagesMetadataSession_PreservesExplicitPromptCacheKey(t *testing.T) {
+ body := []byte(`{"metadata":{"user_id":"claude-code-session"}}`)
+
+ sessionHash, promptCacheKey := resolveOpenAIMessagesMetadataSession("", "explicit-cache", "claude-sonnet-4-5", body)
+
+ require.NotEmpty(t, sessionHash)
+ require.Equal(t, "explicit-cache", promptCacheKey)
+}
+
func TestOpenAIHandleStreamingAwareError_NonStreaming(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
@@ -352,30 +374,6 @@ func TestOpenAIEnsureResponsesDependencies(t *testing.T) {
})
}
-func TestResolveOpenAIForwardDefaultMappedModel(t *testing.T) {
- t.Run("prefers_explicit_fallback_model", func(t *testing.T) {
- apiKey := &service.APIKey{
- Group: &service.Group{DefaultMappedModel: "gpt-5.4"},
- }
- require.Equal(t, "gpt-5.2", resolveOpenAIForwardDefaultMappedModel(apiKey, " gpt-5.2 "))
- })
-
- t.Run("uses_group_default_when_explicit_fallback_absent", func(t *testing.T) {
- apiKey := &service.APIKey{
- Group: &service.Group{DefaultMappedModel: "gpt-5.4"},
- }
- require.Equal(t, "gpt-5.4", resolveOpenAIForwardDefaultMappedModel(apiKey, ""))
- })
-
- t.Run("returns_empty_without_group_default", func(t *testing.T) {
- require.Empty(t, resolveOpenAIForwardDefaultMappedModel(nil, ""))
- require.Empty(t, resolveOpenAIForwardDefaultMappedModel(&service.APIKey{}, ""))
- require.Empty(t, resolveOpenAIForwardDefaultMappedModel(&service.APIKey{
- Group: &service.Group{},
- }, ""))
- })
-}
-
func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) {
t.Run("exact_claude_model_override_wins", func(t *testing.T) {
apiKey := &service.APIKey{
@@ -398,6 +396,21 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) {
require.Equal(t, "gpt-5.4-mini", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-haiku-4-5-20251001"))
})
+ t.Run("dated_haiku_override_can_raise_background_model", func(t *testing.T) {
+ apiKey := &service.APIKey{
+ Group: &service.Group{
+ MessagesDispatchModelConfig: service.OpenAIMessagesDispatchModelConfig{
+ HaikuMappedModel: "gpt-5.4",
+ ExactModelMappings: map[string]string{
+ "claude-haiku-4-5-20251001": "gpt-5.4",
+ },
+ },
+ },
+ }
+ require.Equal(t, "gpt-5.4", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-haiku-4-5-20251001"))
+ require.Equal(t, "gpt-5.4", resolveOpenAIMessagesDispatchMappedModel(apiKey, "claude-haiku-4-5"))
+ })
+
t.Run("returns_empty_for_non_claude_or_missing_group", func(t *testing.T) {
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(nil, "claude-sonnet-4-5-20250929"))
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(&service.APIKey{}, "claude-sonnet-4-5-20250929"))
@@ -415,6 +428,191 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) {
})
}
+func TestResolveOpenAIAccountRoutingModel_ChannelMappingPrecedesProtocolDefault(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ requestedModel string
+ preferredModel string
+ channelMapping service.ChannelMappingResult
+ expectedRouting string
+ expectedValid bool
+ }{
+ {
+ name: "responses channel mapping routes preview entitlement",
+ requestedModel: "gpt-5.4",
+ channelMapping: service.ChannelMappingResult{Mapped: true, MappedModel: "openai/gpt-5.6-sol-high"},
+ expectedRouting: "gpt-5.6-sol",
+ expectedValid: true,
+ },
+ {
+ name: "messages channel mapping wins over legacy dispatch default",
+ requestedModel: "claude-sonnet-4-6",
+ preferredModel: "gpt-5.4",
+ channelMapping: service.ChannelMappingResult{Mapped: true, MappedModel: "gpt-5.6-terra-medium"},
+ expectedRouting: "gpt-5.6-terra",
+ expectedValid: true,
+ },
+ {
+ name: "websocket channel mapping routes preview entitlement",
+ requestedModel: "gpt-5.4",
+ channelMapping: service.ChannelMappingResult{Mapped: true, MappedModel: "OPENAI/GPT-5.6-LUNA-XHIGH"},
+ expectedRouting: "gpt-5.6-luna",
+ expectedValid: true,
+ },
+ {
+ name: "chat completions channel mapping keeps non preview behavior",
+ requestedModel: "gpt-4.1",
+ channelMapping: service.ChannelMappingResult{Mapped: true, MappedModel: "gpt-5.4"},
+ expectedRouting: "gpt-5.4",
+ expectedValid: true,
+ },
+ {
+ name: "messages dispatch default used without channel mapping",
+ requestedModel: "claude-sonnet-4-6",
+ preferredModel: "gpt-5.4",
+ channelMapping: service.ChannelMappingResult{MappedModel: "claude-sonnet-4-6"},
+ expectedRouting: "gpt-5.4",
+ expectedValid: true,
+ },
+ {
+ name: "responses preview downgrade fails closed",
+ requestedModel: "gpt-5.6-sol",
+ channelMapping: service.ChannelMappingResult{Mapped: true, MappedModel: "gpt-5.4"},
+ expectedRouting: "",
+ expectedValid: false,
+ },
+ {
+ name: "messages preview cross tier fails closed",
+ requestedModel: "gpt-5.6-terra-high",
+ preferredModel: "gpt-5.6-terra",
+ channelMapping: service.ChannelMappingResult{Mapped: true, MappedModel: "gpt-5.6-sol"},
+ expectedRouting: "",
+ expectedValid: false,
+ },
+ {
+ name: "websocket empty mapped target fails closed",
+ requestedModel: "gpt-5.4",
+ channelMapping: service.ChannelMappingResult{Mapped: true, MappedModel: " "},
+ expectedRouting: "",
+ expectedValid: false,
+ },
+ {
+ name: "chat malformed preview request fails closed",
+ requestedModel: "gpt-5.6-unknown",
+ channelMapping: service.ChannelMappingResult{MappedModel: "gpt-5.6-unknown"},
+ expectedRouting: "",
+ expectedValid: false,
+ },
+ {
+ name: "preview minimal suffix fails closed",
+ requestedModel: "gpt-5.6-sol-minimal",
+ channelMapping: service.ChannelMappingResult{MappedModel: "gpt-5.6-sol-minimal"},
+ expectedRouting: "",
+ expectedValid: false,
+ },
+ {
+ name: "preview extra high suffix fails closed",
+ requestedModel: "gpt-5.6-luna-extrahigh",
+ channelMapping: service.ChannelMappingResult{MappedModel: "gpt-5.6-luna-extrahigh"},
+ expectedRouting: "",
+ expectedValid: false,
+ },
+ {
+ name: "legacy request to malformed preview target fails closed",
+ requestedModel: "gpt-5.4",
+ channelMapping: service.ChannelMappingResult{Mapped: true, MappedModel: "gpt-5.6"},
+ expectedRouting: "",
+ expectedValid: false,
+ },
+ {
+ name: "preview same tier provider suffix remains valid",
+ requestedModel: "gpt-5.6-sol-high",
+ channelMapping: service.ChannelMappingResult{Mapped: true, MappedModel: "OPENAI/GPT5.6-SOL-XHIGH"},
+ expectedRouting: "gpt-5.6-sol",
+ expectedValid: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRouting, gotValid := resolveOpenAIAccountRoutingModel(tt.requestedModel, tt.preferredModel, tt.channelMapping)
+ require.Equal(t, tt.expectedRouting, gotRouting)
+ require.Equal(t, tt.expectedValid, gotValid)
+ })
+ }
+}
+
+func TestOpenAISelectedAccountSupportsRoutingModel(t *testing.T) {
+ t.Parallel()
+
+ require.True(t, openAISelectedAccountSupportsRoutingModel(&service.Account{
+ Platform: service.PlatformOpenAI,
+ Credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6-terra": "openai/gpt-5.6-terra-high"}},
+ }, "gpt-5.6-terra"))
+ require.False(t, openAISelectedAccountSupportsRoutingModel(&service.Account{
+ Platform: service.PlatformOpenAI,
+ Credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6-terra": "gpt-5.6-sol"}},
+ }, "gpt-5.6-terra"))
+ require.True(t, openAISelectedAccountSupportsRoutingModel(&service.Account{Platform: service.PlatformOpenAI}, "gpt-5.4"))
+ require.False(t, openAISelectedAccountSupportsRoutingModel(&service.Account{
+ Platform: service.PlatformOpenAI,
+ Credentials: map[string]any{"model_mapping": map[string]any{
+ "gpt-5.4": "gpt-5.6-luna",
+ }},
+ }, "gpt-5.4"))
+ require.True(t, openAISelectedAccountSupportsRoutingModel(&service.Account{
+ Platform: service.PlatformOpenAI,
+ Credentials: map[string]any{"model_mapping": map[string]any{
+ "gpt-5.4": "gpt-5.6-luna",
+ "gpt-5.6-luna": "gpt-5.6-luna",
+ }},
+ }, "gpt-5.4"))
+ require.False(t, openAISelectedAccountSupportsRoutingModel(nil, "gpt-5.4"))
+}
+
+func TestOpenAIWSChannelMappingKeepsCanonicalModel(t *testing.T) {
+ tests := []struct {
+ name string
+ requested string
+ mapping service.ChannelMappingResult
+ want bool
+ }{
+ {name: "no mapping", requested: "gpt-5.4", want: true},
+ {name: "same exact preview spelling remains compatible", requested: "gpt-5.6-luna-low", mapping: service.ChannelMappingResult{Mapped: true, MappedModel: "gpt-5.6-luna-low"}, want: true},
+ {name: "preview reasoning suffix rewrite rejected", requested: "gpt-5.6-luna-low", mapping: service.ChannelMappingResult{Mapped: true, MappedModel: "gpt-5.6-luna-xhigh"}, want: false},
+ {name: "legacy same base suffix remains compatible", requested: "gpt-5.4-low", mapping: service.ChannelMappingResult{Mapped: true, MappedModel: "openai/gpt-5.4-xhigh"}, want: true},
+ {name: "cross model mapping rejected", requested: "gpt-5.4", mapping: service.ChannelMappingResult{Mapped: true, MappedModel: "gpt-5.6-luna"}, want: false},
+ {name: "custom provider namespace change rejected", requested: "provider-a/gpt-custom", mapping: service.ChannelMappingResult{Mapped: true, MappedModel: "provider-b/gpt-custom"}, want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.Equal(t, tt.want, openAIWSChannelMappingKeepsCanonicalModel(tt.requested, tt.mapping))
+ })
+ }
+}
+
+func TestPreserveOpenAIMessagesDispatchSub2BillingSource(t *testing.T) {
+ t.Run("claude_dispatch_keeps_native_sub2_billing_basis", func(t *testing.T) {
+ fields := preserveOpenAIMessagesDispatchSub2BillingSource(service.ChannelUsageFields{}, "claude-opus-4-7", "gpt-5.5")
+ require.Empty(t, fields.BillingModelSource)
+ })
+
+ t.Run("preserves_explicit_channel_billing_source", func(t *testing.T) {
+ fields := preserveOpenAIMessagesDispatchSub2BillingSource(service.ChannelUsageFields{
+ BillingModelSource: service.BillingModelSourceChannelMapped,
+ }, "claude-opus-4-7", "gpt-5.5")
+ require.Equal(t, service.BillingModelSourceChannelMapped, fields.BillingModelSource)
+ })
+
+ t.Run("openai_native_model_keeps_default_billing", func(t *testing.T) {
+ fields := preserveOpenAIMessagesDispatchSub2BillingSource(service.ChannelUsageFields{}, "gpt-5.5", "gpt-5.5")
+ require.Empty(t, fields.BillingModelSource)
+ })
+}
+
func TestOpenAIResponses_MissingDependencies_ReturnsServiceUnavailable(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -451,6 +649,239 @@ func TestOpenAIResponses_MissingDependencies_ReturnsServiceUnavailable(t *testin
assert.Equal(t, "Service temporarily unavailable", errorObj["message"])
}
+func TestOpenAIPricingPreflightErrorsReturn400Not503_Responses(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ var upstreamHits atomic.Int32
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ upstreamHits.Add(1)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer upstream.Close()
+
+ h, apiKey := newOpenAIPreflightHandlerTestFixture(t, upstream.URL)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"custom-unpriceable","input":"hello","stream":false}`))
+ c.Request.Header.Set("Content-Type", "application/json")
+ c.Set(string(middleware.ContextKeyAPIKey), apiKey)
+ c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.User.ID, Concurrency: 1})
+
+ h.Responses(c)
+
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+ require.NotEqual(t, http.StatusServiceUnavailable, rec.Code)
+ require.Equal(t, "invalid_request_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
+ require.Zero(t, upstreamHits.Load())
+}
+
+func TestOpenAIGPT56UnavailableReturnsStable403AcrossHTTPProtocols(t *testing.T) {
+ tests := []struct {
+ name string
+ path string
+ body string
+ run func(*OpenAIGatewayHandler, *gin.Context)
+ }{
+ {
+ name: "responses", path: "/v1/responses",
+ body: `{"model":"gpt-5.6-sol","input":"hello","stream":false}`,
+ run: func(h *OpenAIGatewayHandler, c *gin.Context) { h.Responses(c) },
+ },
+ {
+ name: "chat completions", path: "/v1/chat/completions",
+ body: `{"model":"gpt-5.6-sol","messages":[{"role":"user","content":"hello"}],"stream":false}`,
+ run: func(h *OpenAIGatewayHandler, c *gin.Context) { h.ChatCompletions(c) },
+ },
+ {
+ name: "anthropic messages compat", path: "/v1/messages",
+ body: `{"model":"gpt-5.6-sol","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`,
+ run: func(h *OpenAIGatewayHandler, c *gin.Context) { h.Messages(c) },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ var upstreamHits atomic.Int32
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ upstreamHits.Add(1)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer upstream.Close()
+
+ h, apiKey := newOpenAIPreflightHandlerTestFixture(t, upstream.URL)
+ apiKey.Group.AllowMessagesDispatch = true
+ _, _, selectionErr := h.gatewayService.SelectAccountWithScheduler(
+ context.Background(), apiKey.GroupID, "", "", "gpt-5.6-sol", nil,
+ service.OpenAIUpstreamTransportAny, false,
+ )
+ require.ErrorIsf(t, selectionErr, service.ErrNoAvailableOpenAIAccounts, "selectionErr=%v", selectionErr)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, tt.path, strings.NewReader(tt.body))
+ c.Request.Header.Set("Content-Type", "application/json")
+ c.Set(string(middleware.ContextKeyAPIKey), apiKey)
+ c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.User.ID, Concurrency: 1})
+
+ tt.run(h, c)
+
+ require.Equalf(t, http.StatusForbidden, rec.Code, "response=%s", rec.Body.String())
+ require.Equal(t, "model_not_available", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
+ require.Contains(t, gjson.GetBytes(rec.Body.Bytes(), "error.message").String(), "gpt-5.6-sol")
+ require.Zero(t, upstreamHits.Load())
+ })
+ }
+}
+
+func TestOpenAIGPT56AvailabilityError_DoesNotMaskInfrastructureFailures(t *testing.T) {
+ status, errType, message, ok := openAIGPT56AvailabilityError("gpt-5.6-sol", errors.New("database unavailable"), nil)
+ require.False(t, ok)
+ require.Zero(t, status)
+ require.Empty(t, errType)
+ require.Empty(t, message)
+
+ status, errType, message, ok = openAIGPT56AvailabilityError("gpt-5.6-sol", service.ErrNoAvailableOpenAIAccounts, nil)
+ require.True(t, ok)
+ require.Equal(t, http.StatusForbidden, status)
+ require.Equal(t, "model_not_available", errType)
+ require.Contains(t, message, "gpt-5.6-sol")
+
+ _, _, _, ok = openAIGPT56AvailabilityError(
+ "gpt-5.6-sol",
+ service.ErrNoAvailableOpenAIAccounts,
+ &service.UpstreamFailoverError{StatusCode: http.StatusInternalServerError},
+ )
+ require.False(t, ok, "a prior 5xx must remain the authoritative client error")
+
+ _, _, _, ok = openAIGPT56AvailabilityError(
+ "gpt-5.6-sol",
+ service.ErrNoAvailableOpenAIAccounts,
+ &service.UpstreamFailoverError{StatusCode: http.StatusForbidden},
+ )
+ require.True(t, ok, "an exact GPT-5.6 403 may collapse to model_not_available")
+}
+
+func TestOpenAIGPT56PriorUpstream5xxIsNotReclassifiedAsModelUnavailable(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ var upstreamHits atomic.Int32
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ upstreamHits.Add(1)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(`{"error":{"message":"upstream internal failure"}}`))
+ }))
+ defer upstream.Close()
+
+ h, apiKey := newOpenAIPreflightHandlerTestFixtureWithMapping(t, upstream.URL, map[string]any{
+ "gpt-5.6-sol": "gpt-5.6-sol",
+ })
+ h.maxAccountSwitches = 1
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.6-sol","input":"hello","stream":false}`))
+ c.Request.Header.Set("Content-Type", "application/json")
+ c.Set(string(middleware.ContextKeyAPIKey), apiKey)
+ c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.User.ID, Concurrency: 1})
+
+ h.Responses(c)
+
+ require.Equalf(t, http.StatusBadGateway, rec.Code, "response=%s", rec.Body.String())
+ require.Equal(t, "upstream_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
+ require.NotEqual(t, "model_not_available", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
+ require.Equal(t, int32(1), upstreamHits.Load())
+}
+
+func TestOpenAIResponsesWebSocket_GPT56UnavailableClosesWithPolicyViolation(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("unavailable GPT-5.6 must fail before upstream")
+ }))
+ defer upstream.Close()
+
+ h, _ := newOpenAIPreflightHandlerTestFixture(t, upstream.URL)
+ wsServer := newOpenAIWSHandlerTestServer(t, h, middleware.AuthSubject{UserID: 1, Concurrency: 1})
+ defer wsServer.Close()
+
+ dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
+ clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http")+"/openai/v1/responses", nil)
+ cancelDial()
+ require.NoError(t, err)
+ defer func() { _ = clientConn.CloseNow() }()
+
+ writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
+ err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":"hello"}`))
+ cancelWrite()
+ require.NoError(t, err)
+
+ readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
+ _, _, readErr := clientConn.Read(readCtx)
+ cancelRead()
+ var closeErr coderws.CloseError
+ require.ErrorAs(t, readErr, &closeErr)
+ require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
+ require.Contains(t, closeErr.Reason, "gpt-5.6-sol")
+}
+
+func newOpenAIPreflightHandlerTestFixture(t *testing.T, upstreamURL string) (*OpenAIGatewayHandler, *service.APIKey) {
+ return newOpenAIPreflightHandlerTestFixtureWithMapping(t, upstreamURL, nil)
+}
+
+type openAIPreflightHTTPUpstream struct{}
+
+func (openAIPreflightHTTPUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
+ return http.DefaultClient.Do(req)
+}
+
+func (openAIPreflightHTTPUpstream) DoWithTLS(
+ req *http.Request,
+ _ string,
+ _ int64,
+ _ int,
+ _ *tlsfingerprint.Profile,
+) (*http.Response, error) {
+ return http.DefaultClient.Do(req)
+}
+
+func newOpenAIPreflightHandlerTestFixtureWithMapping(
+ t *testing.T,
+ upstreamURL string,
+ modelMapping map[string]any,
+) (*OpenAIGatewayHandler, *service.APIKey) {
+ t.Helper()
+ groupID := int64(5101)
+ credentials := map[string]any{"api_key": "test-only", "base_url": upstreamURL}
+ if len(modelMapping) > 0 {
+ credentials["model_mapping"] = modelMapping
+ }
+ accountRepo := &openAIWSUsageHandlerAccountRepoStub{account: service.Account{
+ ID: 6101, Name: "preflight-account", Platform: service.PlatformOpenAI,
+ Type: service.AccountTypeAPIKey, Status: service.StatusActive, Schedulable: true, Concurrency: 1,
+ Credentials: credentials,
+ }}
+ cfg := &config.Config{RunMode: config.RunModeSimple}
+ cfg.Default.RateMultiplier = 1
+ cfg.Security.URLAllowlist.Enabled = false
+ cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
+ billingCache := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg)
+ gateway := service.NewOpenAIGatewayService(
+ accountRepo, nil, nil, nil, nil, nil, nil, nil, cfg, nil, nil,
+ service.NewBillingService(cfg, nil), nil, billingCache, openAIPreflightHTTPUpstream{}, &service.DeferredService{},
+ nil, nil, nil, nil, nil, nil, nil, nil,
+ )
+ cache := &concurrencyCacheMock{
+ acquireUserSlotFn: func(context.Context, int64, int, string) (bool, error) { return true, nil },
+ acquireAccountSlotFn: func(context.Context, int64, int, string) (bool, error) { return true, nil },
+ }
+ apiKey := &service.APIKey{
+ ID: 7101, GroupID: &groupID, Group: &service.Group{ID: groupID, RateMultiplier: 1},
+ User: &service.User{ID: 8101, Status: service.StatusActive},
+ }
+ return &OpenAIGatewayHandler{
+ gatewayService: gateway, billingCacheService: billingCache, apiKeyService: &service.APIKeyService{},
+ concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(cache), SSEPingFormatNone, time.Second),
+ }, apiKey
+}
+
func TestOpenAIResponses_SetsClientTransportHTTP(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -466,6 +897,36 @@ func TestOpenAIResponses_SetsClientTransportHTTP(t *testing.T) {
require.Equal(t, service.OpenAIClientTransportHTTP, service.GetOpenAIClientTransport(c))
}
+func TestOpenAIClientCanceledAccountSelectError(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ t.Run("suppresses_pool_error_when_request_context_is_canceled", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil).WithContext(ctx)
+
+ require.True(t, isClientCanceledAccountSelectError(c, service.ErrNoAvailableAccounts))
+ })
+
+ t.Run("keeps_real_pool_error_when_request_context_is_active", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+
+ require.False(t, isClientCanceledAccountSelectError(c, service.ErrNoAvailableAccounts))
+ })
+
+ t.Run("suppresses_direct_context_canceled_error", func(t *testing.T) {
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+
+ require.True(t, isClientCanceledAccountSelectError(c, context.Canceled))
+ })
+}
+
func TestOpenAIResponses_RejectsMessageIDAsPreviousResponseID(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -614,18 +1075,210 @@ func TestOpenAIResponsesWebSocket_RejectsMessageIDAsPreviousResponseID(t *testin
require.Contains(t, strings.ToLower(closeErr.Reason), "previous_response_id")
}
-func TestOpenAIResponsesWebSocket_PreviousResponseIDKindLoggedBeforeAcquireFailure(t *testing.T) {
+func TestOpenAIResponsesWebSocketAcquiresUserSlotBeforeUpgrade(t *testing.T) {
gin.SetMode(gin.TestMode)
cache := &concurrencyCacheMock{
- acquireUserSlotFn: func(ctx context.Context, userID int64, maxConcurrency int, requestID string) (bool, error) {
- return false, errors.New("user slot unavailable")
+ acquireUserSlotFn: func(context.Context, int64, int, string) (bool, error) {
+ return false, errors.New("concurrency backend unavailable")
},
}
h := newOpenAIHandlerForPreviousResponseIDValidation(t, cache)
wsServer := newOpenAIWSHandlerTestServer(t, h, middleware.AuthSubject{UserID: 1, Concurrency: 1})
defer wsServer.Close()
+ dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
+ clientConn, response, err := coderws.Dial(
+ dialCtx,
+ "ws"+strings.TrimPrefix(wsServer.URL, "http")+"/openai/v1/responses",
+ nil,
+ )
+ cancelDial()
+ if clientConn != nil {
+ _ = clientConn.CloseNow()
+ }
+ require.Error(t, err)
+ require.NotNil(t, response)
+ require.Equal(t, http.StatusServiceUnavailable, response.StatusCode)
+}
+
+func TestOpenAIWSIngressReadLimitHonorsSmallerConfiguredBodyLimit(t *testing.T) {
+ t.Parallel()
+
+ require.Equal(t, openAIWSIngressMaxMessageBytes, openAIWSIngressReadLimit(nil))
+ require.Equal(t, int64(1024*1024), openAIWSIngressReadLimit(&config.Config{
+ Gateway: config.GatewayConfig{MaxBodySize: 1024 * 1024},
+ }))
+ require.Equal(t, openAIWSIngressMaxMessageBytes, openAIWSIngressReadLimit(&config.Config{
+ Gateway: config.GatewayConfig{MaxBodySize: 256 * 1024 * 1024},
+ }))
+}
+
+type contentModerationHandlerSettingRepo struct {
+ values map[string]string
+}
+
+func (r *contentModerationHandlerSettingRepo) Get(ctx context.Context, key string) (*service.Setting, error) {
+ if value, ok := r.values[key]; ok {
+ return &service.Setting{Key: key, Value: value}, nil
+ }
+ return nil, service.ErrSettingNotFound
+}
+
+func (r *contentModerationHandlerSettingRepo) GetValue(ctx context.Context, key string) (string, error) {
+ if value, ok := r.values[key]; ok {
+ return value, nil
+ }
+ return "", service.ErrSettingNotFound
+}
+
+func (r *contentModerationHandlerSettingRepo) Set(ctx context.Context, key, value string) error {
+ if r.values == nil {
+ r.values = map[string]string{}
+ }
+ r.values[key] = value
+ return nil
+}
+
+func (r *contentModerationHandlerSettingRepo) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
+ out := map[string]string{}
+ for _, key := range keys {
+ if value, ok := r.values[key]; ok {
+ out[key] = value
+ }
+ }
+ return out, nil
+}
+
+func (r *contentModerationHandlerSettingRepo) SetMultiple(ctx context.Context, settings map[string]string) error {
+ if r.values == nil {
+ r.values = map[string]string{}
+ }
+ for key, value := range settings {
+ r.values[key] = value
+ }
+ return nil
+}
+
+func (r *contentModerationHandlerSettingRepo) GetAll(ctx context.Context) (map[string]string, error) {
+ out := make(map[string]string, len(r.values))
+ for key, value := range r.values {
+ out[key] = value
+ }
+ return out, nil
+}
+
+func (r *contentModerationHandlerSettingRepo) Delete(ctx context.Context, key string) error {
+ delete(r.values, key)
+ return nil
+}
+
+type contentModerationHandlerTestRepo struct {
+ logs []service.ContentModerationLog
+}
+
+type contentModerationHandlerTestEncryptor struct{}
+
+func (contentModerationHandlerTestEncryptor) Encrypt(plaintext string) (string, error) {
+ return "test:" + plaintext, nil
+}
+
+func (contentModerationHandlerTestEncryptor) Decrypt(ciphertext string) (string, error) {
+ return "", errors.New("legacy decrypt is disabled")
+}
+
+func (contentModerationHandlerTestEncryptor) EncryptForDomain(_ string, plaintext string) (string, error) {
+ return "test:" + plaintext, nil
+}
+
+func (contentModerationHandlerTestEncryptor) DecryptForDomain(_ string, ciphertext string) (string, error) {
+ if !strings.HasPrefix(ciphertext, "test:") {
+ return "", errors.New("invalid test ciphertext")
+ }
+ return strings.TrimPrefix(ciphertext, "test:"), nil
+}
+
+func (r *contentModerationHandlerTestRepo) CreateLog(ctx context.Context, log *service.ContentModerationLog) error {
+ if log != nil {
+ r.logs = append(r.logs, *log)
+ }
+ return nil
+}
+
+func (r *contentModerationHandlerTestRepo) ListLogs(ctx context.Context, filter service.ContentModerationLogFilter) ([]service.ContentModerationLog, *pagination.PaginationResult, error) {
+ return nil, nil, nil
+}
+
+func (r *contentModerationHandlerTestRepo) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time) (int, error) {
+ return 0, nil
+}
+
+func (r *contentModerationHandlerTestRepo) CleanupExpiredLogs(ctx context.Context, hitBefore time.Time, nonHitBefore time.Time) (*service.ContentModerationCleanupResult, error) {
+ return &service.ContentModerationCleanupResult{}, nil
+}
+
+func TestOpenAIResponsesWebSocket_ContentModerationBlocksFirstFrame(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ moderationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/v1/moderations", r.URL.Path)
+ _, _ = w.Write([]byte(`{"results":[{"category_scores":{"sexual":0.9}}]}`))
+ }))
+ defer moderationServer.Close()
+
+ cfg := &service.ContentModerationConfig{
+ Enabled: true,
+ Mode: service.ContentModerationModePreBlock,
+ BaseURL: moderationServer.URL,
+ Model: "omni-moderation-latest",
+ EncryptedAPIKeys: []string{"test:sk-test"},
+ SampleRate: 100,
+ AllGroups: true,
+ BlockMessage: "内容审计测试阻断",
+ }
+ rawCfg, err := json.Marshal(cfg)
+ require.NoError(t, err)
+
+ repo := &contentModerationHandlerTestRepo{}
+ settingRepo := &contentModerationHandlerSettingRepo{values: map[string]string{
+ service.SettingKeyRiskControlEnabled: "true",
+ service.SettingKeyContentModerationConfig: string(rawCfg),
+ }}
+ moderationSvc := service.NewContentModerationServiceWithEncryptor(
+ settingRepo,
+ repo,
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ contentModerationHandlerTestEncryptor{},
+ )
+ decision, err := moderationSvc.Check(context.Background(), service.ContentModerationCheckInput{
+ UserID: 1,
+ Endpoint: "/v1/responses",
+ Provider: "openai",
+ Model: "gpt-5.5",
+ Protocol: service.ContentModerationProtocolOpenAIResponses,
+ Body: []byte(`{"model":"gpt-5.5","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"bad prompt"}]}]}`),
+ })
+ require.NoError(t, err)
+ require.True(t, decision.Blocked)
+ repo.logs = nil
+ h := &OpenAIGatewayHandler{
+ gatewayService: &service.OpenAIGatewayService{},
+ billingCacheService: &service.BillingCacheService{},
+ apiKeyService: &service.APIKeyService{},
+ contentModerationService: moderationSvc,
+ concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(&concurrencyCacheMock{
+ acquireUserSlotFn: func(context.Context, int64, int, string) (bool, error) {
+ return true, nil
+ },
+ }), SSEPingFormatNone, time.Second),
+ }
+ wsServer := newOpenAIWSHandlerTestServer(t, h, middleware.AuthSubject{UserID: 1, Concurrency: 1})
+ defer wsServer.Close()
+
dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http")+"/openai/v1/responses", nil)
cancelDial()
@@ -635,20 +1288,70 @@ func TestOpenAIResponsesWebSocket_PreviousResponseIDKindLoggedBeforeAcquireFailu
}()
writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
- err = clientConn.Write(writeCtx, coderws.MessageText, []byte(
- `{"type":"response.create","model":"gpt-5.1","stream":false,"previous_response_id":"resp_prev_123"}`,
- ))
+ err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{
+ "type":"response.create",
+ "model":"gpt-5.5",
+ "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"bad prompt"}]}]
+ }`))
cancelWrite()
require.NoError(t, err)
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
- _, _, err = clientConn.Read(readCtx)
+ _, payload, readErr := clientConn.Read(readCtx)
cancelRead()
- require.Error(t, err)
- var closeErr coderws.CloseError
- require.ErrorAs(t, err, &closeErr)
- require.Equal(t, coderws.StatusInternalError, closeErr.Code)
- require.Contains(t, strings.ToLower(closeErr.Reason), "failed to acquire user concurrency slot")
+ if readErr == nil {
+ require.Contains(t, string(payload), "content_policy_violation")
+ require.Contains(t, string(payload), "内容审计测试阻断")
+ } else {
+ var closeErr coderws.CloseError
+ require.ErrorAs(t, readErr, &closeErr)
+ require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
+ require.Contains(t, closeErr.Reason, "内容审计测试阻断")
+ }
+ require.Len(t, repo.logs, 1)
+ require.True(t, repo.logs[0].Flagged)
+ require.Equal(t, service.ContentModerationActionBlock, repo.logs[0].Action)
+ require.Equal(t, "bad prompt", repo.logs[0].InputExcerpt)
+}
+
+func TestOpenAIResponsesWebSocket_PassthroughUsageLogPersistsUserAgentAndReasoningEffort(t *testing.T) {
+ got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
+ firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false,"reasoning":{"effort":"HIGH"}}`,
+ userAgent: testStringPtr("codex_cli_rs/0.125.0 test"),
+ })
+
+ require.NotNil(t, got.log.UserAgent)
+ require.Equal(t, "codex_cli_rs/0.125.0 test", *got.log.UserAgent)
+ require.NotNil(t, got.log.ReasoningEffort)
+ require.Equal(t, "high", *got.log.ReasoningEffort)
+ require.True(t, got.log.OpenAIWSMode)
+}
+
+func TestOpenAIResponsesWebSocket_PassthroughUsageLogInfersReasoningFromInitialRequestModel(t *testing.T) {
+ got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
+ firstPayload: `{"type":"response.create","model":"gpt-5.4-xhigh","stream":false}`,
+ userAgent: testStringPtr("codex_cli_rs/0.125.0 mapped"),
+ channelMapping: map[string]string{
+ "gpt-5.4-xhigh": "gpt-5.4",
+ },
+ })
+
+ require.Equal(t, "gpt-5.4", gjson.GetBytes(got.upstreamFirstPayload, "model").String(),
+ "上游首帧应使用渠道映射后的模型")
+ require.NotNil(t, got.log.ReasoningEffort)
+ require.Equal(t, "xhigh", *got.log.ReasoningEffort,
+ "usage log reasoning effort 必须使用渠道映射前首帧模型后缀推导")
+}
+
+func TestOpenAIResponsesWebSocket_PassthroughUsageLogLeavesUserAgentNilWhenMissing(t *testing.T) {
+ got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
+ firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false,"reasoning":{"effort":"medium"}}`,
+ userAgent: testStringPtr(""),
+ })
+
+ require.Nil(t, got.log.UserAgent, "空入站 User-Agent 不应由上游握手 UA 或默认 UA 兜底")
+ require.NotNil(t, got.log.ReasoningEffort)
+ require.Equal(t, "medium", *got.log.ReasoningEffort)
}
func TestSetOpenAIClientTransportHTTP(t *testing.T) {
@@ -796,3 +1499,336 @@ func newOpenAIWSHandlerTestServer(t *testing.T, h *OpenAIGatewayHandler, subject
router.GET("/openai/v1/responses", h.ResponsesWebSocket)
return httptest.NewServer(router)
}
+
+type openAIResponsesWSUsageLogCase struct {
+ firstPayload string
+ userAgent *string
+ channelMapping map[string]string
+ upstreamCompletedEvent string
+ ingressMode string
+ expectPreflightFailure bool
+}
+
+type openAIResponsesWSUsageLogResult struct {
+ log *service.UsageLog
+ upstreamFirstPayload []byte
+ upstreamHits int32
+}
+
+type openAIWSUsageHandlerAccountRepoStub struct {
+ service.AccountRepository
+ account service.Account
+}
+
+func (s *openAIWSUsageHandlerAccountRepoStub) ListSchedulableByPlatform(ctx context.Context, platform string) ([]service.Account, error) {
+ if s.account.Platform != platform {
+ return nil, nil
+ }
+ return []service.Account{s.account}, nil
+}
+
+func (s *openAIWSUsageHandlerAccountRepoStub) ListSchedulableByGroupIDAndPlatform(ctx context.Context, groupID int64, platform string) ([]service.Account, error) {
+ return s.ListSchedulableByPlatform(ctx, platform)
+}
+
+func (s *openAIWSUsageHandlerAccountRepoStub) GetByID(ctx context.Context, id int64) (*service.Account, error) {
+ if s.account.ID != id {
+ return nil, nil
+ }
+ account := s.account
+ return &account, nil
+}
+
+type openAIWSUsageHandlerUsageLogRepoStub struct {
+ service.UsageLogRepository
+ created chan *service.UsageLog
+}
+
+func (s *openAIWSUsageHandlerUsageLogRepoStub) Create(ctx context.Context, log *service.UsageLog) (bool, error) {
+ if s.created != nil {
+ s.created <- log
+ }
+ return true, nil
+}
+
+type openAIWSUsageHandlerChannelRepoStub struct {
+ service.ChannelRepository
+ channels []service.Channel
+ groupPlatforms map[int64]string
+}
+
+func (s *openAIWSUsageHandlerChannelRepoStub) ListAll(ctx context.Context) ([]service.Channel, error) {
+ return s.channels, nil
+}
+
+func (s *openAIWSUsageHandlerChannelRepoStub) GetGroupPlatforms(ctx context.Context, groupIDs []int64) (map[int64]string, error) {
+ out := make(map[int64]string, len(groupIDs))
+ for _, groupID := range groupIDs {
+ if platform := strings.TrimSpace(s.groupPlatforms[groupID]); platform != "" {
+ out[groupID] = platform
+ }
+ }
+ return out, nil
+}
+
+func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSUsageLogCase) openAIResponsesWSUsageLogResult {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+
+ upstreamPayloadCh := make(chan []byte, 1)
+ upstreamErrCh := make(chan error, 1)
+ var upstreamHits atomic.Int32
+ upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ upstreamHits.Add(1)
+ conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{
+ CompressionMode: coderws.CompressionContextTakeover,
+ })
+ if err != nil {
+ upstreamErrCh <- err
+ return
+ }
+ defer func() {
+ _ = conn.CloseNow()
+ }()
+
+ readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second)
+ msgType, payload, readErr := conn.Read(readCtx)
+ cancelRead()
+ if readErr != nil {
+ upstreamErrCh <- readErr
+ return
+ }
+ if msgType != coderws.MessageText && msgType != coderws.MessageBinary {
+ upstreamErrCh <- errors.New("unexpected upstream websocket message type")
+ return
+ }
+ upstreamPayloadCh <- payload
+
+ completedEvent := tc.upstreamCompletedEvent
+ if completedEvent == "" {
+ completedEvent = `{"type":"response.completed","response":{"id":"resp_usage_e2e","model":"gpt-5.4","usage":{"input_tokens":2,"output_tokens":1}}}`
+ }
+ writeCtx, cancelWrite := context.WithTimeout(r.Context(), 3*time.Second)
+ writeErr := conn.Write(writeCtx, coderws.MessageText, []byte(completedEvent))
+ cancelWrite()
+ if writeErr != nil {
+ upstreamErrCh <- writeErr
+ return
+ }
+ _ = conn.Close(coderws.StatusNormalClosure, "done")
+ upstreamErrCh <- nil
+ }))
+ defer upstreamServer.Close()
+
+ groupID := int64(4201)
+ ingressMode := tc.ingressMode
+ if ingressMode == "" {
+ ingressMode = service.OpenAIWSIngressModePassthrough
+ }
+ account := service.Account{
+ ID: 9901,
+ Name: "openai-ws-passthrough-usage-e2e",
+ Platform: service.PlatformOpenAI,
+ Type: service.AccountTypeAPIKey,
+ Status: service.StatusActive,
+ Schedulable: true,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "api_key": "sk-test",
+ "base_url": upstreamServer.URL,
+ },
+ Extra: map[string]any{
+ "openai_apikey_responses_websockets_v2_enabled": true,
+ "openai_apikey_responses_websockets_v2_mode": ingressMode,
+ },
+ }
+
+ cfg := &config.Config{}
+ cfg.RunMode = config.RunModeSimple
+ cfg.Default.RateMultiplier = 1
+ cfg.Security.URLAllowlist.Enabled = false
+ cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
+ cfg.Gateway.OpenAIWS.Enabled = true
+ cfg.Gateway.OpenAIWS.APIKeyEnabled = true
+ cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
+ cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true
+ cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
+ cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3
+ cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
+
+ accountRepo := &openAIWSUsageHandlerAccountRepoStub{account: account}
+ usageRepo := &openAIWSUsageHandlerUsageLogRepoStub{created: make(chan *service.UsageLog, 1)}
+
+ var channelSvc *service.ChannelService
+ if len(tc.channelMapping) > 0 {
+ channelSvc = service.NewChannelService(&openAIWSUsageHandlerChannelRepoStub{
+ channels: []service.Channel{{
+ ID: 7701,
+ Name: "openai-ws-e2e-channel",
+ Status: service.StatusActive,
+ GroupIDs: []int64{groupID},
+ ModelMapping: map[string]map[string]string{service.PlatformOpenAI: tc.channelMapping},
+ }},
+ groupPlatforms: map[int64]string{groupID: service.PlatformOpenAI},
+ }, nil, nil, nil)
+ }
+
+ billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg)
+ gatewaySvc := service.NewOpenAIGatewayService(
+ accountRepo,
+ usageRepo,
+ nil,
+ nil,
+ nil,
+ nil, // walletRepo
+ nil,
+ nil,
+ cfg,
+ nil,
+ nil,
+ service.NewBillingService(cfg, nil),
+ nil,
+ billingCacheSvc,
+ nil,
+ &service.DeferredService{},
+ nil,
+ nil,
+ channelSvc,
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ )
+
+ cache := &concurrencyCacheMock{
+ acquireUserSlotFn: func(ctx context.Context, userID int64, maxConcurrency int, requestID string) (bool, error) {
+ return true, nil
+ },
+ acquireAccountSlotFn: func(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
+ return true, nil
+ },
+ }
+ h := &OpenAIGatewayHandler{
+ gatewayService: gatewaySvc,
+ billingCacheService: billingCacheSvc,
+ apiKeyService: &service.APIKeyService{},
+ concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(cache), SSEPingFormatNone, time.Second),
+ }
+
+ apiKey := &service.APIKey{
+ ID: 1801,
+ GroupID: &groupID,
+ Group: &service.Group{
+ ID: groupID, RateMultiplier: 1, AllowImageGeneration: true,
+ },
+ User: &service.User{ID: 1701, Status: service.StatusActive},
+ }
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Set(string(middleware.ContextKeyAPIKey), apiKey)
+ c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.User.ID, Concurrency: 1})
+ c.Next()
+ })
+ router.GET("/openai/v1/responses", h.ResponsesWebSocket)
+ handlerServer := httptest.NewServer(router)
+ defer handlerServer.Close()
+
+ headers := http.Header{}
+ if tc.userAgent != nil {
+ headers.Set("User-Agent", *tc.userAgent)
+ }
+ dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
+ clientConn, _, err := coderws.Dial(
+ dialCtx,
+ "ws"+strings.TrimPrefix(handlerServer.URL, "http")+"/openai/v1/responses",
+ &coderws.DialOptions{HTTPHeader: headers, CompressionMode: coderws.CompressionContextTakeover},
+ )
+ cancelDial()
+ require.NoError(t, err)
+ defer func() {
+ _ = clientConn.CloseNow()
+ }()
+
+ writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
+ err = clientConn.Write(writeCtx, coderws.MessageText, []byte(tc.firstPayload))
+ cancelWrite()
+ require.NoError(t, err)
+ if tc.expectPreflightFailure {
+ readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
+ _, _, readErr := clientConn.Read(readCtx)
+ cancelRead()
+ require.Error(t, readErr)
+ var closeErr coderws.CloseError
+ require.ErrorAs(t, readErr, &closeErr)
+ require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
+ require.Contains(t, strings.ToLower(closeErr.Reason), "billing preflight")
+ require.Zero(t, upstreamHits.Load())
+ return openAIResponsesWSUsageLogResult{upstreamHits: upstreamHits.Load()}
+ }
+
+ readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
+ _, event, err := clientConn.Read(readCtx)
+ cancelRead()
+ require.NoError(t, err)
+ require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String())
+ _ = clientConn.Close(coderws.StatusNormalClosure, "done")
+
+ var usageLog *service.UsageLog
+ select {
+ case usageLog = <-usageRepo.created:
+ require.NotNil(t, usageLog)
+ case <-time.After(3 * time.Second):
+ t.Fatal("等待 WebSocket usage log 写入超时")
+ }
+
+ var upstreamFirstPayload []byte
+ select {
+ case upstreamFirstPayload = <-upstreamPayloadCh:
+ case <-time.After(3 * time.Second):
+ t.Fatal("等待上游 WebSocket 首帧超时")
+ }
+
+ if ingressMode == service.OpenAIWSIngressModePassthrough {
+ select {
+ case upstreamErr := <-upstreamErrCh:
+ require.NoError(t, upstreamErr)
+ case <-time.After(3 * time.Second):
+ t.Fatal("等待上游 WebSocket 结束超时")
+ }
+ }
+
+ return openAIResponsesWSUsageLogResult{
+ log: usageLog,
+ upstreamFirstPayload: upstreamFirstPayload,
+ upstreamHits: upstreamHits.Load(),
+ }
+}
+
+func TestOpenAIResponsesWebSocket_UnpriceableImageClosesBeforeUpstreamDial(t *testing.T) {
+ got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
+ firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false,"input":"draw","tools":[{"type":"image_generation","model":"custom-unpriceable-image","size":"1024x1024"}]}`,
+ expectPreflightFailure: true,
+ })
+ require.Zero(t, got.upstreamHits)
+}
+
+func TestOpenAIResponsesWebSocket_ImageTurnPersistsImageBillingIdentity(t *testing.T) {
+ got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
+ firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false,"input":"draw","tools":[{"type":"image_generation","model":"gpt-image-2","size":"1024x1024"}]}`,
+ upstreamCompletedEvent: `{"type":"response.completed","response":{"id":"resp_image_usage_e2e","model":"gpt-5.4","usage":{"input_tokens":2,"output_tokens":1},"output":[{"id":"ig_1","type":"image_generation_call","result":"aGVsbG8="}]}}`,
+ ingressMode: service.OpenAIWSIngressModeCtxPool,
+ })
+
+ require.NotNil(t, got.log)
+ require.NotNil(t, got.log.BillingModel)
+ require.Equal(t, "gpt-image-2", *got.log.BillingModel)
+ require.NotNil(t, got.log.PricingSource)
+ require.NotNil(t, got.log.PricingRevision)
+ require.NotNil(t, got.log.PricingHash)
+}
+
+func testStringPtr(v string) *string {
+ return &v
+}
diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go
index 4d0078a7af5..53109129965 100644
--- a/backend/internal/handler/openai_images.go
+++ b/backend/internal/handler/openai_images.go
@@ -80,6 +80,29 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
zap.Bool("multipart", parsed.Multipart),
zap.String("capability", string(parsed.RequiredCapability)),
)
+ safetyIdentifier := service.BuildOpenAIImageSafetyIdentifier(subject.UserID, apiKey.ID, apiKey.GroupID)
+
+ if safetyErr := service.ValidateOpenAIImagesSafetyRequest(parsed); safetyErr != nil {
+ h.recordImageSafetyPolicyBlock(c, apiKey, subject, parsed, safetyIdentifier, safetyErr.PolicyRule, safetyErr.Message, safetyErr.StatusCode)
+ h.errorResponse(c, safetyErr.StatusCode, safetyErr.Type, safetyErr.Message)
+ return
+ }
+
+ if !service.GroupAllowsImageGeneration(apiKey.Group) {
+ h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage())
+ return
+ }
+ if decision := h.checkImageContentModeration(c, reqLog, apiKey, subject, parsed.Model, parsed.ModerationBody(), safetyIdentifier); decision != nil && decision.Blocked {
+ h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
+ return
+ }
+ imageReleaseFunc, acquired := h.acquireImageGenerationSlot(c, streamStarted)
+ if !acquired {
+ return
+ }
+ if imageReleaseFunc != nil {
+ defer imageReleaseFunc()
+ }
if parsed.Multipart {
setOpsRequestContext(c, parsed.Model, parsed.Stream, nil)
@@ -95,6 +118,22 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
}
subscription, _ := middleware2.GetSubscriptionFromContext(c)
+ billingCtx, err := service.PrepareUsageBillingRequestContext(c.Request.Context())
+ if err != nil {
+ h.errorResponse(c, http.StatusServiceUnavailable, "billing_service_error", "Billing service temporarily unavailable")
+ return
+ }
+ c.Request = c.Request.WithContext(billingCtx)
+ billingRequestBody := body
+ if parsed.Multipart {
+ billingRequestBody = []byte(parsed.StickySessionSeed())
+ }
+ usageBillingAdmission := &service.OpenAIUsageBillingAdmissionInput{
+ APIKey: apiKey, User: apiKey.User, Subscription: subscription,
+ RequestBody: append([]byte(nil), billingRequestBody...), RequestPayloadHash: service.HashUsageRequestPayload(billingRequestBody),
+ RequestCount: parsed.N,
+ }
+ defer h.finalizeOpenAIUsageBillingLifecycle(c.Request.Context(), usageBillingAdmission, reqLog)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
routingStart := time.Now()
@@ -177,7 +216,21 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds())
forwardStart := time.Now()
- result, err := h.gatewayService.ForwardImages(c.Request.Context(), c, account, body, parsed, channelMapping.MappedModel)
+ result, err := h.gatewayService.ForwardImages(
+ c.Request.Context(),
+ c,
+ account,
+ body,
+ parsed,
+ channelMapping.MappedModel,
+ service.WithOpenAIImagesSafetyIdentifier(safetyIdentifier),
+ service.WithOpenAIImagesOutputAuditor(h.openAIImageOutputAuditor(c, apiKey, subject, parsed, safetyIdentifier)),
+ service.WithOpenAIImagesBillingPreflight(service.OpenAIForwardOptions{
+ RequestedModel: parsed.Model, ChannelMapping: channelMapping, GroupID: apiKey.GroupID,
+ ImagePriceConfig: openAIImagePriceConfig(apiKey.Group), RequirePricingPreflight: true,
+ RequireBillingAdmission: true, UsageBilling: usageBillingAdmission,
+ }),
+ )
forwardDurationMs := time.Since(forwardStart).Milliseconds()
if accountReleaseFunc != nil {
accountReleaseFunc()
@@ -188,62 +241,99 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
responseLatencyMs = forwardDurationMs - upstreamLatencyMs
}
service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, responseLatencyMs)
- if err == nil && result != nil && result.FirstTokenMs != nil {
+ if result != nil && result.FirstTokenMs != nil {
service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs))
}
if err != nil {
- var failoverErr *service.UpstreamFailoverError
- if errors.As(err, &failoverErr) {
- h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
- if failoverErr.RetryableOnSameAccount {
- retryLimit := account.GetPoolModeRetryCount()
- if sameAccountRetryCount[account.ID] < retryLimit {
- sameAccountRetryCount[account.ID]++
- reqLog.Warn("openai.images.pool_mode_same_account_retry",
- zap.Int64("account_id", account.ID),
- zap.Int("upstream_status", failoverErr.StatusCode),
- zap.Int("retry_limit", retryLimit),
- zap.Int("retry_count", sameAccountRetryCount[account.ID]),
- )
- select {
- case <-c.Request.Context().Done():
- return
- case <-time.After(sameAccountRetryDelay):
- }
- continue
+ if h.handleOpenAIUsageBillingAdmissionError(c, err, streamStarted, false) {
+ return
+ }
+ if errors.Is(err, service.ErrOpenAIBillingPreflight) || errors.Is(err, service.ErrOpenAIPricingUnavailable) {
+ h.handleStreamingAwareError(c, http.StatusBadRequest, "invalid_request_error", "OpenAI billing preflight failed", streamStarted)
+ return
+ }
+ var outputAuditErr *service.OpenAIImageOutputAuditError
+ if errors.As(err, &outputAuditErr) {
+ decision := outputAuditErr.Decision
+ if decision == nil {
+ decision = &service.ContentModerationDecision{
+ Blocked: true,
+ Message: "Image safety review is temporarily unavailable",
+ StatusCode: http.StatusServiceUnavailable,
+ Action: service.ContentModerationActionError,
}
}
- h.gatewayService.RecordOpenAIAccountSwitch()
- failedAccountIDs[account.ID] = struct{}{}
- lastFailoverErr = failoverErr
- if switchCount >= maxAccountSwitches {
- h.handleFailoverExhausted(c, failoverErr, streamStarted)
- return
+ if !c.Writer.Written() {
+ h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message)
}
- switchCount++
- reqLog.Warn("openai.images.upstream_failover_switching",
+ reqLog.Warn("openai.images.output_audit_blocked",
zap.Int64("account_id", account.ID),
- zap.Int("upstream_status", failoverErr.StatusCode),
- zap.Int("switch_count", switchCount),
- zap.Int("max_switches", maxAccountSwitches),
+ zap.Int("image_count", resultImageCount(result)),
+ zap.String("policy_rule", decision.PolicyRule),
+ zap.String("action", decision.Action),
+ zap.Error(err),
)
- continue
- }
- h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
- wroteFallback := h.ensureForwardErrorResponse(c, streamStarted)
- fields := []zap.Field{
- zap.Int64("account_id", account.ID),
- zap.Bool("fallback_error_response_written", wroteFallback),
- zap.Error(err),
- }
- if shouldLogOpenAIForwardFailureAsWarn(c, wroteFallback) {
- reqLog.Warn("openai.images.forward_failed", fields...)
+ } else if result != nil && result.ImageCount > 0 {
+ reqLog.Warn("openai.images.forward_partial_error_with_image_result",
+ zap.Int64("account_id", account.ID),
+ zap.Int("image_count", result.ImageCount),
+ zap.Error(err),
+ )
+ } else {
+ var failoverErr *service.UpstreamFailoverError
+ if errors.As(err, &failoverErr) {
+ h.markOpenAIUsageBillingAttemptFailed(c.Request.Context(), usageBillingAdmission, reqLog)
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
+ if failoverErr.RetryableOnSameAccount {
+ retryLimit := account.GetPoolModeRetryCount()
+ if sameAccountRetryCount[account.ID] < retryLimit {
+ sameAccountRetryCount[account.ID]++
+ reqLog.Warn("openai.images.pool_mode_same_account_retry",
+ zap.Int64("account_id", account.ID),
+ zap.Int("upstream_status", failoverErr.StatusCode),
+ zap.Int("retry_limit", retryLimit),
+ zap.Int("retry_count", sameAccountRetryCount[account.ID]),
+ )
+ select {
+ case <-c.Request.Context().Done():
+ return
+ case <-time.After(sameAccountRetryDelay):
+ }
+ continue
+ }
+ }
+ h.gatewayService.RecordOpenAIAccountSwitch()
+ failedAccountIDs[account.ID] = struct{}{}
+ lastFailoverErr = failoverErr
+ if switchCount >= maxAccountSwitches {
+ h.handleFailoverExhausted(c, failoverErr, streamStarted)
+ return
+ }
+ switchCount++
+ reqLog.Warn("openai.images.upstream_failover_switching",
+ zap.Int64("account_id", account.ID),
+ zap.Int("upstream_status", failoverErr.StatusCode),
+ zap.Int("switch_count", switchCount),
+ zap.Int("max_switches", maxAccountSwitches),
+ )
+ continue
+ }
+ h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
+ wroteFallback := h.ensureForwardErrorResponse(c, streamStarted)
+ fields := []zap.Field{
+ zap.Int64("account_id", account.ID),
+ zap.Bool("fallback_error_response_written", wroteFallback),
+ zap.Error(err),
+ }
+ if shouldLogOpenAIForwardFailureAsWarn(c, wroteFallback) {
+ reqLog.Warn("openai.images.forward_failed", fields...)
+ return
+ }
+ reqLog.Error("openai.images.forward_failed", fields...)
return
}
- reqLog.Error("openai.images.forward_failed", fields...)
- return
}
-
+ h.gatewayService.MarkOpenAIUsageBillingAccepted(usageBillingAdmission)
if result != nil {
if account.Type == service.AccountTypeOAuth {
h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders)
@@ -259,22 +349,29 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
if parsed.Multipart {
requestPayloadHash = service.HashUsageRequestPayload([]byte(parsed.StickySessionSeed()))
}
+ inboundEndpoint := GetInboundEndpoint(c)
+ upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
- h.submitUsageRecordTask(func(ctx context.Context) {
+ upstreamModel := ""
+ if result != nil {
+ upstreamModel = result.UpstreamModel
+ }
+ h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
- InboundEndpoint: GetInboundEndpoint(c),
- UpstreamEndpoint: GetUpstreamEndpoint(c, account.Platform),
+ InboundEndpoint: inboundEndpoint,
+ UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
- ChannelUsageFields: channelMapping.ToUsageFields(parsed.Model, result.UpstreamModel),
+ ChannelUsageFields: channelMapping.ToUsageFields(parsed.Model, upstreamModel),
}); err != nil {
+ h.markOpenAIUsageBillingResultOrphaned(ctx, result, reqLog)
logger.L().With(
zap.String("component", "handler.openai_gateway.images"),
zap.Int64("user_id", subject.UserID),
@@ -297,3 +394,56 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
func isMultipartImagesContentType(contentType string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(contentType)), "multipart/form-data")
}
+
+func (h *OpenAIGatewayHandler) recordImageSafetyPolicyBlock(c *gin.Context, apiKey *service.APIKey, subject middleware2.AuthSubject, parsed *service.OpenAIImagesRequest, safetyIdentifier string, policyRule string, message string, statusCode int) {
+ if h == nil || h.contentModerationService == nil || c == nil || parsed == nil {
+ return
+ }
+ input := buildContentModerationInput(c, apiKey, subject, service.ContentModerationProtocolOpenAIImages, parsed.Model, parsed.ModerationBody())
+ input.Stage = service.ContentModerationStageInput
+ input.PolicyRule = strings.TrimSpace(policyRule)
+ input.SafetyIdentifier = strings.TrimSpace(safetyIdentifier)
+ h.contentModerationService.RecordPolicyBlock(c.Request.Context(), input, message, statusCode)
+}
+
+func (h *OpenAIGatewayHandler) openAIImageOutputAuditor(c *gin.Context, apiKey *service.APIKey, subject middleware2.AuthSubject, parsed *service.OpenAIImagesRequest, safetyIdentifier string) service.OpenAIImageOutputAuditor {
+ return service.OpenAIImageOutputAuditorFunc(func(ctx context.Context, req service.OpenAIImageOutputAuditRequest) (*service.ContentModerationDecision, error) {
+ if h == nil || h.contentModerationService == nil {
+ return &service.ContentModerationDecision{
+ Allowed: false,
+ Blocked: true,
+ Message: "Image safety review is temporarily unavailable",
+ StatusCode: http.StatusServiceUnavailable,
+ Action: service.ContentModerationActionError,
+ PolicyRule: "moderation_service_unavailable",
+ }, nil
+ }
+ model := ""
+ if parsed != nil {
+ model = parsed.Model
+ }
+ input := buildContentModerationInput(c, apiKey, subject, service.ContentModerationProtocolOpenAIImages, model, req.ModerationBody)
+ input.Stage = service.ContentModerationStageOutput
+ input.FailClosed = true
+ input.PolicyRule = strings.TrimSpace(req.PolicyRule)
+ input.SafetyIdentifier = strings.TrimSpace(safetyIdentifier)
+ input.UpstreamRequestID = strings.TrimSpace(req.UpstreamRequestID)
+ input.OutputHashes = append([]string(nil), req.OutputHashes...)
+ if strings.TrimSpace(req.UnavailableReason) != "" {
+ input.FailClosedStatusCode = http.StatusServiceUnavailable
+ input.FailClosedMessage = "Image safety review is temporarily unavailable"
+ input.FailClosedError = req.UnavailableReason
+ if input.PolicyRule == "" {
+ input.PolicyRule = "output_audit_unavailable"
+ }
+ }
+ return h.contentModerationService.Check(ctx, input)
+ })
+}
+
+func resultImageCount(result *service.OpenAIForwardResult) int {
+ if result == nil {
+ return 0
+ }
+ return result.ImageCount
+}
diff --git a/backend/internal/handler/openai_images_controls_test.go b/backend/internal/handler/openai_images_controls_test.go
new file mode 100644
index 00000000000..5f48bb57ad5
--- /dev/null
+++ b/backend/internal/handler/openai_images_controls_test.go
@@ -0,0 +1,126 @@
+package handler
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+func TestOpenAIGatewayHandlerImages_DisabledGroupRejectsBeforeScheduling(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ body := []byte(`{"model":"gpt-image-2","prompt":"draw","size":"1024x1024"}`)
+ req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+ groupID := int64(111)
+ c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
+ ID: 222,
+ GroupID: &groupID,
+ Group: &service.Group{
+ ID: groupID,
+ AllowImageGeneration: false,
+ },
+ User: &service.User{ID: 333},
+ })
+ c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 333, Concurrency: 1})
+
+ h := &OpenAIGatewayHandler{
+ gatewayService: &service.OpenAIGatewayService{},
+ billingCacheService: &service.BillingCacheService{},
+ apiKeyService: &service.APIKeyService{},
+ concurrencyHelper: &ConcurrencyHelper{concurrencyService: &service.ConcurrencyService{}},
+ }
+
+ h.Images(c)
+
+ require.Equal(t, http.StatusForbidden, rec.Code)
+ require.Equal(t, "permission_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
+ require.Contains(t, rec.Body.String(), service.ImageGenerationPermissionMessage())
+}
+
+func TestOpenAIGatewayHandlerImages_SafetyRequestGuardRejectsUnsafeParams(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ tests := []struct {
+ name string
+ body string
+ wantPolicy string
+ wantText string
+ }{
+ {
+ name: "stream",
+ body: `{"model":"gpt-image-2","prompt":"draw","stream":true}`,
+ wantPolicy: "blocked_image_stream",
+ wantText: "stream is disabled",
+ },
+ {
+ name: "partial images",
+ body: `{"model":"gpt-image-2","prompt":"draw","partial_images":1}`,
+ wantPolicy: "blocked_image_partial_images",
+ wantText: "partial_images is disabled",
+ },
+ {
+ name: "multi n",
+ body: `{"model":"gpt-image-2","prompt":"draw","n":2}`,
+ wantPolicy: "blocked_image_multi_n",
+ wantText: "n must be 1",
+ },
+ {
+ name: "moderation low",
+ body: `{"model":"gpt-image-2","prompt":"draw","moderation":"low"}`,
+ wantPolicy: "blocked_image_moderation_override",
+ wantText: "moderation must be auto",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader([]byte(tt.body)))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+ groupID := int64(111)
+ c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
+ ID: 222,
+ GroupID: &groupID,
+ Group: &service.Group{
+ ID: groupID,
+ AllowImageGeneration: true,
+ },
+ User: &service.User{ID: 333},
+ })
+ c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 333, Concurrency: 1})
+
+ repo := &contentModerationHandlerTestRepo{}
+ h := &OpenAIGatewayHandler{
+ gatewayService: &service.OpenAIGatewayService{},
+ billingCacheService: &service.BillingCacheService{},
+ apiKeyService: &service.APIKeyService{},
+ contentModerationService: service.NewContentModerationService(nil, repo, nil, nil, nil, nil, nil),
+ concurrencyHelper: &ConcurrencyHelper{concurrencyService: &service.ConcurrencyService{}},
+ }
+
+ h.Images(c)
+
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+ require.Equal(t, "invalid_request_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
+ require.Contains(t, rec.Body.String(), tt.wantText)
+ require.Len(t, repo.logs, 1)
+ require.Equal(t, tt.wantPolicy, repo.logs[0].PolicyRule)
+ require.True(t, repo.logs[0].Flagged)
+ require.Equal(t, service.ContentModerationActionBlock, repo.logs[0].Action)
+ require.NotEmpty(t, repo.logs[0].SafetyIdentifier)
+ })
+ }
+}
diff --git a/backend/internal/handler/openai_messages_context_guard.go b/backend/internal/handler/openai_messages_context_guard.go
new file mode 100644
index 00000000000..557dc235987
--- /dev/null
+++ b/backend/internal/handler/openai_messages_context_guard.go
@@ -0,0 +1,201 @@
+package handler
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
+ "go.uber.org/zap"
+)
+
+const (
+ openAIMessagesContextGuardrailMarker = "[HFC_LONG_CONTEXT_FILE_NAV_GUARD]"
+
+ openAIMessagesGuardrailMinReadToolUses = 2
+ openAIMessagesGuardrailMinReadToolResultSize = 96 * 1024
+ openAIMessagesGuardrailMinBodySize = 512 * 1024
+ openAIMessagesRiskLogPromptTokenThreshold = 100000
+)
+
+const openAIMessagesContextGuardrailText = openAIMessagesContextGuardrailMarker + ` Long-context file navigation guardrail: when using file tools, treat line numbers, byte offsets, and character offsets as different coordinate systems. Prefer explicit file paths plus line ranges. Before acting on a Read result, verify the latest returned file path and line range; do not reuse stale offsets from earlier failed reads.`
+
+type openAIMessagesContextRisk struct {
+ BodyBytes int
+ SystemBytes int
+ ToolsBytes int
+ MessageCount int
+ MessageTextBytes int
+ LargestMessageTextBytes int
+ ToolUseCount int
+ ReadToolUseCount int
+ ToolResultCount int
+ ReadToolResultCount int
+ ToolResultBytes int
+ ReadToolResultBytes int
+ LargestReadToolResultBytes int
+ LargestReadFilePath string
+}
+
+func analyzeOpenAIMessagesContextRisk(body []byte) openAIMessagesContextRisk {
+ diag := openAIMessagesContextRisk{
+ BodyBytes: len(body),
+ SystemBytes: jsonTextBytes(gjson.GetBytes(body, "system")),
+ ToolsBytes: len(gjson.GetBytes(body, "tools").Raw),
+ }
+ readToolPathsByID := make(map[string]string)
+
+ gjson.GetBytes(body, "messages").ForEach(func(_, message gjson.Result) bool {
+ diag.MessageCount++
+ content := message.Get("content")
+ if content.Type == gjson.String {
+ n := len(content.String())
+ diag.MessageTextBytes += n
+ if n > diag.LargestMessageTextBytes {
+ diag.LargestMessageTextBytes = n
+ }
+ return true
+ }
+ if !content.IsArray() {
+ return true
+ }
+ content.ForEach(func(_, block gjson.Result) bool {
+ switch block.Get("type").String() {
+ case "text":
+ n := len(block.Get("text").String())
+ diag.MessageTextBytes += n
+ if n > diag.LargestMessageTextBytes {
+ diag.LargestMessageTextBytes = n
+ }
+ case "tool_use":
+ diag.ToolUseCount++
+ if isReadToolName(block.Get("name").String()) {
+ diag.ReadToolUseCount++
+ if id := strings.TrimSpace(block.Get("id").String()); id != "" {
+ readToolPathsByID[id] = strings.TrimSpace(block.Get("input.file_path").String())
+ }
+ }
+ case "tool_result":
+ diag.ToolResultCount++
+ size := jsonTextBytes(block.Get("content"))
+ diag.ToolResultBytes += size
+ if path, ok := readToolPathsByID[strings.TrimSpace(block.Get("tool_use_id").String())]; ok {
+ diag.ReadToolResultCount++
+ diag.ReadToolResultBytes += size
+ if size > diag.LargestReadToolResultBytes {
+ diag.LargestReadToolResultBytes = size
+ diag.LargestReadFilePath = path
+ }
+ }
+ }
+ return true
+ })
+ return true
+ })
+
+ return diag
+}
+
+func (d openAIMessagesContextRisk) needsFileNavigationGuardrail() bool {
+ if d.ReadToolUseCount >= openAIMessagesGuardrailMinReadToolUses && d.ReadToolResultBytes >= openAIMessagesGuardrailMinReadToolResultSize {
+ return true
+ }
+ if d.LargestReadToolResultBytes >= openAIMessagesGuardrailMinReadToolResultSize {
+ return true
+ }
+ if d.BodyBytes >= openAIMessagesGuardrailMinBodySize && d.ReadToolUseCount > 0 {
+ return true
+ }
+ return false
+}
+
+func (d openAIMessagesContextRisk) shouldLog(promptTotalTokens int, guardrailInjected bool) bool {
+ return guardrailInjected ||
+ promptTotalTokens >= openAIMessagesRiskLogPromptTokenThreshold ||
+ d.needsFileNavigationGuardrail()
+}
+
+func (d openAIMessagesContextRisk) zapFields(promptTotalTokens int, guardrailInjected bool) []zap.Field {
+ return []zap.Field{
+ zap.Int("prompt_total_tokens", promptTotalTokens),
+ zap.Bool("long_context_guardrail_injected", guardrailInjected),
+ zap.Int("body_bytes", d.BodyBytes),
+ zap.Int("system_bytes", d.SystemBytes),
+ zap.Int("tools_bytes", d.ToolsBytes),
+ zap.Int("message_count", d.MessageCount),
+ zap.Int("message_text_bytes", d.MessageTextBytes),
+ zap.Int("largest_message_text_bytes", d.LargestMessageTextBytes),
+ zap.Int("tool_use_count", d.ToolUseCount),
+ zap.Int("read_tool_use_count", d.ReadToolUseCount),
+ zap.Int("tool_result_count", d.ToolResultCount),
+ zap.Int("read_tool_result_count", d.ReadToolResultCount),
+ zap.Int("tool_result_bytes", d.ToolResultBytes),
+ zap.Int("read_tool_result_bytes", d.ReadToolResultBytes),
+ zap.Int("largest_read_tool_result_bytes", d.LargestReadToolResultBytes),
+ zap.String("largest_read_file_path", d.LargestReadFilePath),
+ }
+}
+
+func applyOpenAIMessagesContextGuardrail(body []byte, diag openAIMessagesContextRisk) ([]byte, bool, error) {
+ if !diag.needsFileNavigationGuardrail() {
+ return body, false, nil
+ }
+ if strings.Contains(string(body), openAIMessagesContextGuardrailMarker) {
+ return body, false, nil
+ }
+
+ system := gjson.GetBytes(body, "system")
+ if !system.Exists() {
+ updated, err := sjson.SetBytes(body, "system", openAIMessagesContextGuardrailText)
+ return updated, err == nil, err
+ }
+
+ switch system.Type {
+ case gjson.String:
+ updated, err := sjson.SetBytes(body, "system", strings.TrimSpace(system.String())+"\n\n"+openAIMessagesContextGuardrailText)
+ return updated, err == nil, err
+ case gjson.JSON:
+ if system.IsArray() {
+ updated, err := sjson.SetBytes(body, "system.-1", map[string]string{
+ "type": "text",
+ "text": openAIMessagesContextGuardrailText,
+ })
+ return updated, err == nil, err
+ }
+ }
+
+ return body, false, fmt.Errorf("unsupported anthropic system field for context guardrail: %s", system.Type.String())
+}
+
+func jsonTextBytes(v gjson.Result) int {
+ switch {
+ case !v.Exists():
+ return 0
+ case v.Type == gjson.String:
+ return len(v.String())
+ case v.IsArray():
+ total := 0
+ v.ForEach(func(_, item gjson.Result) bool {
+ if item.Type == gjson.String {
+ total += len(item.String())
+ return true
+ }
+ if text := item.Get("text"); text.Type == gjson.String {
+ total += len(text.String())
+ return true
+ }
+ if content := item.Get("content"); content.Exists() {
+ total += jsonTextBytes(content)
+ }
+ return true
+ })
+ return total
+ default:
+ return len(v.Raw)
+ }
+}
+
+func isReadToolName(name string) bool {
+ normalized := strings.ToLower(strings.TrimSpace(name))
+ return normalized == "read" || normalized == "read_file" || normalized == "readfile"
+}
diff --git a/backend/internal/handler/openai_messages_context_guard_test.go b/backend/internal/handler/openai_messages_context_guard_test.go
new file mode 100644
index 00000000000..339ab0bc4b2
--- /dev/null
+++ b/backend/internal/handler/openai_messages_context_guard_test.go
@@ -0,0 +1,61 @@
+package handler
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+func TestAnalyzeOpenAIMessagesContextRiskDetectsReadLoopContributors(t *testing.T) {
+ body := []byte(`{
+ "model":"claude-opus-4-7",
+ "system":"You are Claude Code.",
+ "tools":[{"name":"Read","input_schema":{"type":"object"}}],
+ "messages":[
+ {"role":"assistant","content":[{"type":"tool_use","id":"toolu_read_1","name":"Read","input":{"file_path":"/repo/results.md","offset":300}}]},
+ {"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_read_1","content":"` + strings.Repeat("line\\n", 30000) + `"}]},
+ {"role":"assistant","content":[{"type":"tool_use","id":"toolu_read_2","name":"Read","input":{"file_path":"/repo/results.md","offset":600}}]},
+ {"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_read_2","content":"short"}]},
+ {"role":"assistant","content":[{"type":"tool_use","id":"toolu_bash_1","name":"Bash","input":{"command":"pwd"}}]}
+ ]
+ }`)
+
+ diag := analyzeOpenAIMessagesContextRisk(body)
+
+ require.Equal(t, 5, diag.MessageCount)
+ require.Equal(t, 3, diag.ToolUseCount)
+ require.Equal(t, 2, diag.ReadToolUseCount)
+ require.Equal(t, 2, diag.ToolResultCount)
+ require.Equal(t, 2, diag.ReadToolResultCount)
+ require.Equal(t, "/repo/results.md", diag.LargestReadFilePath)
+ require.GreaterOrEqual(t, diag.LargestReadToolResultBytes, 120000)
+ require.True(t, diag.needsFileNavigationGuardrail())
+}
+
+func TestApplyOpenAIMessagesContextGuardrailAppendsSystemOnce(t *testing.T) {
+ body := []byte(`{
+ "model":"claude-opus-4-7",
+ "system":[{"type":"text","text":"Existing Claude Code system."}],
+ "messages":[
+ {"role":"assistant","content":[{"type":"tool_use","id":"toolu_read_1","name":"Read","input":{"file_path":"/repo/results.md","offset":300}}]},
+ {"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_read_1","content":"` + strings.Repeat("line\\n", 30000) + `"}]}
+ ]
+ }`)
+
+ diag := analyzeOpenAIMessagesContextRisk(body)
+ guarded, injected, err := applyOpenAIMessagesContextGuardrail(body, diag)
+
+ require.NoError(t, err)
+ require.True(t, injected)
+ require.True(t, gjson.GetBytes(guarded, "system").IsArray())
+ require.Contains(t, string(guarded), openAIMessagesContextGuardrailMarker)
+ require.Contains(t, string(guarded), "line numbers")
+ require.Contains(t, string(guarded), "byte offsets")
+
+ guardedAgain, injectedAgain, err := applyOpenAIMessagesContextGuardrail(guarded, analyzeOpenAIMessagesContextRisk(guarded))
+ require.NoError(t, err)
+ require.False(t, injectedAgain)
+ require.Equal(t, string(guarded), string(guardedAgain))
+}
diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go
index 935549121bb..915a3d5475c 100644
--- a/backend/internal/handler/ops_error_logger.go
+++ b/backend/internal/handler/ops_error_logger.go
@@ -1064,6 +1064,10 @@ func resolveOpsPlatform(apiKey *service.APIKey, fallback string) string {
func guessPlatformFromPath(path string) string {
p := strings.ToLower(path)
switch {
+ case strings.HasPrefix(p, "/cursor/"):
+ return service.PlatformCursor
+ case strings.HasPrefix(p, "/kiro/"):
+ return service.PlatformKiro
case strings.HasPrefix(p, "/antigravity/"):
return service.PlatformAntigravity
case strings.HasPrefix(p, "/v1beta/"):
diff --git a/backend/internal/handler/page_handler.go b/backend/internal/handler/page_handler.go
new file mode 100644
index 00000000000..7d4d5078490
--- /dev/null
+++ b/backend/internal/handler/page_handler.go
@@ -0,0 +1,283 @@
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/response"
+ middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+)
+
+var validSlugPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
+
+const maxPageFileSize = 1 << 20 // 1MB
+
+type PageHandler struct {
+ pagesDir string
+ settingService *service.SettingService
+}
+
+func NewPageHandler(dataDir string, settingService *service.SettingService) *PageHandler {
+ pagesDir := filepath.Join(dataDir, "pages")
+ _ = os.MkdirAll(pagesDir, 0755)
+ return &PageHandler{pagesDir: pagesDir, settingService: settingService}
+}
+
+// GetPageContent serves raw markdown content for a given slug.
+// GET /api/v1/pages/:slug
+func (h *PageHandler) GetPageContent(c *gin.Context) {
+ slug := c.Param("slug")
+ if !validSlugPattern.MatchString(slug) || len(slug) > 64 {
+ response.BadRequest(c, "Invalid page slug")
+ return
+ }
+
+ // Visibility check: slug must be configured in custom_menu_items
+ // and the user must have permission based on visibility setting
+ if !h.checkSlugVisibility(c, slug) {
+ c.JSON(http.StatusNotFound, gin.H{"error": "page not found"})
+ return
+ }
+
+ filePath := filepath.Join(h.pagesDir, slug+".md")
+ cleaned := filepath.Clean(filePath)
+ if !strings.HasPrefix(cleaned, filepath.Clean(h.pagesDir)) {
+ response.BadRequest(c, "Invalid page slug")
+ return
+ }
+
+ info, err := os.Stat(cleaned)
+ if err != nil || info.IsDir() {
+ c.JSON(http.StatusNotFound, gin.H{"error": "page not found"})
+ return
+ }
+ if info.Size() > maxPageFileSize {
+ c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "page too large"})
+ return
+ }
+
+ content, err := os.ReadFile(cleaned)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read page"})
+ return
+ }
+
+ c.Data(http.StatusOK, "text/markdown; charset=utf-8", content)
+}
+
+// ListPages returns available page slugs.
+// GET /api/v1/pages
+func (h *PageHandler) ListPages(c *gin.Context) {
+ entries, err := os.ReadDir(h.pagesDir)
+ if err != nil {
+ response.Success(c, []string{})
+ return
+ }
+
+ slugs := make([]string, 0, len(entries))
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ name := e.Name()
+ if strings.HasSuffix(name, ".md") {
+ slugs = append(slugs, strings.TrimSuffix(name, ".md"))
+ }
+ }
+ response.Success(c, slugs)
+}
+
+// ServePageImage serves images from data/pages/{slug}/ directory.
+// GET /api/v1/pages/:slug/images/*filename
+// No JWT required (browser img tags can't carry tokens), but visibility is checked.
+func (h *PageHandler) ServePageImage(c *gin.Context) {
+ slug := c.Param("slug")
+ filename := c.Param("filename")
+ filename = strings.TrimPrefix(filename, "/")
+
+ if !validSlugPattern.MatchString(slug) || len(slug) > 64 {
+ c.Status(http.StatusNotFound)
+ return
+ }
+
+ if !h.checkImageSlugVisibility(c, slug) {
+ c.Status(http.StatusNotFound)
+ return
+ }
+
+ imagesDir := filepath.Join(h.pagesDir, slug)
+ cleaned, ok := resolvePageImagePath(h.pagesDir, imagesDir, filename)
+ if !ok {
+ c.Status(http.StatusNotFound)
+ return
+ }
+
+ info, err := os.Stat(cleaned)
+ if err != nil || info.IsDir() {
+ c.Status(http.StatusNotFound)
+ return
+ }
+
+ c.File(cleaned)
+}
+
+func resolvePageImagePath(pagesDir, imagesDir, filename string) (string, bool) {
+ relPath, ok := cleanPageImageRelativePath(filename)
+ if !ok {
+ return "", false
+ }
+
+ cleanedPagesDir := filepath.Clean(pagesDir)
+ cleanedImagesDir := filepath.Clean(imagesDir)
+ cleanedTarget := filepath.Clean(filepath.Join(cleanedImagesDir, relPath))
+ if !isPathWithinBase(cleanedTarget, cleanedImagesDir) {
+ return "", false
+ }
+
+ realPagesDir, err := filepath.EvalSymlinks(cleanedPagesDir)
+ if err != nil {
+ return "", false
+ }
+ realImagesDir, err := filepath.EvalSymlinks(cleanedImagesDir)
+ if err != nil || !isPathWithinBase(realImagesDir, realPagesDir) {
+ return "", false
+ }
+ realTarget, err := filepath.EvalSymlinks(cleanedTarget)
+ if err != nil || !isPathWithinBase(realTarget, realImagesDir) {
+ return "", false
+ }
+ return realTarget, true
+}
+
+func cleanPageImageRelativePath(filename string) (string, bool) {
+ if filename == "" {
+ return "", false
+ }
+ if strings.HasPrefix(filename, "/") {
+ return "", false
+ }
+ decoded, err := url.PathUnescape(filename)
+ if err != nil {
+ return "", false
+ }
+ if decoded == "" || strings.HasPrefix(decoded, "/") || strings.Contains(decoded, "\\") || strings.ContainsRune(decoded, 0) {
+ return "", false
+ }
+
+ parts := make([]string, 0)
+ for _, part := range strings.Split(decoded, "/") {
+ switch part {
+ case "", ".":
+ continue
+ case "..":
+ return "", false
+ default:
+ parts = append(parts, part)
+ }
+ }
+ if len(parts) == 0 {
+ return "", false
+ }
+
+ relPath := filepath.Join(parts...)
+ if filepath.IsAbs(relPath) || filepath.VolumeName(relPath) != "" {
+ return "", false
+ }
+ return relPath, true
+}
+
+func isPathWithinBase(path, base string) bool {
+ rel, err := filepath.Rel(filepath.Clean(base), filepath.Clean(path))
+ if err != nil {
+ return false
+ }
+ return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
+}
+
+// findSlugVisibility looks up the slug in custom_menu_items and returns (visibility, found).
+func (h *PageHandler) findSlugVisibility(c *gin.Context, slug string) (string, bool) {
+ if h.settingService == nil {
+ return "", false
+ }
+
+ raw := h.settingService.GetCustomMenuItemsRaw(c.Request.Context())
+ if raw == "" || raw == "[]" {
+ return "", false
+ }
+
+ var items []struct {
+ URL string `json:"url"`
+ PageSlug string `json:"page_slug"`
+ Visibility string `json:"visibility"`
+ }
+ if err := json.Unmarshal([]byte(raw), &items); err != nil {
+ return "", false
+ }
+
+ for _, item := range items {
+ itemSlug := item.PageSlug
+ if itemSlug == "" && strings.HasPrefix(item.URL, "md:") {
+ itemSlug = strings.TrimPrefix(item.URL, "md:")
+ }
+ if itemSlug == slug {
+ return item.Visibility, true
+ }
+ }
+ return "", false
+}
+
+// checkSlugVisibility verifies the slug is configured in custom_menu_items
+// and the authenticated user has permission to view it.
+func (h *PageHandler) checkSlugVisibility(c *gin.Context, slug string) bool {
+ visibility, found := h.findSlugVisibility(c, slug)
+ if !found {
+ return false
+ }
+ if visibility == "admin" {
+ role, _ := middleware2.GetUserRoleFromContext(c)
+ return role == "admin"
+ }
+ return true
+}
+
+// checkImageSlugVisibility checks visibility for image requests (no JWT available).
+// Only allows user-visible pages; admin-only pages are blocked.
+func (h *PageHandler) checkImageSlugVisibility(c *gin.Context, slug string) bool {
+ visibility, found := h.findSlugVisibility(c, slug)
+ if !found {
+ return false
+ }
+ return visibility != "admin"
+}
+
+// RegisterPageRoutes registers page routes on a router group.
+func RegisterPageRoutes(v1 *gin.RouterGroup, dataDir string, jwtAuth gin.HandlerFunc, adminAuth gin.HandlerFunc, settingService *service.SettingService) {
+ h := NewPageHandler(dataDir, settingService)
+
+ // Authenticated page content (JWT required + visibility check)
+ pages := v1.Group("/pages")
+ pages.Use(jwtAuth)
+ {
+ pages.GET("/:slug", h.GetPageContent)
+ }
+
+ // Images: no JWT (browser img tags can't carry tokens), visibility check in handler
+ pageImages := v1.Group("/pages")
+ {
+ pageImages.GET("/:slug/images/*filename", h.ServePageImage)
+ }
+
+ // Admin-only: list all available pages
+ adminPages := v1.Group("/pages")
+ adminPages.Use(adminAuth)
+ {
+ adminPages.GET("", h.ListPages)
+ }
+}
diff --git a/backend/internal/handler/page_handler_test.go b/backend/internal/handler/page_handler_test.go
new file mode 100644
index 00000000000..d5f6e7d837c
--- /dev/null
+++ b/backend/internal/handler/page_handler_test.go
@@ -0,0 +1,108 @@
+package handler
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestCleanPageImageRelativePath(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ want string
+ ok bool
+ }{
+ {name: "single filename", in: "logo.png", want: "logo.png", ok: true},
+ {name: "nested path", in: "images/logo.png", want: filepath.Join("images", "logo.png"), ok: true},
+ {name: "dot prefix", in: "./logo.png", want: "logo.png", ok: true},
+ {name: "url escaped slash", in: "images%2Flogo.png", want: filepath.Join("images", "logo.png"), ok: true},
+ {name: "parent traversal", in: "../secret.png", ok: false},
+ {name: "encoded parent traversal", in: "%2e%2e/secret.png", ok: false},
+ {name: "backslash traversal", in: `images\secret.png`, ok: false},
+ {name: "absolute path", in: "/etc/passwd", ok: false},
+ {name: "encoded absolute path", in: "%2fetc/passwd", ok: false},
+ {name: "encoded nul byte", in: "logo.png%00", ok: false},
+ {name: "invalid escape", in: "logo.png%zz", ok: false},
+ {name: "empty path", in: "", ok: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, ok := cleanPageImageRelativePath(tt.in)
+ if ok != tt.ok {
+ t.Fatalf("ok = %v, want %v", ok, tt.ok)
+ }
+ if got != tt.want {
+ t.Fatalf("path = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestResolvePageImagePath(t *testing.T) {
+ root := t.TempDir()
+ pagesDir := filepath.Join(root, "pages")
+ base := filepath.Join(pagesDir, "guide")
+ if err := os.MkdirAll(filepath.Join(base, "images"), 0755); err != nil {
+ t.Fatalf("create images dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(base, "logo.png"), []byte("fake"), 0644); err != nil {
+ t.Fatalf("create direct image: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(base, "images", "logo.png"), []byte("fake"), 0644); err != nil {
+ t.Fatalf("create image: %v", err)
+ }
+
+ got, ok := resolvePageImagePath(pagesDir, base, "logo.png")
+ if !ok {
+ t.Fatal("expected direct image path to be accepted")
+ }
+ want, err := filepath.EvalSymlinks(filepath.Join(base, "logo.png"))
+ if err != nil {
+ t.Fatalf("resolve expected direct image path: %v", err)
+ }
+ if got != want {
+ t.Fatalf("path = %q, want %q", got, want)
+ }
+
+ got, ok = resolvePageImagePath(pagesDir, base, "images/logo.png")
+ if !ok {
+ t.Fatal("expected nested image path to be accepted")
+ }
+ want, err = filepath.EvalSymlinks(filepath.Join(base, "images", "logo.png"))
+ if err != nil {
+ t.Fatalf("resolve expected nested image path: %v", err)
+ }
+ if got != want {
+ t.Fatalf("path = %q, want %q", got, want)
+ }
+
+ if got, ok := resolvePageImagePath(pagesDir, base, "../guide.md"); ok {
+ t.Fatalf("expected traversal to be rejected, got %q", got)
+ }
+}
+
+func TestResolvePageImagePathRejectsSymlinkEscape(t *testing.T) {
+ root := t.TempDir()
+ pagesDir := filepath.Join(root, "pages")
+ base := filepath.Join(pagesDir, "guide")
+ outside := filepath.Join(root, "outside")
+
+ if err := os.MkdirAll(base, 0755); err != nil {
+ t.Fatalf("create page dir: %v", err)
+ }
+ if err := os.MkdirAll(outside, 0755); err != nil {
+ t.Fatalf("create outside dir: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(outside, "secret.png"), []byte("secret"), 0644); err != nil {
+ t.Fatalf("create outside file: %v", err)
+ }
+ if err := os.Symlink(outside, filepath.Join(base, "images")); err != nil {
+ t.Skipf("symlink not supported: %v", err)
+ }
+
+ if got, ok := resolvePageImagePath(pagesDir, base, "images/secret.png"); ok {
+ t.Fatalf("expected symlink escape to be rejected, got %q", got)
+ }
+}
diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go
index 09580442d74..c2e32088eb0 100644
--- a/backend/internal/handler/payment_handler.go
+++ b/backend/internal/handler/payment_handler.go
@@ -1,7 +1,10 @@
package handler
import (
+ "context"
+ "encoding/json"
"fmt"
+ "math"
"strconv"
"strings"
"time"
@@ -22,14 +25,16 @@ type PaymentHandler struct {
channelService *service.ChannelService
paymentService *service.PaymentService
configService *service.PaymentConfigService
+ settingService *service.SettingService
}
// NewPaymentHandler creates a new PaymentHandler.
-func NewPaymentHandler(paymentService *service.PaymentService, configService *service.PaymentConfigService, channelService *service.ChannelService) *PaymentHandler {
+func NewPaymentHandler(paymentService *service.PaymentService, configService *service.PaymentConfigService, channelService *service.ChannelService, settingService *service.SettingService) *PaymentHandler {
return &PaymentHandler{
channelService: channelService,
paymentService: paymentService,
configService: configService,
+ settingService: settingService,
}
}
@@ -52,27 +57,33 @@ func (h *PaymentHandler) GetPlans(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
- // Enrich plans with group platform for frontend color coding
+ // Enrich plans with group platform for frontend color coding.
+ // 钱包模式 (v4) plan: group_id=null, 前端按 wallet_quota_usd 渲染。
type planWithPlatform struct {
- ID int64 `json:"id"`
- GroupID int64 `json:"group_id"`
- GroupPlatform string `json:"group_platform"`
- Name string `json:"name"`
- Description string `json:"description"`
- Price float64 `json:"price"`
- OriginalPrice *float64 `json:"original_price,omitempty"`
- ValidityDays int `json:"validity_days"`
- ValidityUnit string `json:"validity_unit"`
- Features string `json:"features"`
- ProductName string `json:"product_name"`
- ForSale bool `json:"for_sale"`
- SortOrder int `json:"sort_order"`
+ ID int64 `json:"id"`
+ GroupID *int64 `json:"group_id"`
+ GroupPlatform string `json:"group_platform"`
+ WalletQuotaUSD *float64 `json:"wallet_quota_usd,omitempty"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Price float64 `json:"price"`
+ OriginalPrice *float64 `json:"original_price,omitempty"`
+ ValidityDays int `json:"validity_days"`
+ ValidityUnit string `json:"validity_unit"`
+ Features string `json:"features"`
+ ProductName string `json:"product_name"`
+ ForSale bool `json:"for_sale"`
+ SortOrder int `json:"sort_order"`
}
platformMap := h.configService.GetGroupPlatformMap(c.Request.Context(), plans)
result := make([]planWithPlatform, 0, len(plans))
for _, p := range plans {
+ platform := ""
+ if p.GroupID != nil {
+ platform = platformMap[*p.GroupID]
+ }
result = append(result, planWithPlatform{
- ID: int64(p.ID), GroupID: p.GroupID, GroupPlatform: platformMap[p.GroupID],
+ ID: int64(p.ID), GroupID: p.GroupID, GroupPlatform: platform, WalletQuotaUSD: p.WalletQuotaUsd,
Name: p.Name, Description: p.Description, Price: p.Price, OriginalPrice: p.OriginalPrice,
ValidityDays: p.ValidityDays, ValidityUnit: p.ValidityUnit, Features: p.Features,
ProductName: p.ProductName, ForSale: p.ForSale, SortOrder: p.SortOrder,
@@ -117,9 +128,13 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) {
groupInfo := h.configService.GetGroupInfoMap(ctx, plans)
planList := make([]checkoutPlan, 0, len(plans))
for _, p := range plans {
- gi := groupInfo[p.GroupID]
+ // 钱包模式 (v4) plan: GroupID=nil, 单 group 信息留空, 前端走 WalletQuotaUSD 渲染。
+ var gi service.PlanGroupInfo
+ if p.GroupID != nil {
+ gi = groupInfo[*p.GroupID]
+ }
planList = append(planList, checkoutPlan{
- ID: int64(p.ID), GroupID: p.GroupID,
+ ID: int64(p.ID), GroupID: p.GroupID, WalletQuotaUSD: p.WalletQuotaUsd,
GroupPlatform: gi.Platform, GroupName: gi.Name,
RateMultiplier: gi.RateMultiplier, DailyLimitUSD: gi.DailyLimitUSD,
WeeklyLimitUSD: gi.WeeklyLimitUSD, MonthlyLimitUSD: gi.MonthlyLimitUSD,
@@ -159,7 +174,8 @@ type checkoutInfoResponse struct {
type checkoutPlan struct {
ID int64 `json:"id"`
- GroupID int64 `json:"group_id"`
+ GroupID *int64 `json:"group_id"`
+ WalletQuotaUSD *float64 `json:"wallet_quota_usd,omitempty"`
GroupPlatform string `json:"group_platform"`
GroupName string `json:"group_name"`
RateMultiplier float64 `json:"rate_multiplier"`
@@ -215,6 +231,8 @@ type CreateOrderRequest struct {
PaymentSource string `json:"payment_source"`
OrderType string `json:"order_type"`
PlanID int64 `json:"plan_id"`
+ ResumeTokenUserID int64 `json:"-"`
+ ResumeTokenJTI string `json:"-"`
// IsMobile lets the frontend declare its mobile status directly. When
// nil we fall back to User-Agent heuristics (which miss iPadOS / some
// embedded browsers that strip the "Mobile" keyword).
@@ -240,7 +258,7 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
- if err := applyWeChatPaymentResumeClaims(&req, claims); err != nil {
+ if err := applyWeChatPaymentResumeClaims(&req, claims, subject.UserID); err != nil {
response.ErrorFrom(c, err)
return
}
@@ -250,21 +268,27 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
if req.IsMobile != nil {
mobile = *req.IsMobile
}
- result, err := h.paymentService.CreateOrder(c.Request.Context(), service.CreateOrderRequest{
- UserID: subject.UserID,
- Amount: req.Amount,
- PaymentType: req.PaymentType,
- OpenID: req.OpenID,
- ClientIP: c.ClientIP(),
- IsMobile: mobile,
- IsWeChatBrowser: isWeChatBrowser(c),
- SrcHost: c.Request.Host,
- SrcURL: c.Request.Referer(),
- ReturnURL: req.ReturnURL,
- PaymentSource: req.PaymentSource,
- OrderType: req.OrderType,
- PlanID: req.PlanID,
- })
+ serviceReq := service.CreateOrderRequest{
+ UserID: subject.UserID,
+ ResumeTokenUserID: req.ResumeTokenUserID,
+ ResumeTokenJTI: req.ResumeTokenJTI,
+ Amount: req.Amount,
+ PaymentType: req.PaymentType,
+ OpenID: req.OpenID,
+ ClientIP: c.ClientIP(),
+ IsMobile: mobile,
+ IsWeChatBrowser: isWeChatBrowser(c),
+ SrcHost: c.Request.Host,
+ SrcURL: c.Request.Referer(),
+ ReturnURL: req.ReturnURL,
+ PaymentSource: req.PaymentSource,
+ OrderType: req.OrderType,
+ PlanID: req.PlanID,
+ }
+ if h.settingService != nil {
+ serviceReq.TrustedFrontendURL = h.settingService.GetFrontendURL(c.Request.Context())
+ }
+ result, err := executeWeChatResumeOrderCreate(c.Request.Context(), service.DefaultIdempotencyCoordinator(), serviceReq, h.paymentService.CreateOrder)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -272,10 +296,17 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
response.Success(c, result)
}
-func applyWeChatPaymentResumeClaims(req *CreateOrderRequest, claims *service.WeChatPaymentResumeClaims) error {
+func applyWeChatPaymentResumeClaims(req *CreateOrderRequest, claims *service.WeChatPaymentResumeClaims, authenticatedUserID int64) error {
if req == nil || claims == nil {
return infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume context is missing")
}
+ if authenticatedUserID <= 0 || claims.UserID <= 0 || claims.UserID != authenticatedUserID {
+ return infraerrors.Forbidden("WECHAT_PAYMENT_RESUME_USER_MISMATCH", "wechat payment resume token belongs to a different user")
+ }
+ jti := strings.TrimSpace(claims.JTI)
+ if jti == "" {
+ return infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token missing replay binding")
+ }
openid := strings.TrimSpace(claims.OpenID)
if openid == "" {
return infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token missing openid")
@@ -296,20 +327,113 @@ func applyWeChatPaymentResumeClaims(req *CreateOrderRequest, claims *service.WeC
if strings.TrimSpace(claims.Amount) != "" {
amount, err := strconv.ParseFloat(strings.TrimSpace(claims.Amount), 64)
- if err != nil || amount <= 0 {
+ if err != nil || math.IsNaN(amount) || math.IsInf(amount, 0) || amount <= 0 {
return infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", fmt.Sprintf("invalid resume amount: %s", claims.Amount))
}
req.Amount = amount
}
if claims.OrderType != "" {
- req.OrderType = claims.OrderType
+ switch claims.OrderType {
+ case payment.OrderTypeBalance, payment.OrderTypeSubscription:
+ req.OrderType = claims.OrderType
+ default:
+ return infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token order type is invalid")
+ }
}
if claims.PlanID > 0 {
req.PlanID = claims.PlanID
}
+ req.PaymentSource = service.PaymentSourceWechatInAppResume
+ req.ResumeTokenUserID = claims.UserID
+ req.ResumeTokenJTI = jti
return nil
}
+type wechatResumeOrderFingerprint struct {
+ UserID int64 `json:"user_id"`
+ Amount float64 `json:"amount"`
+ PaymentType string `json:"payment_type"`
+ OpenID string `json:"openid"`
+ ReturnURL string `json:"return_url"`
+ PaymentSource string `json:"payment_source"`
+ OrderType string `json:"order_type"`
+ PlanID int64 `json:"plan_id"`
+}
+
+func executeWeChatResumeOrderCreate(
+ ctx context.Context,
+ coordinator *service.IdempotencyCoordinator,
+ req service.CreateOrderRequest,
+ execute func(context.Context, service.CreateOrderRequest) (*service.CreateOrderResponse, error),
+) (*service.CreateOrderResponse, error) {
+ if strings.TrimSpace(req.ResumeTokenJTI) == "" {
+ return execute(ctx, req)
+ }
+ if coordinator == nil {
+ service.RecordIdempotencyStoreUnavailable("/api/v1/payment/orders", service.WeChatPaymentResumeIdempotencyScope, "coordinator_nil")
+ return nil, service.ErrIdempotencyStoreUnavail
+ }
+
+ var fresh *service.CreateOrderResponse
+ result, err := coordinator.Execute(ctx, service.IdempotencyExecuteOptions{
+ Scope: service.WeChatPaymentResumeIdempotencyScope,
+ ActorScope: "user:" + strconv.FormatInt(req.UserID, 10),
+ Method: "POST",
+ Route: "/api/v1/payment/orders",
+ IdempotencyKey: req.ResumeTokenJTI,
+ Payload: wechatResumeOrderFingerprint{
+ UserID: req.UserID,
+ Amount: req.Amount,
+ PaymentType: req.PaymentType,
+ OpenID: req.OpenID,
+ ReturnURL: req.ReturnURL,
+ PaymentSource: req.PaymentSource,
+ OrderType: req.OrderType,
+ PlanID: req.PlanID,
+ },
+ RequireKey: true,
+ TTL: 30 * time.Minute,
+ ProcessingTimeout: 20 * time.Minute,
+ FailedRetryBackoff: 20 * time.Minute,
+ }, func(execCtx context.Context) (any, error) {
+ var execErr error
+ fresh, execErr = execute(execCtx, req)
+ if execErr != nil {
+ return nil, execErr
+ }
+ if fresh == nil || fresh.OrderID <= 0 {
+ return nil, infraerrors.InternalServer("WECHAT_PAYMENT_RESUME_RESULT_INVALID", "wechat payment resume did not create an order")
+ }
+ return fresh, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ if result != nil && result.Replayed {
+ replayed, replayErr := decodeReplayedCreateOrderResponse(result.Data)
+ if replayErr != nil {
+ return nil, replayErr
+ }
+ return replayed, nil
+ }
+ if fresh == nil {
+ return nil, infraerrors.InternalServer("WECHAT_PAYMENT_RESUME_RESULT_INVALID", "wechat payment resume result is unavailable")
+ }
+ return fresh, nil
+}
+
+func decodeReplayedCreateOrderResponse(data any) (*service.CreateOrderResponse, error) {
+ raw, err := json.Marshal(data)
+ if err != nil {
+ return nil, infraerrors.InternalServer("WECHAT_PAYMENT_RESUME_REPLAY_INVALID", "wechat payment resume replay is unavailable").WithCause(err)
+ }
+ var responseData service.CreateOrderResponse
+ if err := json.Unmarshal(raw, &responseData); err != nil || responseData.OrderID <= 0 {
+ return nil, infraerrors.InternalServer("WECHAT_PAYMENT_RESUME_REPLAY_INVALID", "wechat payment resume replay is unavailable").WithCause(err)
+ }
+ return &responseData, nil
+}
+
// GetMyOrders returns the authenticated user's orders.
// GET /api/v1/payment/orders/my
func (h *PaymentHandler) GetMyOrders(c *gin.Context) {
@@ -454,60 +578,45 @@ func (h *PaymentHandler) VerifyOrder(c *gin.Context) {
// PublicOrderResult is the limited order info returned by the public verify endpoint.
// No user details are exposed — only payment status information.
type PublicOrderResult struct {
- ID int64 `json:"id"`
- OutTradeNo string `json:"out_trade_no"`
- Amount float64 `json:"amount"`
- PayAmount float64 `json:"pay_amount"`
- FeeRate float64 `json:"fee_rate"`
- PaymentType string `json:"payment_type"`
- OrderType string `json:"order_type"`
- Status string `json:"status"`
- CreatedAt time.Time `json:"created_at"`
- ExpiresAt time.Time `json:"expires_at"`
- PaidAt *time.Time `json:"paid_at,omitempty"`
- CompletedAt *time.Time `json:"completed_at,omitempty"`
- RefundAmount float64 `json:"refund_amount"`
- RefundReason *string `json:"refund_reason,omitempty"`
- RefundRequestedAt *time.Time `json:"refund_requested_at,omitempty"`
- RefundRequestedBy *string `json:"refund_requested_by,omitempty"`
- RefundRequestReason *string `json:"refund_request_reason,omitempty"`
- PlanID *int64 `json:"plan_id,omitempty"`
+ ID int64 `json:"id"`
+ OutTradeNo string `json:"out_trade_no"`
+ Amount float64 `json:"amount"`
+ PayAmount float64 `json:"pay_amount"`
+ FeeRate float64 `json:"fee_rate"`
+ PaymentType string `json:"payment_type"`
+ OrderType string `json:"order_type"`
+ Status string `json:"status"`
+ ExpiresAt time.Time `json:"expires_at"`
+ PaidAt *time.Time `json:"paid_at,omitempty"`
}
func buildPublicOrderResult(order *dbent.PaymentOrder) PublicOrderResult {
return PublicOrderResult{
- ID: order.ID,
- OutTradeNo: order.OutTradeNo,
- Amount: order.Amount,
- PayAmount: order.PayAmount,
- FeeRate: order.FeeRate,
- PaymentType: order.PaymentType,
- OrderType: order.OrderType,
- Status: order.Status,
- CreatedAt: order.CreatedAt,
- ExpiresAt: order.ExpiresAt,
- PaidAt: order.PaidAt,
- CompletedAt: order.CompletedAt,
- RefundAmount: order.RefundAmount,
- RefundReason: order.RefundReason,
- RefundRequestedAt: order.RefundRequestedAt,
- RefundRequestedBy: order.RefundRequestedBy,
- RefundRequestReason: order.RefundRequestReason,
- PlanID: order.PlanID,
- }
-}
-
-// VerifyOrderPublic keeps the legacy anonymous out_trade_no lookup available as
-// a compatibility path for older result pages and staggered deploys.
+ ID: order.ID,
+ OutTradeNo: order.OutTradeNo,
+ Amount: order.Amount,
+ PayAmount: order.PayAmount,
+ FeeRate: order.FeeRate,
+ PaymentType: order.PaymentType,
+ OrderType: order.OrderType,
+ Status: order.Status,
+ ExpiresAt: order.ExpiresAt,
+ PaidAt: order.PaidAt,
+ }
+}
+
+// VerifyOrderPublic keeps the legacy URL available during staggered deploys,
+// but requires the same signed resume token as the canonical resolve endpoint.
+// Anonymous out_trade_no lookup is deliberately not supported.
// POST /api/v1/payment/public/orders/verify
func (h *PaymentHandler) VerifyOrderPublic(c *gin.Context) {
- var req VerifyOrderRequest
+ var req ResolveOrderByResumeTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
- order, err := h.paymentService.VerifyOrderPublic(c.Request.Context(), req.OutTradeNo)
+ order, err := h.paymentService.GetPublicOrderByResumeToken(c.Request.Context(), req.ResumeToken)
if err != nil {
response.ErrorFrom(c, err)
return
diff --git a/backend/internal/handler/payment_handler_resume_test.go b/backend/internal/handler/payment_handler_resume_test.go
index 377f432e28e..18658b6a901 100644
--- a/backend/internal/handler/payment_handler_resume_test.go
+++ b/backend/internal/handler/payment_handler_resume_test.go
@@ -34,12 +34,14 @@ func TestApplyWeChatPaymentResumeClaims(t *testing.T) {
}
err := applyWeChatPaymentResumeClaims(&req, &service.WeChatPaymentResumeClaims{
+ UserID: 17,
+ JTI: "nonce-17",
OpenID: "openid-123",
PaymentType: payment.TypeWxpay,
Amount: "12.50",
OrderType: payment.OrderTypeSubscription,
PlanID: 7,
- })
+ }, 17)
if err != nil {
t.Fatalf("applyWeChatPaymentResumeClaims returned error: %v", err)
}
@@ -55,6 +57,9 @@ func TestApplyWeChatPaymentResumeClaims(t *testing.T) {
if req.PlanID != 7 {
t.Fatalf("plan_id = %d, want 7", req.PlanID)
}
+ if req.ResumeTokenJTI != "nonce-17" || req.ResumeTokenUserID != 17 {
+ t.Fatalf("resume binding was not propagated: %+v", req)
+ }
}
func TestApplyWeChatPaymentResumeClaimsRejectsPaymentTypeMismatch(t *testing.T) {
@@ -65,20 +70,36 @@ func TestApplyWeChatPaymentResumeClaimsRejectsPaymentTypeMismatch(t *testing.T)
}
err := applyWeChatPaymentResumeClaims(&req, &service.WeChatPaymentResumeClaims{
+ UserID: 17,
+ JTI: "nonce-17",
OpenID: "openid-123",
PaymentType: payment.TypeWxpay,
Amount: "12.50",
OrderType: payment.OrderTypeBalance,
- })
+ }, 17)
if err == nil {
t.Fatal("applyWeChatPaymentResumeClaims should reject mismatched payment types")
}
}
-func TestVerifyOrderPublicReturnsLegacyOrderState(t *testing.T) {
+func TestApplyWeChatPaymentResumeClaimsRejectsCrossUserReplay(t *testing.T) {
t.Parallel()
+ req := CreateOrderRequest{PaymentType: payment.TypeWxpay}
+ err := applyWeChatPaymentResumeClaims(&req, &service.WeChatPaymentResumeClaims{
+ UserID: 17,
+ JTI: "nonce-17",
+ OpenID: "openid-123",
+ PaymentType: payment.TypeWxpay,
+ Amount: "12.50",
+ OrderType: payment.OrderTypeBalance,
+ }, 18)
+ require.Error(t, err)
+}
+
+func TestVerifyOrderPublicRequiresSignedResumeTokenAndMinimizesResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
+ t.Setenv("PAYMENT_RESUME_SIGNING_KEY", "0123456789abcdef0123456789abcdef")
db, err := sql.Open("sqlite", "file:payment_handler_public_verify?mode=memory&cache=shared")
require.NoError(t, err)
@@ -111,56 +132,80 @@ func TestVerifyOrderPublicReturnsLegacyOrderState(t *testing.T) {
SetPaymentTradeNo("trade-public-verify").
SetOrderType(payment.OrderTypeBalance).
SetStatus(service.OrderStatusPending).
+ SetRefundAmount(12.34).
+ SetRefundReason("sensitive-refund-reason").
SetExpiresAt(time.Now().Add(time.Hour)).
SetClientIP("127.0.0.1").
SetSrcHost("api.example.com").
Save(context.Background())
require.NoError(t, err)
- paymentSvc := service.NewPaymentService(client, payment.NewRegistry(), nil, nil, nil, nil, nil, nil, nil)
- h := NewPaymentHandler(paymentSvc, nil, nil)
+ resumeSvc := service.NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
+ token, err := resumeSvc.CreateToken(service.ResumeTokenClaims{
+ OrderID: order.ID,
+ UserID: user.ID,
+ PaymentType: payment.TypeAlipay,
+ CanonicalReturnURL: "https://app.example.com/payment/result",
+ })
+ require.NoError(t, err)
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- ctx.Request = httptest.NewRequest(
+ configSvc := service.NewPaymentConfigService(client, nil, []byte("0123456789abcdef0123456789abcdef"))
+ paymentSvc := service.NewPaymentService(client, payment.NewRegistry(), nil, nil, nil, configSvc, nil, nil, nil)
+ h := NewPaymentHandler(paymentSvc, nil, nil, nil)
+
+ unsignedRecorder := httptest.NewRecorder()
+ unsignedCtx, _ := gin.CreateTestContext(unsignedRecorder)
+ unsignedCtx.Request = httptest.NewRequest(
http.MethodPost,
"/api/v1/payment/public/orders/verify",
bytes.NewBufferString(`{"out_trade_no":"legacy-order-no"}`),
)
- ctx.Request.Header.Set("Content-Type", "application/json")
+ unsignedCtx.Request.Header.Set("Content-Type", "application/json")
- h.VerifyOrderPublic(ctx)
+ h.VerifyOrderPublic(unsignedCtx)
+ require.Equal(t, http.StatusBadRequest, unsignedRecorder.Code)
+ require.NotContains(t, unsignedRecorder.Body.String(), "legacy-order-no")
+ require.NotContains(t, unsignedRecorder.Body.String(), "90.64")
+ require.NotContains(t, unsignedRecorder.Body.String(), "sensitive-refund-reason")
- require.Equal(t, http.StatusOK, recorder.Code)
+ signedRecorder := httptest.NewRecorder()
+ signedCtx, _ := gin.CreateTestContext(signedRecorder)
+ signedCtx.Request = httptest.NewRequest(
+ http.MethodPost,
+ "/api/v1/payment/public/orders/verify",
+ bytes.NewBufferString(`{"resume_token":"`+token+`"}`),
+ )
+ signedCtx.Request.Header.Set("Content-Type", "application/json")
+
+ h.VerifyOrderPublic(signedCtx)
+ require.Equal(t, http.StatusOK, signedRecorder.Code)
var resp struct {
- Code int `json:"code"`
- Data struct {
- ID int64 `json:"id"`
- OutTradeNo string `json:"out_trade_no"`
- Amount float64 `json:"amount"`
- PayAmount float64 `json:"pay_amount"`
- FeeRate float64 `json:"fee_rate"`
- PaymentType string `json:"payment_type"`
- OrderType string `json:"order_type"`
- Status string `json:"status"`
- RefundAmount float64 `json:"refund_amount"`
- CreatedAt string `json:"created_at"`
- ExpiresAt string `json:"expires_at"`
- } `json:"data"`
+ Code int `json:"code"`
+ Data map[string]any `json:"data"`
}
- require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
+ require.NoError(t, json.Unmarshal(signedRecorder.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code)
- require.Equal(t, order.ID, resp.Data.ID)
- require.Equal(t, "legacy-order-no", resp.Data.OutTradeNo)
- require.Equal(t, 90.64, resp.Data.PayAmount)
- require.Equal(t, 0.03, resp.Data.FeeRate)
- require.Equal(t, payment.TypeAlipay, resp.Data.PaymentType)
- require.Equal(t, payment.OrderTypeBalance, resp.Data.OrderType)
- require.Equal(t, service.OrderStatusPending, resp.Data.Status)
- require.Equal(t, 0.0, resp.Data.RefundAmount)
- require.NotEmpty(t, resp.Data.CreatedAt)
- require.NotEmpty(t, resp.Data.ExpiresAt)
+ require.Equal(t, float64(order.ID), resp.Data["id"])
+ require.Equal(t, "legacy-order-no", resp.Data["out_trade_no"])
+ require.Equal(t, 90.64, resp.Data["pay_amount"])
+ require.Equal(t, 0.03, resp.Data["fee_rate"])
+ require.Equal(t, payment.TypeAlipay, resp.Data["payment_type"])
+ require.Equal(t, payment.OrderTypeBalance, resp.Data["order_type"])
+ require.Equal(t, service.OrderStatusPending, resp.Data["status"])
+ require.Contains(t, resp.Data, "expires_at")
+ for _, forbidden := range []string{
+ "created_at",
+ "completed_at",
+ "refund_amount",
+ "refund_reason",
+ "refund_requested_at",
+ "refund_requested_by",
+ "refund_request_reason",
+ "plan_id",
+ } {
+ require.NotContains(t, resp.Data, forbidden)
+ }
}
func TestResolveOrderPublicByResumeTokenReturnsFrontendContractFields(t *testing.T) {
@@ -216,7 +261,7 @@ func TestResolveOrderPublicByResumeTokenReturnsFrontendContractFields(t *testing
configSvc := service.NewPaymentConfigService(client, nil, []byte("0123456789abcdef0123456789abcdef"))
paymentSvc := service.NewPaymentService(client, payment.NewRegistry(), nil, nil, nil, configSvc, nil, nil, nil)
- h := NewPaymentHandler(paymentSvc, nil, nil)
+ h := NewPaymentHandler(paymentSvc, nil, nil, nil)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
@@ -245,9 +290,11 @@ func TestResolveOrderPublicByResumeTokenReturnsFrontendContractFields(t *testing
require.Equal(t, payment.TypeAlipay, resp.Data["payment_type"])
require.Equal(t, payment.OrderTypeBalance, resp.Data["order_type"])
require.Equal(t, service.OrderStatusPaid, resp.Data["status"])
- require.Contains(t, resp.Data, "created_at")
require.Contains(t, resp.Data, "expires_at")
- require.Contains(t, resp.Data, "refund_amount")
+ require.NotContains(t, resp.Data, "created_at")
+ require.NotContains(t, resp.Data, "refund_amount")
+ require.NotContains(t, resp.Data, "refund_reason")
+ require.NotContains(t, resp.Data, "plan_id")
}
func TestResolveOrderPublicByResumeTokenReturnsBadRequestForMismatchedToken(t *testing.T) {
@@ -303,7 +350,7 @@ func TestResolveOrderPublicByResumeTokenReturnsBadRequestForMismatchedToken(t *t
configSvc := service.NewPaymentConfigService(client, nil, []byte("0123456789abcdef0123456789abcdef"))
paymentSvc := service.NewPaymentService(client, payment.NewRegistry(), nil, nil, nil, configSvc, nil, nil, nil)
- h := NewPaymentHandler(paymentSvc, nil, nil)
+ h := NewPaymentHandler(paymentSvc, nil, nil, nil)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
@@ -328,7 +375,7 @@ func TestResolveOrderPublicByResumeTokenReturnsBadRequestForMismatchedToken(t *t
require.Equal(t, "INVALID_RESUME_TOKEN", resp.Reason)
}
-func TestVerifyOrderPublicRejectsBlankOutTradeNo(t *testing.T) {
+func TestVerifyOrderPublicRejectsBlankResumeToken(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := sql.Open("sqlite", "file:payment_handler_public_verify_blank?mode=memory&cache=shared")
@@ -343,14 +390,14 @@ func TestVerifyOrderPublicRejectsBlankOutTradeNo(t *testing.T) {
t.Cleanup(func() { _ = client.Close() })
paymentSvc := service.NewPaymentService(client, payment.NewRegistry(), nil, nil, nil, nil, nil, nil, nil)
- h := NewPaymentHandler(paymentSvc, nil, nil)
+ h := NewPaymentHandler(paymentSvc, nil, nil, nil)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(
http.MethodPost,
"/api/v1/payment/public/orders/verify",
- bytes.NewBufferString(`{"out_trade_no":" "}`),
+ bytes.NewBufferString(`{"resume_token":" "}`),
)
ctx.Request.Header.Set("Content-Type", "application/json")
@@ -364,5 +411,5 @@ func TestVerifyOrderPublicRejectsBlankOutTradeNo(t *testing.T) {
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
require.Equal(t, http.StatusBadRequest, resp.Code)
- require.Equal(t, "INVALID_OUT_TRADE_NO", resp.Reason)
+ require.Equal(t, "INVALID_RESUME_TOKEN", resp.Reason)
}
diff --git a/backend/internal/handler/payment_public_verify_security_test.go b/backend/internal/handler/payment_public_verify_security_test.go
new file mode 100644
index 00000000000..6722d7ff85c
--- /dev/null
+++ b/backend/internal/handler/payment_public_verify_security_test.go
@@ -0,0 +1,214 @@
+package handler
+
+import (
+ "bytes"
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/enttest"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+
+ "entgo.io/ent/dialect"
+ entsql "entgo.io/ent/dialect/sql"
+ _ "modernc.org/sqlite"
+)
+
+func TestVerifyOrderPublicRejectsUnsignedOutTradeNo(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ h := &PaymentHandler{}
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(
+ http.MethodPost,
+ "/api/v1/payment/public/orders/verify",
+ bytes.NewBufferString(`{"out_trade_no":"guessable-order-number"}`),
+ )
+ ctx.Request.Header.Set("Content-Type", "application/json")
+
+ func() {
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ t.Fatalf("unsigned out_trade_no reached the payment service: %v", recovered)
+ }
+ }()
+ h.VerifyOrderPublic(ctx)
+ }()
+
+ if recorder.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest)
+ }
+ if strings.Contains(recorder.Body.String(), "guessable-order-number") {
+ t.Fatalf("response leaked unsigned order reference: %s", recorder.Body.String())
+ }
+}
+
+func TestPublicOrderResultOmitsRefundPlanAndLifecycleDetails(t *testing.T) {
+ reason := "private refund reason"
+ actor := "admin:7"
+ requestReason := "private request reason"
+ now := time.Now()
+ planID := int64(9)
+ result := buildPublicOrderResult(&dbent.PaymentOrder{
+ ID: 42,
+ OutTradeNo: "sub2_public_42",
+ Amount: 88,
+ PayAmount: 90.64,
+ FeeRate: 0.03,
+ PaymentType: "alipay",
+ OrderType: "balance",
+ Status: "PENDING",
+ CreatedAt: now,
+ ExpiresAt: now.Add(time.Hour),
+ PaidAt: &now,
+ CompletedAt: &now,
+ RefundAmount: 12.34,
+ RefundReason: &reason,
+ RefundRequestedAt: &now,
+ RefundRequestedBy: &actor,
+ RefundRequestReason: &requestReason,
+ PlanID: &planID,
+ })
+
+ payload, err := json.Marshal(result)
+ if err != nil {
+ t.Fatalf("marshal public result: %v", err)
+ }
+ var fields map[string]any
+ if err := json.Unmarshal(payload, &fields); err != nil {
+ t.Fatalf("unmarshal public result: %v", err)
+ }
+ for _, forbidden := range []string{
+ "created_at",
+ "completed_at",
+ "refund_amount",
+ "refund_reason",
+ "refund_requested_at",
+ "refund_requested_by",
+ "refund_request_reason",
+ "plan_id",
+ } {
+ if _, ok := fields[forbidden]; ok {
+ t.Errorf("public result exposed %q", forbidden)
+ }
+ }
+}
+
+func TestPaymentResumeTokenRequiresUserBinding(t *testing.T) {
+ resumeService := service.NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
+ if _, err := resumeService.CreateToken(service.ResumeTokenClaims{OrderID: 42}); err == nil {
+ t.Fatal("CreateToken accepted an order token without a user binding")
+ }
+}
+
+func TestPaymentResumeTokenRejectsLegacyTokenWithoutExpiry(t *testing.T) {
+ key := []byte("0123456789abcdef0123456789abcdef")
+ claims, err := json.Marshal(service.ResumeTokenClaims{OrderID: 42, UserID: 7})
+ if err != nil {
+ t.Fatalf("marshal legacy claims: %v", err)
+ }
+ payload := base64.RawURLEncoding.EncodeToString(claims)
+ mac := hmac.New(sha256.New, key)
+ _, _ = mac.Write([]byte(payload))
+ token := payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
+
+ resumeService := service.NewPaymentResumeService(key)
+ if _, err := resumeService.ParseToken(token); err == nil {
+ t.Fatal("ParseToken accepted a legacy token without an expiry")
+ }
+}
+
+func TestVerifyOrderPublicAcceptsMatchingSignedResumeToken(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ t.Setenv("PAYMENT_RESUME_SIGNING_KEY", "0123456789abcdef0123456789abcdef")
+
+ database, err := sql.Open("sqlite", "file:payment_public_verify_security?mode=memory&cache=shared")
+ if err != nil {
+ t.Fatalf("open sqlite: %v", err)
+ }
+ t.Cleanup(func() { _ = database.Close() })
+ if _, err := database.Exec("PRAGMA foreign_keys = ON"); err != nil {
+ t.Fatalf("enable foreign keys: %v", err)
+ }
+ driver := entsql.OpenDB(dialect.SQLite, database)
+ client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(driver)))
+ t.Cleanup(func() { _ = client.Close() })
+
+ ctx := context.Background()
+ user, err := client.User.Create().
+ SetEmail("public-verify-signed@example.com").
+ SetPasswordHash("hash").
+ SetUsername("public-verify-signed-user").
+ Save(ctx)
+ if err != nil {
+ t.Fatalf("create user: %v", err)
+ }
+ order, err := client.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(88).
+ SetPayAmount(90.64).
+ SetFeeRate(0.03).
+ SetRechargeCode("PUBLIC-SIGNED-VERIFY").
+ SetOutTradeNo("signed-order-no").
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("signed-trade-no").
+ SetOrderType(payment.OrderTypeBalance).
+ SetStatus(service.OrderStatusCompleted).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ if err != nil {
+ t.Fatalf("create order: %v", err)
+ }
+
+ resumeService := service.NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
+ token, err := resumeService.CreateToken(service.ResumeTokenClaims{
+ OrderID: order.ID,
+ UserID: user.ID,
+ PaymentType: payment.TypeAlipay,
+ CanonicalReturnURL: "https://app.example.com/payment/result",
+ })
+ if err != nil {
+ t.Fatalf("create resume token: %v", err)
+ }
+ configService := service.NewPaymentConfigService(client, nil, []byte("0123456789abcdef0123456789abcdef"))
+ paymentService := service.NewPaymentService(client, payment.NewRegistry(), nil, nil, nil, configService, nil, nil, nil)
+ h := NewPaymentHandler(paymentService, nil, nil, nil)
+
+ recorder := httptest.NewRecorder()
+ requestContext, _ := gin.CreateTestContext(recorder)
+ requestContext.Request = httptest.NewRequest(
+ http.MethodPost,
+ "/api/v1/payment/public/orders/verify",
+ bytes.NewBufferString(`{"resume_token":"`+token+`"}`),
+ )
+ requestContext.Request.Header.Set("Content-Type", "application/json")
+ h.VerifyOrderPublic(requestContext)
+
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d: %s", recorder.Code, http.StatusOK, recorder.Body.String())
+ }
+ var responseBody struct {
+ Data map[string]any `json:"data"`
+ }
+ if err := json.Unmarshal(recorder.Body.Bytes(), &responseBody); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if responseBody.Data["out_trade_no"] != order.OutTradeNo {
+ t.Fatalf("out_trade_no = %v, want %q", responseBody.Data["out_trade_no"], order.OutTradeNo)
+ }
+}
diff --git a/backend/internal/handler/payment_webhook_handler.go b/backend/internal/handler/payment_webhook_handler.go
index 9ae799fd345..2d113638ae0 100644
--- a/backend/internal/handler/payment_webhook_handler.go
+++ b/backend/internal/handler/payment_webhook_handler.go
@@ -25,9 +25,6 @@ type PaymentWebhookHandler struct {
// maxWebhookBodySize is the maximum allowed webhook request body size (1 MB).
const maxWebhookBodySize = 1 << 20
-// webhookLogTruncateLen is the maximum length of raw body logged on verify failure.
-const webhookLogTruncateLen = 200
-
// NewPaymentWebhookHandler creates a new PaymentWebhookHandler.
func NewPaymentWebhookHandler(paymentService *service.PaymentService, registry *payment.Registry) *PaymentWebhookHandler {
return &PaymentWebhookHandler{
@@ -98,12 +95,9 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
resolvedProviderKey, notification, err := verifyNotificationWithProviders(c.Request.Context(), providers, rawBody, headers)
if err != nil {
- truncatedBody := rawBody
- if len(truncatedBody) > webhookLogTruncateLen {
- truncatedBody = truncatedBody[:webhookLogTruncateLen] + "...(truncated)"
- }
- slog.Error("[Payment Webhook] verify failed", "provider", providerKey, "error", err, "method", c.Request.Method, "bodyLen", len(rawBody))
- slog.Debug("[Payment Webhook] verify failed body", "provider", providerKey, "rawBody", truncatedBody)
+ attrs := webhookVerifyFailureLogAttributes(providerKey, c.Request.Method, len(rawBody))
+ attrs = append(attrs, "error", err)
+ slog.Error("[Payment Webhook] verify failed", attrs...)
c.String(http.StatusBadRequest, "verify failed")
return
}
@@ -137,6 +131,14 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
writeSuccessResponse(c, resolvedProviderKey)
}
+func webhookVerifyFailureLogAttributes(providerKey, method string, bodyLen int) []any {
+ return []any{
+ "provider", providerKey,
+ "method", method,
+ "bodyLen", bodyLen,
+ }
+}
+
// extractOutTradeNo parses the webhook body to find the out_trade_no.
// This allows looking up the correct provider instance before verification.
func extractOutTradeNo(rawBody, providerKey string) string {
diff --git a/backend/internal/handler/payment_webhook_handler_test.go b/backend/internal/handler/payment_webhook_handler_test.go
index 7551fc83ddb..0818bf5339c 100644
--- a/backend/internal/handler/payment_webhook_handler_test.go
+++ b/backend/internal/handler/payment_webhook_handler_test.go
@@ -134,10 +134,16 @@ func TestWebhookConstants(t *testing.T) {
t.Run("maxWebhookBodySize is 1MB", func(t *testing.T) {
assert.Equal(t, int64(1<<20), int64(maxWebhookBodySize))
})
+}
- t.Run("webhookLogTruncateLen is 200", func(t *testing.T) {
- assert.Equal(t, 200, webhookLogTruncateLen)
- })
+func TestWebhookVerifyFailureLogAttributesExcludeRawPayload(t *testing.T) {
+ secret := "cardholder-secret-do-not-log"
+ attrs := webhookVerifyFailureLogAttributes(payment.TypeEasyPay, http.MethodPost, len(secret))
+ encoded := fmt.Sprint(attrs)
+
+ require.NotContains(t, encoded, secret)
+ require.NotContains(t, encoded, "rawBody")
+ require.Contains(t, encoded, "bodyLen")
}
func TestExtractOutTradeNo(t *testing.T) {
@@ -220,7 +226,7 @@ type webhookHandlerProviderStub struct {
verifyErr error
}
-func (p webhookHandlerProviderStub) Name() string { return p.key }
+func (p webhookHandlerProviderStub) Name() string { return p.key }
func (p webhookHandlerProviderStub) ProviderKey() string { return p.key }
func (p webhookHandlerProviderStub) SupportedTypes() []payment.PaymentType {
return []payment.PaymentType{payment.PaymentType(p.key)}
diff --git a/backend/internal/handler/payment_wechat_resume_security_test.go b/backend/internal/handler/payment_wechat_resume_security_test.go
new file mode 100644
index 00000000000..73fa90c1024
--- /dev/null
+++ b/backend/internal/handler/payment_wechat_resume_security_test.go
@@ -0,0 +1,123 @@
+package handler
+
+import (
+ "context"
+ "errors"
+ "math"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestApplyWeChatPaymentResumeClaimsBindsAuthenticatedUser(t *testing.T) {
+ req := CreateOrderRequest{PaymentType: payment.TypeWxpay}
+ err := applyWeChatPaymentResumeClaims(&req, &service.WeChatPaymentResumeClaims{
+ UserID: 17,
+ JTI: "signed-jti",
+ OpenID: "openid-17",
+ PaymentType: payment.TypeWxpay,
+ Amount: "12.50",
+ OrderType: payment.OrderTypeBalance,
+ }, 17)
+ require.NoError(t, err)
+ require.EqualValues(t, 17, req.ResumeTokenUserID)
+ require.Equal(t, "signed-jti", req.ResumeTokenJTI)
+ require.Equal(t, service.PaymentSourceWechatInAppResume, req.PaymentSource)
+}
+
+func TestApplyWeChatPaymentResumeClaimsRejectsCrossUserAndNonFiniteAmount(t *testing.T) {
+ base := service.WeChatPaymentResumeClaims{
+ UserID: 17,
+ JTI: "signed-jti",
+ OpenID: "openid-17",
+ PaymentType: payment.TypeWxpay,
+ OrderType: payment.OrderTypeBalance,
+ }
+
+ req := CreateOrderRequest{PaymentType: payment.TypeWxpay}
+ require.Error(t, applyWeChatPaymentResumeClaims(&req, &base, 18))
+
+ base.Amount = "NaN"
+ req = CreateOrderRequest{PaymentType: payment.TypeWxpay, Amount: math.NaN()}
+ require.Error(t, applyWeChatPaymentResumeClaims(&req, &base, 17))
+}
+
+func TestExecuteWeChatResumeOrderCreateConsumesJTIOnce(t *testing.T) {
+ repo := newUserMemoryIdempotencyRepoStub()
+ coordinator := service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig())
+ req := service.CreateOrderRequest{
+ UserID: 17,
+ ResumeTokenUserID: 17,
+ ResumeTokenJTI: "0123456789abcdef0123456789abcdef",
+ Amount: 12.5,
+ PaymentType: payment.TypeWxpay,
+ OpenID: "openid-17",
+ PaymentSource: service.PaymentSourceWechatInAppResume,
+ OrderType: payment.OrderTypeBalance,
+ }
+
+ calls := 0
+ execute := func(context.Context, service.CreateOrderRequest) (*service.CreateOrderResponse, error) {
+ calls++
+ return &service.CreateOrderResponse{OrderID: 99, PaymentType: payment.TypeWxpay}, nil
+ }
+
+ first, err := executeWeChatResumeOrderCreate(context.Background(), coordinator, req, execute)
+ require.NoError(t, err)
+ require.EqualValues(t, 99, first.OrderID)
+
+ second, err := executeWeChatResumeOrderCreate(context.Background(), coordinator, req, execute)
+ require.NoError(t, err)
+ require.EqualValues(t, 99, second.OrderID)
+ require.Equal(t, 1, calls)
+
+ record, err := repo.GetByScopeAndKeyHash(context.Background(), service.WeChatPaymentResumeIdempotencyScope, service.HashIdempotencyKey(req.ResumeTokenJTI))
+ require.NoError(t, err)
+ require.NotNil(t, record)
+ require.False(t, record.Persistent)
+ require.NotNil(t, record.ResponseBody)
+ require.Contains(t, *record.ResponseBody, `"order_id":99`)
+}
+
+func TestExecuteWeChatResumeOrderCreateFailsClosedWithoutCoordinator(t *testing.T) {
+ calls := 0
+ _, err := executeWeChatResumeOrderCreate(context.Background(), nil, service.CreateOrderRequest{
+ UserID: 17,
+ ResumeTokenJTI: "0123456789abcdef0123456789abcdef",
+ }, func(context.Context, service.CreateOrderRequest) (*service.CreateOrderResponse, error) {
+ calls++
+ return &service.CreateOrderResponse{OrderID: 99}, nil
+ })
+ require.Error(t, err)
+ require.Equal(t, 0, calls)
+}
+
+func TestExecuteWeChatResumeOrderCreateFencesAmbiguousFailurePastTokenTTL(t *testing.T) {
+ repo := newUserMemoryIdempotencyRepoStub()
+ coordinator := service.NewIdempotencyCoordinator(repo, service.DefaultIdempotencyConfig())
+ req := service.CreateOrderRequest{
+ UserID: 17,
+ ResumeTokenJTI: "fedcba9876543210fedcba9876543210",
+ }
+ calls := 0
+ execute := func(context.Context, service.CreateOrderRequest) (*service.CreateOrderResponse, error) {
+ calls++
+ return nil, errors.New("ambiguous provider failure")
+ }
+
+ _, err := executeWeChatResumeOrderCreate(context.Background(), coordinator, req, execute)
+ require.Error(t, err)
+ record, err := repo.GetByScopeAndKeyHash(context.Background(), service.WeChatPaymentResumeIdempotencyScope, service.HashIdempotencyKey(req.ResumeTokenJTI))
+ require.NoError(t, err)
+ require.NotNil(t, record)
+ require.False(t, record.Persistent)
+ require.NotNil(t, record.LockedUntil)
+ require.WithinDuration(t, time.Now().Add(20*time.Minute), *record.LockedUntil, 5*time.Second)
+
+ _, err = executeWeChatResumeOrderCreate(context.Background(), coordinator, req, execute)
+ require.Error(t, err)
+ require.Equal(t, 1, calls)
+}
diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go
index 22f2aa15b61..192c9665cc1 100644
--- a/backend/internal/handler/setting_handler.go
+++ b/backend/internal/handler/setting_handler.go
@@ -1,6 +1,13 @@
package handler
import (
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "net/http"
+ "net/url"
+ "strings"
+
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -40,10 +47,15 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
+ LoginAgreementEnabled: settings.LoginAgreementEnabled,
+ LoginAgreementMode: settings.LoginAgreementMode,
+ LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
+ LoginAgreementRevision: settings.LoginAgreementRevision,
+ LoginAgreementDocuments: publicLoginAgreementDocumentsToDTO(settings.LoginAgreementDocuments),
TurnstileEnabled: settings.TurnstileEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey,
SiteName: settings.SiteName,
- SiteLogo: settings.SiteLogo,
+ SiteLogo: service.PublicSiteLogoReference(settings.SiteLogo),
SiteSubtitle: settings.SiteSubtitle,
APIBaseURL: settings.APIBaseURL,
ContactInfo: settings.ContactInfo,
@@ -63,6 +75,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
+ GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
+ GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
BackendModeEnabled: settings.BackendModeEnabled,
PaymentEnabled: settings.PaymentEnabled,
Version: h.version,
@@ -77,5 +91,92 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
AffiliateEnabled: settings.AffiliateEnabled,
+
+ RiskControlEnabled: settings.RiskControlEnabled,
})
}
+
+// GetPublicSiteLogo returns the configured data-image logo as a cacheable asset.
+// GET /api/v1/settings/site-logo
+func (h *SettingHandler) GetPublicSiteLogo(c *gin.Context) {
+ settings, err := h.settingService.GetPublicSettings(c.Request.Context())
+ if err != nil {
+ response.ErrorFrom(c, err)
+ return
+ }
+
+ contentType, body, ok := decodeDataImageURI(settings.SiteLogo)
+ if !ok {
+ c.Status(http.StatusNotFound)
+ return
+ }
+
+ sum := sha256.Sum256([]byte(settings.SiteLogo))
+ etag := `"` + hex.EncodeToString(sum[:]) + `"`
+ c.Header("Cache-Control", "public, max-age=86400")
+ c.Header("ETag", etag)
+ if c.GetHeader("If-None-Match") == etag {
+ c.Status(http.StatusNotModified)
+ return
+ }
+
+ c.Data(http.StatusOK, contentType, body)
+}
+
+func decodeDataImageURI(raw string) (string, []byte, bool) {
+ trimmed := strings.TrimSpace(raw)
+ if !strings.HasPrefix(strings.ToLower(trimmed), "data:image/") {
+ return "", nil, false
+ }
+
+ metadata, encoded, ok := strings.Cut(strings.TrimPrefix(trimmed, "data:"), ",")
+ if !ok {
+ return "", nil, false
+ }
+
+ parts := strings.Split(metadata, ";")
+ if len(parts) == 0 {
+ return "", nil, false
+ }
+
+ contentType := strings.ToLower(strings.TrimSpace(parts[0]))
+ switch contentType {
+ case "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp", "image/svg+xml":
+ default:
+ return "", nil, false
+ }
+
+ base64Encoded := false
+ for _, part := range parts[1:] {
+ if strings.EqualFold(strings.TrimSpace(part), "base64") {
+ base64Encoded = true
+ break
+ }
+ }
+
+ if base64Encoded {
+ body, err := base64.StdEncoding.DecodeString(encoded)
+ if err != nil {
+ return "", nil, false
+ }
+ return contentType, body, true
+ }
+
+ decoded, err := url.PathUnescape(encoded)
+ if err != nil {
+ return "", nil, false
+ }
+ return contentType, []byte(decoded), true
+}
+
+func publicLoginAgreementDocumentsToDTO(items []service.LoginAgreementDocument) []dto.LoginAgreementDocument {
+ result := make([]dto.LoginAgreementDocument, 0, len(items))
+ for _, item := range items {
+ result = append(result, dto.LoginAgreementDocument{
+ ID: item.ID,
+ Title: item.Title,
+ ContentMD: item.ContentMD,
+ })
+ }
+ return result
+}
diff --git a/backend/internal/handler/setting_handler_public_test.go b/backend/internal/handler/setting_handler_public_test.go
index 45d66f8e337..1dd179ff390 100644
--- a/backend/internal/handler/setting_handler_public_test.go
+++ b/backend/internal/handler/setting_handler_public_test.go
@@ -82,6 +82,59 @@ func TestSettingHandler_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t
require.True(t, resp.Data.ForceEmailOnThirdPartySignup)
}
+func TestSettingHandler_GetPublicSettings_RewritesDataLogoToAssetURL(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ repo := &settingHandlerPublicRepoStub{
+ values: map[string]string{
+ service.SettingKeySiteLogo: "data:image/png;base64,aGVsbG8=",
+ },
+ }
+ h := NewSettingHandler(service.NewSettingService(repo, &config.Config{}), "test-version")
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/settings/public", nil)
+
+ h.GetPublicSettings(c)
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+
+ var resp struct {
+ Code int `json:"code"`
+ Data struct {
+ SiteLogo string `json:"site_logo"`
+ } `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp))
+ require.Equal(t, 0, resp.Code)
+ require.Equal(t, service.PublicSiteLogoAssetPath, resp.Data.SiteLogo)
+ require.NotContains(t, recorder.Body.String(), "aGVsbG8=")
+}
+
+func TestSettingHandler_GetPublicSiteLogo_ServesConfiguredDataImage(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ repo := &settingHandlerPublicRepoStub{
+ values: map[string]string{
+ service.SettingKeySiteLogo: "data:image/png;base64,aGVsbG8=",
+ },
+ }
+ h := NewSettingHandler(service.NewSettingService(repo, &config.Config{}), "test-version")
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodGet, service.PublicSiteLogoAssetPath, nil)
+
+ h.GetPublicSiteLogo(c)
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+ require.Equal(t, "image/png", recorder.Header().Get("Content-Type"))
+ require.Contains(t, recorder.Header().Get("Cache-Control"), "max-age=86400")
+ require.NotEmpty(t, recorder.Header().Get("ETag"))
+ require.Equal(t, []byte("hello"), recorder.Body.Bytes())
+}
+
func TestSettingHandler_GetPublicSettings_ExposesWeChatOAuthModeCapabilities(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewSettingHandler(service.NewSettingService(&settingHandlerPublicRepoStub{
diff --git a/backend/internal/handler/sidecar_content_moderation.go b/backend/internal/handler/sidecar_content_moderation.go
new file mode 100644
index 00000000000..67be06c5e99
--- /dev/null
+++ b/backend/internal/handler/sidecar_content_moderation.go
@@ -0,0 +1,57 @@
+package handler
+
+import (
+ "strings"
+
+ middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+)
+
+func sidecarContentModerationProtocol(sidecarPath string) string {
+ switch strings.TrimSpace(sidecarPath) {
+ case "/v1/messages":
+ return service.ContentModerationProtocolAnthropicMessages
+ case "/v1/responses":
+ return service.ContentModerationProtocolOpenAIResponses
+ case "/v1/chat/completions":
+ return service.ContentModerationProtocolOpenAIChat
+ default:
+ // The moderation extractor's default branch safely inspects the common
+ // input/messages/contents shapes. Keeping the check active here also
+ // prevents a future billable sidecar route from silently bypassing policy.
+ return "sidecar_generic"
+ }
+}
+
+func (h *GatewayHandler) blockSidecarContentModeration(
+ c *gin.Context,
+ reqLog *zap.Logger,
+ apiKey *service.APIKey,
+ subject middleware2.AuthSubject,
+ sidecarPath string,
+ model string,
+ body []byte,
+) bool {
+ decision := h.checkContentModeration(
+ c,
+ reqLog,
+ apiKey,
+ subject,
+ sidecarContentModerationProtocol(sidecarPath),
+ model,
+ body,
+ )
+ if decision == nil || !decision.Blocked {
+ return false
+ }
+ h.handleStreamingAwareError(
+ c,
+ contentModerationStatus(decision),
+ contentModerationErrorCode(decision),
+ decision.Message,
+ false,
+ )
+ return true
+}
diff --git a/backend/internal/handler/sidecar_content_moderation_test.go b/backend/internal/handler/sidecar_content_moderation_test.go
new file mode 100644
index 00000000000..484f5a3e08a
--- /dev/null
+++ b/backend/internal/handler/sidecar_content_moderation_test.go
@@ -0,0 +1,148 @@
+//go:build unit
+
+package handler
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSidecarGenerationContentModerationBlocksBeforeForward(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ tests := []struct {
+ name string
+ platform string
+ path string
+ body []byte
+ invoke func(*GatewayHandler, *gin.Context, []byte)
+ }{
+ {
+ name: "kiro messages",
+ platform: service.PlatformKiro,
+ path: "/kiro/v1/messages",
+ body: []byte(`{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"blocked sidecar prompt"}]}`),
+ invoke: func(h *GatewayHandler, c *gin.Context, body []byte) {
+ h.handleKiroSidecar(c, kiroSidecarRequest{
+ Method: http.MethodPost,
+ Path: "/v1/messages",
+ Model: "claude-sonnet-4-6",
+ RequestBody: body,
+ UpstreamBody: body,
+ RecordUsage: true,
+ })
+ },
+ },
+ {
+ name: "cursor responses",
+ platform: service.PlatformCursor,
+ path: "/cursor/v1/responses",
+ body: []byte(`{"model":"gpt-5.5","input":[{"role":"user","content":[{"type":"input_text","text":"blocked sidecar prompt"}]}]}`),
+ invoke: func(h *GatewayHandler, c *gin.Context, body []byte) {
+ h.handleCursorSidecar(c, cursorSidecarRequest{
+ Method: http.MethodPost,
+ Path: "/v1/responses",
+ Model: "gpt-5.5",
+ RequestBody: body,
+ UpstreamBody: body,
+ RecordUsage: true,
+ })
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var sidecarCalls atomic.Int32
+ sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ sidecarCalls.Add(1)
+ w.WriteHeader(http.StatusServiceUnavailable)
+ }))
+ defer sidecar.Close()
+
+ moderationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte(`{"results":[{"category_scores":{"sexual":0.9}}]}`))
+ }))
+ defer moderationServer.Close()
+
+ moderationConfig := &service.ContentModerationConfig{
+ Enabled: true,
+ Mode: service.ContentModerationModePreBlock,
+ BaseURL: moderationServer.URL,
+ Model: "omni-moderation-latest",
+ EncryptedAPIKeys: []string{"test:sk-test"},
+ SampleRate: 100,
+ AllGroups: true,
+ BlockMessage: "sidecar moderation blocked",
+ }
+ rawConfig, err := json.Marshal(moderationConfig)
+ require.NoError(t, err)
+ moderationRepo := &contentModerationHandlerTestRepo{}
+ moderationService := service.NewContentModerationServiceWithEncryptor(
+ &contentModerationHandlerSettingRepo{values: map[string]string{
+ service.SettingKeyRiskControlEnabled: "true",
+ service.SettingKeyContentModerationConfig: string(rawConfig),
+ }},
+ moderationRepo,
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ contentModerationHandlerTestEncryptor{},
+ )
+
+ group := &service.Group{ID: 77, Name: tt.platform, Platform: tt.platform}
+ account := &service.Account{
+ ID: 88,
+ Name: tt.platform + "-account",
+ Platform: tt.platform,
+ Type: service.AccountTypeAPIKey,
+ Status: service.StatusActive,
+ Schedulable: true,
+ Concurrency: 1,
+ Credentials: map[string]any{"api_key": "sidecar-account-key"},
+ }
+ h, cleanup := newTestGatewayHandler(t, group, []*service.Account{account})
+ defer cleanup()
+ h.contentModerationService = moderationService
+ h.cfg = &config.Config{}
+ if tt.platform == service.PlatformKiro {
+ h.cfg.Kiro.SidecarURL = sidecar.URL
+ } else {
+ h.cfg.Cursor.SidecarURL = sidecar.URL
+ }
+
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, tt.path, nil)
+ apiKey := &service.APIKey{
+ ID: 99,
+ Name: "sidecar-key",
+ UserID: 100,
+ GroupID: &group.ID,
+ Group: group,
+ User: &service.User{ID: 100, Concurrency: 1},
+ }
+ c.Set(string(middleware2.ContextKeyAPIKey), apiKey)
+ c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 100, Concurrency: 1})
+
+ tt.invoke(h, c, tt.body)
+
+ require.Equal(t, http.StatusForbidden, recorder.Code, recorder.Body.String())
+ require.Contains(t, recorder.Body.String(), "sidecar moderation blocked")
+ require.Zero(t, sidecarCalls.Load())
+ require.Len(t, moderationRepo.logs, 1)
+ require.Equal(t, tt.platform, moderationRepo.logs[0].Provider)
+ })
+ }
+}
diff --git a/backend/internal/handler/sidecar_stream_usage.go b/backend/internal/handler/sidecar_stream_usage.go
new file mode 100644
index 00000000000..5c8bc4c4874
--- /dev/null
+++ b/backend/internal/handler/sidecar_stream_usage.go
@@ -0,0 +1,151 @@
+package handler
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+var (
+ errSidecarStreamUsageMissing = errors.New("sidecar stream completed without usage")
+ errSidecarClientDelivery = errors.New("sidecar stream client delivery failed")
+)
+
+type sidecarUsageExtractor func([]byte) service.ClaudeUsage
+
+func sidecarUpstreamContext(requestCtx context.Context, requireMetering bool) context.Context {
+ if requestCtx == nil {
+ return context.Background()
+ }
+ if requireMetering {
+ return context.WithoutCancel(requestCtx)
+ }
+ return requestCtx
+}
+
+// readMeteredSidecarStream buffers a bounded sidecar stream until a terminal
+// usage frame is available. This intentionally trades incremental delivery for
+// a fail-closed billing contract: unmetered output is never released to the
+// caller, and the retained response is capped by the existing sidecar limit.
+func readMeteredSidecarStream(
+ reader io.Reader,
+ maxBytes int64,
+ extract sidecarUsageExtractor,
+ requireUsage bool,
+) ([]byte, service.ClaudeUsage, error) {
+ if reader == nil {
+ return nil, service.ClaudeUsage{}, errors.New("sidecar stream body is nil")
+ }
+ if maxBytes <= 0 {
+ return nil, service.ClaudeUsage{}, errors.New("sidecar stream limit must be positive")
+ }
+ limited := io.LimitReader(reader, maxBytes+1)
+ body, err := io.ReadAll(limited)
+ if err != nil {
+ return nil, service.ClaudeUsage{}, err
+ }
+ if int64(len(body)) > maxBytes {
+ return nil, service.ClaudeUsage{}, fmt.Errorf("sidecar stream exceeds %d bytes", maxBytes)
+ }
+ usage := extractSidecarStreamUsage(body, extract)
+ if requireUsage && !sidecarUsagePresent(usage) {
+ return nil, service.ClaudeUsage{}, errSidecarStreamUsageMissing
+ }
+ return body, usage, nil
+}
+
+func extractSidecarStreamUsage(body []byte, extract sidecarUsageExtractor) service.ClaudeUsage {
+ if extract == nil || len(body) == 0 {
+ return service.ClaudeUsage{}
+ }
+ usage := service.ClaudeUsage{}
+ mergeSidecarUsage(&usage, extract(body))
+
+ var eventData [][]byte
+ flushEvent := func() {
+ if len(eventData) == 0 {
+ return
+ }
+ mergeSidecarUsage(&usage, extract(bytes.Join(eventData, []byte("\n"))))
+ eventData = eventData[:0]
+ }
+ for _, rawLine := range bytes.Split(body, []byte("\n")) {
+ line := bytes.TrimSpace(bytes.TrimSuffix(rawLine, []byte("\r")))
+ if len(line) == 0 {
+ flushEvent()
+ continue
+ }
+ if bytes.HasPrefix(line, []byte("data:")) {
+ data := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:")))
+ if len(data) > 0 && !bytes.Equal(data, []byte("[DONE]")) {
+ eventData = append(eventData, append([]byte(nil), data...))
+ }
+ continue
+ }
+ if bytes.HasPrefix(line, []byte("event:")) || bytes.HasPrefix(line, []byte(":")) {
+ continue
+ }
+ mergeSidecarUsage(&usage, extract(line))
+ }
+ flushEvent()
+ return usage
+}
+
+func extractCommonSidecarUsage(body []byte) service.ClaudeUsage {
+ return service.ClaudeUsage{
+ InputTokens: firstKiroInt(body,
+ "usage.input_tokens", "usage.prompt_tokens", "usage.inputTokens", "usage.promptTokenCount",
+ "message.usage.input_tokens", "message.usage.prompt_tokens",
+ "response.usage.input_tokens", "response.usage.prompt_tokens",
+ ),
+ OutputTokens: firstKiroInt(body,
+ "usage.output_tokens", "usage.completion_tokens", "usage.outputTokens", "usage.candidatesTokenCount",
+ "message.usage.output_tokens", "message.usage.completion_tokens",
+ "response.usage.output_tokens", "response.usage.completion_tokens",
+ ),
+ CacheCreationInputTokens: firstKiroInt(body,
+ "usage.cache_creation_input_tokens", "usage.cache_creation_tokens",
+ "message.usage.cache_creation_input_tokens", "response.usage.cache_creation_input_tokens",
+ ),
+ CacheReadInputTokens: firstKiroInt(body,
+ "usage.cache_read_input_tokens", "usage.cache_read_tokens",
+ "message.usage.cache_read_input_tokens", "response.usage.cache_read_input_tokens",
+ ),
+ }
+}
+
+func mergeSidecarUsage(dst *service.ClaudeUsage, candidate service.ClaudeUsage) {
+ if dst == nil {
+ return
+ }
+ dst.InputTokens = maxPositiveInt(dst.InputTokens, candidate.InputTokens)
+ dst.OutputTokens = maxPositiveInt(dst.OutputTokens, candidate.OutputTokens)
+ dst.CacheCreationInputTokens = maxPositiveInt(dst.CacheCreationInputTokens, candidate.CacheCreationInputTokens)
+ dst.CacheReadInputTokens = maxPositiveInt(dst.CacheReadInputTokens, candidate.CacheReadInputTokens)
+}
+
+func maxPositiveInt(current, candidate int) int {
+ if candidate > current && candidate > 0 {
+ return candidate
+ }
+ return current
+}
+
+func sidecarUsagePresent(usage service.ClaudeUsage) bool {
+ return usage.InputTokens > 0 || usage.OutputTokens > 0 ||
+ usage.CacheCreationInputTokens > 0 || usage.CacheReadInputTokens > 0
+}
+
+func writeBufferedSidecarStream(writer io.Writer, body []byte) error {
+ if writer == nil {
+ return fmt.Errorf("%w: writer is nil", errSidecarClientDelivery)
+ }
+ if _, err := io.Copy(writer, bytes.NewReader(body)); err != nil {
+ return fmt.Errorf("%w: %v", errSidecarClientDelivery, err)
+ }
+ return nil
+}
diff --git a/backend/internal/handler/sidecar_stream_usage_test.go b/backend/internal/handler/sidecar_stream_usage_test.go
new file mode 100644
index 00000000000..4bf9f2e1ca7
--- /dev/null
+++ b/backend/internal/handler/sidecar_stream_usage_test.go
@@ -0,0 +1,54 @@
+package handler
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestReadMeteredSidecarStreamAggregatesSSEUsage(t *testing.T) {
+ t.Parallel()
+ body := "data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":12}}}\n\n" +
+ "data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":8}}\n\n"
+
+ gotBody, usage, err := readMeteredSidecarStream(strings.NewReader(body), 4096, extractKiroUsage, true)
+ require.NoError(t, err)
+ require.Equal(t, body, string(gotBody))
+ require.Equal(t, 12, usage.InputTokens)
+ require.Equal(t, 8, usage.OutputTokens)
+}
+
+func TestReadMeteredSidecarStreamFailsClosed(t *testing.T) {
+ t.Parallel()
+
+ _, _, err := readMeteredSidecarStream(strings.NewReader("data: {}\n\n"), 4096, extractKiroUsage, true)
+ require.ErrorIs(t, err, errSidecarStreamUsageMissing)
+
+ _, _, err = readMeteredSidecarStream(strings.NewReader(strings.Repeat("x", 17)), 16, extractKiroUsage, false)
+ require.ErrorContains(t, err, "exceeds 16 bytes")
+}
+
+type failingSidecarWriter struct{}
+
+func (failingSidecarWriter) Write([]byte) (int, error) {
+ return 0, errors.New("client disconnected")
+}
+
+func TestWriteBufferedSidecarStreamClassifiesClientDeliveryFailure(t *testing.T) {
+ t.Parallel()
+
+ err := writeBufferedSidecarStream(failingSidecarWriter{}, []byte("data: {}\n\n"))
+ require.ErrorIs(t, err, errSidecarClientDelivery)
+}
+
+func TestSidecarUpstreamContextPreservesMeteringAfterClientCancellation(t *testing.T) {
+ t.Parallel()
+
+ parent, cancel := context.WithCancel(context.Background())
+ cancel()
+ require.ErrorIs(t, sidecarUpstreamContext(parent, false).Err(), context.Canceled)
+ require.NoError(t, sidecarUpstreamContext(parent, true).Err())
+}
diff --git a/backend/internal/handler/subscription_handler.go b/backend/internal/handler/subscription_handler.go
index b40df833365..6ce15b39bae 100644
--- a/backend/internal/handler/subscription_handler.go
+++ b/backend/internal/handler/subscription_handler.go
@@ -12,7 +12,7 @@ import (
// SubscriptionSummaryItem represents a subscription item in summary
type SubscriptionSummaryItem struct {
ID int64 `json:"id"`
- GroupID int64 `json:"group_id"`
+ GroupID *int64 `json:"group_id"`
GroupName string `json:"group_name"`
Status string `json:"status"`
DailyUsedUSD float64 `json:"daily_used_usd,omitempty"`
@@ -22,6 +22,9 @@ type SubscriptionSummaryItem struct {
MonthlyUsedUSD float64 `json:"monthly_used_usd,omitempty"`
MonthlyLimitUSD float64 `json:"monthly_limit_usd,omitempty"`
ExpiresAt *string `json:"expires_at,omitempty"`
+ // 钱包模式 (v4) 字段
+ WalletBalanceUSD *float64 `json:"wallet_balance_usd,omitempty"`
+ WalletInitialUSD *float64 `json:"wallet_initial_usd,omitempty"`
}
// SubscriptionProgressInfo represents subscription with progress info
@@ -140,12 +143,14 @@ func (h *SubscriptionHandler) GetSummary(c *gin.Context) {
for _, sub := range subscriptions {
item := SubscriptionSummaryItem{
- ID: sub.ID,
- GroupID: sub.GroupID,
- Status: sub.Status,
- DailyUsedUSD: sub.DailyUsageUSD,
- WeeklyUsedUSD: sub.WeeklyUsageUSD,
- MonthlyUsedUSD: sub.MonthlyUsageUSD,
+ ID: sub.ID,
+ GroupID: sub.GroupID,
+ Status: sub.Status,
+ DailyUsedUSD: sub.DailyUsageUSD,
+ WeeklyUsedUSD: sub.WeeklyUsageUSD,
+ MonthlyUsedUSD: sub.MonthlyUsageUSD,
+ WalletBalanceUSD: sub.WalletBalanceUSD,
+ WalletInitialUSD: sub.WalletInitialUSD,
}
// Add group info if preloaded
diff --git a/backend/internal/handler/usage_handler.go b/backend/internal/handler/usage_handler.go
index b8506154365..5ec823755c6 100644
--- a/backend/internal/handler/usage_handler.go
+++ b/backend/internal/handler/usage_handler.go
@@ -1,6 +1,8 @@
package handler
import (
+ "context"
+ "fmt"
"strconv"
"strings"
"time"
@@ -16,6 +18,14 @@ import (
"github.com/gin-gonic/gin"
)
+const (
+ maxUserUsagePage = 1000
+ maxUserUsagePageSize = 100
+ maxUserUsageOffset = 10_000
+ maxUserUsageRangeDays = 366
+ maxUserUsageQueryTime = 10 * time.Second
+)
+
// UsageHandler handles usage-related requests
type UsageHandler struct {
usageService *service.UsageService
@@ -38,8 +48,14 @@ func (h *UsageHandler) List(c *gin.Context) {
response.Unauthorized(c, "User not authenticated")
return
}
+ requestCtx, cancel := boundedUsageContext(c.Request.Context())
+ defer cancel()
- page, pageSize := response.ParsePagination(c)
+ page, pageSize, err := parseUsagePagination(c)
+ if err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
var apiKeyID int64
if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
@@ -50,7 +66,7 @@ func (h *UsageHandler) List(c *gin.Context) {
}
// [Security Fix] Verify API Key ownership to prevent horizontal privilege escalation
- apiKey, err := h.apiKeyService.GetByID(c.Request.Context(), id)
+ apiKey, err := h.apiKeyService.GetByID(requestCtx, id)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -96,27 +112,10 @@ func (h *UsageHandler) List(c *gin.Context) {
billingType = &bt
}
- // Parse date range
- var startTime, endTime *time.Time
- userTZ := c.Query("timezone") // Get user's timezone from request
- if startDateStr := c.Query("start_date"); startDateStr != "" {
- t, err := timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
- if err != nil {
- response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
- return
- }
- startTime = &t
- }
-
- if endDateStr := c.Query("end_date"); endDateStr != "" {
- t, err := timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
- if err != nil {
- response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
- return
- }
- // Use half-open range [start, end), move to next calendar day start (DST-safe).
- t = t.AddDate(0, 0, 1)
- endTime = &t
+ startTime, endTime, err := parseBoundedUsageTimeRange(c, 365, false)
+ if err != nil {
+ response.BadRequest(c, err.Error())
+ return
}
params := pagination.PaginationParams{
@@ -132,11 +131,11 @@ func (h *UsageHandler) List(c *gin.Context) {
RequestType: requestType,
Stream: stream,
BillingType: billingType,
- StartTime: startTime,
- EndTime: endTime,
+ StartTime: &startTime,
+ EndTime: &endTime,
}
- records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
+ records, result, err := h.usageService.ListWithFilters(requestCtx, params, filters)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -157,6 +156,8 @@ func (h *UsageHandler) GetByID(c *gin.Context) {
response.Unauthorized(c, "User not authenticated")
return
}
+ requestCtx, cancel := boundedUsageContext(c.Request.Context())
+ defer cancel()
usageID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
@@ -164,18 +165,12 @@ func (h *UsageHandler) GetByID(c *gin.Context) {
return
}
- record, err := h.usageService.GetByID(c.Request.Context(), usageID)
+ record, err := h.usageService.GetByIDForUser(requestCtx, usageID, subject.UserID)
if err != nil {
response.ErrorFrom(c, err)
return
}
- // 验证所有权
- if record.UserID != subject.UserID {
- response.Forbidden(c, "Not authorized to access this record")
- return
- }
-
response.Success(c, dto.UsageLogFromService(record))
}
@@ -187,6 +182,8 @@ func (h *UsageHandler) Stats(c *gin.Context) {
response.Unauthorized(c, "User not authenticated")
return
}
+ requestCtx, cancel := boundedUsageContext(c.Request.Context())
+ defer cancel()
var apiKeyID int64
if apiKeyIDStr := c.Query("api_key_id"); apiKeyIDStr != "" {
@@ -197,7 +194,7 @@ func (h *UsageHandler) Stats(c *gin.Context) {
}
// [Security Fix] Verify API Key ownership to prevent horizontal privilege escalation
- apiKey, err := h.apiKeyService.GetByID(c.Request.Context(), id)
+ apiKey, err := h.apiKeyService.GetByID(requestCtx, id)
if err != nil {
response.NotFound(c, "API key not found")
return
@@ -219,21 +216,13 @@ func (h *UsageHandler) Stats(c *gin.Context) {
startDateStr := c.Query("start_date")
endDateStr := c.Query("end_date")
- if startDateStr != "" && endDateStr != "" {
- // 使用自定义日期范围
+ if startDateStr != "" || endDateStr != "" {
var err error
- startTime, err = timezone.ParseInUserLocation("2006-01-02", startDateStr, userTZ)
- if err != nil {
- response.BadRequest(c, "Invalid start_date format, use YYYY-MM-DD")
- return
- }
- endTime, err = timezone.ParseInUserLocation("2006-01-02", endDateStr, userTZ)
+ startTime, endTime, err = parseBoundedUsageTimeRange(c, 365, true)
if err != nil {
- response.BadRequest(c, "Invalid end_date format, use YYYY-MM-DD")
+ response.BadRequest(c, err.Error())
return
}
- // 与 SQL 条件 created_at < end 对齐,使用次日 00:00 作为上边界(DST-safe)。
- endTime = endTime.AddDate(0, 0, 1)
} else {
// 使用 period 参数
period := c.DefaultQuery("period", "today")
@@ -253,9 +242,9 @@ func (h *UsageHandler) Stats(c *gin.Context) {
var stats *service.UsageStats
var err error
if apiKeyID > 0 {
- stats, err = h.usageService.GetStatsByAPIKey(c.Request.Context(), apiKeyID, startTime, endTime)
+ stats, err = h.usageService.GetStatsByAPIKey(requestCtx, apiKeyID, startTime, endTime)
} else {
- stats, err = h.usageService.GetStatsByUser(c.Request.Context(), subject.UserID, startTime, endTime)
+ stats, err = h.usageService.GetStatsByUser(requestCtx, subject.UserID, startTime, endTime)
}
if err != nil {
response.ErrorFrom(c, err)
@@ -265,37 +254,84 @@ func (h *UsageHandler) Stats(c *gin.Context) {
response.Success(c, stats)
}
-// parseUserTimeRange parses start_date, end_date query parameters for user dashboard
-// Uses user's timezone if provided, otherwise falls back to server timezone
-func parseUserTimeRange(c *gin.Context) (time.Time, time.Time) {
- userTZ := c.Query("timezone") // Get user's timezone from request
- now := timezone.NowInUserLocation(userTZ)
- startDate := c.Query("start_date")
- endDate := c.Query("end_date")
+func parseUsagePagination(c *gin.Context) (int, int, error) {
+ parse := func(name string, defaultValue int) (int, error) {
+ raw := strings.TrimSpace(c.Query(name))
+ if raw == "" {
+ return defaultValue, nil
+ }
+ value, err := strconv.ParseInt(raw, 10, 32)
+ if err != nil || value <= 0 {
+ return 0, fmt.Errorf("invalid %s", name)
+ }
+ return int(value), nil
+ }
- var startTime, endTime time.Time
+ page, err := parse("page", 1)
+ if err != nil {
+ return 0, 0, err
+ }
+ if page > maxUserUsagePage {
+ return 0, 0, fmt.Errorf("page exceeds maximum %d", maxUserUsagePage)
+ }
- if startDate != "" {
- if t, err := timezone.ParseInUserLocation("2006-01-02", startDate, userTZ); err == nil {
- startTime = t
- } else {
- startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
- }
- } else {
- startTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, -7), userTZ)
+ sizeName := "page_size"
+ if strings.TrimSpace(c.Query(sizeName)) == "" && strings.TrimSpace(c.Query("limit")) != "" {
+ sizeName = "limit"
+ }
+ pageSize, err := parse(sizeName, 20)
+ if err != nil {
+ return 0, 0, err
}
+ if pageSize > maxUserUsagePageSize {
+ return 0, 0, fmt.Errorf("%s exceeds maximum %d", sizeName, maxUserUsagePageSize)
+ }
+ if page > 1 && page-1 > maxUserUsageOffset/pageSize {
+ return 0, 0, fmt.Errorf("page offset exceeds maximum %d", maxUserUsageOffset)
+ }
+ return page, pageSize, nil
+}
- if endDate != "" {
- if t, err := timezone.ParseInUserLocation("2006-01-02", endDate, userTZ); err == nil {
- endTime = t.Add(24 * time.Hour) // Include the end date
- } else {
- endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
- }
- } else {
- endTime = timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
+func boundedUsageContext(parent context.Context) (context.Context, context.CancelFunc) {
+ return context.WithTimeout(parent, maxUserUsageQueryTime)
+}
+
+// parseBoundedUsageTimeRange returns a half-open, calendar-day-aligned range.
+func parseBoundedUsageTimeRange(c *gin.Context, defaultLookbackDays int, requirePair bool) (time.Time, time.Time, error) {
+ userTZ := c.Query("timezone")
+ startRaw := strings.TrimSpace(c.Query("start_date"))
+ endRaw := strings.TrimSpace(c.Query("end_date"))
+ if requirePair && (startRaw == "") != (endRaw == "") {
+ return time.Time{}, time.Time{}, fmt.Errorf("start_date and end_date must be provided together")
}
- return startTime, endTime
+ now := timezone.NowInUserLocation(userTZ)
+ endTime := timezone.StartOfDayInUserLocation(now.AddDate(0, 0, 1), userTZ)
+ startTime := endTime.AddDate(0, 0, -defaultLookbackDays)
+ var err error
+ if startRaw != "" {
+ startTime, err = timezone.ParseInUserLocation("2006-01-02", startRaw, userTZ)
+ if err != nil {
+ return time.Time{}, time.Time{}, fmt.Errorf("invalid start_date format, use YYYY-MM-DD")
+ }
+ }
+ if endRaw != "" {
+ endTime, err = timezone.ParseInUserLocation("2006-01-02", endRaw, userTZ)
+ if err != nil {
+ return time.Time{}, time.Time{}, fmt.Errorf("invalid end_date format, use YYYY-MM-DD")
+ }
+ endTime = endTime.AddDate(0, 0, 1)
+ }
+ if startRaw == "" {
+ startTime = endTime.AddDate(0, 0, -defaultLookbackDays)
+ }
+ if !startTime.Before(endTime) {
+ return time.Time{}, time.Time{}, fmt.Errorf("start_date must be before or equal to end_date")
+ }
+ if endTime.After(startTime.AddDate(0, 0, maxUserUsageRangeDays)) {
+ return time.Time{}, time.Time{}, fmt.Errorf("usage date range exceeds maximum %d days", maxUserUsageRangeDays)
+ }
+ return startTime, endTime, nil
}
// DashboardStats handles getting user dashboard statistics
@@ -306,8 +342,10 @@ func (h *UsageHandler) DashboardStats(c *gin.Context) {
response.Unauthorized(c, "User not authenticated")
return
}
+ requestCtx, cancel := boundedUsageContext(c.Request.Context())
+ defer cancel()
- stats, err := h.usageService.GetUserDashboardStats(c.Request.Context(), subject.UserID)
+ stats, err := h.usageService.GetUserDashboardStats(requestCtx, subject.UserID)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -324,11 +362,17 @@ func (h *UsageHandler) DashboardTrend(c *gin.Context) {
response.Unauthorized(c, "User not authenticated")
return
}
+ requestCtx, cancel := boundedUsageContext(c.Request.Context())
+ defer cancel()
- startTime, endTime := parseUserTimeRange(c)
+ startTime, endTime, err := parseBoundedUsageTimeRange(c, 8, false)
+ if err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
granularity := c.DefaultQuery("granularity", "day")
- trend, err := h.usageService.GetUserUsageTrendByUserID(c.Request.Context(), subject.UserID, startTime, endTime, granularity)
+ trend, err := h.usageService.GetUserUsageTrendByUserID(requestCtx, subject.UserID, startTime, endTime, granularity)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -337,7 +381,7 @@ func (h *UsageHandler) DashboardTrend(c *gin.Context) {
response.Success(c, gin.H{
"trend": trend,
"start_date": startTime.Format("2006-01-02"),
- "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
+ "end_date": endTime.AddDate(0, 0, -1).Format("2006-01-02"),
"granularity": granularity,
})
}
@@ -350,10 +394,16 @@ func (h *UsageHandler) DashboardModels(c *gin.Context) {
response.Unauthorized(c, "User not authenticated")
return
}
+ requestCtx, cancel := boundedUsageContext(c.Request.Context())
+ defer cancel()
- startTime, endTime := parseUserTimeRange(c)
+ startTime, endTime, err := parseBoundedUsageTimeRange(c, 8, false)
+ if err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
- stats, err := h.usageService.GetUserModelStats(c.Request.Context(), subject.UserID, startTime, endTime)
+ stats, err := h.usageService.GetUserModelStats(requestCtx, subject.UserID, startTime, endTime)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -362,7 +412,7 @@ func (h *UsageHandler) DashboardModels(c *gin.Context) {
response.Success(c, gin.H{
"models": stats,
"start_date": startTime.Format("2006-01-02"),
- "end_date": endTime.Add(-24 * time.Hour).Format("2006-01-02"),
+ "end_date": endTime.AddDate(0, 0, -1).Format("2006-01-02"),
})
}
@@ -379,6 +429,8 @@ func (h *UsageHandler) DashboardAPIKeysUsage(c *gin.Context) {
response.Unauthorized(c, "User not authenticated")
return
}
+ requestCtx, cancel := boundedUsageContext(c.Request.Context())
+ defer cancel()
var req BatchAPIKeysUsageRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -397,7 +449,7 @@ func (h *UsageHandler) DashboardAPIKeysUsage(c *gin.Context) {
return
}
- validAPIKeyIDs, err := h.apiKeyService.VerifyOwnership(c.Request.Context(), subject.UserID, req.APIKeyIDs)
+ validAPIKeyIDs, err := h.apiKeyService.VerifyOwnership(requestCtx, subject.UserID, req.APIKeyIDs)
if err != nil {
response.ErrorFrom(c, err)
return
@@ -408,7 +460,12 @@ func (h *UsageHandler) DashboardAPIKeysUsage(c *gin.Context) {
return
}
- stats, err := h.usageService.GetBatchAPIKeyUsageStats(c.Request.Context(), validAPIKeyIDs, time.Time{}, time.Time{})
+ startTime, endTime, err := parseBoundedUsageTimeRange(c, 365, false)
+ if err != nil {
+ response.BadRequest(c, err.Error())
+ return
+ }
+ stats, err := h.usageService.GetBatchAPIKeyUsageStats(requestCtx, validAPIKeyIDs, startTime, endTime)
if err != nil {
response.ErrorFrom(c, err)
return
diff --git a/backend/internal/handler/usage_handler_security_test.go b/backend/internal/handler/usage_handler_security_test.go
new file mode 100644
index 00000000000..e0062a82c10
--- /dev/null
+++ b/backend/internal/handler/usage_handler_security_test.go
@@ -0,0 +1,69 @@
+//go:build unit
+
+package handler
+
+import (
+ "context"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func usageSecurityTestContext(target string) *gin.Context {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest("GET", target, nil)
+ return c
+}
+
+func TestBoundedUsageContextHasQueryDeadline(t *testing.T) {
+ ctx, cancel := boundedUsageContext(context.Background())
+ defer cancel()
+
+ deadline, ok := ctx.Deadline()
+ require.True(t, ok)
+ require.LessOrEqual(t, time.Until(deadline), maxUserUsageQueryTime)
+ require.Greater(t, time.Until(deadline), 0*time.Second)
+}
+
+func TestParseUsagePaginationRejectsDeepAndOverflowingOffsets(t *testing.T) {
+ for _, target := range []string{
+ "/?page=1001",
+ "/?page=102&page_size=100",
+ "/?page=999999999999999999999999999999",
+ "/?page_size=101",
+ "/?limit=101",
+ } {
+ _, _, err := parseUsagePagination(usageSecurityTestContext(target))
+ require.Error(t, err, target)
+ }
+
+ page, pageSize, err := parseUsagePagination(usageSecurityTestContext("/?page=101&page_size=100"))
+ require.NoError(t, err)
+ require.Equal(t, 101, page)
+ require.Equal(t, 100, pageSize)
+ require.Equal(t, maxUserUsageOffset, (page-1)*pageSize)
+}
+
+func TestParseBoundedUsageTimeRangeRejectsInvertedAndOversizedRanges(t *testing.T) {
+ for _, target := range []string{
+ "/?timezone=UTC&start_date=2026-07-10&end_date=2026-07-01",
+ "/?timezone=UTC&start_date=2020-01-01&end_date=2026-07-01",
+ "/?timezone=UTC&start_date=2026-07-01",
+ } {
+ _, _, err := parseBoundedUsageTimeRange(usageSecurityTestContext(target), 365, true)
+ require.Error(t, err, target)
+ }
+
+ start, end, err := parseBoundedUsageTimeRange(
+ usageSecurityTestContext("/?timezone=UTC&start_date=2025-07-02&end_date=2026-07-01"),
+ 365,
+ true,
+ )
+ require.NoError(t, err)
+ require.True(t, start.Before(end))
+ require.False(t, end.After(start.AddDate(0, 0, maxUserUsageRangeDays)))
+}
diff --git a/backend/internal/handler/usage_record_submit_task_test.go b/backend/internal/handler/usage_record_submit_task_test.go
index 5c9458158a2..21bdf76ba8c 100644
--- a/backend/internal/handler/usage_record_submit_task_test.go
+++ b/backend/internal/handler/usage_record_submit_task_test.go
@@ -6,6 +6,7 @@ import (
"testing"
"time"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
@@ -24,30 +25,39 @@ func newUsageRecordTestPool(t *testing.T) *service.UsageRecordWorkerPool {
return pool
}
-func TestGatewayHandlerSubmitUsageRecordTask_WithPool(t *testing.T) {
+func TestOpenAIWSBillingRequestID_IsStablePerConnectionTurnAndDistinctAcrossTurns(t *testing.T) {
+ ctx := context.WithValue(context.Background(), ctxkey.RequestID, "connection-request-1")
+ connectionID := openAIWSBillingConnectionID(ctx, "session-hash")
+ first := deriveOpenAIWSBillingRequestID(connectionID, 1)
+ second := deriveOpenAIWSBillingRequestID(connectionID, 2)
+
+ require.Equal(t, first, deriveOpenAIWSBillingRequestID(openAIWSBillingConnectionID(ctx, "different-session"), 1))
+ require.NotEqual(t, first, second)
+ require.Contains(t, first, ":turn:1")
+}
+
+func TestGatewayHandlerSubmitUsageRecordTask_BypassesInMemoryPool(t *testing.T) {
pool := newUsageRecordTestPool(t)
h := &GatewayHandler{usageRecordWorkerPool: pool}
- done := make(chan struct{})
- h.submitUsageRecordTask(func(ctx context.Context) {
- close(done)
+ var called atomic.Bool
+ h.submitUsageRecordTask(context.Background(), func(ctx context.Context) {
+ _, hasDeadline := ctx.Deadline()
+ require.False(t, hasDeadline)
+ called.Store(true)
})
- select {
- case <-done:
- case <-time.After(time.Second):
- t.Fatal("task not executed")
- }
+ require.True(t, called.Load(), "durable producer must finish before helper returns")
+ require.Zero(t, pool.Stats().SubmittedTasks, "billable gateway usage must not enter an in-memory queue")
}
-func TestGatewayHandlerSubmitUsageRecordTask_WithoutPoolSyncFallback(t *testing.T) {
+func TestGatewayHandlerSubmitUsageRecordTask_WithoutPoolSyncFallbackHasNoShortDeadline(t *testing.T) {
h := &GatewayHandler{}
var called atomic.Bool
- h.submitUsageRecordTask(func(ctx context.Context) {
- if _, ok := ctx.Deadline(); !ok {
- t.Fatal("expected deadline in fallback context")
- }
+ h.submitUsageRecordTask(context.Background(), func(ctx context.Context) {
+ _, hasDeadline := ctx.Deadline()
+ require.False(t, hasDeadline)
called.Store(true)
})
@@ -57,7 +67,7 @@ func TestGatewayHandlerSubmitUsageRecordTask_WithoutPoolSyncFallback(t *testing.
func TestGatewayHandlerSubmitUsageRecordTask_NilTask(t *testing.T) {
h := &GatewayHandler{}
require.NotPanics(t, func() {
- h.submitUsageRecordTask(nil)
+ h.submitUsageRecordTask(context.Background(), nil)
})
}
@@ -66,41 +76,39 @@ func TestGatewayHandlerSubmitUsageRecordTask_WithoutPool_TaskPanicRecovered(t *t
var called atomic.Bool
require.NotPanics(t, func() {
- h.submitUsageRecordTask(func(ctx context.Context) {
+ h.submitUsageRecordTask(context.Background(), func(ctx context.Context) {
panic("usage task panic")
})
})
- h.submitUsageRecordTask(func(ctx context.Context) {
+ h.submitUsageRecordTask(context.Background(), func(ctx context.Context) {
called.Store(true)
})
require.True(t, called.Load(), "panic 后后续任务应仍可执行")
}
-func TestOpenAIGatewayHandlerSubmitUsageRecordTask_WithPool(t *testing.T) {
+func TestOpenAIGatewayHandlerSubmitUsageRecordTask_BypassesInMemoryPool(t *testing.T) {
pool := newUsageRecordTestPool(t)
h := &OpenAIGatewayHandler{usageRecordWorkerPool: pool}
- done := make(chan struct{})
- h.submitUsageRecordTask(func(ctx context.Context) {
- close(done)
+ var called atomic.Bool
+ h.submitUsageRecordTask(context.Background(), func(ctx context.Context) {
+ _, hasDeadline := ctx.Deadline()
+ require.False(t, hasDeadline)
+ called.Store(true)
})
- select {
- case <-done:
- case <-time.After(time.Second):
- t.Fatal("task not executed")
- }
+ require.True(t, called.Load(), "durable producer must finish before helper returns")
+ require.Zero(t, pool.Stats().SubmittedTasks, "billable gateway usage must not enter an in-memory queue")
}
-func TestOpenAIGatewayHandlerSubmitUsageRecordTask_WithoutPoolSyncFallback(t *testing.T) {
+func TestOpenAIGatewayHandlerSubmitUsageRecordTask_WithoutPoolSyncFallbackHasNoShortDeadline(t *testing.T) {
h := &OpenAIGatewayHandler{}
var called atomic.Bool
- h.submitUsageRecordTask(func(ctx context.Context) {
- if _, ok := ctx.Deadline(); !ok {
- t.Fatal("expected deadline in fallback context")
- }
+ h.submitUsageRecordTask(context.Background(), func(ctx context.Context) {
+ _, hasDeadline := ctx.Deadline()
+ require.False(t, hasDeadline)
called.Store(true)
})
@@ -110,7 +118,7 @@ func TestOpenAIGatewayHandlerSubmitUsageRecordTask_WithoutPoolSyncFallback(t *te
func TestOpenAIGatewayHandlerSubmitUsageRecordTask_NilTask(t *testing.T) {
h := &OpenAIGatewayHandler{}
require.NotPanics(t, func() {
- h.submitUsageRecordTask(nil)
+ h.submitUsageRecordTask(context.Background(), nil)
})
}
@@ -119,13 +127,88 @@ func TestOpenAIGatewayHandlerSubmitUsageRecordTask_WithoutPool_TaskPanicRecovere
var called atomic.Bool
require.NotPanics(t, func() {
- h.submitUsageRecordTask(func(ctx context.Context) {
+ h.submitUsageRecordTask(context.Background(), func(ctx context.Context) {
panic("usage task panic")
})
})
- h.submitUsageRecordTask(func(ctx context.Context) {
+ h.submitUsageRecordTask(context.Background(), func(ctx context.Context) {
called.Store(true)
})
require.True(t, called.Load(), "panic 后后续任务应仍可执行")
}
+
+func TestOpenAIGatewayHandlerSubmitMandatoryUsageRecordTask_DroppedTaskSyncFallback(t *testing.T) {
+ pool := service.NewUsageRecordWorkerPoolWithOptions(service.UsageRecordWorkerPoolOptions{
+ WorkerCount: 1,
+ QueueSize: 1,
+ TaskTimeout: time.Second,
+ OverflowPolicy: "drop",
+ OverflowSamplePercent: 0,
+ AutoScaleEnabled: false,
+ })
+ t.Cleanup(pool.Stop)
+ h := &OpenAIGatewayHandler{usageRecordWorkerPool: pool}
+
+ block := make(chan struct{})
+ release := make(chan struct{})
+ pool.Submit(func(ctx context.Context) {
+ close(block)
+ <-release
+ })
+ <-block
+ pool.Submit(func(ctx context.Context) {})
+
+ var called atomic.Bool
+ h.submitMandatoryUsageRecordTask(context.Background(), func(ctx context.Context) {
+ called.Store(true)
+ })
+ close(release)
+
+ require.True(t, called.Load(), "mandatory usage task must run synchronously when async submit is dropped")
+}
+
+func TestOpenAIGatewayHandlerSubmitOpenAIUsageRecordTask_ImageResultUsesMandatoryFallback(t *testing.T) {
+ pool := service.NewUsageRecordWorkerPoolWithOptions(service.UsageRecordWorkerPoolOptions{
+ WorkerCount: 1,
+ QueueSize: 1,
+ TaskTimeout: time.Second,
+ OverflowPolicy: "drop",
+ OverflowSamplePercent: 0,
+ AutoScaleEnabled: false,
+ })
+ t.Cleanup(pool.Stop)
+ h := &OpenAIGatewayHandler{usageRecordWorkerPool: pool}
+
+ block := make(chan struct{})
+ release := make(chan struct{})
+ pool.Submit(func(ctx context.Context) {
+ close(block)
+ <-release
+ })
+ <-block
+ pool.Submit(func(ctx context.Context) {})
+
+ var called atomic.Bool
+ h.submitOpenAIUsageRecordTask(context.Background(), &service.OpenAIForwardResult{ImageCount: 1}, func(ctx context.Context) {
+ called.Store(true)
+ })
+ close(release)
+
+ require.True(t, called.Load(), "image usage task must be mandatory when async submit is dropped")
+}
+
+func TestOpenAIGatewayHandlerSubmitOpenAIUsageRecordTask_BypassesInMemoryPool(t *testing.T) {
+ pool := newUsageRecordTestPool(t)
+ h := &OpenAIGatewayHandler{usageRecordWorkerPool: pool}
+ var called atomic.Bool
+
+ h.submitOpenAIUsageRecordTask(context.Background(), &service.OpenAIForwardResult{}, func(ctx context.Context) {
+ _, hasDeadline := ctx.Deadline()
+ require.False(t, hasDeadline, "durable outbox admission must not inherit the legacy short task timeout")
+ called.Store(true)
+ })
+
+ require.True(t, called.Load(), "durable producer must finish before helper returns")
+ require.Zero(t, pool.Stats().SubmittedTasks, "billable OpenAI usage must not enter drop/sample worker pool")
+}
diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go
index 8a864b5131f..33f7273c0e3 100644
--- a/backend/internal/handler/user_handler_test.go
+++ b/backend/internal/handler/user_handler_test.go
@@ -87,6 +87,12 @@ func (s *userHandlerRepoStub) ListWithFilters(context.Context, pagination.Pagina
func (s *userHandlerRepoStub) UpdateBalance(context.Context, int64, float64) error { return nil }
func (s *userHandlerRepoStub) DeductBalance(context.Context, int64, float64) error { return nil }
func (s *userHandlerRepoStub) UpdateConcurrency(context.Context, int64, int) error { return nil }
+func (s *userHandlerRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) {
+ return 0, nil
+}
+func (s *userHandlerRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
+ return 0, nil
+}
func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil }
func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
return 0, nil
@@ -270,19 +276,19 @@ func TestUserHandlerGetProfileReturnsLegacyCompatibilityFields(t *testing.T) {
AvatarURL: "https://cdn.example.com/linuxdo.png",
AvatarSource: "remote_url",
},
- identities: []service.UserAuthIdentityRecord{
- {
- ProviderType: "linuxdo",
- ProviderKey: "linuxdo",
- ProviderSubject: "linuxdo-subject-21",
- VerifiedAt: &verifiedAt,
- Metadata: map[string]any{
- "username": "linuxdo-handle",
- "avatar_url": "https://cdn.example.com/linuxdo.png",
- },
+ identities: []service.UserAuthIdentityRecord{
+ {
+ ProviderType: "linuxdo",
+ ProviderKey: "linuxdo",
+ ProviderSubject: "linuxdo-subject-21",
+ VerifiedAt: &verifiedAt,
+ Metadata: map[string]any{
+ "username": "linuxdo-handle",
+ "avatar_url": "https://cdn.example.com/linuxdo.png",
},
},
- }
+ },
+ }
handler := NewUserHandler(service.NewUserService(repo, nil, nil, nil), nil, nil, nil, nil)
recorder := httptest.NewRecorder()
@@ -400,6 +406,10 @@ func (s *userHandlerRefreshTokenCacheStub) GetRefreshToken(context.Context, stri
return nil, service.ErrRefreshTokenNotFound
}
+func (s *userHandlerRefreshTokenCacheStub) ConsumeRefreshToken(context.Context, string) (*service.RefreshTokenData, error) {
+ return nil, service.ErrRefreshTokenNotFound
+}
+
func (s *userHandlerRefreshTokenCacheStub) DeleteRefreshToken(context.Context, string) error {
return nil
}
diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go
index a8725875fba..a3cd21d185c 100644
--- a/backend/internal/handler/wire.go
+++ b/backend/internal/handler/wire.go
@@ -36,6 +36,7 @@ func ProvideAdminHandlers(
channelHandler *admin.ChannelHandler,
channelMonitorHandler *admin.ChannelMonitorHandler,
channelMonitorTemplateHandler *admin.ChannelMonitorRequestTemplateHandler,
+ contentModerationHandler *admin.ContentModerationHandler,
paymentHandler *admin.PaymentHandler,
affiliateHandler *admin.AffiliateHandler,
) *AdminHandlers {
@@ -67,6 +68,7 @@ func ProvideAdminHandlers(
Channel: channelHandler,
ChannelMonitor: channelMonitorHandler,
ChannelMonitorTemplate: channelMonitorTemplateHandler,
+ ContentModeration: contentModerationHandler,
Payment: paymentHandler,
Affiliate: affiliateHandler,
}
@@ -128,7 +130,7 @@ var ProviderSet = wire.NewSet(
// Top-level handlers
NewAuthHandler,
NewUserHandler,
- NewAPIKeyHandler,
+ ProvideAPIKeyHandler,
NewUsageHandler,
NewRedeemHandler,
NewSubscriptionHandler,
@@ -168,8 +170,9 @@ var ProviderSet = wire.NewSet(
admin.NewAdminAPIKeyHandler,
admin.NewScheduledTestHandler,
admin.NewChannelHandler,
- admin.NewChannelMonitorHandler,
+ admin.ProvideChannelMonitorHandler,
admin.NewChannelMonitorRequestTemplateHandler,
+ admin.NewContentModerationHandler,
admin.NewPaymentHandler,
admin.NewAffiliateHandler,
diff --git a/backend/internal/middleware/rate_limiter.go b/backend/internal/middleware/rate_limiter.go
index 819d74c27c5..123c0e11b4b 100644
--- a/backend/internal/middleware/rate_limiter.go
+++ b/backend/internal/middleware/rate_limiter.go
@@ -23,6 +23,9 @@ const (
// RateLimitOptions 限流可选配置
type RateLimitOptions struct {
FailureMode RateLimitFailureMode
+ // KeyFunc may return an authenticated stable subject instead of ClientIP.
+ // Empty values fall back to ClientIP.
+ KeyFunc func(*gin.Context) string
}
var rateLimitScript = redis.NewScript(`
@@ -88,8 +91,23 @@ func (r *RateLimiter) LimitWithOptions(key string, limit int, window time.Durati
}
return func(c *gin.Context) {
- ip := c.ClientIP()
- redisKey := r.prefix + key + ":" + ip
+ if r == nil || r.redis == nil {
+ log.Printf("[RateLimit] redis client unavailable: mode=%s", failureModeLabel(failureMode))
+ if failureMode == RateLimitFailClose {
+ abortRateLimit(c)
+ return
+ }
+ c.Next()
+ return
+ }
+ identity := ""
+ if opts.KeyFunc != nil {
+ identity = opts.KeyFunc(c)
+ }
+ if identity == "" {
+ identity = c.ClientIP()
+ }
+ redisKey := r.prefix + key + ":" + identity
ctx := c.Request.Context()
diff --git a/backend/internal/middleware/rate_limiter_test.go b/backend/internal/middleware/rate_limiter_test.go
index e362274f5e0..1f1e2cec571 100644
--- a/backend/internal/middleware/rate_limiter_test.go
+++ b/backend/internal/middleware/rate_limiter_test.go
@@ -60,6 +60,25 @@ func TestRateLimiterFailureModes(t *testing.T) {
require.Equal(t, http.StatusTooManyRequests, recorder.Code)
}
+func TestRateLimiterNilClientHonorsFailureMode(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ limiter := NewRateLimiter(nil)
+
+ failCloseRouter := gin.New()
+ failCloseRouter.Use(limiter.LimitWithOptions("test", 1, time.Second, RateLimitOptions{FailureMode: RateLimitFailClose}))
+ failCloseRouter.GET("/test", func(c *gin.Context) { c.Status(http.StatusNoContent) })
+ recorder := httptest.NewRecorder()
+ failCloseRouter.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/test", nil))
+ require.Equal(t, http.StatusTooManyRequests, recorder.Code)
+
+ failOpenRouter := gin.New()
+ failOpenRouter.Use(limiter.Limit("test", 1, time.Second))
+ failOpenRouter.GET("/test", func(c *gin.Context) { c.Status(http.StatusNoContent) })
+ recorder = httptest.NewRecorder()
+ failOpenRouter.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/test", nil))
+ require.Equal(t, http.StatusNoContent, recorder.Code)
+}
+
func TestRateLimiterDifferentIPsIndependent(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -141,3 +160,28 @@ func TestRateLimiterSuccessAndLimit(t *testing.T) {
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusTooManyRequests, recorder.Code)
}
+
+func TestRateLimiterUsesConfiguredAuthenticatedIdentity(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ originalRun := rateLimitRun
+ var capturedKey string
+ rateLimitRun = func(ctx context.Context, client *redis.Client, key string, windowMillis int64) (int64, bool, error) {
+ capturedKey = key
+ return 1, false, nil
+ }
+ t.Cleanup(func() { rateLimitRun = originalRun })
+
+ limiter := NewRateLimiter(redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"}))
+ router := gin.New()
+ router.Use(limiter.LimitWithOptions("payment-auth-status", 120, time.Minute, RateLimitOptions{
+ KeyFunc: func(*gin.Context) string { return "user-42" },
+ }))
+ router.GET("/test", func(c *gin.Context) { c.Status(http.StatusNoContent) })
+
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ req.RemoteAddr = "10.0.0.1:1234"
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, req)
+ require.Equal(t, http.StatusNoContent, recorder.Code)
+ require.Equal(t, "rate_limit:payment-auth-status:user-42", capturedKey)
+}
diff --git a/backend/internal/model/error_passthrough_rule.go b/backend/internal/model/error_passthrough_rule.go
index 620736cd870..ffa0f379709 100644
--- a/backend/internal/model/error_passthrough_rule.go
+++ b/backend/internal/model/error_passthrough_rule.go
@@ -1,7 +1,11 @@
// Package model 定义服务层使用的数据模型。
package model
-import "time"
+import (
+ "strings"
+ "time"
+ "unicode/utf8"
+)
// ErrorPassthroughRule 全局错误透传规则
// 用于控制上游错误如何返回给客户端
@@ -16,8 +20,8 @@ type ErrorPassthroughRule struct {
Platforms []string `json:"platforms"` // 适用平台列表
PassthroughCode bool `json:"passthrough_code"` // 是否透传原始状态码
ResponseCode *int `json:"response_code"` // 自定义状态码(passthrough_code=false 时使用)
- PassthroughBody bool `json:"passthrough_body"` // 是否透传原始错误信息
- CustomMessage *string `json:"custom_message"` // 自定义错误信息(passthrough_body=false 时使用)
+ PassthroughBody bool `json:"passthrough_body"` // 已弃用;原始上游错误信息不得返回给客户端
+ CustomMessage *string `json:"custom_message"` // 返回给客户端的自定义错误信息
SkipMonitoring bool `json:"skip_monitoring"` // 是否跳过运维监控记录
Description *string `json:"description"` // 规则描述
CreatedAt time.Time `json:"created_at"`
@@ -36,11 +40,13 @@ const (
PlatformOpenAI = "openai"
PlatformGemini = "gemini"
PlatformAntigravity = "antigravity"
+ PlatformKiro = "kiro"
+ PlatformCursor = "cursor"
)
// AllPlatforms 返回所有支持的平台列表
func AllPlatforms() []string {
- return []string{PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity}
+ return []string{PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformKiro, PlatformCursor}
}
// Validate 验证规则配置的有效性
@@ -58,8 +64,14 @@ func (r *ErrorPassthroughRule) Validate() error {
if !r.PassthroughCode && (r.ResponseCode == nil || *r.ResponseCode <= 0) {
return &ValidationError{Field: "response_code", Message: "response_code is required when passthrough_code is false"}
}
- if !r.PassthroughBody && (r.CustomMessage == nil || *r.CustomMessage == "") {
- return &ValidationError{Field: "custom_message", Message: "custom_message is required when passthrough_body is false"}
+ if r.PassthroughBody {
+ return &ValidationError{Field: "passthrough_body", Message: "raw upstream error body passthrough is disabled"}
+ }
+ if r.CustomMessage == nil || strings.TrimSpace(*r.CustomMessage) == "" {
+ return &ValidationError{Field: "custom_message", Message: "custom_message is required"}
+ }
+ if utf8.RuneCountInString(strings.TrimSpace(*r.CustomMessage)) > 512 {
+ return &ValidationError{Field: "custom_message", Message: "custom_message must not exceed 512 characters"}
}
return nil
}
diff --git a/backend/internal/payment/amount.go b/backend/internal/payment/amount.go
index 8489ffa3265..903d68c6dab 100644
--- a/backend/internal/payment/amount.go
+++ b/backend/internal/payment/amount.go
@@ -2,20 +2,34 @@ package payment
import (
"fmt"
+ "math"
+ "regexp"
"github.com/shopspring/decimal"
)
const centsPerYuan = 100
+var yuanAmountPattern = regexp.MustCompile(`^[0-9]+(?:\.[0-9]{1,2})?$`)
+
// YuanToFen converts a CNY yuan string (e.g. "10.50") to fen (int64).
// Uses shopspring/decimal for precision.
func YuanToFen(yuanStr string) (int64, error) {
+ if !yuanAmountPattern.MatchString(yuanStr) {
+ return 0, fmt.Errorf("invalid amount format")
+ }
d, err := decimal.NewFromString(yuanStr)
if err != nil {
- return 0, fmt.Errorf("invalid amount: %s", yuanStr)
+ return 0, fmt.Errorf("invalid amount")
+ }
+ if d.Cmp(decimal.Zero) <= 0 {
+ return 0, fmt.Errorf("amount must be positive")
+ }
+ scaled := d.Mul(decimal.NewFromInt(centsPerYuan))
+ if scaled.GreaterThan(decimal.NewFromInt(math.MaxInt64)) {
+ return 0, fmt.Errorf("amount exceeds supported range")
}
- return d.Mul(decimal.NewFromInt(centsPerYuan)).IntPart(), nil
+ return scaled.IntPart(), nil
}
// FenToYuan converts fen (int64) to yuan as a float64 for interface compatibility.
diff --git a/backend/internal/payment/amount_test.go b/backend/internal/payment/amount_test.go
index 6120b1894ad..ea7df9cff83 100644
--- a/backend/internal/payment/amount_test.go
+++ b/backend/internal/payment/amount_test.go
@@ -20,9 +20,9 @@ func TestYuanToFen(t *testing.T) {
{name: "one fen", input: "0.01", want: 1},
{name: "large amount", input: "99999.99", want: 9999999},
- // Edge: zero
- {name: "zero no decimal", input: "0", want: 0},
- {name: "zero with decimal", input: "0.00", want: 0},
+ // Provider create/refund amounts must be strictly positive.
+ {name: "zero no decimal", input: "0", wantErr: true},
+ {name: "zero with decimal", input: "0.00", wantErr: true},
// IEEE 754 precision edge case: 1.15 * 100 = 114.99999... in float64
{name: "ieee754 precision 1.15", input: "1.15", want: 115},
@@ -42,9 +42,14 @@ func TestYuanToFen(t *testing.T) {
// Single decimal place
{name: "single decimal 1.5", input: "1.5", want: 150},
- // Negative values
- {name: "negative one yuan", input: "-1.00", want: -100},
- {name: "negative with fen", input: "-10.50", want: -1050},
+ // Unsafe sign, scale and range inputs
+ {name: "negative one yuan", input: "-1.00", wantErr: true},
+ {name: "negative with fen", input: "-10.50", wantErr: true},
+ {name: "fractional fen", input: "0.009", wantErr: true},
+ {name: "three decimal places", input: "1.001", wantErr: true},
+ {name: "scientific notation", input: "1e2", wantErr: true},
+ {name: "int64 maximum fen", input: "92233720368547758.07", want: 9223372036854775807},
+ {name: "int64 overflow fen", input: "92233720368547758.08", wantErr: true},
// Invalid inputs
{name: "empty string", input: "", wantErr: true},
diff --git a/backend/internal/payment/crypto.go b/backend/internal/payment/crypto.go
index 0581469d654..3c12e4cbed8 100644
--- a/backend/internal/payment/crypto.go
+++ b/backend/internal/payment/crypto.go
@@ -5,6 +5,7 @@ import (
"crypto/cipher"
"crypto/rand"
"encoding/base64"
+ "encoding/json"
"fmt"
"io"
"strings"
@@ -13,14 +14,55 @@ import (
// AES256KeySize is the required key length (in bytes) for AES-256-GCM.
const AES256KeySize = 32
+// EncryptProviderConfig serializes and encrypts a payment-provider config.
+// Provider credentials must never be persisted as plaintext JSON.
+func EncryptProviderConfig(config map[string]string, key []byte) (string, error) {
+ if config == nil {
+ config = map[string]string{}
+ }
+ plaintext, err := json.Marshal(config)
+ if err != nil {
+ return "", fmt.Errorf("marshal provider config: %w", err)
+ }
+ encrypted, err := Encrypt(string(plaintext), key)
+ if err != nil {
+ return "", fmt.Errorf("encrypt provider config: %w", err)
+ }
+ return encrypted, nil
+}
+
+// DecryptProviderConfig reads encrypted provider config and, during the
+// controlled migration window, legacy plaintext JSON. The boolean reports
+// whether the stored value was plaintext and therefore must be re-encrypted.
+func DecryptProviderConfig(stored string, key []byte) (map[string]string, bool, error) {
+ if stored == "" {
+ return nil, false, nil
+ }
+ if len(key) != AES256KeySize {
+ return nil, false, fmt.Errorf("provider config encryption key must be %d bytes, got %d", AES256KeySize, len(key))
+ }
+
+ var config map[string]string
+ if err := json.Unmarshal([]byte(stored), &config); err == nil && config != nil {
+ return config, true, nil
+ }
+
+ plaintext, err := Decrypt(stored, key)
+ if err != nil {
+ return nil, false, fmt.Errorf("decrypt provider config: %w", err)
+ }
+ if err := json.Unmarshal([]byte(plaintext), &config); err != nil {
+ return nil, false, fmt.Errorf("decode provider config JSON: %w", err)
+ }
+ if config == nil {
+ return nil, false, fmt.Errorf("decode provider config JSON: object is required")
+ }
+ return config, false, nil
+}
+
// Encrypt encrypts plaintext using AES-256-GCM with the given 32-byte key.
// The output format is "iv:authTag:ciphertext" where each component is base64-encoded,
// matching the Node.js crypto.ts format for cross-compatibility.
-//
-// Deprecated: payment provider configs are now stored as plaintext JSON.
-// This function is kept only for seeding legacy ciphertext in tests and for
-// the transitional Decrypt fallback. Scheduled for removal after all live
-// deployments complete migration by re-saving their configs.
func Encrypt(plaintext string, key []byte) (string, error) {
if len(key) != AES256KeySize {
return "", fmt.Errorf("encryption key must be %d bytes, got %d", AES256KeySize, len(key))
@@ -59,11 +101,6 @@ func Encrypt(plaintext string, key []byte) (string, error) {
// Decrypt decrypts a ciphertext string produced by Encrypt.
// The input format is "iv:authTag:ciphertext" where each component is base64-encoded.
-//
-// Deprecated: payment provider configs are now stored as plaintext JSON.
-// This function remains only as a read-path fallback for pre-migration
-// ciphertext records. Scheduled for removal once all deployments re-save
-// their provider configs through the admin UI.
func Decrypt(ciphertext string, key []byte) (string, error) {
if len(key) != AES256KeySize {
return "", fmt.Errorf("encryption key must be %d bytes, got %d", AES256KeySize, len(key))
@@ -98,6 +135,12 @@ func Decrypt(ciphertext string, key []byte) (string, error) {
if err != nil {
return "", fmt.Errorf("create GCM: %w", err)
}
+ if len(nonce) != gcm.NonceSize() {
+ return "", fmt.Errorf("invalid IV length: expected %d bytes, got %d", gcm.NonceSize(), len(nonce))
+ }
+ if len(authTag) != gcm.Overhead() {
+ return "", fmt.Errorf("invalid auth tag length: expected %d bytes, got %d", gcm.Overhead(), len(authTag))
+ }
// Reconstruct the sealed data: ciphertext + authTag
sealed := append(encrypted, authTag...)
diff --git a/backend/internal/payment/crypto_test.go b/backend/internal/payment/crypto_test.go
index da8b6006e1e..010a0ca3dbd 100644
--- a/backend/internal/payment/crypto_test.go
+++ b/backend/internal/payment/crypto_test.go
@@ -2,6 +2,8 @@ package payment
import (
"crypto/rand"
+ "encoding/base64"
+ "encoding/json"
"strings"
"testing"
)
@@ -162,6 +164,86 @@ func TestDecryptInvalidFormat(t *testing.T) {
}
}
+func TestDecryptRejectsInvalidNonceAndTagLengthsWithoutPanic(t *testing.T) {
+ t.Parallel()
+ key := makeKey(t)
+ encoded := func(raw []byte) string { return base64.StdEncoding.EncodeToString(raw) }
+
+ inputs := []string{
+ encoded(make([]byte, 1)) + ":" + encoded(make([]byte, 16)) + ":" + encoded([]byte("ciphertext")),
+ encoded(make([]byte, 12)) + ":" + encoded(make([]byte, 1)) + ":" + encoded([]byte("ciphertext")),
+ }
+ for _, input := range inputs {
+ func() {
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ t.Fatalf("Decrypt panicked for malformed ciphertext: %v", recovered)
+ }
+ }()
+ if _, err := Decrypt(input, key); err == nil {
+ t.Fatal("Decrypt should reject malformed nonce or tag length")
+ }
+ }()
+ }
+}
+
+func TestProviderConfigEncryptionUsesCiphertextAndReadsLegacyPlaintext(t *testing.T) {
+ t.Parallel()
+ key := makeKey(t)
+ cfg := map[string]string{"secretKey": "sk-live-secret", "publishableKey": "pk-live"}
+
+ stored, err := EncryptProviderConfig(cfg, key)
+ if err != nil {
+ t.Fatalf("EncryptProviderConfig returned error: %v", err)
+ }
+ if strings.Contains(stored, "sk-live-secret") || json.Valid([]byte(stored)) {
+ t.Fatalf("provider config was not stored as opaque ciphertext: %q", stored)
+ }
+
+ decoded, legacyPlaintext, err := DecryptProviderConfig(stored, key)
+ if err != nil {
+ t.Fatalf("DecryptProviderConfig returned error: %v", err)
+ }
+ if legacyPlaintext || decoded["secretKey"] != cfg["secretKey"] {
+ t.Fatalf("decoded=%v legacyPlaintext=%v", decoded, legacyPlaintext)
+ }
+
+ legacy := `{"secretKey":"sk-legacy","publishableKey":"pk-legacy"}`
+ decoded, legacyPlaintext, err = DecryptProviderConfig(legacy, key)
+ if err != nil {
+ t.Fatalf("DecryptProviderConfig legacy plaintext returned error: %v", err)
+ }
+ if !legacyPlaintext || decoded["secretKey"] != "sk-legacy" {
+ t.Fatalf("decoded=%v legacyPlaintext=%v", decoded, legacyPlaintext)
+ }
+}
+
+func TestProviderConfigDecryptionFailsClosed(t *testing.T) {
+ t.Parallel()
+ key := makeKey(t)
+ wrongKey := makeKey(t)
+ stored, err := EncryptProviderConfig(map[string]string{"secret": "value"}, key)
+ if err != nil {
+ t.Fatalf("EncryptProviderConfig returned error: %v", err)
+ }
+
+ for name, tc := range map[string]struct {
+ stored string
+ key []byte
+ }{
+ "missing key": {stored: stored, key: nil},
+ "wrong key": {stored: stored, key: wrongKey},
+ "malformed config": {stored: "not-json-or-ciphertext", key: key},
+ } {
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+ if _, _, err := DecryptProviderConfig(tc.stored, tc.key); err == nil {
+ t.Fatal("expected fail-closed provider config error")
+ }
+ })
+ }
+}
+
func TestCiphertextFormat(t *testing.T) {
t.Parallel()
key := makeKey(t)
diff --git a/backend/internal/payment/limits.go b/backend/internal/payment/limits.go
new file mode 100644
index 00000000000..b1853cd5a3f
--- /dev/null
+++ b/backend/internal/payment/limits.go
@@ -0,0 +1,105 @@
+package payment
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "math"
+ "strings"
+)
+
+const maxInstanceLimitsBytes = 64 << 10
+
+var validInstanceLimitKeys = map[string]struct{}{
+ TypeAlipay: {},
+ TypeWxpay: {},
+ TypeAlipayDirect: {},
+ TypeWxpayDirect: {},
+ TypeStripe: {},
+ TypeCard: {},
+ TypeLink: {},
+}
+
+// ParseInstanceLimits strictly decodes per-provider financial limits. An empty
+// string intentionally means that only the global payment limits apply.
+func ParseInstanceLimits(raw string) (InstanceLimits, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return InstanceLimits{}, nil
+ }
+ if len(raw) > maxInstanceLimitsBytes {
+ return nil, fmt.Errorf("limits exceed %d bytes", maxInstanceLimitsBytes)
+ }
+
+ decoder := json.NewDecoder(strings.NewReader(raw))
+ decoder.DisallowUnknownFields()
+ limits := InstanceLimits{}
+ if err := decoder.Decode(&limits); err != nil {
+ return nil, fmt.Errorf("decode limits: %w", err)
+ }
+ if err := decoder.Decode(&struct{}{}); err != io.EOF {
+ if err == nil {
+ return nil, fmt.Errorf("decode limits: trailing JSON value")
+ }
+ return nil, fmt.Errorf("decode limits: %w", err)
+ }
+ if len(limits) == 0 {
+ return nil, fmt.Errorf("limits object must contain at least one configured channel")
+ }
+
+ for key, channel := range limits {
+ if _, ok := validInstanceLimitKeys[key]; !ok {
+ return nil, fmt.Errorf("unsupported payment type %q", key)
+ }
+ if err := validateChannelLimits(key, channel); err != nil {
+ return nil, err
+ }
+ }
+ return limits, nil
+}
+
+// ValidateInstanceLimits also binds configured channel keys to the provider's
+// supported types, preventing dormant or misspelled limits from being saved.
+func ValidateInstanceLimits(raw, providerKey, supportedTypes string) error {
+ limits, err := ParseInstanceLimits(raw)
+ if err != nil {
+ return err
+ }
+ for key := range limits {
+ if providerKey == TypeStripe {
+ if key != TypeStripe {
+ return fmt.Errorf("stripe limits must use the %q key", TypeStripe)
+ }
+ continue
+ }
+ if !InstanceSupportsType(supportedTypes, key) {
+ return fmt.Errorf("limits key %q is not an enabled supported type", key)
+ }
+ }
+ return nil
+}
+
+func validateChannelLimits(key string, limits ChannelLimits) error {
+ values := []struct {
+ name string
+ value float64
+ }{
+ {name: "dailyLimit", value: limits.DailyLimit},
+ {name: "singleMin", value: limits.SingleMin},
+ {name: "singleMax", value: limits.SingleMax},
+ }
+ configured := false
+ for _, item := range values {
+ if math.IsNaN(item.value) || math.IsInf(item.value, 0) || item.value < 0 {
+ return fmt.Errorf("%s.%s must be a finite non-negative number", key, item.name)
+ }
+ configured = configured || item.value > 0
+ }
+ if !configured {
+ return fmt.Errorf("%s must configure at least one positive limit", key)
+ }
+ if limits.SingleMin > 0 && limits.SingleMax > 0 && limits.SingleMin > limits.SingleMax {
+ return fmt.Errorf("%s.singleMin must not exceed singleMax", key)
+ }
+ return nil
+}
diff --git a/backend/internal/payment/load_balancer.go b/backend/internal/payment/load_balancer.go
index 41fd2c50cf5..3b0302d501e 100644
--- a/backend/internal/payment/load_balancer.go
+++ b/backend/internal/payment/load_balancer.go
@@ -2,7 +2,6 @@ package payment
import (
"context"
- "encoding/json"
"fmt"
"log/slog"
"strings"
@@ -83,7 +82,7 @@ type instanceCandidate struct {
// 2. Batch-query daily usage (PENDING + PAID + COMPLETED + RECHARGING) for all candidates
// 3. Filter out instances where: single-min/max violated OR daily remaining < orderAmount
// 4. Pick from survivors using the configured strategy (round-robin / least-amount)
-// 5. If all filtered out, fall back to full list (let the provider itself reject)
+// 5. If no instance survives, fail closed without issuing a provider request
func (lb *DefaultLoadBalancer) SelectInstance(
ctx context.Context,
providerKey string,
@@ -98,15 +97,21 @@ func (lb *DefaultLoadBalancer) SelectInstance(
}
// Step 2: batch-fetch daily usage for all candidates.
- candidates := lb.attachDailyUsage(ctx, instances)
+ candidates, err := lb.attachDailyUsage(ctx, instances)
+ if err != nil {
+ return nil, err
+ }
// Step 3: filter by limits.
- available := filterByLimits(candidates, paymentType, orderAmount)
+ available, err := filterByLimits(candidates, paymentType, orderAmount)
+ if err != nil {
+ return nil, err
+ }
if len(available) == 0 {
- slog.Warn("all instances exceeded limits, using full candidate list",
+ slog.Warn("all payment provider instances rejected by configured limits",
"provider", providerKey, "payment_type", paymentType,
"order_amount", orderAmount, "count", len(candidates))
- available = candidates
+ return nil, fmt.Errorf("no payment provider instance has capacity for %s", paymentType)
}
// Step 4: pick by strategy.
@@ -169,7 +174,7 @@ func (lb *DefaultLoadBalancer) queryEnabledInstances(
func (lb *DefaultLoadBalancer) attachDailyUsage(
ctx context.Context,
instances []*dbent.PaymentProviderInstance,
-) []instanceCandidate {
+) ([]instanceCandidate, error) {
todayStart := startOfDay(time.Now())
// Collect instance IDs.
@@ -197,7 +202,7 @@ func (lb *DefaultLoadBalancer) attachDailyUsage(
Aggregate(dbent.Sum(paymentorder.FieldPayAmount)).
Scan(ctx, &rows)
if err != nil {
- slog.Warn("batch daily usage query failed, treating all as zero", "error", err)
+ return nil, fmt.Errorf("query provider daily usage: %w", err)
}
usageMap := make(map[string]float64, len(rows))
@@ -212,16 +217,19 @@ func (lb *DefaultLoadBalancer) attachDailyUsage(
dailyUsed: usageMap[fmt.Sprintf("%d", inst.ID)],
}
}
- return candidates
+ return candidates, nil
}
// filterByLimits removes instances that cannot accommodate the order:
// - orderAmount outside single-transaction [min, max]
// - daily remaining capacity (limit - used) < orderAmount
-func filterByLimits(candidates []instanceCandidate, paymentType PaymentType, orderAmount float64) []instanceCandidate {
+func filterByLimits(candidates []instanceCandidate, paymentType PaymentType, orderAmount float64) ([]instanceCandidate, error) {
var result []instanceCandidate
for _, c := range candidates {
- cl := getInstanceChannelLimits(c.inst, paymentType)
+ cl, err := getInstanceChannelLimits(c.inst, paymentType)
+ if err != nil {
+ return nil, fmt.Errorf("provider instance %d has invalid limits: %w", c.inst.ID, err)
+ }
if cl.SingleMin > 0 && orderAmount < cl.SingleMin {
slog.Info("order below instance single min, skipping",
@@ -242,17 +250,17 @@ func filterByLimits(candidates []instanceCandidate, paymentType PaymentType, ord
result = append(result, c)
}
- return result
+ return result, nil
}
// getInstanceChannelLimits returns the channel limits for a specific payment type.
-func getInstanceChannelLimits(inst *dbent.PaymentProviderInstance, paymentType PaymentType) ChannelLimits {
- if inst.Limits == "" {
- return ChannelLimits{}
+func getInstanceChannelLimits(inst *dbent.PaymentProviderInstance, paymentType PaymentType) (ChannelLimits, error) {
+ if err := ValidateInstanceLimits(inst.Limits, inst.ProviderKey, inst.SupportedTypes); err != nil {
+ return ChannelLimits{}, err
}
- var limits InstanceLimits
- if err := json.Unmarshal([]byte(inst.Limits), &limits); err != nil {
- return ChannelLimits{}
+ limits, err := ParseInstanceLimits(inst.Limits)
+ if err != nil {
+ return ChannelLimits{}, err
}
// For Stripe, limits are stored under the provider key "stripe".
lookupKey := paymentType
@@ -260,14 +268,24 @@ func getInstanceChannelLimits(inst *dbent.PaymentProviderInstance, paymentType P
lookupKey = "stripe"
}
if cl, ok := limits[lookupKey]; ok {
- return cl
+ return cl, nil
}
if aliasKey := legacyVisibleMethodAlias(lookupKey); aliasKey != "" {
if cl, ok := limits[aliasKey]; ok {
- return cl
+ return cl, nil
}
}
- return ChannelLimits{}
+ return ChannelLimits{}, nil
+}
+
+// GetInstanceChannelLimits exposes the exact runtime limit resolution used by
+// selection so the order transaction can re-check capacity while holding the
+// provider-instance admission lock.
+func GetInstanceChannelLimits(inst *dbent.PaymentProviderInstance, paymentType PaymentType) (ChannelLimits, error) {
+ if inst == nil {
+ return ChannelLimits{}, fmt.Errorf("payment provider instance is required")
+ }
+ return getInstanceChannelLimits(inst, paymentType)
}
// pickByStrategy selects one instance from the available candidates.
@@ -314,36 +332,9 @@ func (lb *DefaultLoadBalancer) buildSelection(selected *dbent.PaymentProviderIns
}, nil
}
-// decryptConfig parses a stored provider config.
-// New records are plaintext JSON; legacy records are AES-256-GCM ciphertext.
-// Unreadable values (legacy ciphertext without a valid key, or malformed data)
-// are treated as empty so the service keeps running while the admin re-enters
-// the config via the UI.
-//
-// TODO(deprecated-legacy-ciphertext): The AES fallback branch below is a
-// transitional compatibility shim for pre-plaintext records. Remove it (and
-// the encryptionKey field + the Decrypt import) after a few releases once all
-// live deployments have re-saved their provider configs through the UI.
func (lb *DefaultLoadBalancer) decryptConfig(stored string) (map[string]string, error) {
- if stored == "" {
- return nil, nil
- }
- var config map[string]string
- if err := json.Unmarshal([]byte(stored), &config); err == nil {
- return config, nil
- }
- // Deprecated: legacy AES-256-GCM ciphertext fallback — scheduled for removal.
- if len(lb.encryptionKey) == AES256KeySize {
- //nolint:staticcheck // SA1019: intentional legacy fallback, scheduled for removal
- if plaintext, err := Decrypt(stored, lb.encryptionKey); err == nil {
- if err := json.Unmarshal([]byte(plaintext), &config); err == nil {
- return config, nil
- }
- }
- }
- slog.Warn("payment provider config unreadable, treating as empty for re-entry",
- "stored_len", len(stored))
- return nil, nil
+ config, _, err := DecryptProviderConfig(stored, lb.encryptionKey)
+ return config, err
}
// GetInstanceDailyAmount returns the total completed order amount for an instance today.
diff --git a/backend/internal/payment/load_balancer_test.go b/backend/internal/payment/load_balancer_test.go
index ed08a7dd490..bd0467f0082 100644
--- a/backend/internal/payment/load_balancer_test.go
+++ b/backend/internal/payment/load_balancer_test.go
@@ -3,11 +3,20 @@
package payment
import (
+ "context"
+ "database/sql"
"encoding/json"
+ "fmt"
+ "strings"
"testing"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/enttest"
+
+ "entgo.io/ent/dialect"
+ entsql "entgo.io/ent/dialect/sql"
+ _ "modernc.org/sqlite"
)
func TestInstanceSupportsType(t *testing.T) {
@@ -102,13 +111,19 @@ func TestGetInstanceChannelLimitsFallsBackToLegacyDirectAliases(t *testing.T) {
t.Parallel()
inst := testInstance(1, TypeAlipay, makeLimitsJSON(TypeAlipayDirect, ChannelLimits{SingleMax: 66}))
- got := getInstanceChannelLimits(inst, TypeAlipay)
+ got, err := getInstanceChannelLimits(inst, TypeAlipay)
+ if err != nil {
+ t.Fatalf("getInstanceChannelLimits() error: %v", err)
+ }
if got.SingleMax != 66 {
t.Fatalf("getInstanceChannelLimits() = %+v, want SingleMax=66", got)
}
wxInst := testInstance(2, TypeWxpay, makeLimitsJSON(TypeWxpayDirect, ChannelLimits{SingleMin: 8}))
- wxGot := getInstanceChannelLimits(wxInst, TypeWxpay)
+ wxGot, err := getInstanceChannelLimits(wxInst, TypeWxpay)
+ if err != nil {
+ t.Fatalf("getInstanceChannelLimits() error: %v", err)
+ }
if wxGot.SingleMin != 8 {
t.Fatalf("getInstanceChannelLimits() = %+v, want SingleMin=8", wxGot)
}
@@ -147,6 +162,7 @@ func TestFilterByLimits(t *testing.T) {
paymentType PaymentType
orderAmount float64
wantIDs []int64 // expected surviving instance IDs
+ wantErr bool
}{
{
name: "order below SingleMin is filtered out",
@@ -243,7 +259,7 @@ func TestFilterByLimits(t *testing.T) {
},
paymentType: "alipay",
orderAmount: 99999,
- wantIDs: []int64{1},
+ wantErr: true,
},
{
name: "all limits combined - order passes all checks",
@@ -275,7 +291,16 @@ func TestFilterByLimits(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
- got := filterByLimits(tt.candidates, tt.paymentType, tt.orderAmount)
+ got, err := filterByLimits(tt.candidates, tt.paymentType, tt.orderAmount)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatalf("filterByLimits() expected error, got IDs %v", got)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("filterByLimits() error: %v", err)
+ }
gotIDs := make([]int64, len(got))
for i, c := range got {
gotIDs[i] = c.inst.ID
@@ -287,6 +312,90 @@ func TestFilterByLimits(t *testing.T) {
}
}
+func TestFilterByLimitsRejectsMalformedLimitsInsteadOfTreatingThemAsUnlimited(t *testing.T) {
+ t.Parallel()
+
+ candidates := []instanceCandidate{{
+ inst: testInstance(1, TypeEasyPay, `{not-json}`),
+ dailyUsed: 0,
+ }}
+ got, err := filterByLimits(candidates, TypeAlipay, 100)
+ if err == nil {
+ t.Fatalf("malformed limits returned candidates %v without a fail-closed error", got)
+ }
+}
+
+func TestSelectInstanceFailsClosedWhenAllCandidatesExceedLimits(t *testing.T) {
+ t.Parallel()
+
+ client, _ := newLoadBalancerTestClient(t)
+ ctx := context.Background()
+ _, err := client.PaymentProviderInstance.Create().
+ SetProviderKey(TypeEasyPay).
+ SetName("capacity-limited").
+ SetConfig("").
+ SetSupportedTypes(TypeAlipay).
+ SetEnabled(true).
+ SetLimits(`{"alipay":{"singleMax":10}}`).
+ Save(ctx)
+ if err != nil {
+ t.Fatalf("create provider instance: %v", err)
+ }
+
+ lb := NewDefaultLoadBalancer(client, nil)
+ selection, err := lb.SelectInstance(ctx, TypeEasyPay, TypeAlipay, StrategyRoundRobin, 100)
+ if err == nil || selection != nil {
+ t.Fatalf("SelectInstance = (%+v, %v), want fail-closed capacity error", selection, err)
+ }
+}
+
+func TestSelectInstanceFailsClosedWhenDailyUsageCannotBeRead(t *testing.T) {
+ t.Parallel()
+
+ client, sqlDB := newLoadBalancerTestClient(t)
+ ctx := context.Background()
+ _, err := client.PaymentProviderInstance.Create().
+ SetProviderKey(TypeEasyPay).
+ SetName("daily-limited").
+ SetConfig("").
+ SetSupportedTypes(TypeAlipay).
+ SetEnabled(true).
+ SetLimits(`{"alipay":{"dailyLimit":1000}}`).
+ Save(ctx)
+ if err != nil {
+ t.Fatalf("create provider instance: %v", err)
+ }
+ if _, err := sqlDB.ExecContext(ctx, `DROP TABLE payment_orders`); err != nil {
+ t.Fatalf("drop payment_orders: %v", err)
+ }
+
+ lb := NewDefaultLoadBalancer(client, nil)
+ selection, err := lb.SelectInstance(ctx, TypeEasyPay, TypeAlipay, StrategyRoundRobin, 10)
+ if err == nil || selection != nil {
+ t.Fatalf("SelectInstance = (%+v, %v), want fail-closed usage-query error", selection, err)
+ }
+}
+
+func newLoadBalancerTestClient(t *testing.T) (*dbent.Client, *sql.DB) {
+ t.Helper()
+ dbName := fmt.Sprintf(
+ "file:%s?mode=memory&cache=shared",
+ strings.NewReplacer("/", "_", " ", "_").Replace(t.Name()),
+ )
+ db, err := sql.Open("sqlite", dbName)
+ if err != nil {
+ t.Fatalf("open sqlite: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+ if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
+ t.Fatalf("enable foreign keys: %v", err)
+ }
+ driver := entsql.OpenDB(dialect.SQLite, db)
+ client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(driver)))
+ t.Cleanup(func() { _ = client.Close() })
+ return client, db
+}
+
// ---------------------------------------------------------------------------
// pickLeastAmount
// ---------------------------------------------------------------------------
@@ -357,6 +466,7 @@ func TestGetInstanceChannelLimits(t *testing.T) {
inst *dbent.PaymentProviderInstance
paymentType PaymentType
want ChannelLimits
+ wantErr bool
}{
{
name: "empty limits string returns zero ChannelLimits",
@@ -365,10 +475,10 @@ func TestGetInstanceChannelLimits(t *testing.T) {
want: ChannelLimits{},
},
{
- name: "invalid JSON returns zero ChannelLimits",
+ name: "invalid JSON fails closed",
inst: testInstance(1, "easypay", "not-json{"),
paymentType: "alipay",
- want: ChannelLimits{},
+ wantErr: true,
},
{
name: "valid JSON with matching payment type",
@@ -392,11 +502,22 @@ func TestGetInstanceChannelLimits(t *testing.T) {
want: ChannelLimits{SingleMin: 10, SingleMax: 500, DailyLimit: 5000},
},
{
- name: "stripe provider ignores payment type key even if present",
+ name: "stripe provider rejects dormant non-stripe limit key",
inst: testInstance(1, "stripe",
`{"stripe":{"singleMin":10,"singleMax":500},"alipay":{"singleMin":1,"singleMax":100}}`),
paymentType: "alipay",
- want: ChannelLimits{SingleMin: 10, SingleMax: 500},
+ wantErr: true,
+ },
+ {
+ name: "runtime rejects a limit key not enabled by supported types",
+ inst: &dbent.PaymentProviderInstance{
+ ID: 1,
+ ProviderKey: "easypay",
+ SupportedTypes: "alipay",
+ Limits: `{"wxpay":{"dailyLimit":100}}`,
+ },
+ paymentType: "alipay",
+ wantErr: true,
},
{
name: "non-stripe provider uses payment type as lookup key",
@@ -417,7 +538,16 @@ func TestGetInstanceChannelLimits(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
- got := getInstanceChannelLimits(tt.inst, tt.paymentType)
+ got, err := getInstanceChannelLimits(tt.inst, tt.paymentType)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatalf("getInstanceChannelLimits() expected error, got %+v", got)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("getInstanceChannelLimits() error: %v", err)
+ }
if got != tt.want {
t.Fatalf("getInstanceChannelLimits() = %+v, want %+v", got, tt.want)
}
@@ -474,7 +604,7 @@ func TestStartOfDay(t *testing.T) {
}
}
-func TestDecryptConfig_PlaintextAndLegacyCompat(t *testing.T) {
+func TestDecryptConfig_EncryptedAndPlaintextMigrationCompat(t *testing.T) {
t.Parallel()
key := make([]byte, AES256KeySize)
@@ -494,10 +624,11 @@ func TestDecryptConfig_PlaintextAndLegacyCompat(t *testing.T) {
}
tests := []struct {
- name string
- stored string
- key []byte
- want map[string]string
+ name string
+ stored string
+ key []byte
+ want map[string]string
+ wantErr bool
}{
{
name: "empty stored returns nil map",
@@ -506,10 +637,10 @@ func TestDecryptConfig_PlaintextAndLegacyCompat(t *testing.T) {
want: nil,
},
{
- name: "plaintext JSON parses directly",
- stored: plaintextJSON,
- key: nil,
- want: map[string]string{"appId": "app-123", "secret": "sec-xyz"},
+ name: "plaintext JSON without key fails closed",
+ stored: plaintextJSON,
+ key: nil,
+ wantErr: true,
},
{
name: "plaintext JSON works even with key present",
@@ -518,28 +649,28 @@ func TestDecryptConfig_PlaintextAndLegacyCompat(t *testing.T) {
want: map[string]string{"appId": "app-123", "secret": "sec-xyz"},
},
{
- name: "legacy ciphertext with correct key decrypts",
+ name: "encrypted config with correct key decrypts",
stored: legacyEncrypted,
key: key,
want: map[string]string{"appId": "app-123", "secret": "sec-xyz"},
},
{
- name: "legacy ciphertext with no key treated as empty",
- stored: legacyEncrypted,
- key: nil,
- want: nil,
+ name: "encrypted config with no key fails closed",
+ stored: legacyEncrypted,
+ key: nil,
+ wantErr: true,
},
{
- name: "legacy ciphertext with wrong key treated as empty",
- stored: legacyEncrypted,
- key: wrongKey,
- want: nil,
+ name: "encrypted config with wrong key fails closed",
+ stored: legacyEncrypted,
+ key: wrongKey,
+ wantErr: true,
},
{
- name: "garbage data treated as empty",
- stored: "not-json-and-not-ciphertext",
- key: key,
- want: nil,
+ name: "garbage data fails closed",
+ stored: "not-json-and-not-ciphertext",
+ key: key,
+ wantErr: true,
},
}
@@ -548,6 +679,12 @@ func TestDecryptConfig_PlaintextAndLegacyCompat(t *testing.T) {
t.Parallel()
lb := NewDefaultLoadBalancer(nil, tt.key)
got, err := lb.decryptConfig(tt.stored)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatal("decryptConfig expected fail-closed error")
+ }
+ return
+ }
if err != nil {
t.Fatalf("decryptConfig unexpected error: %v", err)
}
diff --git a/backend/internal/payment/provider/alipay.go b/backend/internal/payment/provider/alipay.go
index 1234b56819e..ecc598bc8dc 100644
--- a/backend/internal/payment/provider/alipay.go
+++ b/backend/internal/payment/provider/alipay.go
@@ -293,12 +293,9 @@ func (a *Alipay) VerifyNotification(ctx context.Context, rawBody string, _ map[s
}
}
- metadata := a.MerchantIdentityMetadata()
- if appID := strings.TrimSpace(notification.AppId); appID != "" {
- if metadata == nil {
- metadata = map[string]string{}
- }
- metadata["app_id"] = appID
+ metadata, err := a.notificationMerchantMetadata(notification)
+ if err != nil {
+ return nil, err
}
return &payment.PaymentNotification{
@@ -311,6 +308,24 @@ func (a *Alipay) VerifyNotification(ctx context.Context, rawBody string, _ map[s
}, nil
}
+func (a *Alipay) notificationMerchantMetadata(notification *alipay.Notification) (map[string]string, error) {
+ if notification == nil {
+ return nil, fmt.Errorf("alipay notification is missing")
+ }
+ actual := strings.TrimSpace(notification.AppId)
+ if actual == "" {
+ return nil, fmt.Errorf("alipay notification missing signed app_id")
+ }
+ expected := strings.TrimSpace(a.config["appId"])
+ if expected == "" {
+ return nil, fmt.Errorf("alipay configured app_id is missing")
+ }
+ if !strings.EqualFold(expected, actual) {
+ return nil, fmt.Errorf("alipay notification app_id mismatch")
+ }
+ return map[string]string{"app_id": actual}, nil
+}
+
// Refund requests a refund through Alipay.
func (a *Alipay) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
client, err := a.getClient()
@@ -318,11 +333,15 @@ func (a *Alipay) Refund(ctx context.Context, req payment.RefundRequest) (*paymen
return nil, err
}
+ outRequestNo := strings.TrimSpace(req.IdempotencyKey)
+ if outRequestNo == "" {
+ outRequestNo = fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano())
+ }
result, err := client.TradeRefund(ctx, alipay.TradeRefund{
OutTradeNo: req.OrderID,
RefundAmount: req.Amount,
RefundReason: req.Reason,
- OutRequestNo: fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano()),
+ OutRequestNo: outRequestNo,
})
if err != nil {
return nil, fmt.Errorf("alipay TradeRefund: %w", err)
diff --git a/backend/internal/payment/provider/alipay_test.go b/backend/internal/payment/provider/alipay_test.go
index fdc8eec1ac5..0ca0d02b423 100644
--- a/backend/internal/payment/provider/alipay_test.go
+++ b/backend/internal/payment/provider/alipay_test.go
@@ -290,6 +290,25 @@ func TestAlipayMerchantIdentityMetadata(t *testing.T) {
}
}
+func TestAlipayNotificationMerchantMetadataRequiresSignedAppID(t *testing.T) {
+ t.Parallel()
+
+ provider := &Alipay{config: map[string]string{"appId": "2021001234567890"}}
+ if _, err := provider.notificationMerchantMetadata(&alipay.Notification{}); err == nil {
+ t.Fatal("missing notification app_id was accepted")
+ }
+ if _, err := provider.notificationMerchantMetadata(&alipay.Notification{AppId: "2021009999999999"}); err == nil {
+ t.Fatal("mismatched notification app_id was accepted")
+ }
+ metadata, err := provider.notificationMerchantMetadata(&alipay.Notification{AppId: "2021001234567890"})
+ if err != nil {
+ t.Fatalf("matching notification app_id: %v", err)
+ }
+ if got := metadata["app_id"]; got != "2021001234567890" {
+ t.Fatalf("metadata app_id = %q", got)
+ }
+}
+
func TestParseAlipayAmount(t *testing.T) {
t.Parallel()
diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go
index e7d8aab982d..ea5b078c5fe 100644
--- a/backend/internal/payment/provider/easypay.go
+++ b/backend/internal/payment/provider/easypay.go
@@ -17,6 +17,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/payment"
+ "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
)
// EasyPay constants.
@@ -51,14 +52,41 @@ func NewEasyPay(instanceID string, config map[string]string) (*EasyPay, error) {
for k, v := range config {
cfg[k] = v
}
- cfg["apiBase"] = normalizeEasyPayAPIBase(cfg["apiBase"])
+ apiBase, err := validateEasyPayAPIBase(cfg["apiBase"])
+ if err != nil {
+ return nil, err
+ }
+ cfg["apiBase"] = apiBase
return &EasyPay{
instanceID: instanceID,
config: cfg,
- httpClient: &http.Client{Timeout: easypayHTTPTimeout},
+ httpClient: newEasyPayHTTPClient(),
}, nil
}
+func newEasyPayHTTPClient() *http.Client {
+ return &http.Client{
+ Timeout: easypayHTTPTimeout,
+ Transport: &http.Transport{
+ DialContext: urlvalidator.NewSafeDialContext(false),
+ TLSHandshakeTimeout: easypayHTTPTimeout,
+ ResponseHeaderTimeout: easypayHTTPTimeout,
+ },
+ CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
+ return http.ErrUseLastResponse
+ },
+ }
+}
+
+func validateEasyPayAPIBase(raw string) (string, error) {
+ base := normalizeEasyPayAPIBase(raw)
+ normalized, err := urlvalidator.ValidateHTTPSURL(base, urlvalidator.ValidationOptions{AllowPrivate: false})
+ if err != nil {
+ return "", fmt.Errorf("invalid easypay apiBase: %w", err)
+ }
+ return normalized, nil
+}
+
func normalizeEasyPayAPIBase(apiBase string) string {
base := strings.TrimSpace(apiBase)
if base == "" {
@@ -426,7 +454,13 @@ func (e *EasyPay) postRaw(ctx context.Context, endpoint string, params map[strin
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := e.httpClient
if client == nil {
- client = &http.Client{Timeout: easypayHTTPTimeout}
+ client = newEasyPayHTTPClient()
+ } else {
+ clone := *client
+ clone.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
+ return http.ErrUseLastResponse
+ }
+ client = &clone
}
resp, err := client.Do(req)
if err != nil {
diff --git a/backend/internal/payment/provider/easypay_redirect_test.go b/backend/internal/payment/provider/easypay_redirect_test.go
new file mode 100644
index 00000000000..10edbf65d52
--- /dev/null
+++ b/backend/internal/payment/provider/easypay_redirect_test.go
@@ -0,0 +1,38 @@
+package provider
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestEasyPayDoesNotForwardSecretFormAcrossRedirect(t *testing.T) {
+ var redirectedCalls atomic.Int32
+ target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+ redirectedCalls.Add(1)
+ }))
+ defer target.Close()
+
+ source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.NoError(t, r.ParseForm())
+ require.Equal(t, "secret-pkey", r.Form.Get("key"))
+ http.Redirect(w, r, target.URL+"/stolen", http.StatusTemporaryRedirect)
+ }))
+ defer source.Close()
+
+ provider := newTestEasyPay(t, source.URL)
+ provider.config["pkey"] = "secret-pkey"
+ client := source.Client()
+ client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
+ return http.ErrUseLastResponse
+ }
+ provider.httpClient = client
+
+ _, err := provider.QueryOrder(context.Background(), "order-1")
+ require.Error(t, err)
+ require.Zero(t, redirectedCalls.Load(), "EasyPay must never follow redirects carrying the merchant secret")
+}
diff --git a/backend/internal/payment/provider/easypay_refund_test.go b/backend/internal/payment/provider/easypay_refund_test.go
index 9e0e4942c2f..6aa0529b488 100644
--- a/backend/internal/payment/provider/easypay_refund_test.go
+++ b/backend/internal/payment/provider/easypay_refund_test.go
@@ -36,6 +36,48 @@ func TestNormalizeEasyPayAPIBase(t *testing.T) {
}
}
+func TestNewEasyPayRejectsExternalPlainHTTPAPIBase(t *testing.T) {
+ _, err := NewEasyPay("test-instance", map[string]string{
+ "pid": "merchant",
+ "pkey": "secret",
+ "apiBase": "http://payments.example.com",
+ "notifyUrl": "https://relay.example.com/easypay/notify",
+ "returnUrl": "https://relay.example.com/payment/result",
+ })
+
+ if err == nil || !strings.Contains(err.Error(), "invalid url scheme") {
+ t.Fatalf("NewEasyPay error=%v, want HTTPS rejection", err)
+ }
+}
+
+func TestNewEasyPayRejectsPrivateAPIBase(t *testing.T) {
+ for _, raw := range []string{"https://127.0.0.1:8443", "https://169.254.169.254", "https://localhost"} {
+ _, err := NewEasyPay("test-instance", map[string]string{
+ "pid": "merchant",
+ "pkey": "secret",
+ "apiBase": raw,
+ "notifyUrl": "https://merchant.example/notify",
+ "returnUrl": "https://merchant.example/return",
+ })
+ if err == nil {
+ t.Fatalf("private apiBase %q was accepted", raw)
+ }
+ }
+}
+
+func TestEasyPaySafeClientRejectsPrivateDestinationBeforeRequest(t *testing.T) {
+ provider := &EasyPay{
+ instanceID: "private-runtime-test",
+ config: map[string]string{
+ "pid": "merchant", "pkey": "secret", "apiBase": "https://127.0.0.1:1",
+ },
+ httpClient: newEasyPayHTTPClient(),
+ }
+ if _, err := provider.QueryOrder(context.Background(), "order-1"); err == nil || !strings.Contains(err.Error(), "not publicly routable") {
+ t.Fatalf("private runtime destination error = %v", err)
+ }
+}
+
func TestEasyPayRefundNormalizesAPIBaseAndSendsOutTradeNoOnly(t *testing.T) {
t.Parallel()
@@ -182,15 +224,18 @@ func TestEasyPayRefundResponseErrors(t *testing.T) {
func newTestEasyPay(t *testing.T, apiBase string) *EasyPay {
t.Helper()
- provider, err := NewEasyPay("test-instance", map[string]string{
- "pid": "pid-1",
- "pkey": "pkey-1",
- "apiBase": apiBase,
- "notifyUrl": "https://example.com/notify",
- "returnUrl": "https://example.com/return",
- })
- if err != nil {
- t.Fatalf("NewEasyPay: %v", err)
+ return &EasyPay{
+ instanceID: "test-instance",
+ config: map[string]string{
+ "pid": "pid-1",
+ "pkey": "pkey-1",
+ "apiBase": apiBase,
+ "notifyUrl": "https://example.com/notify",
+ "returnUrl": "https://example.com/return",
+ },
+ httpClient: &http.Client{
+ Timeout: easypayHTTPTimeout,
+ Transport: &http.Transport{},
+ },
}
- return provider
}
diff --git a/backend/internal/payment/provider/stripe.go b/backend/internal/payment/provider/stripe.go
index 15359d45baf..65619a55d4b 100644
--- a/backend/internal/payment/provider/stripe.go
+++ b/backend/internal/payment/provider/stripe.go
@@ -124,6 +124,10 @@ func (s *Stripe) QueryOrder(ctx context.Context, tradeNo string) (*payment.Query
if err != nil {
return nil, fmt.Errorf("stripe query order: %w", err)
}
+ currency, err := validatedStripeCurrency(pi.Currency)
+ if err != nil {
+ return nil, fmt.Errorf("stripe query order: %w", err)
+ }
status := payment.ProviderStatusPending
switch pi.Status {
@@ -137,6 +141,9 @@ func (s *Stripe) QueryOrder(ctx context.Context, tradeNo string) (*payment.Query
TradeNo: pi.ID,
Status: status,
Amount: payment.FenToYuan(pi.Amount),
+ Metadata: map[string]string{
+ "currency": currency,
+ },
}, nil
}
@@ -170,19 +177,37 @@ func (s *Stripe) VerifyNotification(_ context.Context, rawBody string, headers m
}
func parseStripePaymentIntent(event *stripe.Event, status string, rawBody string) (*payment.PaymentNotification, error) {
+ if event == nil || event.Data == nil {
+ return nil, fmt.Errorf("stripe parse payment_intent: event data is missing")
+ }
var pi stripe.PaymentIntent
if err := json.Unmarshal(event.Data.Raw, &pi); err != nil {
return nil, fmt.Errorf("stripe parse payment_intent: %w", err)
}
+ currency, err := validatedStripeCurrency(pi.Currency)
+ if err != nil {
+ return nil, fmt.Errorf("stripe parse payment_intent: %w", err)
+ }
return &payment.PaymentNotification{
TradeNo: pi.ID,
OrderID: pi.Metadata["orderId"],
Amount: payment.FenToYuan(pi.Amount),
Status: status,
RawData: rawBody,
+ Metadata: map[string]string{
+ "currency": currency,
+ },
}, nil
}
+func validatedStripeCurrency(currency stripe.Currency) (string, error) {
+ normalized := strings.ToLower(strings.TrimSpace(string(currency)))
+ if normalized != stripeCurrency {
+ return "", fmt.Errorf("unexpected currency %q", normalized)
+ }
+ return strings.ToUpper(normalized), nil
+}
+
// Refund creates a Stripe refund.
func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) {
s.ensureInit()
@@ -197,6 +222,9 @@ func (s *Stripe) Refund(ctx context.Context, req payment.RefundRequest) (*paymen
Amount: stripe.Int64(amountInCents),
Reason: stripe.String(string(stripe.RefundReasonRequestedByCustomer)),
}
+ if idempotencyKey := strings.TrimSpace(req.IdempotencyKey); idempotencyKey != "" {
+ params.SetIdempotencyKey(idempotencyKey)
+ }
params.Context = ctx
r, err := s.sc.V1Refunds.Create(ctx, params)
diff --git a/backend/internal/payment/provider/stripe_currency_test.go b/backend/internal/payment/provider/stripe_currency_test.go
new file mode 100644
index 00000000000..e5bd460ef66
--- /dev/null
+++ b/backend/internal/payment/provider/stripe_currency_test.go
@@ -0,0 +1,53 @@
+package provider
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ stripe "github.com/stripe/stripe-go/v85"
+)
+
+func TestParseStripePaymentIntentRejectsNonCNYCurrency(t *testing.T) {
+ t.Parallel()
+
+ for _, currency := range []string{"usd", ""} {
+ t.Run(currency, func(t *testing.T) {
+ t.Parallel()
+ event := stripePaymentIntentTestEvent(t, currency)
+ if _, err := parseStripePaymentIntent(event, payment.ProviderStatusSuccess, "signed-body"); err == nil {
+ t.Fatalf("parseStripePaymentIntent accepted currency %q", currency)
+ }
+ })
+ }
+}
+
+func TestParseStripePaymentIntentReturnsSignedCurrencyEvidence(t *testing.T) {
+ t.Parallel()
+
+ notification, err := parseStripePaymentIntent(
+ stripePaymentIntentTestEvent(t, stripeCurrency),
+ payment.ProviderStatusSuccess,
+ "signed-body",
+ )
+ if err != nil {
+ t.Fatalf("parseStripePaymentIntent: %v", err)
+ }
+ if got := notification.Metadata["currency"]; got != "CNY" {
+ t.Fatalf("metadata currency = %q, want CNY", got)
+ }
+}
+
+func stripePaymentIntentTestEvent(t *testing.T, currency string) *stripe.Event {
+ t.Helper()
+ raw, err := json.Marshal(map[string]any{
+ "id": "pi_currency_test",
+ "amount": 1234,
+ "currency": currency,
+ "metadata": map[string]string{"orderId": "order-currency-test"},
+ })
+ if err != nil {
+ t.Fatalf("marshal payment intent: %v", err)
+ }
+ return &stripe.Event{Data: &stripe.EventData{Raw: raw}}
+}
diff --git a/backend/internal/payment/provider/wxpay.go b/backend/internal/payment/provider/wxpay.go
index e6291dd31df..5ec40b03cb9 100644
--- a/backend/internal/payment/provider/wxpay.go
+++ b/backend/internal/payment/provider/wxpay.go
@@ -28,9 +28,10 @@ import (
// WeChat Pay constants.
const (
- wxpayCurrency = "CNY"
- wxpayH5Type = "Wap"
- wxpayResultPath = "/payment/result"
+ wxpayCurrency = "CNY"
+ wxpayH5Type = "Wap"
+ wxpayResultPath = "/payment/result"
+ wxpayOutTradeNoMaxSize = 32
)
const (
@@ -170,6 +171,9 @@ func (w *Wxpay) ensureClient() (*core.Client, error) {
}
func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
+ if err := validateWxpayOutTradeNo(req.OrderID); err != nil {
+ return nil, err
+ }
client, err := w.ensureClient()
if err != nil {
return nil, err
@@ -203,6 +207,24 @@ func (w *Wxpay) CreatePayment(ctx context.Context, req payment.CreatePaymentRequ
}
}
+func validateWxpayOutTradeNo(raw string) error {
+ orderID := strings.TrimSpace(raw)
+ if orderID == "" || len(orderID) > wxpayOutTradeNoMaxSize {
+ return infraerrors.BadRequest("WXPAY_OUT_TRADE_NO_INVALID", "wxpay out_trade_no must contain 1 to 32 characters")
+ }
+ for _, ch := range orderID {
+ switch {
+ case ch >= 'a' && ch <= 'z':
+ case ch >= 'A' && ch <= 'Z':
+ case ch >= '0' && ch <= '9':
+ case ch == '_' || ch == '-':
+ default:
+ return infraerrors.BadRequest("WXPAY_OUT_TRADE_NO_INVALID", "wxpay out_trade_no contains unsupported characters")
+ }
+ }
+ return nil
+}
+
func (w *Wxpay) prepayJSAPI(ctx context.Context, c *core.Client, req payment.CreatePaymentRequest, notifyURL string, totalFen int64) (*payment.CreatePaymentResponse, error) {
svc := jsapi.JsapiApiService{Client: c}
cur := wxpayCurrency
@@ -471,9 +493,13 @@ func (w *Wxpay) Refund(ctx context.Context, req payment.RefundRequest) (*payment
}
rs := refunddomestic.RefundsApiService{Client: c}
cur := wxpayCurrency
+ outRefundNo := strings.TrimSpace(req.IdempotencyKey)
+ if outRefundNo == "" {
+ outRefundNo = fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano())
+ }
res, _, err := rs.Create(ctx, refunddomestic.CreateRequest{
OutTradeNo: core.String(req.OrderID),
- OutRefundNo: core.String(fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano())),
+ OutRefundNo: core.String(outRefundNo),
Reason: core.String(req.Reason),
Amount: &refunddomestic.AmountReq{Refund: core.Int64(rf), Total: core.Int64(tf), Currency: &cur},
})
diff --git a/backend/internal/payment/provider/wxpay_order_id_test.go b/backend/internal/payment/provider/wxpay_order_id_test.go
new file mode 100644
index 00000000000..eb6310b6dcc
--- /dev/null
+++ b/backend/internal/payment/provider/wxpay_order_id_test.go
@@ -0,0 +1,14 @@
+package provider
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateWxpayOutTradeNoEnforcesProviderBoundary(t *testing.T) {
+ require.NoError(t, validateWxpayOutTradeNo("0123456789abcdef0123456789abcdef"))
+ require.Error(t, validateWxpayOutTradeNo(strings.Repeat("a", 33)))
+ require.Error(t, validateWxpayOutTradeNo("contains space"))
+}
diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go
index e7ac6727b95..78b940fab6e 100644
--- a/backend/internal/payment/types.go
+++ b/backend/internal/payment/types.go
@@ -50,6 +50,7 @@ const (
const (
DeductionTypeBalance = "balance"
DeductionTypeSubscription = "subscription"
+ DeductionTypeWallet = "wallet"
DeductionTypeNone = "none"
)
@@ -168,10 +169,11 @@ type PaymentNotification struct {
// RefundRequest contains the parameters for requesting a refund.
type RefundRequest struct {
- TradeNo string
- OrderID string
- Amount string // Refund amount formatted to 2 decimal places
- Reason string
+ TradeNo string
+ OrderID string
+ Amount string // Refund amount formatted to 2 decimal places
+ Reason string
+ IdempotencyKey string // Stable across retries for the same local refund operation.
}
// RefundResponse is returned after a refund request.
diff --git a/backend/internal/payment/wire.go b/backend/internal/payment/wire.go
index 4b7f422dec0..d8fa7137712 100644
--- a/backend/internal/payment/wire.go
+++ b/backend/internal/payment/wire.go
@@ -3,7 +3,6 @@ package payment
import (
"encoding/hex"
"fmt"
- "log/slog"
"strings"
dbent "github.com/Wei-Shaw/sub2api/ent"
@@ -15,34 +14,41 @@ import (
// Using a named type avoids Wire ambiguity with other []byte parameters.
type EncryptionKey []byte
-// ProvideEncryptionKey derives the payment encryption key from the TOTP encryption key in config.
-// When the key is empty, nil is returned (payment features that need encryption will be disabled).
-// When the key is non-empty but invalid (bad hex or wrong length), an error is returned
-// to prevent startup with a misconfigured encryption key.
+// ProvideEncryptionKey returns the independent v3 payment-provider root.
func ProvideEncryptionKey(cfg *config.Config) (EncryptionKey, error) {
if cfg == nil {
- slog.Warn("payment encryption key not configured — encrypted payment config and resume signing will be unavailable")
- return nil, nil
+ return nil, fmt.Errorf("SECRET_ENCRYPTION_PAYMENT_PROVIDER_KEY must be explicitly configured and stable")
}
- keyHex := strings.TrimSpace(cfg.Totp.EncryptionKey)
- if keyHex == "" {
- slog.Warn("payment encryption key not configured — encrypted payment config will be unavailable")
+ key, err := decodeConfiguredEncryptionKey(
+ "SECRET_ENCRYPTION_PAYMENT_PROVIDER_KEY",
+ cfg.SecretEncryption.PaymentProviderKey,
+ )
+ return EncryptionKey(key), err
+}
+
+// ProvideLegacyEncryptionKey returns the optional shared v1/v2 root used only
+// by the startup payment-provider migration.
+func ProvideLegacyEncryptionKey(cfg *config.Config) (EncryptionKey, error) {
+ if cfg == nil || !cfg.Totp.EncryptionKeyConfigured {
return nil, nil
}
- // Reject auto-generated TOTP keys for payment signing.
- // They change across restarts/instances and can silently break resume-token flows.
- if !cfg.Totp.EncryptionKeyConfigured {
- slog.Warn("payment encryption/signing key is not explicitly configured; set TOTP_ENCRYPTION_KEY to enable payment resume tokens")
- return nil, nil
+ key, err := decodeConfiguredEncryptionKey("TOTP_ENCRYPTION_KEY", cfg.Totp.EncryptionKey)
+ return EncryptionKey(key), err
+}
+
+func decodeConfiguredEncryptionKey(name, value string) ([]byte, error) {
+ keyHex := strings.TrimSpace(value)
+ if keyHex == "" {
+ return nil, fmt.Errorf("%s must be explicitly configured and stable", name)
}
key, err := hex.DecodeString(keyHex)
if err != nil {
- return nil, fmt.Errorf("invalid payment encryption key (hex decode): %w", err)
+ return nil, fmt.Errorf("invalid %s (hex decode): %w", name, err)
}
if len(key) != 32 {
- return nil, fmt.Errorf("payment encryption key must be 32 bytes, got %d", len(key))
+ return nil, fmt.Errorf("%s must be 32 bytes, got %d", name, len(key))
}
- return EncryptionKey(key), nil
+ return key, nil
}
// ProvideRegistry creates an empty payment provider registry.
diff --git a/backend/internal/payment/wire_test.go b/backend/internal/payment/wire_test.go
index 1b360f89f73..5ca19d1fdf0 100644
--- a/backend/internal/payment/wire_test.go
+++ b/backend/internal/payment/wire_test.go
@@ -7,32 +7,21 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
)
-func TestProvideEncryptionKeySkipsAutoGeneratedTotpKey(t *testing.T) {
+func TestProvideEncryptionKeyRejectsMissingIndependentRoot(t *testing.T) {
t.Parallel()
- cfg := &config.Config{
- Totp: config.TotpConfig{
- EncryptionKey: strings.Repeat("a", 64),
- EncryptionKeyConfigured: false,
- },
- }
-
- key, err := ProvideEncryptionKey(cfg)
- if err != nil {
- t.Fatalf("ProvideEncryptionKey returned error: %v", err)
- }
- if len(key) != 0 {
- t.Fatalf("encryption key len = %d, want 0", len(key))
+ _, err := ProvideEncryptionKey(&config.Config{})
+ if err == nil || !strings.Contains(err.Error(), "SECRET_ENCRYPTION_PAYMENT_PROVIDER_KEY") {
+ t.Fatalf("ProvideEncryptionKey error = %v", err)
}
}
-func TestProvideEncryptionKeyUsesConfiguredTotpKey(t *testing.T) {
+func TestProvideEncryptionKeyUsesIndependentPaymentProviderRoot(t *testing.T) {
t.Parallel()
cfg := &config.Config{
- Totp: config.TotpConfig{
- EncryptionKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
- EncryptionKeyConfigured: true,
+ SecretEncryption: config.SecretEncryptionConfig{
+ PaymentProviderKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
}
@@ -49,9 +38,8 @@ func TestProvideEncryptionKeyRejectsConfiguredInvalidLength(t *testing.T) {
t.Parallel()
cfg := &config.Config{
- Totp: config.TotpConfig{
- EncryptionKey: "abcd",
- EncryptionKeyConfigured: true,
+ SecretEncryption: config.SecretEncryptionConfig{
+ PaymentProviderKey: "abcd",
},
}
@@ -60,3 +48,20 @@ func TestProvideEncryptionKeyRejectsConfiguredInvalidLength(t *testing.T) {
t.Fatal("expected error for invalid key length")
}
}
+
+func TestProvideLegacyEncryptionKeyIsMigrationOnly(t *testing.T) {
+ t.Parallel()
+
+ missing, err := ProvideLegacyEncryptionKey(&config.Config{})
+ if err != nil || len(missing) != 0 {
+ t.Fatalf("missing legacy key = %x, err=%v", missing, err)
+ }
+ cfg := &config.Config{Totp: config.TotpConfig{
+ EncryptionKey: strings.Repeat("a", 64),
+ EncryptionKeyConfigured: true,
+ }}
+ key, err := ProvideLegacyEncryptionKey(cfg)
+ if err != nil || len(key) != 32 {
+ t.Fatalf("legacy key len = %d, err=%v", len(key), err)
+ }
+}
diff --git a/backend/internal/pkg/antigravity/claude_types.go b/backend/internal/pkg/antigravity/claude_types.go
index 0b8ae5f2bc8..6cf38b99127 100644
--- a/backend/internal/pkg/antigravity/claude_types.go
+++ b/backend/internal/pkg/antigravity/claude_types.go
@@ -36,6 +36,12 @@ type ClaudeMetadata struct {
UserID string `json:"user_id,omitempty"`
}
+// CacheControl Claude 内容块缓存控制。
+type CacheControl struct {
+ Type string `json:"type"`
+ TTL string `json:"ttl,omitempty"`
+}
+
// ClaudeTool Claude 工具定义
// 支持两种格式:
// 1. 标准格式: { "name": "...", "description": "...", "input_schema": {...} }
@@ -59,15 +65,17 @@ type ClaudeCustomToolSpec = CustomToolSpec
// SystemBlock system prompt 数组形式的元素
type SystemBlock struct {
- Type string `json:"type"`
- Text string `json:"text"`
+ Type string `json:"type"`
+ Text string `json:"text"`
+ CacheControl *CacheControl `json:"cache_control,omitempty"`
}
// ContentBlock Claude 消息内容块(解析后)
type ContentBlock struct {
Type string `json:"type"`
// text
- Text string `json:"text,omitempty"`
+ Text string `json:"text,omitempty"`
+ CacheControl *CacheControl `json:"cache_control,omitempty"`
// thinking
Thinking string `json:"thinking,omitempty"`
Signature string `json:"signature,omitempty"`
diff --git a/backend/internal/pkg/antigravity/malformed_function_log_test.go b/backend/internal/pkg/antigravity/malformed_function_log_test.go
new file mode 100644
index 00000000000..72fad6204e6
--- /dev/null
+++ b/backend/internal/pkg/antigravity/malformed_function_log_test.go
@@ -0,0 +1,50 @@
+package antigravity
+
+import (
+ "bytes"
+ "encoding/json"
+ "log"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestMalformedFunctionCallLogsMetadataWithoutCandidateContent(t *testing.T) {
+ const secret = "tenant-tool-argument-must-not-reach-logs"
+ response := V1InternalResponse{
+ Response: GeminiResponse{
+ Candidates: []GeminiCandidate{{
+ FinishReason: "MALFORMED_FUNCTION_CALL",
+ Content: &GeminiContent{
+ Role: "model",
+ Parts: []GeminiPart{{
+ FunctionCall: &GeminiFunctionCall{
+ Name: "sensitive_tool",
+ Args: map[string]any{"token": secret},
+ },
+ }},
+ },
+ }},
+ },
+ }
+ payload, err := json.Marshal(response)
+ require.NoError(t, err)
+
+ var captured bytes.Buffer
+ previousOutput := log.Writer()
+ log.SetOutput(&captured)
+ t.Cleanup(func() { log.SetOutput(previousOutput) })
+
+ processor := NewStreamingProcessor("model-stream")
+ _ = processor.ProcessLine("data: " + string(payload))
+ _, _, err = TransformGeminiToClaude(payload, "model-buffered")
+ require.NoError(t, err)
+
+ logs := captured.String()
+ require.Contains(t, logs, "MALFORMED_FUNCTION_CALL")
+ require.Contains(t, logs, "model-stream")
+ require.Contains(t, logs, "model-buffered")
+ require.False(t, strings.Contains(logs, secret), logs)
+ require.False(t, strings.Contains(logs, "sensitive_tool"), logs)
+}
diff --git a/backend/internal/pkg/antigravity/oauth.go b/backend/internal/pkg/antigravity/oauth.go
index 7c963d9e510..cef658b31a6 100644
--- a/backend/internal/pkg/antigravity/oauth.go
+++ b/backend/internal/pkg/antigravity/oauth.go
@@ -52,15 +52,16 @@ const (
// defaultUserAgentVersion 可通过环境变量 ANTIGRAVITY_USER_AGENT_VERSION 配置,默认 1.20.5
var defaultUserAgentVersion = "1.21.9"
-// defaultClientSecret 可通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET 配置
-var defaultClientSecret = "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"
+// defaultClientSecret 只能通过环境变量 ANTIGRAVITY_OAUTH_CLIENT_SECRET 配置。
+// 不在仓库内嵌 client_secret,避免把可轮换凭证固化进镜像和源码历史。
+var defaultClientSecret string
func init() {
// 从环境变量读取版本号,未设置则使用默认值
if version := os.Getenv("ANTIGRAVITY_USER_AGENT_VERSION"); version != "" {
defaultUserAgentVersion = version
}
- // 从环境变量读取 client_secret,未设置则使用默认值
+ // 从环境变量读取 client_secret。
if secret := os.Getenv(AntigravityOAuthClientSecretEnv); secret != "" {
defaultClientSecret = secret
}
diff --git a/backend/internal/pkg/antigravity/oauth_test.go b/backend/internal/pkg/antigravity/oauth_test.go
index 9850af17e1a..821f7cf85a5 100644
--- a/backend/internal/pkg/antigravity/oauth_test.go
+++ b/backend/internal/pkg/antigravity/oauth_test.go
@@ -680,12 +680,11 @@ func TestConstants_值正确(t *testing.T) {
if ClientID != "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" {
t.Errorf("ClientID 不匹配: got %s", ClientID)
}
- secret, err := getClientSecret()
- if err != nil {
- t.Fatalf("getClientSecret 应返回默认值,但报错: %v", err)
- }
- if secret != "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" {
- t.Errorf("默认 client_secret 不匹配: got %s", secret)
+ old := defaultClientSecret
+ defaultClientSecret = ""
+ t.Cleanup(func() { defaultClientSecret = old })
+ if _, err := getClientSecret(); err == nil {
+ t.Fatal("未配置 client_secret 时应 fail closed")
}
if RedirectURI != "http://localhost:8085/callback" {
t.Errorf("RedirectURI 不匹配: got %s", RedirectURI)
diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go
index b5de8166ce9..e1eaa48f5fa 100644
--- a/backend/internal/pkg/antigravity/request_transformer.go
+++ b/backend/internal/pkg/antigravity/request_transformer.go
@@ -20,15 +20,19 @@ var (
sessionRandMutex sync.Mutex
)
+func stableSessionIDFromText(text string) string {
+ h := sha256.Sum256([]byte(text))
+ n := int64(binary.BigEndian.Uint64(h[:8])) & 0x7FFFFFFFFFFFFFFF
+ return "-" + strconv.FormatInt(n, 10)
+}
+
// generateStableSessionID 基于用户消息内容生成稳定的 session ID
func generateStableSessionID(contents []GeminiContent) string {
// 查找第一个 user 消息的文本
for _, content := range contents {
if content.Role == "user" && len(content.Parts) > 0 {
if text := content.Parts[0].Text; text != "" {
- h := sha256.Sum256([]byte(text))
- n := int64(binary.BigEndian.Uint64(h[:8])) & 0x7FFFFFFFFFFFFFFF
- return "-" + strconv.FormatInt(n, 10)
+ return stableSessionIDFromText(text)
}
}
}
@@ -39,6 +43,109 @@ func generateStableSessionID(contents []GeminiContent) string {
return "-" + strconv.FormatInt(n, 10)
}
+func generateAntigravitySessionID(claudeReq *ClaudeRequest, contents []GeminiContent) string {
+ if claudeReq != nil && claudeReq.Metadata != nil && claudeReq.Metadata.UserID != "" {
+ return claudeReq.Metadata.UserID
+ }
+ if seed := extractAnthropicCacheableSeed(claudeReq); seed != "" {
+ return stableSessionIDFromText("anthropic-cache-control-v1\n" + seed)
+ }
+ return generateStableSessionID(contents)
+}
+
+func extractAnthropicCacheableSeed(claudeReq *ClaudeRequest) string {
+ if claudeReq == nil {
+ return ""
+ }
+
+ var builder strings.Builder
+ cacheableFound := false
+ _, _ = builder.WriteString("model:")
+ _, _ = builder.WriteString(claudeReq.Model)
+ _, _ = builder.WriteString("\n")
+
+ if len(claudeReq.System) > 0 {
+ var sysBlocks []SystemBlock
+ if err := json.Unmarshal(claudeReq.System, &sysBlocks); err == nil {
+ for _, block := range sysBlocks {
+ if block.Type == "text" && isEphemeralCacheControl(block.CacheControl) && strings.TrimSpace(block.Text) != "" {
+ cacheableFound = true
+ _, _ = builder.WriteString("system:")
+ _, _ = builder.WriteString(block.Text)
+ _, _ = builder.WriteString("\n")
+ }
+ }
+ }
+ }
+
+ for _, msg := range claudeReq.Messages {
+ var blocks []ContentBlock
+ if err := json.Unmarshal(msg.Content, &blocks); err != nil {
+ continue
+ }
+ for _, block := range blocks {
+ if block.Type == "text" && isEphemeralCacheControl(block.CacheControl) && strings.TrimSpace(block.Text) != "" {
+ cacheableFound = true
+ _, _ = builder.WriteString("message:")
+ _, _ = builder.WriteString(block.Text)
+ _, _ = builder.WriteString("\n")
+ }
+ }
+ }
+
+ if !cacheableFound {
+ return ""
+ }
+ return builder.String()
+}
+
+func extractAnthropicSystemCacheableText(claudeReq *ClaudeRequest) string {
+ if claudeReq == nil || len(claudeReq.System) == 0 {
+ return ""
+ }
+
+ var sysBlocks []SystemBlock
+ if err := json.Unmarshal(claudeReq.System, &sysBlocks); err != nil {
+ return ""
+ }
+
+ var builder strings.Builder
+ for _, block := range sysBlocks {
+ if block.Type == "text" && isEphemeralCacheControl(block.CacheControl) && strings.TrimSpace(block.Text) != "" {
+ if builder.Len() > 0 {
+ _, _ = builder.WriteString("\n\n")
+ }
+ _, _ = builder.WriteString(block.Text)
+ }
+ }
+ return builder.String()
+}
+
+func prependCacheableSystemContent(contents []GeminiContent, text string) []GeminiContent {
+ if strings.TrimSpace(text) == "" {
+ return contents
+ }
+
+ part := GeminiPart{Text: text}
+ out := make([]GeminiContent, len(contents))
+ copy(out, contents)
+ for i := range out {
+ if out[i].Role == "user" {
+ parts := make([]GeminiPart, 0, len(out[i].Parts)+1)
+ parts = append(parts, part)
+ parts = append(parts, out[i].Parts...)
+ out[i].Parts = parts
+ return out
+ }
+ }
+
+ return append([]GeminiContent{{Role: "user", Parts: []GeminiPart{part}}}, out...)
+}
+
+func isEphemeralCacheControl(cacheControl *CacheControl) bool {
+ return cacheControl != nil && strings.EqualFold(cacheControl.Type, "ephemeral")
+}
+
type TransformOptions struct {
EnableIdentityPatch bool
// IdentityPatch 可选:自定义注入到 systemInstruction 开头的身份防护提示词;
@@ -111,6 +218,7 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
if err != nil {
return nil, fmt.Errorf("build contents: %w", err)
}
+ contents = prependCacheableSystemContent(contents, extractAnthropicSystemCacheableText(claudeReq))
// 2. 构建 systemInstruction(使用 targetModel 而非原始请求模型,确保身份注入基于最终模型)
systemInstruction := buildSystemInstruction(claudeReq.System, targetModel, opts, claudeReq.Tools)
@@ -143,8 +251,8 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
Mode: "VALIDATED",
},
},
- // 总是生成 sessionId,基于用户消息内容
- SessionID: generateStableSessionID(contents),
+ // 总是生成 sessionId;优先使用 Anthropic cache_control 标记的稳定块,提升长上下文缓存命中率。
+ SessionID: generateAntigravitySessionID(claudeReq, contents),
}
if systemInstruction != nil {
@@ -157,11 +265,6 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
innerRequest.Tools = tools
}
- // 如果提供了 metadata.user_id,优先使用
- if claudeReq.Metadata != nil && claudeReq.Metadata.UserID != "" {
- innerRequest.SessionID = claudeReq.Metadata.UserID
- }
-
// 6. 包装为 v1internal 请求
v1Req := V1InternalRequest{
Project: projectID,
diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go
index 6fae5b7c562..15a330c3913 100644
--- a/backend/internal/pkg/antigravity/request_transformer_test.go
+++ b/backend/internal/pkg/antigravity/request_transformer_test.go
@@ -2,6 +2,7 @@ package antigravity
import (
"encoding/json"
+ "strconv"
"strings"
"testing"
@@ -424,6 +425,86 @@ func TestTransformClaudeToGeminiWithOptions_PreservesBillingHeaderSystemBlock(t
}
}
+func TestTransformClaudeToGeminiWithOptions_SessionIDUsesCacheControlSystemBlocks(t *testing.T) {
+ buildRequest := func(userText string) *ClaudeRequest {
+ return &ClaudeRequest{
+ Model: "claude-sonnet-4-6",
+ System: json.RawMessage(`[
+ {"type":"text","text":"stable Claude Code system prompt","cache_control":{"type":"ephemeral","ttl":"1h"}}
+ ]`),
+ Messages: []ClaudeMessage{
+ {
+ Role: "user",
+ Content: json.RawMessage(`[{"type":"text","text":` + strconv.Quote(userText) + `}]`),
+ },
+ },
+ }
+ }
+
+ body1, err := TransformClaudeToGeminiWithOptions(buildRequest("dynamic user turn one"), "project-1", "gemini-2.5-flash", DefaultTransformOptions())
+ require.NoError(t, err)
+ body2, err := TransformClaudeToGeminiWithOptions(buildRequest("dynamic user turn two"), "project-1", "gemini-2.5-flash", DefaultTransformOptions())
+ require.NoError(t, err)
+
+ var req1, req2 V1InternalRequest
+ require.NoError(t, json.Unmarshal(body1, &req1))
+ require.NoError(t, json.Unmarshal(body2, &req2))
+ require.NotEmpty(t, req1.Request.SessionID)
+ require.Equal(t, req1.Request.SessionID, req2.Request.SessionID)
+ require.NotEmpty(t, req1.Request.Contents)
+ require.NotEmpty(t, req1.Request.Contents[0].Parts)
+ require.Contains(t, req1.Request.Contents[0].Parts[0].Text, "stable Claude Code system prompt")
+ require.Contains(t, req1.Request.Contents[0].Parts[1].Text, "dynamic user turn one")
+}
+
+func TestTransformClaudeToGeminiWithOptions_MetadataUserIDOverridesCacheSessionSeed(t *testing.T) {
+ claudeReq := &ClaudeRequest{
+ Model: "claude-sonnet-4-6",
+ Metadata: &ClaudeMetadata{UserID: "session-from-client"},
+ System: json.RawMessage(`[
+ {"type":"text","text":"stable system","cache_control":{"type":"ephemeral"}}
+ ]`),
+ Messages: []ClaudeMessage{
+ {
+ Role: "user",
+ Content: json.RawMessage(`[{"type":"text","text":"hello"}]`),
+ },
+ },
+ }
+
+ body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "gemini-2.5-flash", DefaultTransformOptions())
+ require.NoError(t, err)
+
+ var req V1InternalRequest
+ require.NoError(t, json.Unmarshal(body, &req))
+ require.Equal(t, "session-from-client", req.Request.SessionID)
+}
+
+func TestTransformClaudeToGeminiWithOptions_SessionIDFallsBackToFirstUserText(t *testing.T) {
+ buildRequest := func(userText string) *ClaudeRequest {
+ return &ClaudeRequest{
+ Model: "claude-sonnet-4-6",
+ Messages: []ClaudeMessage{
+ {
+ Role: "user",
+ Content: json.RawMessage(`[{"type":"text","text":` + strconv.Quote(userText) + `}]`),
+ },
+ },
+ }
+ }
+
+ body1, err := TransformClaudeToGeminiWithOptions(buildRequest("first user text"), "project-1", "gemini-2.5-flash", DefaultTransformOptions())
+ require.NoError(t, err)
+ body2, err := TransformClaudeToGeminiWithOptions(buildRequest("second user text"), "project-1", "gemini-2.5-flash", DefaultTransformOptions())
+ require.NoError(t, err)
+
+ var req1, req2 V1InternalRequest
+ require.NoError(t, json.Unmarshal(body1, &req1))
+ require.NoError(t, json.Unmarshal(body2, &req2))
+ require.NotEmpty(t, req1.Request.SessionID)
+ require.NotEqual(t, req1.Request.SessionID, req2.Request.SessionID)
+}
+
func TestTransformClaudeToGeminiWithOptions_PreservesWebSearchAlongsideFunctions(t *testing.T) {
claudeReq := &ClaudeRequest{
Model: "claude-3-5-sonnet-latest",
diff --git a/backend/internal/pkg/antigravity/response_transformer.go b/backend/internal/pkg/antigravity/response_transformer.go
index bc1fd32e388..1f2a56a7536 100644
--- a/backend/internal/pkg/antigravity/response_transformer.go
+++ b/backend/internal/pkg/antigravity/response_transformer.go
@@ -261,11 +261,6 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon
finishReason = geminiResp.Candidates[0].FinishReason
if finishReason == "MALFORMED_FUNCTION_CALL" {
log.Printf("[Antigravity] MALFORMED_FUNCTION_CALL detected in response for model %s", originalModel)
- if geminiResp.Candidates[0].Content != nil {
- if b, err := json.Marshal(geminiResp.Candidates[0].Content); err == nil {
- log.Printf("[Antigravity] Malformed content: %s", string(b))
- }
- }
}
}
diff --git a/backend/internal/pkg/antigravity/schema_cleaner.go b/backend/internal/pkg/antigravity/schema_cleaner.go
index 0ee746aa378..f9835d20b53 100644
--- a/backend/internal/pkg/antigravity/schema_cleaner.go
+++ b/backend/internal/pkg/antigravity/schema_cleaner.go
@@ -5,6 +5,19 @@ import (
"strings"
)
+const (
+ maxJSONSchemaDepth = 64
+ maxJSONSchemaNodes = 4096
+ maxJSONSchemaRefExpansions = 256
+ maxJSONSchemaExpansionWork = maxJSONSchemaNodes * 4
+)
+
+type jsonSchemaExpansionState struct {
+ work int
+ refExpansions int
+ activeRefs map[string]struct{}
+}
+
// CleanJSONSchema 清理 JSON Schema,移除 Antigravity/Gemini 不支持的字段
// 参考 Antigravity-Manager/src-tauri/src/proxy/common/json_schema.rs 实现
// 确保 schema 符合 JSON Schema draft 2020-12 且适配 Gemini v1internal
@@ -12,9 +25,15 @@ func CleanJSONSchema(schema map[string]any) map[string]any {
if schema == nil {
return nil
}
+ if !jsonSchemaWithinLimits(schema) {
+ return safeJSONSchemaFallback()
+ }
// 0. 预处理:展开 $ref (Schema Flattening)
// (Go map 是引用的,直接修改 schema)
- flattenRefs(schema, extractDefs(schema))
+ state := &jsonSchemaExpansionState{activeRefs: make(map[string]struct{})}
+ if !flattenRefs(schema, extractDefs(schema), state, 0) || !jsonSchemaWithinLimits(schema) {
+ return safeJSONSchemaFallback()
+ }
// 递归清理
cleaned := cleanJSONSchemaRecursive(schema)
@@ -26,6 +45,50 @@ func CleanJSONSchema(schema map[string]any) map[string]any {
return result
}
+func safeJSONSchemaFallback() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "reason": map[string]any{
+ "type": "string",
+ "description": "Reason for calling this tool",
+ },
+ },
+ "required": []any{"reason"},
+ }
+}
+
+func jsonSchemaWithinLimits(root any) bool {
+ type frame struct {
+ value any
+ depth int
+ }
+ stack := []frame{{value: root}}
+ nodes := 0
+ for len(stack) > 0 {
+ current := stack[len(stack)-1]
+ stack = stack[:len(stack)-1]
+ if current.depth > maxJSONSchemaDepth {
+ return false
+ }
+ nodes++
+ if nodes > maxJSONSchemaNodes {
+ return false
+ }
+ switch value := current.value.(type) {
+ case map[string]any:
+ for _, child := range value {
+ stack = append(stack, frame{value: child, depth: current.depth + 1})
+ }
+ case []any:
+ for _, child := range value {
+ stack = append(stack, frame{value: child, depth: current.depth + 1})
+ }
+ }
+ }
+ return true
+}
+
// extractDefs 提取并移除定义的 helper
func extractDefs(schema map[string]any) map[string]any {
defs := make(map[string]any)
@@ -44,10 +107,20 @@ func extractDefs(schema map[string]any) map[string]any {
return defs
}
-// flattenRefs 递归展开 $ref
-func flattenRefs(schema map[string]any, defs map[string]any) {
+// flattenRefs recursively expands local references while enforcing a strict
+// work, depth and active-reference budget. Returning false makes the caller
+// replace the untrusted schema with a safe fallback instead of partially using
+// a cyclic or explosively expanding graph.
+func flattenRefs(schema map[string]any, defs map[string]any, state *jsonSchemaExpansionState, depth int) bool {
if len(defs) == 0 {
- return // 无需展开
+ return true
+ }
+ if state == nil || depth > maxJSONSchemaDepth {
+ return false
+ }
+ state.work++
+ if state.work > maxJSONSchemaExpansionWork {
+ return false
}
// 检查并替换 $ref
@@ -57,32 +130,88 @@ func flattenRefs(schema map[string]any, defs map[string]any) {
parts := strings.Split(ref, "/")
refName := parts[len(parts)-1]
- if defSchema, exists := defs[refName]; exists {
- if defMap, ok := defSchema.(map[string]any); ok {
- // 合并定义内容 (不覆盖现有 key)
- for k, v := range defMap {
- if _, has := schema[k]; !has {
- schema[k] = deepCopy(v) // 需深拷贝避免共享引用
- }
- }
- // 递归处理刚刚合并进来的内容
- flattenRefs(schema, defs)
+ defSchema, exists := defs[refName]
+ defMap, isMap := defSchema.(map[string]any)
+ if !exists || !isMap {
+ return flattenRefChildren(schema, defs, state, depth)
+ }
+ if _, active := state.activeRefs[ref]; active {
+ return false
+ }
+ state.refExpansions++
+ if state.refExpansions > maxJSONSchemaRefExpansions {
+ return false
+ }
+ state.activeRefs[ref] = struct{}{}
+ for key, value := range defMap {
+ if _, has := schema[key]; has {
+ continue
+ }
+ copied, ok := deepCopyJSONSchemaWithBudget(value, state, depth+1)
+ if !ok {
+ delete(state.activeRefs, ref)
+ return false
}
+ schema[key] = copied
}
+ ok := flattenRefs(schema, defs, state, depth+1)
+ delete(state.activeRefs, ref)
+ return ok
}
+ return flattenRefChildren(schema, defs, state, depth)
+}
- // 遍历子节点
+func flattenRefChildren(schema map[string]any, defs map[string]any, state *jsonSchemaExpansionState, depth int) bool {
for _, v := range schema {
if subMap, ok := v.(map[string]any); ok {
- flattenRefs(subMap, defs)
+ if !flattenRefs(subMap, defs, state, depth+1) {
+ return false
+ }
} else if subArr, ok := v.([]any); ok {
for _, item := range subArr {
if itemMap, ok := item.(map[string]any); ok {
- flattenRefs(itemMap, defs)
+ if !flattenRefs(itemMap, defs, state, depth+1) {
+ return false
+ }
}
}
}
}
+ return true
+}
+
+func deepCopyJSONSchemaWithBudget(src any, state *jsonSchemaExpansionState, depth int) (any, bool) {
+ if state == nil || depth > maxJSONSchemaDepth {
+ return nil, false
+ }
+ state.work++
+ if state.work > maxJSONSchemaExpansionWork {
+ return nil, false
+ }
+ switch value := src.(type) {
+ case map[string]any:
+ copied := make(map[string]any, len(value))
+ for key, child := range value {
+ childCopy, ok := deepCopyJSONSchemaWithBudget(child, state, depth+1)
+ if !ok {
+ return nil, false
+ }
+ copied[key] = childCopy
+ }
+ return copied, true
+ case []any:
+ copied := make([]any, len(value))
+ for i, child := range value {
+ childCopy, ok := deepCopyJSONSchemaWithBudget(child, state, depth+1)
+ if !ok {
+ return nil, false
+ }
+ copied[i] = childCopy
+ }
+ return copied, true
+ default:
+ return src, true
+ }
}
// deepCopy 深拷贝 (简单实现,仅针对 JSON 类型)
diff --git a/backend/internal/pkg/antigravity/schema_cleaner_security_test.go b/backend/internal/pkg/antigravity/schema_cleaner_security_test.go
new file mode 100644
index 00000000000..b3549bc7590
--- /dev/null
+++ b/backend/internal/pkg/antigravity/schema_cleaner_security_test.go
@@ -0,0 +1,86 @@
+//go:build unit
+
+package antigravity
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestCleanJSONSchemaRejectsSelfReferentialDefinitionWithoutRecursing(t *testing.T) {
+ t.Parallel()
+
+ schema := map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "node": map[string]any{"$ref": "#/$defs/Node"},
+ },
+ "$defs": map[string]any{
+ "Node": map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "next": map[string]any{"$ref": "#/$defs/Node"},
+ },
+ },
+ },
+ }
+
+ cleaned := CleanJSONSchema(schema)
+ require.Equal(t, safeJSONSchemaFallback(), cleaned)
+}
+
+func TestCleanJSONSchemaRejectsMutuallyRecursiveDefinitions(t *testing.T) {
+ t.Parallel()
+
+ schema := map[string]any{
+ "$ref": "#/$defs/A",
+ "$defs": map[string]any{
+ "A": map[string]any{"$ref": "#/$defs/B"},
+ "B": map[string]any{"$ref": "#/$defs/A"},
+ },
+ }
+
+ cleaned := CleanJSONSchema(schema)
+ require.Equal(t, safeJSONSchemaFallback(), cleaned)
+}
+
+func TestJSONSchemaWithinLimitsRejectsExcessiveDepth(t *testing.T) {
+ t.Parallel()
+
+ root := map[string]any{"type": "object"}
+ current := root
+ for i := 0; i <= maxJSONSchemaDepth; i++ {
+ next := map[string]any{"type": "object"}
+ current["properties"] = map[string]any{"next": next}
+ current = next
+ }
+
+ require.False(t, jsonSchemaWithinLimits(root))
+ require.Equal(t, safeJSONSchemaFallback(), CleanJSONSchema(root))
+}
+
+func TestCleanJSONSchemaStillExpandsBoundedLocalReferences(t *testing.T) {
+ t.Parallel()
+
+ schema := map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "first": map[string]any{"$ref": "#/$defs/Name"},
+ "second": map[string]any{"$ref": "#/$defs/Name"},
+ },
+ "$defs": map[string]any{
+ "Name": map[string]any{"type": "string", "description": "display name"},
+ },
+ }
+
+ cleaned := CleanJSONSchema(schema)
+ properties, ok := cleaned["properties"].(map[string]any)
+ require.True(t, ok)
+ for _, key := range []string{"first", "second"} {
+ property, ok := properties[key].(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, "string", property["type"])
+ require.NotContains(t, property, "$ref")
+ }
+}
diff --git a/backend/internal/pkg/antigravity/stream_transformer.go b/backend/internal/pkg/antigravity/stream_transformer.go
index 4a68f3a92d3..1315bc4e6d9 100644
--- a/backend/internal/pkg/antigravity/stream_transformer.go
+++ b/backend/internal/pkg/antigravity/stream_transformer.go
@@ -133,11 +133,6 @@ func (p *StreamingProcessor) ProcessLine(line string) []byte {
finishReason := geminiResp.Candidates[0].FinishReason
if finishReason == "MALFORMED_FUNCTION_CALL" {
log.Printf("[Antigravity] MALFORMED_FUNCTION_CALL detected in stream for model %s", p.originalModel)
- if geminiResp.Candidates[0].Content != nil {
- if b, err := json.Marshal(geminiResp.Candidates[0].Content); err == nil {
- log.Printf("[Antigravity] Malformed content: %s", string(b))
- }
- }
}
if finishReason != "" {
_, _ = result.Write(p.emitFinish(finishReason))
diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go
index e8b25c2b2a3..aa36ef0b9a9 100644
--- a/backend/internal/pkg/apicompat/anthropic_responses_test.go
+++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go
@@ -32,7 +32,13 @@ func TestAnthropicToResponses_BasicText(t *testing.T) {
var items []ResponsesInputItem
require.NoError(t, json.Unmarshal(resp.Input, &items))
require.Len(t, items, 1)
+ assert.Equal(t, "message", items[0].Type)
assert.Equal(t, "user", items[0].Role)
+ var parts []ResponsesContentPart
+ require.NoError(t, json.Unmarshal(items[0].Content, &parts))
+ require.Len(t, parts, 1)
+ assert.Equal(t, "input_text", parts[0].Type)
+ assert.Equal(t, "Hello", parts[0].Text)
}
func TestAnthropicToResponses_SystemPrompt(t *testing.T) {
@@ -49,7 +55,12 @@ func TestAnthropicToResponses_SystemPrompt(t *testing.T) {
var items []ResponsesInputItem
require.NoError(t, json.Unmarshal(resp.Input, &items))
require.Len(t, items, 2)
- assert.Equal(t, "system", items[0].Role)
+ assert.Equal(t, "developer", items[0].Role)
+ var parts []ResponsesContentPart
+ require.NoError(t, json.Unmarshal(items[0].Content, &parts))
+ require.Len(t, parts, 1)
+ assert.Equal(t, "input_text", parts[0].Type)
+ assert.Equal(t, "You are helpful.", parts[0].Text)
})
t.Run("array", func(t *testing.T) {
@@ -65,11 +76,33 @@ func TestAnthropicToResponses_SystemPrompt(t *testing.T) {
var items []ResponsesInputItem
require.NoError(t, json.Unmarshal(resp.Input, &items))
require.Len(t, items, 2)
- assert.Equal(t, "system", items[0].Role)
- // System text should be joined with double newline.
- var text string
- require.NoError(t, json.Unmarshal(items[0].Content, &text))
- assert.Equal(t, "Part 1\n\nPart 2", text)
+ assert.Equal(t, "developer", items[0].Role)
+ var parts []ResponsesContentPart
+ require.NoError(t, json.Unmarshal(items[0].Content, &parts))
+ require.Len(t, parts, 2)
+ assert.Equal(t, "input_text", parts[0].Type)
+ assert.Equal(t, "Part 1", parts[0].Text)
+ assert.Equal(t, "input_text", parts[1].Type)
+ assert.Equal(t, "Part 2", parts[1].Text)
+ })
+
+ t.Run("billing header skipped", func(t *testing.T) {
+ req := &AnthropicRequest{
+ Model: "gpt-5.2",
+ MaxTokens: 100,
+ System: json.RawMessage(`[{"type":"text","text":"x-anthropic-billing-header: cc_version=1;"},{"type":"text","text":"Project prompt"}]`),
+ Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
+ }
+ resp, err := AnthropicToResponses(req)
+ require.NoError(t, err)
+
+ var items []ResponsesInputItem
+ require.NoError(t, json.Unmarshal(resp.Input, &items))
+ require.Len(t, items, 2)
+ var parts []ResponsesContentPart
+ require.NoError(t, json.Unmarshal(items[0].Content, &parts))
+ require.Len(t, parts, 1)
+ assert.Equal(t, "Project prompt", parts[0].Text)
})
}
@@ -94,6 +127,8 @@ func TestAnthropicToResponses_ToolUse(t *testing.T) {
require.Len(t, resp.Tools, 1)
assert.Equal(t, "function", resp.Tools[0].Type)
assert.Equal(t, "get_weather", resp.Tools[0].Name)
+ require.NotNil(t, resp.Tools[0].Strict)
+ assert.False(t, *resp.Tools[0].Strict)
// Check input items
var items []ResponsesInputItem
@@ -104,10 +139,10 @@ func TestAnthropicToResponses_ToolUse(t *testing.T) {
assert.Equal(t, "user", items[0].Role)
assert.Equal(t, "assistant", items[1].Role)
assert.Equal(t, "function_call", items[2].Type)
- assert.Equal(t, "fc_call_1", items[2].CallID)
+ assert.Equal(t, "call_1", items[2].CallID)
assert.Empty(t, items[2].ID)
assert.Equal(t, "function_call_output", items[3].Type)
- assert.Equal(t, "fc_call_1", items[3].CallID)
+ assert.Equal(t, "call_1", items[3].CallID)
assert.Equal(t, "Sunny, 72°F", items[3].Output)
}
@@ -261,6 +296,34 @@ func TestResponsesToAnthropic_ToolUse(t *testing.T) {
assert.JSONEq(t, `{"city":"NYC"}`, string(anth.Content[1].Input))
}
+func TestResponsesToAnthropic_ToolUseStopReasonDoesNotDependOnLastBlock(t *testing.T) {
+ resp := &ResponsesResponse{
+ ID: "resp_tool_then_text",
+ Model: "gpt-5.5",
+ Status: "completed",
+ Output: []ResponsesOutput{
+ {
+ Type: "function_call",
+ CallID: "call_todo",
+ Name: "TodoWrite",
+ Arguments: `{"todos":[{"content":"review changes","status":"in_progress"}]}`,
+ },
+ {
+ Type: "message",
+ Content: []ResponsesContentPart{
+ {Type: "output_text", Text: "Task list updated."},
+ },
+ },
+ },
+ }
+
+ anth := ResponsesToAnthropic(resp, "claude-opus-4-6")
+ assert.Equal(t, "tool_use", anth.StopReason)
+ require.Len(t, anth.Content, 2)
+ assert.Equal(t, "tool_use", anth.Content[0].Type)
+ assert.Equal(t, "text", anth.Content[1].Type)
+}
+
func TestResponsesToAnthropic_ReadToolDropsEmptyPages(t *testing.T) {
resp := &ResponsesResponse{
ID: "resp_read",
@@ -434,6 +497,45 @@ func TestStreamingTextOnly(t *testing.T) {
assert.Equal(t, "message_stop", events[1].Type)
}
+func TestResponsesEventToAnthropicEvents_ResponseDone(t *testing.T) {
+ state := NewResponsesEventToAnthropicState()
+ state.Model = "gpt-4o"
+
+ events := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
+ Type: "response.done",
+ Response: &ResponsesResponse{
+ Status: "completed",
+ Usage: &ResponsesUsage{InputTokens: 12, OutputTokens: 4},
+ },
+ }, state)
+ require.Len(t, events, 2)
+ assert.Equal(t, "message_delta", events[0].Type)
+ assert.Equal(t, "end_turn", events[0].Delta.StopReason)
+ assert.Equal(t, 12, events[0].Usage.InputTokens)
+ assert.Equal(t, 4, events[0].Usage.OutputTokens)
+ assert.Equal(t, "message_stop", events[1].Type)
+ assert.Nil(t, FinalizeResponsesAnthropicStream(state))
+}
+
+func TestResponsesEventToAnthropicEvents_ResponseDoneIncomplete(t *testing.T) {
+ state := NewResponsesEventToAnthropicState()
+ state.Model = "gpt-4o"
+
+ events := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
+ Type: "response.done",
+ Response: &ResponsesResponse{
+ Status: "incomplete",
+ IncompleteDetails: &ResponsesIncompleteDetails{Reason: "max_output_tokens"},
+ Usage: &ResponsesUsage{InputTokens: 12, OutputTokens: 4},
+ },
+ }, state)
+ require.Len(t, events, 2)
+ assert.Equal(t, "message_delta", events[0].Type)
+ assert.Equal(t, "max_tokens", events[0].Delta.StopReason)
+ assert.Equal(t, "message_stop", events[1].Type)
+ assert.Nil(t, FinalizeResponsesAnthropicStream(state))
+}
+
func TestStreamingCachedTokensUseAnthropicInputSemantics(t *testing.T) {
state := NewResponsesEventToAnthropicState()
ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
@@ -514,6 +616,81 @@ func TestStreamingToolCall(t *testing.T) {
assert.Equal(t, "tool_use", events[0].Delta.StopReason)
}
+func TestStreamingToolCallStopReasonSurvivesLaterText(t *testing.T) {
+ state := NewResponsesEventToAnthropicState()
+
+ ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
+ Type: "response.created",
+ Response: &ResponsesResponse{ID: "resp_tool_then_text", Model: "gpt-5.5"},
+ }, state)
+
+ events := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
+ Type: "response.output_item.added",
+ OutputIndex: 0,
+ Item: &ResponsesOutput{Type: "function_call", CallID: "call_todo", Name: "TodoWrite"},
+ }, state)
+ require.Len(t, events, 1)
+ assert.Equal(t, "content_block_start", events[0].Type)
+
+ events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
+ Type: "response.function_call_arguments.done",
+ OutputIndex: 0,
+ Arguments: `{"todos":[{"content":"review changes","status":"in_progress","activeForm":"reviewing changes"}]}`,
+ }, state)
+ require.Len(t, events, 2)
+ assert.Equal(t, "content_block_delta", events[0].Type)
+ assert.Equal(t, "content_block_stop", events[1].Type)
+
+ events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
+ Type: "response.output_text.delta",
+ OutputIndex: 1,
+ Delta: "I will continue after the task list updates.",
+ }, state)
+ require.Len(t, events, 2)
+ assert.Equal(t, "content_block_start", events[0].Type)
+ assert.Equal(t, "content_block_delta", events[1].Type)
+
+ events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
+ Type: "response.completed",
+ Response: &ResponsesResponse{
+ Status: "completed",
+ Usage: &ResponsesUsage{InputTokens: 20, OutputTokens: 10},
+ },
+ }, state)
+ require.Len(t, events, 3)
+ assert.Equal(t, "content_block_stop", events[0].Type)
+ assert.Equal(t, "tool_use", events[1].Delta.StopReason)
+ assert.Equal(t, "message_stop", events[2].Type)
+}
+
+func TestStreamingToolCallDoneWithoutDeltaEmitsArguments(t *testing.T) {
+ state := NewResponsesEventToAnthropicState()
+
+ ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
+ Type: "response.created",
+ Response: &ResponsesResponse{ID: "resp_bash", Model: "gpt-5.5"},
+ }, state)
+
+ events := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
+ Type: "response.output_item.added",
+ OutputIndex: 0,
+ Item: &ResponsesOutput{Type: "function_call", CallID: "call_bash", Name: "Bash"},
+ }, state)
+ require.Len(t, events, 1)
+ assert.Equal(t, "content_block_start", events[0].Type)
+
+ events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
+ Type: "response.function_call_arguments.done",
+ OutputIndex: 0,
+ Arguments: `{"command":"git -C \"/mnt/d/nodejs/other/edmt\" status --short --ignored"}`,
+ }, state)
+ require.Len(t, events, 2)
+ assert.Equal(t, "content_block_delta", events[0].Type)
+ assert.Equal(t, "input_json_delta", events[0].Delta.Type)
+ assert.JSONEq(t, `{"command":"git -C \"/mnt/d/nodejs/other/edmt\" status --short --ignored"}`, events[0].Delta.PartialJSON)
+ assert.Equal(t, "content_block_stop", events[1].Type)
+}
+
func TestStreamingReadToolDropsEmptyPages(t *testing.T) {
state := NewResponsesEventToAnthropicState()
@@ -653,6 +830,27 @@ func TestFinalizeStream_AbnormalTermination(t *testing.T) {
assert.Equal(t, "message_stop", events[2].Type)
}
+func TestFinalizeStream_ToolCallAbnormalTerminationKeepsToolUseStopReason(t *testing.T) {
+ state := NewResponsesEventToAnthropicState()
+
+ ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
+ Type: "response.created",
+ Response: &ResponsesResponse{ID: "resp_tool_interrupted", Model: "gpt-5.5"},
+ }, state)
+ ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{
+ Type: "response.output_item.added",
+ OutputIndex: 0,
+ Item: &ResponsesOutput{Type: "function_call", CallID: "call_todo", Name: "TodoWrite"},
+ }, state)
+
+ events := FinalizeResponsesAnthropicStream(state)
+ require.Len(t, events, 3)
+ assert.Equal(t, "content_block_stop", events[0].Type)
+ assert.Equal(t, "message_delta", events[1].Type)
+ assert.Equal(t, "tool_use", events[1].Delta.StopReason)
+ assert.Equal(t, "message_stop", events[2].Type)
+}
+
func TestStreamingEmptyResponse(t *testing.T) {
state := NewResponsesEventToAnthropicState()
@@ -788,8 +986,8 @@ func TestAnthropicToResponses_ThinkingEnabled(t *testing.T) {
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
require.NotNil(t, resp.Reasoning)
- // thinking.type is ignored for effort; default high applies.
- assert.Equal(t, "high", resp.Reasoning.Effort)
+ // thinking.type is ignored for effort; Codex bridge default medium applies.
+ assert.Equal(t, "medium", resp.Reasoning.Effort)
assert.Equal(t, "auto", resp.Reasoning.Summary)
assert.Contains(t, resp.Include, "reasoning.encrypted_content")
assert.NotContains(t, resp.Include, "reasoning.summary")
@@ -806,8 +1004,8 @@ func TestAnthropicToResponses_ThinkingAdaptive(t *testing.T) {
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
require.NotNil(t, resp.Reasoning)
- // thinking.type is ignored for effort; default high applies.
- assert.Equal(t, "high", resp.Reasoning.Effort)
+ // thinking.type is ignored for effort; Codex bridge default medium applies.
+ assert.Equal(t, "medium", resp.Reasoning.Effort)
assert.Equal(t, "auto", resp.Reasoning.Summary)
assert.NotContains(t, resp.Include, "reasoning.summary")
}
@@ -822,9 +1020,9 @@ func TestAnthropicToResponses_ThinkingDisabled(t *testing.T) {
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
- // Default effort applies (high → high) even when thinking is disabled.
+ // Default effort applies (medium) even when thinking is disabled.
require.NotNil(t, resp.Reasoning)
- assert.Equal(t, "high", resp.Reasoning.Effort)
+ assert.Equal(t, "medium", resp.Reasoning.Effort)
}
func TestAnthropicToResponses_NoThinking(t *testing.T) {
@@ -836,9 +1034,9 @@ func TestAnthropicToResponses_NoThinking(t *testing.T) {
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
- // Default effort applies (high → high) when no thinking/output_config is set.
+ // Default effort applies (medium) when no thinking/output_config is set.
require.NotNil(t, resp.Reasoning)
- assert.Equal(t, "high", resp.Reasoning.Effort)
+ assert.Equal(t, "medium", resp.Reasoning.Effort)
}
// ---------------------------------------------------------------------------
@@ -846,7 +1044,7 @@ func TestAnthropicToResponses_NoThinking(t *testing.T) {
// ---------------------------------------------------------------------------
func TestAnthropicToResponses_OutputConfigOverridesDefault(t *testing.T) {
- // Default is high, but output_config.effort="low" overrides. low→low after mapping.
+ // Default is medium, but output_config.effort="low" overrides. low→low after mapping.
req := &AnthropicRequest{
Model: "gpt-5.2",
MaxTokens: 1024,
@@ -880,7 +1078,7 @@ func TestAnthropicToResponses_OutputConfigWithoutThinking(t *testing.T) {
}
func TestAnthropicToResponses_OutputConfigHigh(t *testing.T) {
- // output_config.effort="high" → mapped to "high" (1:1, both sides' default).
+ // output_config.effort="high" → mapped to "high" (1:1).
req := &AnthropicRequest{
Model: "gpt-5.2",
MaxTokens: 1024,
@@ -912,7 +1110,7 @@ func TestAnthropicToResponses_OutputConfigMax(t *testing.T) {
}
func TestAnthropicToResponses_NoOutputConfig(t *testing.T) {
- // No output_config → default high regardless of thinking.type.
+ // No output_config → default medium regardless of thinking.type.
req := &AnthropicRequest{
Model: "gpt-5.2",
MaxTokens: 1024,
@@ -923,11 +1121,11 @@ func TestAnthropicToResponses_NoOutputConfig(t *testing.T) {
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
require.NotNil(t, resp.Reasoning)
- assert.Equal(t, "high", resp.Reasoning.Effort)
+ assert.Equal(t, "medium", resp.Reasoning.Effort)
}
func TestAnthropicToResponses_OutputConfigWithoutEffort(t *testing.T) {
- // output_config present but effort empty (e.g. only format set) → default high.
+ // output_config present but effort empty (e.g. only format set) → default medium.
req := &AnthropicRequest{
Model: "gpt-5.2",
MaxTokens: 1024,
@@ -938,7 +1136,7 @@ func TestAnthropicToResponses_OutputConfigWithoutEffort(t *testing.T) {
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
require.NotNil(t, resp.Reasoning)
- assert.Equal(t, "high", resp.Reasoning.Effort)
+ assert.Equal(t, "medium", resp.Reasoning.Effort)
}
// ---------------------------------------------------------------------------
@@ -1110,7 +1308,7 @@ func TestAnthropicToResponses_ToolResultWithImage(t *testing.T) {
// function_call_output should have text-only output (no image).
assert.Equal(t, "function_call_output", items[2].Type)
- assert.Equal(t, "fc_toolu_1", items[2].CallID)
+ assert.Equal(t, "toolu_1", items[2].CallID)
assert.Equal(t, "(empty)", items[2].Output)
// Image should be in a separate user message.
diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go
index 268f9f22e32..5f04004de50 100644
--- a/backend/internal/pkg/apicompat/anthropic_to_responses.go
+++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go
@@ -32,6 +32,9 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) {
storeFalse := false
out.Store = &storeFalse
+ parallelToolCalls := true
+ out.ParallelToolCalls = ¶llelToolCalls
+ out.Text = &ResponsesText{Verbosity: "medium"}
if req.MaxTokens > 0 {
v := req.MaxTokens
@@ -46,10 +49,10 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) {
}
// Determine reasoning effort: only output_config.effort controls the
- // level; thinking.type is ignored. Default is high when unset (both
- // Anthropic and OpenAI default to high).
+ // level; thinking.type is ignored. Default follows Codex CLI / airgate's
+ // Anthropic bridge shape, which uses medium when unset.
// Anthropic levels map 1:1 to OpenAI: low→low, medium→medium, high→high, max→xhigh.
- effort := "high" // default → both sides' default
+ effort := "medium"
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
effort = req.OutputConfig.Effort
}
@@ -108,16 +111,19 @@ func convertAnthropicToolChoiceToResponses(raw json.RawMessage) (json.RawMessage
func convertAnthropicToResponsesInput(system json.RawMessage, msgs []AnthropicMessage) ([]ResponsesInputItem, error) {
var out []ResponsesInputItem
- // System prompt → system role input item.
+ // System prompt → developer role input item. ChatGPT Codex SSE behaves like
+ // Codex CLI here: keeping Anthropic system text in input preserves the
+ // conversation/cache shape better than moving it into instructions.
if len(system) > 0 {
- sysText, err := parseAnthropicSystemPrompt(system)
+ sysParts, err := parseAnthropicSystemContentParts(system)
if err != nil {
return nil, err
}
- if sysText != "" {
- content, _ := json.Marshal(sysText)
+ if len(sysParts) > 0 {
+ content, _ := json.Marshal(sysParts)
out = append(out, ResponsesInputItem{
- Role: "system",
+ Type: "message",
+ Role: "developer",
Content: content,
})
}
@@ -133,24 +139,32 @@ func convertAnthropicToResponsesInput(system json.RawMessage, msgs []AnthropicMe
return out, nil
}
-// parseAnthropicSystemPrompt handles the Anthropic system field which can be
-// a plain string or an array of text blocks.
-func parseAnthropicSystemPrompt(raw json.RawMessage) (string, error) {
+// parseAnthropicSystemContentParts handles the Anthropic system field which can
+// be a plain string or an array of text blocks. Claude Code may include an
+// x-anthropic-billing-header block; airgate drops it before sending to Codex.
+func parseAnthropicSystemContentParts(raw json.RawMessage) ([]ResponsesContentPart, error) {
var s string
if err := json.Unmarshal(raw, &s); err == nil {
- return s, nil
+ if isAnthropicBillingHeaderText(s) || s == "" {
+ return nil, nil
+ }
+ return []ResponsesContentPart{{Type: "input_text", Text: s}}, nil
}
var blocks []AnthropicContentBlock
if err := json.Unmarshal(raw, &blocks); err != nil {
- return "", err
+ return nil, err
}
- var parts []string
+ var parts []ResponsesContentPart
for _, b := range blocks {
- if b.Type == "text" && b.Text != "" {
- parts = append(parts, b.Text)
+ if b.Type == "text" && b.Text != "" && !isAnthropicBillingHeaderText(b.Text) {
+ parts = append(parts, ResponsesContentPart{Type: "input_text", Text: b.Text})
}
}
- return strings.Join(parts, "\n\n"), nil
+ return parts, nil
+}
+
+func isAnthropicBillingHeaderText(text string) bool {
+ return strings.HasPrefix(text, "x-anthropic-billing-header: ")
}
// anthropicMsgToResponsesItems converts a single Anthropic message into one
@@ -173,8 +187,12 @@ func anthropicUserToResponses(raw json.RawMessage) ([]ResponsesInputItem, error)
// Try plain string.
var s string
if err := json.Unmarshal(raw, &s); err == nil {
- content, _ := json.Marshal(s)
- return []ResponsesInputItem{{Role: "user", Content: content}}, nil
+ parts := []ResponsesContentPart{{Type: "input_text", Text: s}}
+ partsJSON, err := json.Marshal(parts)
+ if err != nil {
+ return nil, err
+ }
+ return []ResponsesInputItem{{Type: "message", Role: "user", Content: partsJSON}}, nil
}
var blocks []AnthropicContentBlock
@@ -223,7 +241,7 @@ func anthropicUserToResponses(raw json.RawMessage) ([]ResponsesInputItem, error)
if err != nil {
return nil, err
}
- out = append(out, ResponsesInputItem{Role: "user", Content: content})
+ out = append(out, ResponsesInputItem{Type: "message", Role: "user", Content: content})
}
return out, nil
@@ -242,7 +260,7 @@ func anthropicAssistantToResponses(raw json.RawMessage) ([]ResponsesInputItem, e
if err != nil {
return nil, err
}
- return []ResponsesInputItem{{Role: "assistant", Content: partsJSON}}, nil
+ return []ResponsesInputItem{{Type: "message", Role: "assistant", Content: partsJSON}}, nil
}
var blocks []AnthropicContentBlock
@@ -260,7 +278,7 @@ func anthropicAssistantToResponses(raw json.RawMessage) ([]ResponsesInputItem, e
if err != nil {
return nil, err
}
- items = append(items, ResponsesInputItem{Role: "assistant", Content: partsJSON})
+ items = append(items, ResponsesInputItem{Type: "message", Role: "assistant", Content: partsJSON})
}
// tool_use → function_call items.
@@ -284,17 +302,14 @@ func anthropicAssistantToResponses(raw json.RawMessage) ([]ResponsesInputItem, e
return items, nil
}
-// toResponsesCallID converts an Anthropic tool ID (toolu_xxx / call_xxx) to a
-// Responses API function_call ID that starts with "fc_".
+// toResponsesCallID preserves Anthropic tool IDs as Responses call_id values.
+// Claude Code sends tool_result.tool_use_id back verbatim, and ChatGPT Codex
+// continuation expects that call_id to match the original tool_use id.
func toResponsesCallID(id string) string {
- if strings.HasPrefix(id, "fc_") {
- return id
- }
- return "fc_" + id
+ return id
}
-// fromResponsesCallID reverses toResponsesCallID, stripping the "fc_" prefix
-// that was added during request conversion.
+// fromResponsesCallID reverses old prefixed IDs while preserving current IDs.
func fromResponsesCallID(id string) string {
if after, ok := strings.CutPrefix(id, "fc_"); ok {
// Only strip if the remainder doesn't look like it was already "fc_" prefixed.
@@ -412,11 +427,16 @@ func convertAnthropicToolsToResponses(tools []AnthropicTool) []ResponsesTool {
Name: t.Name,
Description: t.Description,
Parameters: normalizeToolParameters(t.InputSchema),
+ Strict: boolPtr(false),
})
}
return out
}
+func boolPtr(v bool) *bool {
+ return &v
+}
+
// normalizeToolParameters ensures the tool parameter schema is valid for
// OpenAI's Responses API, which requires "properties" on object schemas.
//
diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go
index 35d42999c97..f55bd12e6f5 100644
--- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go
+++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go
@@ -720,6 +720,49 @@ func TestResponsesEventToChatChunks_Completed(t *testing.T) {
assert.Equal(t, 30, chunks[1].Usage.PromptTokensDetails.CachedTokens)
}
+func TestResponsesEventToChatChunks_ResponseDone(t *testing.T) {
+ state := NewResponsesEventToChatState()
+ state.Model = "gpt-4o"
+ state.IncludeUsage = true
+
+ chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
+ Type: "response.done",
+ Response: &ResponsesResponse{
+ Status: "completed",
+ Usage: &ResponsesUsage{InputTokens: 13, OutputTokens: 7},
+ },
+ }, state)
+ require.Len(t, chunks, 2)
+ require.NotNil(t, chunks[0].Choices[0].FinishReason)
+ assert.Equal(t, "stop", *chunks[0].Choices[0].FinishReason)
+ require.NotNil(t, chunks[1].Usage)
+ assert.Equal(t, 13, chunks[1].Usage.PromptTokens)
+ assert.Equal(t, 7, chunks[1].Usage.CompletionTokens)
+ assert.Nil(t, FinalizeResponsesChatStream(state))
+}
+
+func TestResponsesEventToChatChunks_ResponseDoneIncomplete(t *testing.T) {
+ state := NewResponsesEventToChatState()
+ state.Model = "gpt-4o"
+ state.IncludeUsage = true
+
+ chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
+ Type: "response.done",
+ Response: &ResponsesResponse{
+ Status: "incomplete",
+ IncompleteDetails: &ResponsesIncompleteDetails{Reason: "max_output_tokens"},
+ Usage: &ResponsesUsage{InputTokens: 13, OutputTokens: 7},
+ },
+ }, state)
+ require.Len(t, chunks, 2)
+ require.NotNil(t, chunks[0].Choices[0].FinishReason)
+ assert.Equal(t, "length", *chunks[0].Choices[0].FinishReason)
+ require.NotNil(t, chunks[1].Usage)
+ assert.Equal(t, 13, chunks[1].Usage.PromptTokens)
+ assert.Equal(t, 7, chunks[1].Usage.CompletionTokens)
+ assert.Nil(t, FinalizeResponsesChatStream(state))
+}
+
func TestResponsesEventToChatChunks_CompletedWithToolCalls(t *testing.T) {
state := NewResponsesEventToChatState()
state.Model = "gpt-4o"
@@ -934,6 +977,7 @@ func TestBufferedResponseAccumulator_TextOnly(t *testing.T) {
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: ", world!"})
assert.True(t, acc.HasContent())
+ assert.False(t, acc.HasFunctionCalls())
output := acc.BuildOutput()
require.Len(t, output, 1)
@@ -969,6 +1013,7 @@ func TestBufferedResponseAccumulator_ToolCalls(t *testing.T) {
})
assert.True(t, acc.HasContent())
+ assert.True(t, acc.HasFunctionCalls())
output := acc.BuildOutput()
require.Len(t, output, 1)
diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic.go b/backend/internal/pkg/apicompat/responses_to_anthropic.go
index 489ed23866c..d7ef014537b 100644
--- a/backend/internal/pkg/apicompat/responses_to_anthropic.go
+++ b/backend/internal/pkg/apicompat/responses_to_anthropic.go
@@ -120,7 +120,7 @@ func responsesStatusToAnthropicStopReason(status string, details *ResponsesIncom
}
return "end_turn"
case "completed":
- if len(blocks) > 0 && blocks[len(blocks)-1].Type == "tool_use" {
+ if containsAnthropicToolUseBlock(blocks) {
return "tool_use"
}
return "end_turn"
@@ -129,6 +129,15 @@ func responsesStatusToAnthropicStopReason(status string, details *ResponsesIncom
}
}
+func containsAnthropicToolUseBlock(blocks []AnthropicContentBlock) bool {
+ for _, block := range blocks {
+ if block.Type == "tool_use" {
+ return true
+ }
+ }
+ return false
+}
+
func sanitizeAnthropicToolUseInput(name string, raw string) json.RawMessage {
if name != "Read" || raw == "" {
return json.RawMessage(raw)
@@ -161,11 +170,13 @@ type ResponsesEventToAnthropicState struct {
MessageStartSent bool
MessageStopSent bool
- ContentBlockIndex int
- ContentBlockOpen bool
- CurrentBlockType string // "text" | "thinking" | "tool_use"
- CurrentToolName string
- CurrentToolArgs string
+ ContentBlockIndex int
+ ContentBlockOpen bool
+ CurrentBlockType string // "text" | "thinking" | "tool_use"
+ CurrentToolName string
+ CurrentToolArgs string
+ CurrentToolHadDelta bool
+ HasToolCall bool
// OutputIndexToBlockIdx maps Responses output_index → Anthropic content block index.
OutputIndexToBlockIdx map[int]int
@@ -212,7 +223,9 @@ func ResponsesEventToAnthropicEvents(
return resToAnthHandleReasoningDelta(evt, state)
case "response.reasoning_summary_text.done":
return resToAnthHandleBlockDone(state)
- case "response.completed", "response.incomplete", "response.failed":
+ // response.done 是 Realtime/WS 与项目透传路径使用的终止别名;
+ // 普通 Responses HTTP SSE 的公开终止事件仍以 response.completed 为主。
+ case "response.completed", "response.done", "response.incomplete", "response.failed":
return resToAnthHandleCompleted(evt, state)
default:
return nil
@@ -229,11 +242,16 @@ func FinalizeResponsesAnthropicStream(state *ResponsesEventToAnthropicState) []A
var events []AnthropicStreamEvent
events = append(events, closeCurrentBlock(state)...)
+ stopReason := "end_turn"
+ if state.HasToolCall {
+ stopReason = "tool_use"
+ }
+
events = append(events,
AnthropicStreamEvent{
Type: "message_delta",
Delta: &AnthropicDelta{
- StopReason: "end_turn",
+ StopReason: stopReason,
},
Usage: &AnthropicUsage{
InputTokens: state.InputTokens,
@@ -304,6 +322,8 @@ func resToAnthHandleOutputItemAdded(evt *ResponsesStreamEvent, state *ResponsesE
state.CurrentBlockType = "tool_use"
state.CurrentToolName = evt.Item.Name
state.CurrentToolArgs = ""
+ state.CurrentToolHadDelta = false
+ state.HasToolCall = true
events = append(events, AnthropicStreamEvent{
Type: "content_block_start",
@@ -388,6 +408,9 @@ func resToAnthHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve
state.CurrentToolArgs += evt.Delta
return nil
}
+ if state.CurrentBlockType == "tool_use" {
+ state.CurrentToolHadDelta = true
+ }
blockIdx, ok := state.OutputIndexToBlockIdx[evt.OutputIndex]
if !ok {
@@ -405,7 +428,7 @@ func resToAnthHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve
}
func resToAnthHandleFuncArgsDone(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
- if state.CurrentBlockType != "tool_use" || state.CurrentToolName != "Read" {
+ if state.CurrentBlockType != "tool_use" {
return resToAnthHandleBlockDone(state)
}
@@ -413,10 +436,16 @@ func resToAnthHandleFuncArgsDone(evt *ResponsesStreamEvent, state *ResponsesEven
if raw == "" {
raw = state.CurrentToolArgs
}
- sanitized := sanitizeAnthropicToolUseInput(state.CurrentToolName, raw)
- if len(sanitized) == 0 {
+ if raw == "" || state.CurrentToolHadDelta {
return closeCurrentBlock(state)
}
+ if state.CurrentToolName == "Read" {
+ sanitized := sanitizeAnthropicToolUseInput(state.CurrentToolName, raw)
+ if len(sanitized) == 0 {
+ return closeCurrentBlock(state)
+ }
+ raw = string(sanitized)
+ }
idx := state.ContentBlockIndex
events := []AnthropicStreamEvent{{
@@ -424,7 +453,7 @@ func resToAnthHandleFuncArgsDone(evt *ResponsesStreamEvent, state *ResponsesEven
Index: &idx,
Delta: &AnthropicDelta{
Type: "input_json_delta",
- PartialJSON: string(sanitized),
+ PartialJSON: raw,
},
}}
events = append(events, closeCurrentBlock(state)...)
@@ -551,7 +580,7 @@ func resToAnthHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo
stopReason = "max_tokens"
}
case "completed":
- if state.ContentBlockIndex > 0 && state.CurrentBlockType == "tool_use" {
+ if state.HasToolCall {
stopReason = "tool_use"
}
}
@@ -584,6 +613,7 @@ func closeCurrentBlock(state *ResponsesEventToAnthropicState) []AnthropicStreamE
state.ContentBlockIndex++
state.CurrentToolName = ""
state.CurrentToolArgs = ""
+ state.CurrentToolHadDelta = false
return []AnthropicStreamEvent{{
Type: "content_block_stop",
Index: &idx,
diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go
index 8fa652f2bd1..020e2e31eb9 100644
--- a/backend/internal/pkg/apicompat/responses_to_anthropic_request.go
+++ b/backend/internal/pkg/apicompat/responses_to_anthropic_request.go
@@ -353,19 +353,27 @@ func mergeConsecutiveMessages(messages []AnthropicMessage) []AnthropicMessage {
return messages
}
- var merged []AnthropicMessage
- for _, msg := range messages {
- if len(merged) == 0 || merged[len(merged)-1].Role != msg.Role {
- merged = append(merged, msg)
+ merged := make([]AnthropicMessage, 0, len(messages))
+ for start := 0; start < len(messages); {
+ end := start + 1
+ for end < len(messages) && messages[end].Role == messages[start].Role {
+ end++
+ }
+
+ if end == start+1 {
+ merged = append(merged, messages[start])
+ start = end
continue
}
- // Same role — merge content arrays
- last := &merged[len(merged)-1]
- lastBlocks := parseContentBlocks(last.Content)
- newBlocks := parseContentBlocks(msg.Content)
- combined := append(lastBlocks, newBlocks...)
- last.Content, _ = json.Marshal(combined)
+ combined := make([]AnthropicContentBlock, 0, end-start)
+ for i := start; i < end; i++ {
+ combined = append(combined, parseContentBlocks(messages[i].Content)...)
+ }
+ msg := messages[start]
+ msg.Content, _ = json.Marshal(combined)
+ merged = append(merged, msg)
+ start = end
}
return merged
}
diff --git a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go
index 61b3bf9cde8..3380f1a5fce 100644
--- a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go
+++ b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go
@@ -4,11 +4,23 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
+ "errors"
"fmt"
"strings"
"time"
)
+const (
+ maxResponsesCompatibilityEvents = 65_536
+ maxResponsesCompatibilityToolCalls = 128
+ maxResponsesCompatibilityOutputBytes = 16 << 20
+)
+
+// ErrResponsesCompatibilityResourceLimit marks an upstream compatibility
+// stream that exceeded bounded in-memory conversion work. Callers should stop
+// reading that response rather than returning a partial reconstructed result.
+var ErrResponsesCompatibilityResourceLimit = errors.New("responses compatibility resource limit exceeded")
+
// ---------------------------------------------------------------------------
// Non-streaming: ResponsesResponse → ChatCompletionsResponse
// ---------------------------------------------------------------------------
@@ -29,8 +41,8 @@ func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatComp
Model: model,
}
- var contentText string
- var reasoningText string
+ var contentText strings.Builder
+ var reasoningText strings.Builder
var toolCalls []ChatToolCall
for _, item := range resp.Output {
@@ -38,7 +50,7 @@ func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatComp
case "message":
for _, part := range item.Content {
if part.Type == "output_text" && part.Text != "" {
- contentText += part.Text
+ _, _ = contentText.WriteString(part.Text)
}
}
case "function_call":
@@ -53,7 +65,7 @@ func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatComp
case "reasoning":
for _, s := range item.Summary {
if s.Type == "summary_text" && s.Text != "" {
- reasoningText += s.Text
+ _, _ = reasoningText.WriteString(s.Text)
}
}
case "web_search_call":
@@ -65,12 +77,12 @@ func ResponsesToChatCompletions(resp *ResponsesResponse, model string) *ChatComp
if len(toolCalls) > 0 {
msg.ToolCalls = toolCalls
}
- if contentText != "" {
- raw, _ := json.Marshal(contentText)
+ if contentText.Len() > 0 {
+ raw, _ := json.Marshal(contentText.String())
msg.Content = raw
}
- if reasoningText != "" {
- msg.ReasoningContent = reasoningText
+ if reasoningText.Len() > 0 {
+ msg.ReasoningContent = reasoningText.String()
}
finishReason := responsesStatusToChatFinishReason(resp.Status, resp.IncompleteDetails, toolCalls)
@@ -133,6 +145,8 @@ type ResponsesEventToChatState struct {
OutputIndexToToolIndex map[int]int // Responses output_index → Chat tool_calls index
IncludeUsage bool
Usage *ChatUsage
+ eventCount int
+ limitErr error
}
// NewResponsesEventToChatState returns an initialised stream state.
@@ -144,9 +158,33 @@ func NewResponsesEventToChatState() *ResponsesEventToChatState {
}
}
+// Err returns the first resource-limit violation observed by the stream
+// converter. Once set, subsequent events are ignored.
+func (s *ResponsesEventToChatState) Err() error {
+ if s == nil {
+ return nil
+ }
+ return s.limitErr
+}
+
+func (s *ResponsesEventToChatState) failLimit(reason string) {
+ if s.limitErr == nil {
+ s.limitErr = fmt.Errorf("%w: %s", ErrResponsesCompatibilityResourceLimit, reason)
+ }
+}
+
// ResponsesEventToChatChunks converts a single Responses SSE event into zero
// or more Chat Completions chunks, updating state as it goes.
func ResponsesEventToChatChunks(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
+ if evt == nil || state == nil || state.limitErr != nil {
+ return nil
+ }
+ state.eventCount++
+ if state.eventCount > maxResponsesCompatibilityEvents {
+ state.failLimit("event count")
+ return nil
+ }
+
switch evt.Type {
case "response.created":
return resToChatHandleCreated(evt, state)
@@ -160,7 +198,9 @@ func ResponsesEventToChatChunks(evt *ResponsesStreamEvent, state *ResponsesEvent
return resToChatHandleReasoningDelta(evt, state)
case "response.reasoning_summary_text.done":
return nil
- case "response.completed", "response.incomplete", "response.failed":
+ // response.done 是 Realtime/WS 与项目透传路径使用的终止别名;
+ // 普通 Responses HTTP SSE 的公开终止事件仍以 response.completed 为主。
+ case "response.completed", "response.done", "response.incomplete", "response.failed":
return resToChatHandleCompleted(evt, state)
default:
return nil
@@ -242,6 +282,14 @@ func resToChatHandleOutputItemAdded(evt *ResponsesStreamEvent, state *ResponsesE
return nil
}
+ if _, exists := state.OutputIndexToToolIndex[evt.OutputIndex]; exists {
+ return nil
+ }
+ if len(state.OutputIndexToToolIndex) >= maxResponsesCompatibilityToolCalls {
+ state.failLimit("tool call count")
+ return nil
+ }
+
state.SawToolCall = true
idx := state.NextToolCallIndex
state.OutputIndexToToolIndex[evt.OutputIndex] = idx
@@ -391,8 +439,13 @@ type bufferedFuncCall struct {
type BufferedResponseAccumulator struct {
text strings.Builder
reasoning strings.Builder
- funcCalls []bufferedFuncCall
+ funcCalls []*bufferedFuncCall
outputIndexToFuncIdx map[int]int
+ responseID string
+ responseModel string
+ eventCount int
+ retainedBytes int
+ limitErr error
}
// NewBufferedResponseAccumulator returns an initialised accumulator.
@@ -405,17 +458,58 @@ func NewBufferedResponseAccumulator() *BufferedResponseAccumulator {
// ProcessEvent inspects a single Responses SSE event and accumulates any
// content it carries. Only delta events that contribute to the final output
// are handled; all other event types are silently ignored.
-func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent) {
+func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent) error {
+ if a == nil || event == nil {
+ return nil
+ }
+ if a.limitErr != nil {
+ return a.limitErr
+ }
+ a.eventCount++
+ if a.eventCount > maxResponsesCompatibilityEvents {
+ return a.failLimit("event count")
+ }
+ if event.Response != nil {
+ responseID := ""
+ responseModel := ""
+ if a.responseID == "" {
+ responseID = strings.TrimSpace(event.Response.ID)
+ }
+ if a.responseModel == "" {
+ responseModel = strings.TrimSpace(event.Response.Model)
+ }
+ if err := a.reserveBytes(len(responseID) + len(responseModel)); err != nil {
+ return err
+ }
+ if responseID != "" {
+ a.responseID = responseID
+ }
+ if responseModel != "" {
+ a.responseModel = responseModel
+ }
+ }
switch event.Type {
case "response.output_text.delta":
if event.Delta != "" {
+ if err := a.reserveBytes(len(event.Delta)); err != nil {
+ return err
+ }
_, _ = a.text.WriteString(event.Delta)
}
case "response.output_item.added":
if event.Item != nil && event.Item.Type == "function_call" {
+ if _, exists := a.outputIndexToFuncIdx[event.OutputIndex]; exists {
+ return nil
+ }
+ if len(a.funcCalls) >= maxResponsesCompatibilityToolCalls {
+ return a.failLimit("tool call count")
+ }
+ if err := a.reserveBytes(len(event.Item.CallID) + len(event.Item.Name)); err != nil {
+ return err
+ }
idx := len(a.funcCalls)
a.outputIndexToFuncIdx[event.OutputIndex] = idx
- a.funcCalls = append(a.funcCalls, bufferedFuncCall{
+ a.funcCalls = append(a.funcCalls, &bufferedFuncCall{
CallID: event.Item.CallID,
Name: event.Item.Name,
})
@@ -423,25 +517,87 @@ func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent)
case "response.function_call_arguments.delta":
if event.Delta != "" {
if idx, ok := a.outputIndexToFuncIdx[event.OutputIndex]; ok {
+ if err := a.reserveBytes(len(event.Delta)); err != nil {
+ return err
+ }
_, _ = a.funcCalls[idx].Args.WriteString(event.Delta)
}
}
case "response.reasoning_summary_text.delta":
if event.Delta != "" {
+ if err := a.reserveBytes(len(event.Delta)); err != nil {
+ return err
+ }
_, _ = a.reasoning.WriteString(event.Delta)
}
}
+ return nil
+}
+
+// Err returns the first cumulative conversion limit violation.
+func (a *BufferedResponseAccumulator) Err() error {
+ if a == nil {
+ return nil
+ }
+ return a.limitErr
+}
+
+func (a *BufferedResponseAccumulator) reserveBytes(size int) error {
+ if size < 0 || size > maxResponsesCompatibilityOutputBytes-a.retainedBytes {
+ return a.failLimit("retained output bytes")
+ }
+ a.retainedBytes += size
+ return nil
+}
+
+func (a *BufferedResponseAccumulator) failLimit(reason string) error {
+ if a.limitErr == nil {
+ a.limitErr = fmt.Errorf("%w: %s", ErrResponsesCompatibilityResourceLimit, reason)
+ }
+ return a.limitErr
+}
+
+// ResponseID returns the first response ID observed in the stream.
+func (a *BufferedResponseAccumulator) ResponseID() string {
+ if a == nil {
+ return ""
+ }
+ return a.responseID
+}
+
+// ResponseModel returns the first response model observed in the stream.
+func (a *BufferedResponseAccumulator) ResponseModel() string {
+ if a == nil {
+ return ""
+ }
+ return a.responseModel
}
// HasContent reports whether any content has been accumulated.
func (a *BufferedResponseAccumulator) HasContent() bool {
+ if a == nil || a.limitErr != nil {
+ return false
+ }
return a.text.Len() > 0 || len(a.funcCalls) > 0 || a.reasoning.Len() > 0
}
+// HasFunctionCalls reports whether function/tool call output was accumulated.
+// Non-streaming gateways use this to avoid synthesizing a successful response
+// from a potentially partial tool call when an upstream stream ends abruptly.
+func (a *BufferedResponseAccumulator) HasFunctionCalls() bool {
+ if a == nil || a.limitErr != nil {
+ return false
+ }
+ return len(a.funcCalls) > 0
+}
+
// BuildOutput constructs a []ResponsesOutput from the accumulated delta
// content. The order matches what ResponsesToChatCompletions expects:
// reasoning → message → function_calls.
func (a *BufferedResponseAccumulator) BuildOutput() []ResponsesOutput {
+ if a == nil || a.limitErr != nil {
+ return nil
+ }
var out []ResponsesOutput
if a.reasoning.Len() > 0 {
diff --git a/backend/internal/pkg/apicompat/sanitize.go b/backend/internal/pkg/apicompat/sanitize.go
new file mode 100644
index 00000000000..14317dc673a
--- /dev/null
+++ b/backend/internal/pkg/apicompat/sanitize.go
@@ -0,0 +1,207 @@
+package apicompat
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+)
+
+// thinkingKeyMarker is the cheapest substring that must be present in the body
+// for any block to qualify as an empty-thinking placeholder. The vast majority
+// of /v1/messages requests do not carry a "thinking" key at all, so a single
+// bytes.Contains scan lets us short-circuit before paying for json.Unmarshal.
+//
+// A false positive (e.g. user text that contains the literal string
+// `"thinking"`) is harmless — the slow path runs and finds nothing to drop.
+var thinkingKeyMarker = []byte(`"thinking"`)
+
+const maxAnthropicSanitizeBodyBytes = 16 << 20
+
+// ErrAnthropicSanitizeBodyTooLarge is returned before JSON decoding when the
+// compatibility slow path would otherwise multiply a large request's working
+// set. Callers must reject this request instead of forwarding it unchanged.
+var ErrAnthropicSanitizeBodyTooLarge = errors.New("anthropic compatibility sanitization body limit exceeded")
+
+// SanitizeAnthropicRequestBody removes placeholder "empty thinking" content
+// blocks from a POST /v1/messages request body before it is forwarded upstream.
+//
+// Some Claude Code CLI versions (notably the multi-turn agentic loop after a
+// 2.1.x auto-update) serialize the model's *in-progress* thinking block as
+//
+// {"type":"thinking","thinking":"","signature":""}
+//
+// when packaging the previous turn back into messages for the next turn. The
+// real Anthropic /v1/messages API rejects these blocks with a 400 schema
+// validation error ("thinking content is required"), and that 400 surfaces to
+// the customer mid-task as an apparent "做到一半就停了" failure.
+//
+// On the OpenAI translation path the existing AnthropicToResponses converter
+// already discards thinking blocks entirely, so the empty-thinking artifact
+// never reaches OpenAI — but for the small fraction of requests that fall
+// through to a native Anthropic upstream, the schema error fires.
+//
+// This helper performs a single, conservative scrub at ingress, applied before
+// either upstream path branches:
+//
+// - Only blocks where Type=="thinking" AND Thinking=="" AND Signature=="" are
+// dropped. Any block carrying real thinking text or a server-issued
+// signature is preserved untouched, so legitimate extended-thinking
+// continuations work unchanged.
+// - The body is parsed via map[string]json.RawMessage so unknown / future
+// top-level fields (top_k, service_tier, mcp_servers, container, …) round
+// trip without loss.
+// - Surviving content blocks are also passed through as raw JSON, so any
+// block-level fields not modeled by AnthropicContentBlock are preserved.
+// - On any parse failure the original body is returned unchanged together
+// with the error, so the caller can decide whether to log it. The function
+// never substitutes a partially-modified body when something goes wrong.
+// - Idempotent: a body with no empty-thinking blocks is returned byte-for-byte
+// unchanged.
+//
+// Returns (possibly new body, count of blocks removed, error).
+func SanitizeAnthropicRequestBody(body []byte) ([]byte, int, error) {
+ if len(body) == 0 {
+ return body, 0, nil
+ }
+
+ // Fast path: if the body never mentions a "thinking" key, no block can
+ // possibly match the empty-thinking placeholder shape. Skip the two-pass
+ // json.Unmarshal entirely. Critical for hot path performance — ~95% of
+ // /v1/messages requests in production do not carry thinking blocks.
+ if !bytes.Contains(body, thinkingKeyMarker) {
+ return body, 0, nil
+ }
+ if len(body) > maxAnthropicSanitizeBodyBytes {
+ return body, 0, ErrAnthropicSanitizeBodyTooLarge
+ }
+
+ var top map[string]json.RawMessage
+ if err := json.Unmarshal(body, &top); err != nil {
+ return body, 0, err
+ }
+
+ msgsRaw, ok := top["messages"]
+ if !ok || !looksLikeJSONArray(msgsRaw) {
+ return body, 0, nil
+ }
+
+ var msgs []json.RawMessage
+ if err := json.Unmarshal(msgsRaw, &msgs); err != nil {
+ return body, 0, err
+ }
+
+ totalRemoved := 0
+ anyChanged := false
+
+ for i, msgRaw := range msgs {
+ newMsg, removed, err := sanitizeMessage(msgRaw)
+ if err != nil {
+ // Best-effort: a single malformed message should not abort the whole
+ // request. Leave it as-is and let the upstream return its native
+ // error, which preserves existing error semantics.
+ continue
+ }
+ if removed == 0 {
+ continue
+ }
+ msgs[i] = newMsg
+ totalRemoved += removed
+ anyChanged = true
+ }
+
+ if !anyChanged {
+ return body, 0, nil
+ }
+
+ newMsgs, err := json.Marshal(msgs)
+ if err != nil {
+ return body, 0, err
+ }
+ top["messages"] = newMsgs
+
+ out, err := json.Marshal(top)
+ if err != nil {
+ return body, 0, err
+ }
+ return out, totalRemoved, nil
+}
+
+// sanitizeMessage processes a single Anthropic message object and returns the
+// rewritten message bytes (when content blocks were dropped) plus the count of
+// removed blocks. A message whose content is not a JSON array (e.g. a plain
+// string content) is returned with removed=0 and the original bytes.
+func sanitizeMessage(msgRaw json.RawMessage) (json.RawMessage, int, error) {
+ var msg map[string]json.RawMessage
+ if err := json.Unmarshal(msgRaw, &msg); err != nil {
+ return msgRaw, 0, err
+ }
+
+ contentRaw, ok := msg["content"]
+ if !ok || !looksLikeJSONArray(contentRaw) {
+ return msgRaw, 0, nil
+ }
+
+ var blocks []json.RawMessage
+ if err := json.Unmarshal(contentRaw, &blocks); err != nil {
+ return msgRaw, 0, err
+ }
+
+ kept := make([]json.RawMessage, 0, len(blocks))
+ removed := 0
+ for _, b := range blocks {
+ if isEmptyThinkingBlockRaw(b) {
+ removed++
+ continue
+ }
+ kept = append(kept, b)
+ }
+
+ if removed == 0 {
+ return msgRaw, 0, nil
+ }
+
+ newContent, err := json.Marshal(kept)
+ if err != nil {
+ return msgRaw, 0, err
+ }
+ msg["content"] = newContent
+
+ newMsg, err := json.Marshal(msg)
+ if err != nil {
+ return msgRaw, 0, err
+ }
+ return newMsg, removed, nil
+}
+
+// isEmptyThinkingBlockRaw reports whether a raw content block is a placeholder
+// thinking block: Type=="thinking", empty Thinking, and no Signature. A small
+// dedicated probe struct is used so unrelated block fields (text, tool_use_id,
+// source, …) do not affect the decision.
+func isEmptyThinkingBlockRaw(raw json.RawMessage) bool {
+ var probe struct {
+ Type string `json:"type"`
+ Thinking string `json:"thinking"`
+ Signature string `json:"signature"`
+ }
+ if err := json.Unmarshal(raw, &probe); err != nil {
+ return false
+ }
+ return probe.Type == "thinking" && probe.Thinking == "" && probe.Signature == ""
+}
+
+// looksLikeJSONArray reports whether the first non-whitespace byte of raw is
+// '['. Used to cheaply distinguish array content from string / null / object
+// without paying for a full unmarshal.
+func looksLikeJSONArray(raw []byte) bool {
+ for _, c := range raw {
+ switch c {
+ case ' ', '\t', '\n', '\r':
+ continue
+ case '[':
+ return true
+ default:
+ return false
+ }
+ }
+ return false
+}
diff --git a/backend/internal/pkg/apicompat/sanitize_test.go b/backend/internal/pkg/apicompat/sanitize_test.go
new file mode 100644
index 00000000000..af18bff552f
--- /dev/null
+++ b/backend/internal/pkg/apicompat/sanitize_test.go
@@ -0,0 +1,392 @@
+package apicompat
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+// helper: re-decode result body so test assertions are key-order-agnostic
+// (Go's encoding/json sorts map keys alphabetically on marshal).
+func decodeRequest(t *testing.T, body []byte) map[string]any {
+ t.Helper()
+ var got map[string]any
+ if err := json.Unmarshal(body, &got); err != nil {
+ t.Fatalf("decode result: %v\nbody: %s", err, string(body))
+ }
+ return got
+}
+
+func TestSanitize_EmptyBody_NoOp(t *testing.T) {
+ out, removed, err := SanitizeAnthropicRequestBody(nil)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if removed != 0 {
+ t.Fatalf("removed=%d want 0", removed)
+ }
+ if out != nil {
+ t.Fatalf("want nil out, got %v", out)
+ }
+}
+
+func TestSanitize_BadJSON_ReturnsOriginal(t *testing.T) {
+ // Must contain `"thinking"` to bypass the fast path, otherwise the
+ // pre-check short-circuits with no error and the parse path is never
+ // exercised. The substring forces SanitizeAnthropicRequestBody into the
+ // real json.Unmarshal which then surfaces the bad-JSON error.
+ in := []byte(`{not json "thinking" placeholder`)
+ out, removed, err := SanitizeAnthropicRequestBody(in)
+ if err == nil {
+ t.Fatalf("expected error for bad JSON")
+ }
+ if removed != 0 {
+ t.Fatalf("removed=%d want 0", removed)
+ }
+ if string(out) != string(in) {
+ t.Fatalf("body mutated on error: got %q want %q", string(out), string(in))
+ }
+}
+
+func TestSanitize_FastPath_NoThinkingSubstring(t *testing.T) {
+ // Realistic /v1/messages body that does NOT contain the literal
+ // substring `"thinking"` anywhere. The fast path must short-circuit
+ // before json.Unmarshal — verified indirectly by feeding a body that is
+ // NOT valid JSON yet expecting err == nil and the original bytes back.
+ // If the fast path is removed (or broken), the bad JSON would fail
+ // json.Unmarshal and surface an error.
+ in := []byte(`{not json at all, but no t-h-i-n-k-i-n-g key here`)
+ out, removed, err := SanitizeAnthropicRequestBody(in)
+ if err != nil {
+ t.Fatalf("fast path should skip parse, got err=%v", err)
+ }
+ if removed != 0 {
+ t.Fatalf("removed=%d want 0", removed)
+ }
+ if string(out) != string(in) {
+ t.Fatalf("fast path must return original body byte-for-byte")
+ }
+}
+
+func TestSanitize_FastPath_ThinkingSubstringInUserText(t *testing.T) {
+ // False-positive case: user text legitimately contains the literal string
+ // "thinking" so the fast path falls through to the slow path. The slow
+ // path must find no empty-thinking blocks and return byte-identical body.
+ in := []byte(`{"model":"claude-opus-4-7","messages":[` +
+ `{"role":"user","content":[{"type":"text","text":"What were you \"thinking\" about?"}]}` +
+ `]}`)
+ out, removed, err := SanitizeAnthropicRequestBody(in)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if removed != 0 {
+ t.Fatalf("removed=%d want 0", removed)
+ }
+ if string(out) != string(in) {
+ t.Fatalf("body should be byte-identical when thinking is only in user text")
+ }
+}
+
+func TestSanitize_NoMessagesField_NoOp(t *testing.T) {
+ in := []byte(`{"model":"claude-opus-4-7","max_tokens":100}`)
+ out, removed, _ := SanitizeAnthropicRequestBody(in)
+ if removed != 0 {
+ t.Fatalf("removed=%d want 0", removed)
+ }
+ if string(out) != string(in) {
+ t.Fatalf("body should be unchanged when messages absent")
+ }
+}
+
+func TestSanitize_StringContent_NoOp(t *testing.T) {
+ // content is a plain string — many older clients send this shape.
+ in := []byte(`{"model":"claude-opus-4-7","messages":[{"role":"user","content":"hello"}]}`)
+ out, removed, err := SanitizeAnthropicRequestBody(in)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if removed != 0 {
+ t.Fatalf("removed=%d want 0", removed)
+ }
+ if string(out) != string(in) {
+ t.Fatalf("string-content body should be byte-identical, got %q", string(out))
+ }
+}
+
+func TestSanitize_NoThinkingBlocks_Idempotent(t *testing.T) {
+ in := []byte(`{"model":"claude-opus-4-7","messages":[` +
+ `{"role":"user","content":[{"type":"text","text":"hi"}]}` +
+ `]}`)
+ out, removed, err := SanitizeAnthropicRequestBody(in)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if removed != 0 {
+ t.Fatalf("removed=%d want 0", removed)
+ }
+ if string(out) != string(in) {
+ t.Fatalf("body should be unchanged when no thinking blocks present")
+ }
+}
+
+func TestSanitize_EmptyThinkingBlockStripped(t *testing.T) {
+ in := []byte(`{"model":"claude-opus-4-7","messages":[` +
+ `{"role":"assistant","content":[` +
+ `{"type":"thinking","thinking":"","signature":""},` +
+ `{"type":"text","text":"the answer"}` +
+ `]}` +
+ `]}`)
+ out, removed, err := SanitizeAnthropicRequestBody(in)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if removed != 1 {
+ t.Fatalf("removed=%d want 1", removed)
+ }
+ got := decodeRequest(t, out)
+ msgs, _ := got["messages"].([]any)
+ if len(msgs) != 1 {
+ t.Fatalf("messages len=%d want 1", len(msgs))
+ }
+ msg, _ := msgs[0].(map[string]any)
+ content, _ := msg["content"].([]any)
+ if len(content) != 1 {
+ t.Fatalf("content len=%d want 1 (only text remains)", len(content))
+ }
+ first, _ := content[0].(map[string]any)
+ if first["type"] != "text" {
+ t.Fatalf("surviving block type=%v want text", first["type"])
+ }
+}
+
+func TestSanitize_ThinkingBlockWithoutSignatureKey_Stripped(t *testing.T) {
+ // Some clients omit the signature key entirely instead of sending "".
+ in := []byte(`{"model":"claude-opus-4-7","messages":[` +
+ `{"role":"assistant","content":[` +
+ `{"type":"thinking","thinking":""},` +
+ `{"type":"text","text":"hi"}` +
+ `]}` +
+ `]}`)
+ _, removed, err := SanitizeAnthropicRequestBody(in)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if removed != 1 {
+ t.Fatalf("removed=%d want 1 (missing signature key counts as empty)", removed)
+ }
+}
+
+func TestSanitize_NonEmptyThinkingPreserved(t *testing.T) {
+ in := []byte(`{"model":"claude-opus-4-7","messages":[` +
+ `{"role":"assistant","content":[` +
+ `{"type":"thinking","thinking":"let me think about this carefully"},` +
+ `{"type":"text","text":"answer"}` +
+ `]}` +
+ `]}`)
+ _, removed, err := SanitizeAnthropicRequestBody(in)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if removed != 0 {
+ t.Fatalf("removed=%d want 0 (thinking text present must be preserved)", removed)
+ }
+}
+
+func TestSanitize_SignedThinkingPreserved(t *testing.T) {
+ // Real extended-thinking continuation: thinking text is empty but server
+ // issued a signature. Must NOT be stripped — Anthropic verifies signature
+ // on the next turn.
+ in := []byte(`{"model":"claude-opus-4-7","messages":[` +
+ `{"role":"assistant","content":[` +
+ `{"type":"thinking","thinking":"","signature":"sig_abc123"},` +
+ `{"type":"text","text":"hi"}` +
+ `]}` +
+ `]}`)
+ _, removed, err := SanitizeAnthropicRequestBody(in)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if removed != 0 {
+ t.Fatalf("removed=%d want 0 (signed thinking block must survive)", removed)
+ }
+}
+
+func TestSanitize_MixedBlocks_OnlyEmptyThinkingDropped(t *testing.T) {
+ in := []byte(`{"model":"claude-opus-4-7","messages":[` +
+ `{"role":"assistant","content":[` +
+ `{"type":"thinking","thinking":"","signature":""},` +
+ `{"type":"text","text":"step 1"},` +
+ `{"type":"tool_use","id":"toolu_01","name":"bash","input":{"cmd":"ls"}},` +
+ `{"type":"thinking","thinking":"","signature":""}` +
+ `]}` +
+ `]}`)
+ out, removed, err := SanitizeAnthropicRequestBody(in)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if removed != 2 {
+ t.Fatalf("removed=%d want 2", removed)
+ }
+ got := decodeRequest(t, out)
+ msgs, _ := got["messages"].([]any)
+ msg, _ := msgs[0].(map[string]any)
+ content, _ := msg["content"].([]any)
+ if len(content) != 2 {
+ t.Fatalf("content len=%d want 2 (text + tool_use)", len(content))
+ }
+ types := []string{}
+ for _, c := range content {
+ m, _ := c.(map[string]any)
+ t, _ := m["type"].(string)
+ types = append(types, t)
+ }
+ if types[0] != "text" || types[1] != "tool_use" {
+ t.Fatalf("surviving types=%v want [text tool_use]", types)
+ }
+}
+
+func TestSanitize_MultipleMessages_AllScrubbed(t *testing.T) {
+ in := []byte(`{"model":"claude-opus-4-7","messages":[` +
+ `{"role":"user","content":[{"type":"text","text":"q1"}]},` +
+ `{"role":"assistant","content":[` +
+ `{"type":"thinking","thinking":"","signature":""},` +
+ `{"type":"text","text":"a1"}]},` +
+ `{"role":"user","content":[{"type":"text","text":"q2"}]},` +
+ `{"role":"assistant","content":[` +
+ `{"type":"thinking","thinking":"","signature":""},` +
+ `{"type":"text","text":"a2"}]}` +
+ `]}`)
+ _, removed, err := SanitizeAnthropicRequestBody(in)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if removed != 2 {
+ t.Fatalf("removed=%d want 2 (one per assistant turn)", removed)
+ }
+}
+
+func TestSanitize_PreservesUnknownTopLevelFields(t *testing.T) {
+ // top_k, service_tier, container, mcp_servers — these are real or possible
+ // Anthropic fields not modeled in AnthropicRequest. We must round-trip them.
+ in := []byte(`{"model":"claude-opus-4-7","top_k":40,"service_tier":"priority",` +
+ `"some_future_field":{"x":1},"messages":[` +
+ `{"role":"assistant","content":[` +
+ `{"type":"thinking","thinking":"","signature":""},` +
+ `{"type":"text","text":"hi"}]}` +
+ `]}`)
+ out, removed, err := SanitizeAnthropicRequestBody(in)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if removed != 1 {
+ t.Fatalf("removed=%d want 1", removed)
+ }
+ got := decodeRequest(t, out)
+ if got["top_k"] != float64(40) {
+ t.Fatalf("top_k missing or wrong: %v", got["top_k"])
+ }
+ if got["service_tier"] != "priority" {
+ t.Fatalf("service_tier missing: %v", got["service_tier"])
+ }
+ if _, ok := got["some_future_field"]; !ok {
+ t.Fatalf("unknown field dropped on round-trip")
+ }
+}
+
+func TestSanitize_PreservesUnknownBlockFields(t *testing.T) {
+ // A block carries a hypothetical future field "cache_control"; surviving
+ // blocks must be passed through byte-for-byte (modulo key reorder).
+ in := []byte(`{"model":"claude-opus-4-7","messages":[` +
+ `{"role":"user","content":[` +
+ `{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}` +
+ `]}` +
+ `]}`)
+ out, removed, err := SanitizeAnthropicRequestBody(in)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if removed != 0 {
+ t.Fatalf("removed=%d want 0", removed)
+ }
+ if !strings.Contains(string(out), "cache_control") {
+ t.Fatalf("cache_control field dropped from surviving block: %s", string(out))
+ }
+}
+
+func TestSanitize_PreservesSystemAndTools(t *testing.T) {
+ in := []byte(`{"model":"claude-opus-4-7","system":"you are helpful",` +
+ `"tools":[{"name":"bash","description":"run shell","input_schema":{"type":"object"}}],` +
+ `"messages":[` +
+ `{"role":"assistant","content":[` +
+ `{"type":"thinking","thinking":"","signature":""},` +
+ `{"type":"text","text":"hi"}]}` +
+ `]}`)
+ out, removed, err := SanitizeAnthropicRequestBody(in)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if removed != 1 {
+ t.Fatalf("removed=%d want 1", removed)
+ }
+ got := decodeRequest(t, out)
+ if got["system"] != "you are helpful" {
+ t.Fatalf("system field lost: %v", got["system"])
+ }
+ tools, ok := got["tools"].([]any)
+ if !ok || len(tools) != 1 {
+ t.Fatalf("tools array lost: %v", got["tools"])
+ }
+}
+
+func TestSanitize_Idempotent(t *testing.T) {
+ in := []byte(`{"model":"claude-opus-4-7","messages":[` +
+ `{"role":"assistant","content":[` +
+ `{"type":"thinking","thinking":"","signature":""},` +
+ `{"type":"text","text":"hi"}]}` +
+ `]}`)
+ first, removed1, err := SanitizeAnthropicRequestBody(in)
+ if err != nil {
+ t.Fatalf("first pass error: %v", err)
+ }
+ if removed1 != 1 {
+ t.Fatalf("first pass removed=%d want 1", removed1)
+ }
+ second, removed2, err := SanitizeAnthropicRequestBody(first)
+ if err != nil {
+ t.Fatalf("second pass error: %v", err)
+ }
+ if removed2 != 0 {
+ t.Fatalf("second pass removed=%d want 0 (idempotency)", removed2)
+ }
+ if string(second) != string(first) {
+ t.Fatalf("idempotency broken: second pass mutated body\nfirst: %s\nsecond: %s", string(first), string(second))
+ }
+}
+
+func TestSanitize_AssistantOnlyEmptyThinking_LeavesEmptyArray(t *testing.T) {
+ // Pathological: an assistant turn whose only block is empty thinking.
+ // We strip it and leave content as []. This is what the API would have
+ // rejected anyway — better an empty content (likely a different error)
+ // than the misleading thinking-schema error.
+ in := []byte(`{"model":"claude-opus-4-7","messages":[` +
+ `{"role":"assistant","content":[` +
+ `{"type":"thinking","thinking":"","signature":""}]}` +
+ `]}`)
+ out, removed, err := SanitizeAnthropicRequestBody(in)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if removed != 1 {
+ t.Fatalf("removed=%d want 1", removed)
+ }
+ got := decodeRequest(t, out)
+ msgs, _ := got["messages"].([]any)
+ msg, _ := msgs[0].(map[string]any)
+ content, ok := msg["content"].([]any)
+ if !ok {
+ t.Fatalf("content should remain an array, got %T", msg["content"])
+ }
+ if len(content) != 0 {
+ t.Fatalf("content len=%d want 0", len(content))
+ }
+}
diff --git a/backend/internal/pkg/apicompat/security_resource_limits_test.go b/backend/internal/pkg/apicompat/security_resource_limits_test.go
new file mode 100644
index 00000000000..739644dd597
--- /dev/null
+++ b/backend/internal/pkg/apicompat/security_resource_limits_test.go
@@ -0,0 +1,155 @@
+package apicompat
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "strconv"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestSanitizeAnthropicRequestBodyRejectsOversizedSlowPath(t *testing.T) {
+ body := make([]byte, 0, 16<<20+128)
+ body = append(body, `{"messages":[],"thinking":"","padding":"`...)
+ body = append(body, bytes.Repeat([]byte{'x'}, 16<<20)...)
+ body = append(body, `"}`...)
+
+ out, removed, err := SanitizeAnthropicRequestBody(body)
+ require.ErrorIs(t, err, ErrAnthropicSanitizeBodyTooLarge)
+ require.Zero(t, removed)
+ require.Equal(t, body, out)
+}
+
+func TestResponsesEventToChatChunksBoundsToolCallState(t *testing.T) {
+ state := NewResponsesEventToChatState()
+ for i := 0; i < 129; i++ {
+ ResponsesEventToChatChunks(&ResponsesStreamEvent{
+ Type: "response.output_item.added",
+ OutputIndex: i,
+ Item: &ResponsesOutput{
+ Type: "function_call",
+ CallID: "call_" + strconv.Itoa(i),
+ Name: "tool",
+ },
+ }, state)
+ }
+
+ require.LessOrEqual(t, len(state.OutputIndexToToolIndex), 128)
+ require.ErrorIs(t, state.Err(), ErrResponsesCompatibilityResourceLimit)
+}
+
+func TestBufferedResponseAccumulatorBoundsFunctionCallState(t *testing.T) {
+ acc := NewBufferedResponseAccumulator()
+ for i := 0; i < 129; i++ {
+ acc.ProcessEvent(&ResponsesStreamEvent{
+ Type: "response.output_item.added",
+ OutputIndex: i,
+ Item: &ResponsesOutput{
+ Type: "function_call",
+ CallID: "call_" + strconv.Itoa(i),
+ Name: "tool",
+ },
+ })
+ }
+
+ require.LessOrEqual(t, len(acc.funcCalls), 128)
+ require.ErrorIs(t, acc.Err(), ErrResponsesCompatibilityResourceLimit)
+}
+
+func TestResponsesEventToChatChunksBoundsTotalEventCount(t *testing.T) {
+ state := NewResponsesEventToChatState()
+ state.eventCount = maxResponsesCompatibilityEvents
+
+ chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{Type: "response.created"}, state)
+
+ require.Empty(t, chunks)
+ require.ErrorIs(t, state.Err(), ErrResponsesCompatibilityResourceLimit)
+}
+
+func TestBufferedResponseAccumulatorBoundsRetainedBytes(t *testing.T) {
+ acc := NewBufferedResponseAccumulator()
+ acc.retainedBytes = maxResponsesCompatibilityOutputBytes - 1
+
+ err := acc.ProcessEvent(&ResponsesStreamEvent{
+ Type: "response.output_text.delta",
+ Delta: "xx",
+ })
+
+ require.ErrorIs(t, err, ErrResponsesCompatibilityResourceLimit)
+ require.ErrorIs(t, acc.Err(), ErrResponsesCompatibilityResourceLimit)
+ require.Zero(t, acc.text.Len())
+ require.False(t, acc.HasContent())
+ require.Nil(t, acc.BuildOutput())
+}
+
+func TestBufferedResponseAccumulatorBoundsTotalEventCount(t *testing.T) {
+ acc := NewBufferedResponseAccumulator()
+ acc.eventCount = maxResponsesCompatibilityEvents
+
+ err := acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.created"})
+
+ require.True(t, errors.Is(err, ErrResponsesCompatibilityResourceLimit))
+}
+
+func TestBufferedResponseAccumulatorKeepsBuildersStableAcrossSliceGrowth(t *testing.T) {
+ acc := NewBufferedResponseAccumulator()
+ require.NoError(t, acc.ProcessEvent(&ResponsesStreamEvent{
+ Type: "response.output_item.added",
+ OutputIndex: 1,
+ Item: &ResponsesOutput{Type: "function_call", CallID: "call_1", Name: "one"},
+ }))
+ require.NoError(t, acc.ProcessEvent(&ResponsesStreamEvent{
+ Type: "response.function_call_arguments.delta",
+ OutputIndex: 1,
+ Delta: `{"a":`,
+ }))
+ require.NoError(t, acc.ProcessEvent(&ResponsesStreamEvent{
+ Type: "response.output_item.added",
+ OutputIndex: 2,
+ Item: &ResponsesOutput{Type: "function_call", CallID: "call_2", Name: "two"},
+ }))
+ require.NoError(t, acc.ProcessEvent(&ResponsesStreamEvent{
+ Type: "response.function_call_arguments.delta",
+ OutputIndex: 1,
+ Delta: `1}`,
+ }))
+
+ output := acc.BuildOutput()
+ require.Len(t, output, 2)
+ require.Equal(t, `{"a":1}`, output[0].Arguments)
+}
+
+func BenchmarkMergeConsecutiveMessages1000(b *testing.B) {
+ messages := make([]AnthropicMessage, 1000)
+ for i := range messages {
+ content, err := json.Marshal([]AnthropicContentBlock{{Type: "text", Text: "x"}})
+ require.NoError(b, err)
+ messages[i] = AnthropicMessage{Role: "user", Content: content}
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = mergeConsecutiveMessages(messages)
+ }
+}
+
+func BenchmarkResponsesToChatCompletions1000Parts(b *testing.B) {
+ parts := make([]ResponsesContentPart, 1000)
+ for i := range parts {
+ parts[i] = ResponsesContentPart{Type: "output_text", Text: "x"}
+ }
+ resp := &ResponsesResponse{
+ ID: "resp_benchmark",
+ Status: "completed",
+ Output: []ResponsesOutput{{Type: "message", Content: parts}},
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _ = ResponsesToChatCompletions(resp, "gpt-test")
+ }
+}
diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go
index f8c6b75f023..903548b4359 100644
--- a/backend/internal/pkg/apicompat/types.go
+++ b/backend/internal/pkg/apicompat/types.go
@@ -53,12 +53,20 @@ type AnthropicMessage struct {
type AnthropicContentBlock struct {
Type string `json:"type"`
+ CacheControl *AnthropicCacheControl `json:"cache_control,omitempty"`
+
// type=text
Text string `json:"text,omitempty"`
// type=thinking
Thinking string `json:"thinking,omitempty"`
+ // type=thinking — signature returned by Anthropic upstream for verification on
+ // subsequent turns. Presence of a non-empty Signature is the marker that this
+ // block is a *real* extended-thinking continuation rather than a placeholder
+ // emitted by buggy clients (see SanitizeAnthropicRequestBody in sanitize.go).
+ Signature string `json:"signature,omitempty"`
+
// type=image
Source *AnthropicImageSource `json:"source,omitempty"`
@@ -165,19 +173,23 @@ type AnthropicDelta struct {
// ResponsesRequest is the request body for POST /v1/responses.
type ResponsesRequest struct {
- Model string `json:"model"`
- Instructions string `json:"instructions,omitempty"`
- Input json.RawMessage `json:"input"` // string or []ResponsesInputItem
- MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
- Temperature *float64 `json:"temperature,omitempty"`
- TopP *float64 `json:"top_p,omitempty"`
- Stream bool `json:"stream,omitempty"`
- Tools []ResponsesTool `json:"tools,omitempty"`
- Include []string `json:"include,omitempty"`
- Store *bool `json:"store,omitempty"`
- Reasoning *ResponsesReasoning `json:"reasoning,omitempty"`
- ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
- ServiceTier string `json:"service_tier,omitempty"`
+ Model string `json:"model"`
+ Instructions string `json:"instructions,omitempty"`
+ Input json.RawMessage `json:"input"` // string or []ResponsesInputItem
+ MaxOutputTokens *int `json:"max_output_tokens,omitempty"`
+ Temperature *float64 `json:"temperature,omitempty"`
+ TopP *float64 `json:"top_p,omitempty"`
+ Stream bool `json:"stream,omitempty"`
+ Tools []ResponsesTool `json:"tools,omitempty"`
+ Include []string `json:"include,omitempty"`
+ Store *bool `json:"store,omitempty"`
+ ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
+ Reasoning *ResponsesReasoning `json:"reasoning,omitempty"`
+ Text *ResponsesText `json:"text,omitempty"`
+ ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
+ ServiceTier string `json:"service_tier,omitempty"`
+ PromptCacheKey string `json:"prompt_cache_key,omitempty"`
+ PreviousResponseID string `json:"previous_response_id,omitempty"`
}
// ResponsesReasoning configures reasoning effort in the Responses API.
@@ -186,13 +198,18 @@ type ResponsesReasoning struct {
Summary string `json:"summary,omitempty"` // "auto" | "concise" | "detailed"
}
+// ResponsesText configures text output options in the Responses API.
+type ResponsesText struct {
+ Verbosity string `json:"verbosity,omitempty"` // "low" | "medium" | "high"
+}
+
// ResponsesInputItem is one item in the Responses API input array.
// The Type field determines which other fields are populated.
type ResponsesInputItem struct {
// Common
Type string `json:"type,omitempty"` // "" for role-based messages
- // Role-based messages (system/user/assistant)
+ // Role-based messages (developer/system/user/assistant)
Role string `json:"role,omitempty"`
Content json.RawMessage `json:"content,omitempty"` // string or []ResponsesContentPart
@@ -314,7 +331,7 @@ type ResponsesOutputTokensDetails struct {
type ResponsesStreamEvent struct {
Type string `json:"type"`
- // response.created / response.completed / response.failed / response.incomplete
+ // response.created / response.completed / response.done / response.failed / response.incomplete
Response *ResponsesResponse `json:"response,omitempty"`
// response.output_item.added / response.output_item.done
diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go
index aa59ba645fc..62325df62fb 100644
--- a/backend/internal/pkg/claude/constants.go
+++ b/backend/internal/pkg/claude/constants.go
@@ -1,16 +1,10 @@
// Package claude provides constants and helpers for Claude API integration.
package claude
-// Claude Code 客户端相关常量
-
-// Beta header 常量
-//
-// 这里的常量对齐真实 Claude Code CLI 的最新流量(截至 2026-04)。
-// 选型参考:与 Parrot (src/transform/cc_mimicry.py) 的 BETAS 保持一致,
-// 原因:Anthropic 上游会基于 anthropic-beta 的完整集合判定请求来源;
-// 缺少任何"官方 Claude Code 请求才会带"的 beta,都会被降级到第三方额度,
-// 对应报错:`Third-party apps now draw from your extra usage, not your plan limits.`
+// Anthropic beta tokens used by the neutral API-key gateway integration.
const (
+ // These two tokens identify OAuth / Claude Code scoped traffic. The shared
+ // gateway strips them because it cannot authenticate official-client origin.
BetaOAuth = "oauth-2025-04-20"
BetaClaudeCode = "claude-code-20250219"
BetaInterleavedThinking = "interleaved-thinking-2025-05-14"
@@ -27,32 +21,12 @@ const (
BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11"
)
-// DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。
-// 这些 token 是客户端特有的,不应透传给上游 API。
-var DroppedBetas = []string{}
-
-// DefaultBetaHeader Claude Code 客户端默认的 anthropic-beta header
-const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming
-
-// MessageBetaHeaderNoTools /v1/messages 在无工具时的 beta header
-//
-// NOTE: Claude Code OAuth credentials are scoped to Claude Code. When we "mimic"
-// Claude Code for non-Claude-Code clients, we must include the claude-code beta
-// even if the request doesn't use tools, otherwise upstream may reject the
-// request as a non-Claude-Code API request.
-const MessageBetaHeaderNoTools = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking
-
-// MessageBetaHeaderWithTools /v1/messages 在有工具时的 beta header
-const MessageBetaHeaderWithTools = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking
-
-// CountTokensBetaHeader count_tokens 请求使用的 anthropic-beta header
-const CountTokensBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaTokenCounting
-
-// HaikuBetaHeader Haiku 模型使用的 anthropic-beta header(不需要 claude-code beta)
-const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking
+// DroppedBetas are provider identity tokens that an untrusted downstream
+// client cannot assert through the shared gateway.
+var DroppedBetas = []string{BetaOAuth, BetaClaudeCode}
// APIKeyBetaHeader API-key 账号建议使用的 anthropic-beta header(不包含 oauth)
-const APIKeyBetaHeader = BetaClaudeCode + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming
+const APIKeyBetaHeader = BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming
// APIKeyHaikuBetaHeader Haiku 模型在 API-key 账号下使用的 anthropic-beta header(不包含 oauth / claude-code)
const APIKeyHaikuBetaHeader = BetaInterleavedThinking
@@ -62,50 +36,6 @@ const APIKeyHaikuBetaHeader = BetaInterleavedThinking
// 客户端缺省时统一使用 5m",这样既不浪费 1h 缓存额度,也保留客户端自定义能力。
const DefaultCacheControlTTL = "5m"
-// CLICurrentVersion 是 sub2api 当前对外伪装的 Claude Code CLI 版本号(三段 semver)。
-// 用于 billing attribution block 中的 cc_version=X.Y.Z.{fp} 前缀以及 fingerprint 计算。
-// 必须与 DefaultHeaders["User-Agent"] 中的版本号严格一致;不一致会被 Anthropic 判第三方。
-const CLICurrentVersion = "2.1.92"
-
-// FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 的完整 beta 列表,
-// 用于 OAuth 账号伪装成 Claude Code 时使用。
-// 顺序与真实 CLI 抓包一致。
-//
-// 使用建议:
-// - OAuth 账号 + 非 haiku:追加这整份列表,再按需保留 client 带来的 beta。
-// - OAuth 账号 + haiku:Anthropic 对 haiku 不做 third-party 判定,使用 HaikuBetaHeader 即可。
-// - API-key 账号:不要使用本函数,参见 APIKeyBetaHeader。
-func FullClaudeCodeMimicryBetas() []string {
- return []string{
- BetaClaudeCode,
- BetaOAuth,
- BetaInterleavedThinking,
- BetaPromptCachingScope,
- BetaEffort,
- BetaRedactThinking,
- BetaContextManagement,
- BetaExtendedCacheTTL,
- }
-}
-
-// DefaultHeaders 是 Claude Code 客户端默认请求头。
-var DefaultHeaders = map[string]string{
- // Keep these in sync with recent Claude CLI traffic to reduce the chance
- // that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage.
- // 版本参考:对齐 Parrot (src/transform/cc_mimicry.py:49) 的 CLI_USER_AGENT。
- "User-Agent": "claude-cli/2.1.92 (external, cli)",
- "X-Stainless-Lang": "js",
- "X-Stainless-Package-Version": "0.70.0",
- "X-Stainless-OS": "Linux",
- "X-Stainless-Arch": "arm64",
- "X-Stainless-Runtime": "node",
- "X-Stainless-Runtime-Version": "v24.13.0",
- "X-Stainless-Retry-Count": "0",
- "X-Stainless-Timeout": "600",
- "X-App": "cli",
- "Anthropic-Dangerous-Direct-Browser-Access": "true",
-}
-
// Model 表示一个 Claude 模型
type Model struct {
ID string `json:"id"`
@@ -154,6 +84,30 @@ var DefaultModels = []Model{
},
}
+// LatestClaudeCodeModels is the scoped Kiro discovery list. Keep it aligned
+// with Anthropic's current Claude Code / Claude API model guidance without
+// changing the legacy Anthropic account picker defaults.
+var LatestClaudeCodeModels = []Model{
+ {
+ ID: "claude-opus-4-7",
+ Type: "model",
+ DisplayName: "Claude Opus 4.7",
+ CreatedAt: "",
+ },
+ {
+ ID: "claude-sonnet-4-6",
+ Type: "model",
+ DisplayName: "Claude Sonnet 4.6",
+ CreatedAt: "",
+ },
+ {
+ ID: "claude-haiku-4-5-20251001",
+ Type: "model",
+ DisplayName: "Claude Haiku 4.5",
+ CreatedAt: "2025-10-01T00:00:00Z",
+ },
+}
+
// DefaultModelIDs 返回默认模型的 ID 列表
func DefaultModelIDs() []string {
ids := make([]string, len(DefaultModels))
diff --git a/backend/internal/pkg/ctxkey/ctxkey.go b/backend/internal/pkg/ctxkey/ctxkey.go
index 25782c55172..b8014fac65e 100644
--- a/backend/internal/pkg/ctxkey/ctxkey.go
+++ b/backend/internal/pkg/ctxkey/ctxkey.go
@@ -14,6 +14,14 @@ const (
// ClientRequestID 客户端请求的唯一标识,用于追踪请求全生命周期(用于 Ops 监控与排障)。
ClientRequestID Key = "ctx_client_request_id"
+ // UsageBillingRequestID freezes the server-side billing identity before any
+ // upstream bytes are sent. It survives request cancellation via WithoutCancel.
+ UsageBillingRequestID Key = "ctx_usage_billing_request_id"
+
+ // UsageBillingOwnerToken fences one live handler from concurrent reuse of
+ // the same request identity. Account failover attempts share this owner.
+ UsageBillingOwnerToken Key = "ctx_usage_billing_owner_token"
+
// Model 请求模型标识(用于统一请求链路日志字段)。
Model Key = "ctx_model"
diff --git a/backend/internal/pkg/geminicli/constants.go b/backend/internal/pkg/geminicli/constants.go
index 97234ffd279..54bd49fb950 100644
--- a/backend/internal/pkg/geminicli/constants.go
+++ b/backend/internal/pkg/geminicli/constants.go
@@ -35,11 +35,11 @@ const (
// GeminiCLIRedirectURI is the redirect URI used by Gemini CLI for Code Assist OAuth.
GeminiCLIRedirectURI = "https://codeassist.google.com/authcode"
- // GeminiCLIOAuthClientID/Secret are the public OAuth client credentials used by Google Gemini CLI.
- // They enable the "login without creating your own OAuth client" experience, but Google may
- // restrict which scopes are allowed for this client.
+ // GeminiCLIOAuthClientID is the public OAuth client ID used by Google Gemini CLI.
+ // The matching client_secret is deliberately not embedded in this repo; set
+ // GEMINI_CLI_OAUTH_CLIENT_SECRET or provide a custom OAuth client.
GeminiCLIOAuthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
- GeminiCLIOAuthClientSecret = "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"
+ GeminiCLIOAuthClientSecret = ""
// GeminiCLIOAuthClientSecretEnv is the environment variable name for the built-in client secret.
GeminiCLIOAuthClientSecretEnv = "GEMINI_CLI_OAUTH_CLIENT_SECRET"
diff --git a/backend/internal/pkg/geminicli/oauth_test.go b/backend/internal/pkg/geminicli/oauth_test.go
index 2a430f9e0d9..50d2c4db682 100644
--- a/backend/internal/pkg/geminicli/oauth_test.go
+++ b/backend/internal/pkg/geminicli/oauth_test.go
@@ -408,10 +408,10 @@ func TestBuildAuthorizationURL_WithProjectID(t *testing.T) {
}
}
-func TestBuildAuthorizationURL_UsesBuiltinSecretFallback(t *testing.T) {
+func TestBuildAuthorizationURL_RequiresConfiguredBuiltinSecret(t *testing.T) {
t.Setenv(GeminiCLIOAuthClientSecretEnv, "")
- authURL, err := BuildAuthorizationURL(
+ _, err := BuildAuthorizationURL(
OAuthConfig{},
"test-state",
"test-challenge",
@@ -419,11 +419,8 @@ func TestBuildAuthorizationURL_UsesBuiltinSecretFallback(t *testing.T) {
"",
"code_assist",
)
- if err != nil {
- t.Fatalf("BuildAuthorizationURL() 不应报错: %v", err)
- }
- if !strings.Contains(authURL, "client_id="+GeminiCLIOAuthClientID) {
- t.Errorf("应使用内置 Gemini CLI client_id,实际 URL: %s", authURL)
+ if err == nil || !strings.Contains(err.Error(), GeminiCLIOAuthClientSecretEnv) {
+ t.Fatalf("BuildAuthorizationURL() 应要求配置 %s,实际错误: %v", GeminiCLIOAuthClientSecretEnv, err)
}
}
@@ -689,15 +686,9 @@ func TestEffectiveOAuthConfig_WhitespaceTriming(t *testing.T) {
func TestEffectiveOAuthConfig_NoEnvSecret(t *testing.T) {
t.Setenv(GeminiCLIOAuthClientSecretEnv, "")
- cfg, err := EffectiveOAuthConfig(OAuthConfig{}, "code_assist")
- if err != nil {
- t.Fatalf("不设置环境变量时应回退到内置 secret,实际报错: %v", err)
- }
- if strings.TrimSpace(cfg.ClientSecret) == "" {
- t.Error("ClientSecret 不应为空")
- }
- if cfg.ClientID != GeminiCLIOAuthClientID {
- t.Errorf("ClientID 应回退为内置客户端 ID,实际: %q", cfg.ClientID)
+ _, err := EffectiveOAuthConfig(OAuthConfig{}, "code_assist")
+ if err == nil || !strings.Contains(err.Error(), GeminiCLIOAuthClientSecretEnv) {
+ t.Fatalf("不设置环境变量时应 fail closed 并提示 %s,实际: %v", GeminiCLIOAuthClientSecretEnv, err)
}
}
diff --git a/backend/internal/pkg/httpclient/pool.go b/backend/internal/pkg/httpclient/pool.go
index 12804cc67d1..0fe47a9b0b7 100644
--- a/backend/internal/pkg/httpclient/pool.go
+++ b/backend/internal/pkg/httpclient/pool.go
@@ -35,7 +35,6 @@ const (
defaultIdleConnTimeout = 90 * time.Second // 空闲连接超时时间(建议小于上游 LB 超时)
defaultDialTimeout = 5 * time.Second // TCP 连接超时(含代理握手),代理不通时快速失败
defaultTLSHandshakeTimeout = 5 * time.Second // TLS 握手超时
- validatedHostTTL = 30 * time.Second // DNS Rebinding 校验缓存 TTL
)
// Options 定义共享 HTTP 客户端的构建参数
@@ -110,9 +109,7 @@ func buildTransport(opts Options) (*http.Transport, error) {
}
transport := &http.Transport{
- DialContext: (&net.Dialer{
- Timeout: defaultDialTimeout,
- }).DialContext,
+ DialContext: (&net.Dialer{Timeout: defaultDialTimeout}).DialContext,
TLSHandshakeTimeout: defaultTLSHandshakeTimeout,
MaxIdleConns: maxIdleConns,
MaxIdleConnsPerHost: maxIdleConnsPerHost,
@@ -120,6 +117,9 @@ func buildTransport(opts Options) (*http.Transport, error) {
IdleConnTimeout: defaultIdleConnTimeout,
ResponseHeaderTimeout: opts.ResponseHeaderTimeout,
}
+ if opts.ValidateResolvedIP && !opts.AllowPrivateHosts {
+ transport.DialContext = urlvalidator.NewSafeDialContext(false)
+ }
if opts.InsecureSkipVerify {
// 安全要求:禁止跳过证书验证,避免中间人攻击。
@@ -156,51 +156,19 @@ func buildClientKey(opts Options) string {
}
type validatedTransport struct {
- base http.RoundTripper
- validatedHosts sync.Map // map[string]time.Time, value 为过期时间
- now func() time.Time
+ base http.RoundTripper
}
func newValidatedTransport(base http.RoundTripper) *validatedTransport {
- return &validatedTransport{
- base: base,
- now: time.Now,
- }
-}
-
-func (t *validatedTransport) isValidatedHost(host string, now time.Time) bool {
- if t == nil {
- return false
- }
- raw, ok := t.validatedHosts.Load(host)
- if !ok {
- return false
- }
- expireAt, ok := raw.(time.Time)
- if !ok {
- t.validatedHosts.Delete(host)
- return false
- }
- if now.Before(expireAt) {
- return true
- }
- t.validatedHosts.Delete(host)
- return false
+ return &validatedTransport{base: base}
}
func (t *validatedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if req != nil && req.URL != nil {
host := strings.ToLower(strings.TrimSpace(req.URL.Hostname()))
if host != "" {
- now := time.Now()
- if t != nil && t.now != nil {
- now = t.now()
- }
- if !t.isValidatedHost(host, now) {
- if err := validateResolvedIP(host); err != nil {
- return nil, err
- }
- t.validatedHosts.Store(host, now.Add(validatedHostTTL))
+ if err := validateResolvedIP(host); err != nil {
+ return nil, err
}
}
}
diff --git a/backend/internal/pkg/httpclient/pool_test.go b/backend/internal/pkg/httpclient/pool_test.go
index f945758a94e..c70ec47718a 100644
--- a/backend/internal/pkg/httpclient/pool_test.go
+++ b/backend/internal/pkg/httpclient/pool_test.go
@@ -7,7 +7,6 @@ import (
"strings"
"sync/atomic"
"testing"
- "time"
"github.com/stretchr/testify/require"
)
@@ -18,7 +17,7 @@ func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
-func TestValidatedTransport_CacheHostValidation(t *testing.T) {
+func TestValidatedTransport_RevalidatesHostOnEveryRequest(t *testing.T) {
originalValidate := validateResolvedIP
defer func() { validateResolvedIP = originalValidate }()
@@ -39,9 +38,7 @@ func TestValidatedTransport_CacheHostValidation(t *testing.T) {
}, nil
})
- now := time.Unix(1730000000, 0)
transport := newValidatedTransport(base)
- transport.now = func() time.Time { return now }
req, err := http.NewRequest(http.MethodGet, "https://api.openai.com/v1/responses", nil)
require.NoError(t, err)
@@ -51,43 +48,17 @@ func TestValidatedTransport_CacheHostValidation(t *testing.T) {
_, err = transport.RoundTrip(req)
require.NoError(t, err)
- require.Equal(t, int32(1), atomic.LoadInt32(&validateCalls))
+ require.Equal(t, int32(2), atomic.LoadInt32(&validateCalls))
require.Equal(t, int32(2), atomic.LoadInt32(&baseCalls))
}
-func TestValidatedTransport_ExpiredCacheTriggersRevalidation(t *testing.T) {
- originalValidate := validateResolvedIP
- defer func() { validateResolvedIP = originalValidate }()
-
- var validateCalls int32
- validateResolvedIP = func(_ string) error {
- atomic.AddInt32(&validateCalls, 1)
- return nil
- }
-
- base := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
- return &http.Response{
- StatusCode: http.StatusOK,
- Body: io.NopCloser(strings.NewReader(`{}`)),
- Header: make(http.Header),
- }, nil
- })
-
- now := time.Unix(1730001000, 0)
- transport := newValidatedTransport(base)
- transport.now = func() time.Time { return now }
-
- req, err := http.NewRequest(http.MethodGet, "https://api.openai.com/v1/responses", nil)
- require.NoError(t, err)
-
- _, err = transport.RoundTrip(req)
+func TestBuildTransportUsesAddressBindingSafeDialer(t *testing.T) {
+ transport, err := buildTransport(Options{ValidateResolvedIP: true})
require.NoError(t, err)
-
- now = now.Add(validatedHostTTL + time.Second)
- _, err = transport.RoundTrip(req)
- require.NoError(t, err)
-
- require.Equal(t, int32(2), atomic.LoadInt32(&validateCalls))
+ require.NotNil(t, transport.DialContext)
+ _, err = transport.DialContext(t.Context(), "tcp", "127.0.0.1:1")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "not publicly routable")
}
func TestValidatedTransport_ValidationErrorStopsRoundTrip(t *testing.T) {
diff --git a/backend/internal/pkg/openai/constants.go b/backend/internal/pkg/openai/constants.go
index be9f3aae789..dea07a6d837 100644
--- a/backend/internal/pkg/openai/constants.go
+++ b/backend/internal/pkg/openai/constants.go
@@ -3,6 +3,12 @@ package openai
import _ "embed"
+const (
+ ModelGPT56Sol = "gpt-5.6-sol"
+ ModelGPT56Terra = "gpt-5.6-terra"
+ ModelGPT56Luna = "gpt-5.6-luna"
+)
+
// Model represents an OpenAI model
type Model struct {
ID string `json:"id"`
diff --git a/backend/internal/pkg/openai_compat/upstream_capability.go b/backend/internal/pkg/openai_compat/upstream_capability.go
new file mode 100644
index 00000000000..ff05afe55b8
--- /dev/null
+++ b/backend/internal/pkg/openai_compat/upstream_capability.go
@@ -0,0 +1,75 @@
+// Package openai_compat 提供 OpenAI 协议族在不同上游间的能力差异判定工具。
+//
+// 背景:sub2api 的 OpenAI APIKey 账号通过 base_url 接入多种第三方 OpenAI 兼容上游
+// (DeepSeek、Kimi、GLM、Qwen 等)。这些上游普遍只支持 /v1/chat/completions,
+// 不存在 /v1/responses 端点。但网关历史代码无差别走 CC→Responses 转换并打到
+// /v1/responses,导致兼容上游 404。
+//
+// 本包提供基于"账号探测标记"的能力判定,配合
+// internal/service/openai_apikey_responses_probe.go 在创建/修改账号时一次性
+// 探测并落标。
+//
+// 设计取舍:
+// - 不维护静态 host 白名单——避免新增厂商时必须改代码(讨论沉淀于
+// pensieve/short-term/knowledge/upstream-capability-detection-design-tradeoffs)
+// - 标记缺失时默认 true(即"走 Responses"),保持与重构前老代码完全一致的存量
+// 账号行为("现状即证据"原则;详见
+// pensieve/short-term/maxims/preserve-existing-runtime-behavior-when-replacing-logic-in-stateful-systems)
+package openai_compat
+
+// AccountResponsesSupport 描述账号上游对 OpenAI Responses API 的支持状态。
+//
+// 仅用于 platform=openai + type=apikey 的账号;其他账号类型不应调用本包判定。
+type AccountResponsesSupport int
+
+const (
+ // ResponsesSupportUnknown 表示账号尚未完成能力探测(extra 字段缺失)。
+ // 上游路由层应按"现状即证据"原则默认走 Responses,保持与重构前一致。
+ ResponsesSupportUnknown AccountResponsesSupport = iota
+
+ // ResponsesSupportYes 探测确认上游支持 /v1/responses。
+ ResponsesSupportYes
+
+ // ResponsesSupportNo 探测确认上游不支持 /v1/responses,应走
+ // /v1/chat/completions 直转路径。
+ ResponsesSupportNo
+)
+
+// ExtraKeyResponsesSupported 是 accounts.extra JSON 中存储探测结果的键名。
+// 值类型为 bool:true=支持、false=不支持、键缺失=未探测。
+const ExtraKeyResponsesSupported = "openai_responses_supported"
+
+// ResolveResponsesSupport 从账号的 extra map 中读取探测标记。
+//
+// 标记缺失或类型不匹配时返回 ResponsesSupportUnknown——调用方应按
+// "未探测=保留旧行为=走 Responses" 处理(参见 ShouldUseResponsesAPI)。
+func ResolveResponsesSupport(extra map[string]any) AccountResponsesSupport {
+ if extra == nil {
+ return ResponsesSupportUnknown
+ }
+ v, ok := extra[ExtraKeyResponsesSupported]
+ if !ok {
+ return ResponsesSupportUnknown
+ }
+ supported, ok := v.(bool)
+ if !ok {
+ return ResponsesSupportUnknown
+ }
+ if supported {
+ return ResponsesSupportYes
+ }
+ return ResponsesSupportNo
+}
+
+// ShouldUseResponsesAPI 判断 OpenAI APIKey 账号的入站 /v1/chat/completions 请求
+// 是否应走"CC→Responses 转换 + 上游 /v1/responses"路径。
+//
+// 返回 true 的两种情况:
+// 1. 账号已探测确认支持 Responses
+// 2. 账号未探测(标记缺失)——按"现状即证据"原则保留旧行为
+//
+// 仅当账号已探测且确认不支持时返回 false,此时调用方应走 CC 直转路径
+// (详见 internal/service/openai_gateway_chat_completions_raw.go)。
+func ShouldUseResponsesAPI(extra map[string]any) bool {
+ return ResolveResponsesSupport(extra) != ResponsesSupportNo
+}
diff --git a/backend/internal/pkg/openai_compat/upstream_capability_test.go b/backend/internal/pkg/openai_compat/upstream_capability_test.go
new file mode 100644
index 00000000000..d650daa4365
--- /dev/null
+++ b/backend/internal/pkg/openai_compat/upstream_capability_test.go
@@ -0,0 +1,55 @@
+package openai_compat
+
+import "testing"
+
+func TestResolveResponsesSupport(t *testing.T) {
+ tests := []struct {
+ name string
+ extra map[string]any
+ want AccountResponsesSupport
+ }{
+ {"nil extra", nil, ResponsesSupportUnknown},
+ {"empty extra", map[string]any{}, ResponsesSupportUnknown},
+ {"key missing", map[string]any{"other": "value"}, ResponsesSupportUnknown},
+ {"value true", map[string]any{ExtraKeyResponsesSupported: true}, ResponsesSupportYes},
+ {"value false", map[string]any{ExtraKeyResponsesSupported: false}, ResponsesSupportNo},
+ {"value wrong type string", map[string]any{ExtraKeyResponsesSupported: "true"}, ResponsesSupportUnknown},
+ {"value wrong type number", map[string]any{ExtraKeyResponsesSupported: 1}, ResponsesSupportUnknown},
+ {"value nil", map[string]any{ExtraKeyResponsesSupported: nil}, ResponsesSupportUnknown},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := ResolveResponsesSupport(tc.extra)
+ if got != tc.want {
+ t.Errorf("ResolveResponsesSupport(%v) = %v, want %v", tc.extra, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestShouldUseResponsesAPI(t *testing.T) {
+ tests := []struct {
+ name string
+ extra map[string]any
+ want bool
+ }{
+ // 关键不变量:未探测必须返回 true(保留旧行为)
+ {"unknown defaults to true (preserve old behavior)", nil, true},
+ {"unknown empty defaults to true", map[string]any{}, true},
+ {"unknown wrong type defaults to true", map[string]any{ExtraKeyResponsesSupported: "yes"}, true},
+
+ // 已探测:标记决定
+ {"explicitly supported", map[string]any{ExtraKeyResponsesSupported: true}, true},
+ {"explicitly unsupported", map[string]any{ExtraKeyResponsesSupported: false}, false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := ShouldUseResponsesAPI(tc.extra)
+ if got != tc.want {
+ t.Errorf("ShouldUseResponsesAPI(%v) = %v, want %v", tc.extra, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/backend/internal/pkg/proxyurl/log.go b/backend/internal/pkg/proxyurl/log.go
new file mode 100644
index 00000000000..9c346e6d707
--- /dev/null
+++ b/backend/internal/pkg/proxyurl/log.go
@@ -0,0 +1,60 @@
+package proxyurl
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "net"
+ "net/url"
+ "strings"
+)
+
+const (
+ directLogTarget = "direct"
+ configuredLogTarget = "configured"
+)
+
+// ForLog returns only the validated proxy endpoint. It never returns userinfo,
+// path, query, fragment, or malformed input.
+func ForLog(raw string) string {
+ if strings.TrimSpace(raw) == "" {
+ return directLogTarget
+ }
+ _, parsed, err := Parse(raw)
+ if err != nil || parsed == nil {
+ return configuredLogTarget
+ }
+ return endpoint(parsed)
+}
+
+// TransportCacheKey distinguishes proxy credentials without retaining them in
+// the key. The parsed URL remains unchanged for transport use.
+func TransportCacheKey(parsed *url.URL) string {
+ if parsed == nil {
+ return directLogTarget
+ }
+ key := endpoint(parsed)
+ if parsed.User == nil {
+ return key
+ }
+ digest := sha256.Sum256([]byte(parsed.User.String()))
+ return key + "|auth_sha256=" + hex.EncodeToString(digest[:])
+}
+
+func endpoint(parsed *url.URL) string {
+ if parsed == nil {
+ return directLogTarget
+ }
+ scheme := strings.ToLower(parsed.Scheme)
+ hostname := strings.ToLower(parsed.Hostname())
+ port := parsed.Port()
+ if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
+ port = ""
+ }
+ host := hostname
+ if port != "" {
+ host = net.JoinHostPort(hostname, port)
+ } else if strings.Contains(hostname, ":") {
+ host = "[" + hostname + "]"
+ }
+ return (&url.URL{Scheme: scheme, Host: host}).String()
+}
diff --git a/backend/internal/pkg/proxyurl/log_test.go b/backend/internal/pkg/proxyurl/log_test.go
new file mode 100644
index 00000000000..580c6370e5a
--- /dev/null
+++ b/backend/internal/pkg/proxyurl/log_test.go
@@ -0,0 +1,37 @@
+package proxyurl
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestForLogRemovesAllProxyUserInfoAndURLPayload(t *testing.T) {
+ raw := "http://alice:p%40ssword@Proxy.Example.com:8080/private?access_token=query-secret#fragment-secret"
+ got := ForLog(raw)
+ require.Equal(t, "http://proxy.example.com:8080", got)
+ for _, secret := range []string{"alice", "ssword", "private", "query-secret", "fragment-secret"} {
+ require.NotContains(t, got, secret)
+ }
+ require.Equal(t, "direct", ForLog(""))
+ require.Equal(t, "configured", ForLog("://not-a-url"))
+}
+
+func TestTransportCacheKeyHashesCredentialsButKeepsTransportURL(t *testing.T) {
+ _, first, err := Parse("http://alice:first-secret@proxy.example.com:8080/path?token=ignored")
+ require.NoError(t, err)
+ _, same, err := Parse("http://alice:first-secret@proxy.example.com:8080")
+ require.NoError(t, err)
+ _, second, err := Parse("http://alice:second-secret@proxy.example.com:8080")
+ require.NoError(t, err)
+
+ firstKey := TransportCacheKey(first)
+ require.Equal(t, firstKey, TransportCacheKey(same))
+ require.NotEqual(t, firstKey, TransportCacheKey(second))
+ require.Contains(t, firstKey, "http://proxy.example.com:8080")
+ require.Contains(t, firstKey, "auth_sha256=")
+ for _, secret := range []string{"alice", "first-secret", "second-secret", "token=ignored"} {
+ require.False(t, strings.Contains(firstKey, secret), "cache key leaked %q", secret)
+ }
+}
diff --git a/backend/internal/pkg/proxyurl/parse.go b/backend/internal/pkg/proxyurl/parse.go
index 217556f2a42..41187d4e6bf 100644
--- a/backend/internal/pkg/proxyurl/parse.go
+++ b/backend/internal/pkg/proxyurl/parse.go
@@ -7,6 +7,7 @@
package proxyurl
import (
+ "errors"
"fmt"
"net/url"
"strings"
@@ -30,7 +31,7 @@ var allowedSchemes = map[string]bool{
// 验证规则:
// - TrimSpace 后为空视为直连
// - url.Parse 失败返回 error(不含原始 URL,防凭据泄露)
-// - Host 为空返回 error(用 Redacted() 脱敏)
+// - Host 为空返回不含原始 URL 的 error
// - Scheme 必须为 http/https/socks5/socks5h
// - socks5:// 自动升级为 socks5h://(确保 DNS 由代理端解析,防止 DNS 泄漏)
func Parse(raw string) (trimmed string, parsed *url.URL, err error) {
@@ -41,12 +42,11 @@ func Parse(raw string) (trimmed string, parsed *url.URL, err error) {
parsed, err = url.Parse(trimmed)
if err != nil {
- // 不使用 %w 包装,避免 url.Parse 的底层错误消息泄漏原始 URL(可能含凭据)
- return "", nil, fmt.Errorf("invalid proxy URL: %v", err)
+ return "", nil, errors.New("invalid proxy URL")
}
if parsed.Host == "" || parsed.Hostname() == "" {
- return "", nil, fmt.Errorf("proxy URL missing host: %s", parsed.Redacted())
+ return "", nil, errors.New("proxy URL missing host")
}
scheme := strings.ToLower(parsed.Scheme)
diff --git a/backend/internal/pkg/proxyurl/parse_test.go b/backend/internal/pkg/proxyurl/parse_test.go
index 5fb57c16f7b..50cebfa8d6c 100644
--- a/backend/internal/pkg/proxyurl/parse_test.go
+++ b/backend/internal/pkg/proxyurl/parse_test.go
@@ -133,6 +133,20 @@ func TestParse_含密码URL脱敏(t *testing.T) {
}
}
+func TestParse_MalformedCredentialURLDoesNotLeakUserinfo(t *testing.T) {
+ const secret = "proxy-password-must-not-leak"
+ _, _, err := Parse("http://user:" + secret + "@%zz")
+ if err == nil {
+ t.Fatal("malformed credential URL should fail")
+ }
+ if strings.Contains(err.Error(), secret) || strings.Contains(err.Error(), "user") {
+ t.Fatalf("parse error leaked proxy userinfo: %q", err.Error())
+ }
+ if err.Error() != "invalid proxy URL" {
+ t.Fatalf("unexpected safe error: %q", err.Error())
+ }
+}
+
func TestParse_带空白的有效URL(t *testing.T) {
trimmed, parsed, err := Parse(" http://proxy.example.com:8080 ")
if err != nil {
diff --git a/backend/internal/pkg/tlsfingerprint/dialer_capture_test.go b/backend/internal/pkg/tlsfingerprint/dialer_capture_test.go
index de9d79a0db6..39e25aa102e 100644
--- a/backend/internal/pkg/tlsfingerprint/dialer_capture_test.go
+++ b/backend/internal/pkg/tlsfingerprint/dialer_capture_test.go
@@ -38,14 +38,14 @@ type CapturedFingerprint struct {
// TestDialerAgainstCaptureServer connects to the tls-fingerprint-web capture server
// and verifies that the dialer's TLS fingerprint matches the configured Profile.
//
-// Default capture server: https://tls.sub2api.org:8090
-// Override with env: TLSFINGERPRINT_CAPTURE_URL=https://localhost:8443
+// Requires a capture server via env:
+// TLSFINGERPRINT_CAPTURE_URL=https://localhost:8443
//
// Run: go test -v -run TestDialerAgainstCaptureServer ./internal/pkg/tlsfingerprint/...
func TestDialerAgainstCaptureServer(t *testing.T) {
captureURL := os.Getenv("TLSFINGERPRINT_CAPTURE_URL")
if captureURL == "" {
- captureURL = "https://tls.sub2api.org:8090"
+ t.Skip("set TLSFINGERPRINT_CAPTURE_URL to run capture-server fingerprint assertions")
}
tests := []struct {
diff --git a/backend/internal/pkg/usagestats/usage_log_types.go b/backend/internal/pkg/usagestats/usage_log_types.go
index fe5f98d66e0..6104503025e 100644
--- a/backend/internal/pkg/usagestats/usage_log_types.go
+++ b/backend/internal/pkg/usagestats/usage_log_types.go
@@ -22,7 +22,7 @@ func NormalizeModelSource(source string) string {
if IsValidModelSource(source) {
return source
}
- return ModelSourceRequested
+ return ModelSourceUpstream
}
// DashboardStats 仪表盘统计
diff --git a/backend/internal/pkg/usagestats/usage_log_types_test.go b/backend/internal/pkg/usagestats/usage_log_types_test.go
index 95cf6069137..f6b7992c1b1 100644
--- a/backend/internal/pkg/usagestats/usage_log_types_test.go
+++ b/backend/internal/pkg/usagestats/usage_log_types_test.go
@@ -33,8 +33,8 @@ func TestNormalizeModelSource(t *testing.T) {
{name: "requested", source: ModelSourceRequested, want: ModelSourceRequested},
{name: "upstream", source: ModelSourceUpstream, want: ModelSourceUpstream},
{name: "mapping", source: ModelSourceMapping, want: ModelSourceMapping},
- {name: "invalid falls back", source: "foobar", want: ModelSourceRequested},
- {name: "empty falls back", source: "", want: ModelSourceRequested},
+ {name: "invalid falls back", source: "foobar", want: ModelSourceUpstream},
+ {name: "empty falls back", source: "", want: ModelSourceUpstream},
}
for _, tc := range tests {
diff --git a/backend/internal/pkg/websearch/manager.go b/backend/internal/pkg/websearch/manager.go
index 307aa1e9c7a..fe91c1a7c10 100644
--- a/backend/internal/pkg/websearch/manager.go
+++ b/backend/internal/pkg/websearch/manager.go
@@ -9,12 +9,12 @@ import (
"math/rand"
"net"
"net/http"
- "net/url"
"sort"
"strings"
"sync"
"time"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/proxyurl"
"github.com/Wei-Shaw/sub2api/internal/pkg/proxyutil"
"github.com/redis/go-redis/v9"
)
@@ -413,9 +413,9 @@ func newHTTPClient(proxyURL string) (*http.Client, error) {
ResponseHeaderTimeout: searchDataTimeout,
}
if proxyURL != "" {
- parsed, err := url.Parse(proxyURL)
+ _, parsed, err := proxyurl.Parse(proxyURL)
if err != nil {
- return nil, fmt.Errorf("invalid proxy URL %q: %w", proxyURL, err)
+ return nil, fmt.Errorf("invalid proxy URL: %w", err)
}
if err := proxyutil.ConfigureTransportProxy(transport, parsed); err != nil {
return nil, fmt.Errorf("configure proxy: %w", err)
diff --git a/backend/internal/pkg/websearch/manager_test.go b/backend/internal/pkg/websearch/manager_test.go
index a44134179db..5ce2ea83066 100644
--- a/backend/internal/pkg/websearch/manager_test.go
+++ b/backend/internal/pkg/websearch/manager_test.go
@@ -302,6 +302,13 @@ func TestNewHTTPClient_InvalidProxy(t *testing.T) {
require.Contains(t, err.Error(), "invalid proxy URL")
}
+func TestNewHTTPClient_InvalidProxyErrorDoesNotLeakCredentials(t *testing.T) {
+ _, err := newHTTPClient("http://alice:super-secret@proxy.example.com:bad-port")
+ require.Error(t, err)
+ require.NotContains(t, err.Error(), "alice")
+ require.NotContains(t, err.Error(), "super-secret")
+}
+
func TestNewHTTPClient_ValidHTTPProxy(t *testing.T) {
c, err := newHTTPClient("http://proxy.example.com:8080")
require.NoError(t, err)
diff --git a/backend/internal/repository/account_credentials_backfill.go b/backend/internal/repository/account_credentials_backfill.go
new file mode 100644
index 00000000000..211e7aeaae9
--- /dev/null
+++ b/backend/internal/repository/account_credentials_backfill.go
@@ -0,0 +1,178 @@
+package repository
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "reflect"
+
+ "entgo.io/ent/dialect"
+ entsql "entgo.io/ent/dialect/sql"
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ dbaccount "github.com/Wei-Shaw/sub2api/ent/account"
+ dbpredicate "github.com/Wei-Shaw/sub2api/ent/predicate"
+ "github.com/Wei-Shaw/sub2api/ent/schema/mixins"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+// AccountCredentialsEncryptionResult summarizes a one-shot credentials encryption pass.
+type AccountCredentialsEncryptionResult struct {
+ Scanned int
+ NeedsEncryption int
+ Updated int
+}
+
+type DomainSecretMigrationResult struct {
+ AccountCredentials int
+ TOTPSecrets int
+ ChannelMonitorKeys int
+ ChannelMonitorPayloads int
+ BackupS3Configs int
+ ContentModerationConfigs int
+ ProxyCredentials int
+ SettingSecrets int
+}
+
+// EncryptPlaintextAccountCredentials rewrites legacy plaintext account credentials
+// through the same encryption envelope used by accountRepository writes.
+func EncryptPlaintextAccountCredentials(ctx context.Context, client *dbent.Client, encryptor service.SecretEncryptor, dryRun bool) (AccountCredentialsEncryptionResult, error) {
+ if client == nil {
+ return AccountCredentialsEncryptionResult{}, errors.New("nil ent client")
+ }
+ if encryptor == nil {
+ return AccountCredentialsEncryptionResult{}, errors.New("nil credential encryptor")
+ }
+
+ // Include soft-deleted accounts: their credentials remain recoverable data
+ // at rest and must not retain the relocatable legacy envelope.
+ migrationCtx := mixins.SkipSoftDelete(ctx)
+ accounts, err := client.Account.Query().All(migrationCtx)
+ if err != nil {
+ return AccountCredentialsEncryptionResult{}, err
+ }
+
+ result := AccountCredentialsEncryptionResult{Scanned: len(accounts)}
+ for _, account := range accounts {
+ encrypted, err := migrateAccountCredentials(account.Credentials, encryptor)
+ if err != nil {
+ return result, err
+ }
+ if reflect.DeepEqual(encrypted, account.Credentials) {
+ continue
+ }
+
+ result.NeedsEncryption++
+ if dryRun {
+ continue
+ }
+ credentialsPredicate, err := accountCredentialsEqual(account.Credentials)
+ if err != nil {
+ return result, err
+ }
+ if _, err := client.Account.UpdateOneID(account.ID).
+ Where(credentialsPredicate).
+ SetCredentials(encrypted).
+ Save(migrationCtx); err != nil {
+ return result, fmt.Errorf("update account %d credentials with concurrency guard: %w", account.ID, err)
+ }
+ result.Updated++
+ }
+
+ return result, nil
+}
+
+func accountCredentialsEqual(credentials map[string]any) (dbpredicate.Account, error) {
+ raw, err := json.Marshal(credentials)
+ if err != nil {
+ return nil, fmt.Errorf("marshal account credentials concurrency guard: %w", err)
+ }
+ return func(selector *entsql.Selector) {
+ selector.Where(entsql.P(func(builder *entsql.Builder) {
+ builder.Ident(selector.C(dbaccount.FieldCredentials)).WriteOp(entsql.OpEQ)
+ if builder.Dialect() == dialect.Postgres {
+ builder.Arg(string(raw)).WriteString("::jsonb")
+ } else {
+ builder.Arg(raw)
+ }
+ }))
+ }, nil
+}
+
+func migrateAccountCredentials(in map[string]any, encryptor service.SecretEncryptor) (map[string]any, error) {
+ if in == nil {
+ return nil, nil
+ }
+ out := make(map[string]any, len(in))
+ for key, value := range in {
+ migrated, err := migrateCredentialValue(key, value, encryptor)
+ if err != nil {
+ return nil, fmt.Errorf("migrate credential %q: %w", key, err)
+ }
+ out[key] = migrated
+ }
+ return out, nil
+}
+
+func migrateCredentialValue(key string, value any, encryptor service.SecretEncryptor) (any, error) {
+ alg, ciphertext, envelope, err := parseEncryptedCredentialEnvelope(value)
+ if err != nil {
+ return nil, err
+ }
+ if envelope {
+ return migrateCredentialEnvelope(value, alg, ciphertext, encryptor)
+ }
+ if isSensitiveCredentialKey(key) {
+ return encryptCredentialJSON(value, encryptor)
+ }
+ switch typed := value.(type) {
+ case map[string]any:
+ return migrateAccountCredentials(typed, encryptor)
+ case []any:
+ return migrateCredentialList(typed, encryptor)
+ default:
+ return copyJSONValue(value), nil
+ }
+}
+
+func migrateCredentialEnvelope(value any, alg, ciphertext string, encryptor service.SecretEncryptor) (any, error) {
+ if alg == encryptedCredentialAlgV3 {
+ if _, err := service.DecryptForSecretDomain(encryptor, service.SecretDomainAccountCredential, ciphertext); err != nil {
+ return nil, fmt.Errorf("validate domain-bound credential: %w", err)
+ }
+ return copyJSONValue(value), nil
+ }
+ plaintext, err := decryptCredentialForMigration(alg, ciphertext, encryptor)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt legacy credential: %w", err)
+ }
+ var decoded any
+ if err := json.Unmarshal([]byte(plaintext), &decoded); err != nil {
+ return nil, fmt.Errorf("decode legacy credential: %w", err)
+ }
+ return encryptCredentialJSON(decoded, encryptor)
+}
+
+func decryptCredentialForMigration(alg, ciphertext string, encryptor service.SecretEncryptor) (string, error) {
+ if alg == encryptedCredentialAlgV2 {
+ _, plaintext, _, err := migrateLegacyCiphertextWithPlaintext(
+ encryptor,
+ service.SecretDomainAccountCredential,
+ ciphertext,
+ )
+ return plaintext, err
+ }
+ return encryptor.Decrypt(ciphertext)
+}
+
+func migrateCredentialList(in []any, encryptor service.SecretEncryptor) ([]any, error) {
+ out := make([]any, len(in))
+ for i, item := range in {
+ migrated, err := migrateCredentialValue("", item, encryptor)
+ if err != nil {
+ return nil, err
+ }
+ out[i] = migrated
+ }
+ return out, nil
+}
diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go
index 78f739ac205..2f2198b47bd 100644
--- a/backend/internal/repository/account_repo.go
+++ b/backend/internal/repository/account_repo.go
@@ -15,6 +15,7 @@ import (
"database/sql"
"encoding/json"
"errors"
+ "maps"
"strconv"
"strings"
"time"
@@ -49,6 +50,8 @@ type accountRepository struct {
// Used to proactively sync account snapshot to cache when status changes,
// ensuring sticky sessions can promptly detect unavailable accounts.
schedulerCache service.SchedulerCache
+
+ credentialEncryptor service.SecretEncryptor
}
var schedulerNeutralExtraKeyPrefixes = []string{
@@ -66,27 +69,37 @@ var schedulerNeutralExtraKeys = map[string]struct{}{
// NewAccountRepository 创建账户仓储实例。
// 这是对外暴露的构造函数,返回接口类型以便于依赖注入。
-func NewAccountRepository(client *dbent.Client, sqlDB *sql.DB, schedulerCache service.SchedulerCache) service.AccountRepository {
- return newAccountRepositoryWithSQL(client, sqlDB, schedulerCache)
+func NewAccountRepository(client *dbent.Client, sqlDB *sql.DB, schedulerCache service.SchedulerCache, credentialEncryptor service.SecretEncryptor) service.AccountRepository {
+ return newAccountRepositoryWithSQL(client, sqlDB, schedulerCache, credentialEncryptor)
}
// newAccountRepositoryWithSQL 是内部构造函数,支持依赖注入 SQL 执行器。
// 这种设计便于单元测试时注入 mock 对象。
-func newAccountRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor, schedulerCache service.SchedulerCache) *accountRepository {
- return &accountRepository{client: client, sql: sqlq, schedulerCache: schedulerCache}
+func newAccountRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor, schedulerCache service.SchedulerCache, credentialEncryptors ...service.SecretEncryptor) *accountRepository {
+ var credentialEncryptor service.SecretEncryptor
+ if len(credentialEncryptors) > 0 {
+ credentialEncryptor = credentialEncryptors[0]
+ }
+ return &accountRepository{client: client, sql: sqlq, schedulerCache: schedulerCache, credentialEncryptor: credentialEncryptor}
}
func (r *accountRepository) Create(ctx context.Context, account *service.Account) error {
if account == nil {
return service.ErrAccountNilInput
}
+ service.AdvanceOAuthTokenVersion(account)
+
+ credentials, err := r.encryptCredentials(normalizeJSONMap(account.Credentials))
+ if err != nil {
+ return err
+ }
builder := r.client.Account.Create().
SetName(account.Name).
SetNillableNotes(account.Notes).
SetPlatform(account.Platform).
SetType(account.Type).
- SetCredentials(normalizeJSONMap(account.Credentials)).
+ SetCredentials(credentials).
SetExtra(normalizeJSONMap(account.Extra)).
SetConcurrency(account.Concurrency).
SetPriority(account.Priority).
@@ -182,7 +195,8 @@ func (r *accountRepository) GetByIDs(ctx context.Context, ids []int64) ([]*servi
return []*service.Account{}, nil
}
- entAccounts, err := r.client.Account.
+ client := clientFromContext(ctx, r.client)
+ entAccounts, err := client.Account.
Query().
Where(dbaccount.IDIn(uniqueIDs...)).
WithProxy().
@@ -208,14 +222,20 @@ func (r *accountRepository) GetByIDs(ctx context.Context, ids []int64) ([]*servi
outByID := make(map[int64]*service.Account, len(entAccounts))
for _, entAcc := range entAccounts {
- out := accountEntityToService(entAcc)
+ out, err := r.accountEntityToService(entAcc)
+ if err != nil {
+ return nil, err
+ }
if out == nil {
continue
}
// Prefer the preloaded proxy edge when available.
if entAcc.Edges.Proxy != nil {
- out.Proxy = proxyEntityToService(entAcc.Edges.Proxy)
+ out.Proxy, err = proxyEntityToService(entAcc.Edges.Proxy, r.credentialEncryptor)
+ if err != nil {
+ return nil, err
+ }
}
if groups, ok := groupsByAccount[entAcc.ID]; ok {
@@ -317,13 +337,19 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account
if account == nil {
return nil
}
+ service.AdvanceOAuthTokenVersion(account)
+
+ credentials, err := r.encryptCredentials(normalizeJSONMap(account.Credentials))
+ if err != nil {
+ return err
+ }
builder := r.client.Account.UpdateOneID(account.ID).
SetName(account.Name).
SetNillableNotes(account.Notes).
SetPlatform(account.Platform).
SetType(account.Type).
- SetCredentials(normalizeJSONMap(account.Credentials)).
+ SetCredentials(credentials).
SetExtra(normalizeJSONMap(account.Extra)).
SetConcurrency(account.Concurrency).
SetPriority(account.Priority).
@@ -405,8 +431,12 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account
}
func (r *accountRepository) UpdateCredentials(ctx context.Context, id int64, credentials map[string]any) error {
- _, err := r.client.Account.UpdateOneID(id).
- SetCredentials(normalizeJSONMap(credentials)).
+ encryptedCredentials, err := r.encryptCredentials(normalizeJSONMap(credentials))
+ if err != nil {
+ return err
+ }
+ _, err = r.client.Account.UpdateOneID(id).
+ SetCredentials(encryptedCredentials).
Save(ctx)
if err != nil {
return translatePersistenceError(err, service.ErrAccountNotFound, nil)
@@ -1423,13 +1453,35 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
}
// JSONB 需要合并而非覆盖,使用 raw SQL 保持旧行为。
if len(updates.Credentials) > 0 {
- payload, err := json.Marshal(updates.Credentials)
+ credentialPatch := maps.Clone(updates.Credentials)
+ // _token_version is a repository-managed generation. Never allow a
+ // caller-provided bulk patch to choose or roll it back.
+ delete(credentialPatch, "_token_version")
+ encryptedCredentials, err := r.encryptCredentials(normalizeJSONMap(credentialPatch))
if err != nil {
return 0, err
}
- setClauses = append(setClauses, "credentials = COALESCE(credentials, '{}'::jsonb) || $"+itoa(idx)+"::jsonb")
+ payload, err := json.Marshal(encryptedCredentials)
+ if err != nil {
+ return 0, err
+ }
+ credentialPatchArg := idx
args = append(args, payload)
idx++
+ versionFloorArg := idx
+ args = append(args, time.Now().UnixMilli())
+ idx++
+ currentVersionSQL := "CASE " +
+ "WHEN jsonb_typeof(COALESCE(credentials, '{}'::jsonb)->'_token_version') = 'number' " +
+ "THEN ((credentials->>'_token_version')::numeric)::bigint " +
+ "WHEN COALESCE(credentials->>'_token_version', '') ~ '^[0-9]+$' " +
+ "THEN (credentials->>'_token_version')::bigint " +
+ "ELSE 0 END"
+ nextVersionSQL := "GREATEST((" + currentVersionSQL + ") + 1, $" + itoa(versionFloorArg) + "::bigint)"
+ setClauses = append(setClauses,
+ "credentials = COALESCE(credentials, '{}'::jsonb) || $"+itoa(credentialPatchArg)+"::jsonb || "+
+ "CASE WHEN type = 'oauth' THEN jsonb_build_object('_token_version', "+nextVersionSQL+") ELSE '{}'::jsonb END",
+ )
}
if len(updates.Extra) > 0 {
payload, err := json.Marshal(updates.Extra)
@@ -1570,7 +1622,10 @@ func (r *accountRepository) accountsToService(ctx context.Context, accounts []*d
outAccounts := make([]service.Account, 0, len(accounts))
for _, acc := range accounts {
- out := accountEntityToService(acc)
+ out, err := r.accountEntityToService(acc)
+ if err != nil {
+ return nil, err
+ }
if out == nil {
continue
}
@@ -1618,13 +1673,18 @@ func (r *accountRepository) loadProxies(ctx context.Context, proxyIDs []int64) (
return proxyMap, nil
}
- proxies, err := r.client.Proxy.Query().Where(dbproxy.IDIn(proxyIDs...)).All(ctx)
+ client := clientFromContext(ctx, r.client)
+ proxies, err := client.Proxy.Query().Where(dbproxy.IDIn(proxyIDs...)).All(ctx)
if err != nil {
return nil, err
}
for _, p := range proxies {
- proxyMap[p.ID] = proxyEntityToService(p)
+ mapped, err := proxyEntityToService(p, r.credentialEncryptor)
+ if err != nil {
+ return nil, err
+ }
+ proxyMap[p.ID] = mapped
}
return proxyMap, nil
}
@@ -1638,7 +1698,8 @@ func (r *accountRepository) loadAccountGroups(ctx context.Context, accountIDs []
return groupsByAccount, groupIDsByAccount, accountGroupsByAccount, nil
}
- entries, err := r.client.AccountGroup.Query().
+ client := clientFromContext(ctx, r.client)
+ entries, err := client.AccountGroup.Query().
Where(dbaccountgroup.AccountIDIn(accountIDs...)).
WithGroup().
Order(dbaccountgroup.ByAccountID(), dbaccountgroup.ByPriority()).
@@ -1714,10 +1775,36 @@ func buildSchedulerGroupPayload(groupIDs []int64) map[string]any {
return map[string]any{"group_ids": groupIDs}
}
+func (r *accountRepository) encryptCredentials(credentials map[string]any) (map[string]any, error) {
+ return encryptAccountCredentials(credentials, r.credentialEncryptor)
+}
+
+func (r *accountRepository) decryptCredentials(credentials map[string]any) (map[string]any, error) {
+ return decryptAccountCredentials(credentials, r.credentialEncryptor)
+}
+
+func (r *accountRepository) accountEntityToService(m *dbent.Account) (*service.Account, error) {
+ if m == nil {
+ return nil, nil
+ }
+ credentials, err := r.decryptCredentials(copyJSONMap(m.Credentials))
+ if err != nil {
+ return nil, err
+ }
+ return accountEntityToServiceWithCredentials(m, credentials), nil
+}
+
func accountEntityToService(m *dbent.Account) *service.Account {
if m == nil {
return nil
}
+ return accountEntityToServiceWithCredentials(m, copyJSONMap(m.Credentials))
+}
+
+func accountEntityToServiceWithCredentials(m *dbent.Account, credentials map[string]any) *service.Account {
+ if m == nil {
+ return nil
+ }
rateMultiplier := m.RateMultiplier
@@ -1727,7 +1814,7 @@ func accountEntityToService(m *dbent.Account) *service.Account {
Notes: m.Notes,
Platform: m.Platform,
Type: m.Type,
- Credentials: copyJSONMap(m.Credentials),
+ Credentials: credentials,
Extra: copyJSONMap(m.Extra),
ProxyID: m.ProxyID,
Concurrency: m.Concurrency,
diff --git a/backend/internal/repository/account_repo_integration_test.go b/backend/internal/repository/account_repo_integration_test.go
index d1cea9eb3b0..5f286f80587 100644
--- a/backend/internal/repository/account_repo_integration_test.go
+++ b/backend/internal/repository/account_repo_integration_test.go
@@ -956,6 +956,40 @@ func (s *AccountRepoSuite) TestBulkUpdate_MergeCredentials() {
s.Require().Equal("new_value", got.Credentials["new_key"])
}
+func (s *AccountRepoSuite) TestBulkUpdate_OAuthCredentialsAdvanceSystemVersionOnlyForOAuthRows() {
+ oauth := mustCreateAccount(s.T(), s.client, &service.Account{
+ Name: "bulk-oauth-version",
+ Type: service.AccountTypeOAuth,
+ Credentials: map[string]any{
+ "existing": "oauth-value",
+ "_token_version": int64(9_999_999_999_999),
+ },
+ })
+ apiKey := mustCreateAccount(s.T(), s.client, &service.Account{
+ Name: "bulk-api-key-version",
+ Type: service.AccountTypeAPIKey,
+ Credentials: map[string]any{"existing": "api-key-value"},
+ })
+
+ _, err := s.repo.BulkUpdate(s.ctx, []int64{oauth.ID, apiKey.ID}, service.AccountBulkUpdate{
+ Credentials: map[string]any{
+ "project_id": "replacement-project",
+ "_token_version": int64(1),
+ },
+ })
+ s.Require().NoError(err)
+
+ updatedOAuth, err := s.repo.GetByID(s.ctx, oauth.ID)
+ s.Require().NoError(err)
+ s.Require().EqualValues(10_000_000_000_000, updatedOAuth.GetCredentialAsInt64("_token_version"))
+ s.Require().Equal("replacement-project", updatedOAuth.GetCredential("project_id"))
+
+ updatedAPIKey, err := s.repo.GetByID(s.ctx, apiKey.ID)
+ s.Require().NoError(err)
+ s.Require().Zero(updatedAPIKey.GetCredentialAsInt64("_token_version"))
+ s.Require().Equal("replacement-project", updatedAPIKey.GetCredential("project_id"))
+}
+
func (s *AccountRepoSuite) TestBulkUpdate_MergeExtra() {
a1 := mustCreateAccount(s.T(), s.client, &service.Account{
Name: "bulk-extra",
diff --git a/backend/internal/repository/account_repo_oauth_token_version_unit_test.go b/backend/internal/repository/account_repo_oauth_token_version_unit_test.go
new file mode 100644
index 00000000000..67fa4b3c556
--- /dev/null
+++ b/backend/internal/repository/account_repo_oauth_token_version_unit_test.go
@@ -0,0 +1,50 @@
+//go:build unit
+
+package repository
+
+import (
+ "context"
+ "database/sql/driver"
+ "encoding/json"
+ "testing"
+
+ "github.com/DATA-DOG/go-sqlmock"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+type credentialPatchWithoutCallerTokenVersion struct{}
+
+func (credentialPatchWithoutCallerTokenVersion) Match(value driver.Value) bool {
+ payload, ok := value.([]byte)
+ if !ok {
+ return false
+ }
+ var credentials map[string]any
+ if err := json.Unmarshal(payload, &credentials); err != nil {
+ return false
+ }
+ _, hasCallerVersion := credentials["_token_version"]
+ return !hasCallerVersion && credentials["project_id"] == "replacement-project"
+}
+
+func TestAccountRepositoryBulkUpdateAdvancesOAuthTokenVersionInSQL(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ repo := newAccountRepositoryWithSQL(nil, db, nil)
+ mock.ExpectExec(`UPDATE accounts SET credentials = .*CASE WHEN type = 'oauth'.*jsonb_build_object\('_token_version'.*WHERE id = ANY`).
+ WithArgs(credentialPatchWithoutCallerTokenVersion{}, sqlmock.AnyArg(), sqlmock.AnyArg()).
+ WillReturnResult(sqlmock.NewResult(0, 0))
+
+ _, err = repo.BulkUpdate(context.Background(), []int64{17}, service.AccountBulkUpdate{
+ Credentials: map[string]any{
+ "project_id": "replacement-project",
+ "_token_version": int64(1),
+ },
+ })
+
+ require.NoError(t, err)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
diff --git a/backend/internal/repository/admin_balance_atomicity_integration_test.go b/backend/internal/repository/admin_balance_atomicity_integration_test.go
new file mode 100644
index 00000000000..e178130a1a9
--- /dev/null
+++ b/backend/internal/repository/admin_balance_atomicity_integration_test.go
@@ -0,0 +1,94 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+)
+
+type failingBalanceAuditRepository struct {
+ service.RedeemCodeRepository
+}
+
+func (failingBalanceAuditRepository) Create(context.Context, *service.RedeemCode) error {
+ return errors.New("injected balance audit failure")
+}
+
+func TestAdminBalanceConcurrentAddsSerializeAndWriteAuditOnceEach(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ userRepo := NewUserRepository(client, integrationDB)
+ redeemRepo := NewRedeemCodeRepository(client)
+ adminService := service.NewAdminService(
+ userRepo, nil, nil, nil, nil, redeemRepo, nil, nil, nil,
+ nil, nil, nil, client, nil, nil, nil, nil,
+ )
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("admin-balance-concurrency-%s@example.com", uuid.NewString()),
+ Username: "admin-balance-concurrency",
+ Balance: 0,
+ })
+
+ start := make(chan struct{})
+ errs := make(chan error, 2)
+ var wg sync.WaitGroup
+ for i := 0; i < 2; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-start
+ _, err := adminService.UpdateUserBalance(ctx, user.ID, 10, "add", "concurrent topup")
+ errs <- err
+ }()
+ }
+ close(start)
+ wg.Wait()
+ close(errs)
+ for err := range errs {
+ require.NoError(t, err)
+ }
+
+ updated, err := userRepo.GetByID(ctx, user.ID)
+ require.NoError(t, err)
+ require.InDelta(t, 20, updated.Balance, 0.000001)
+
+ var auditCount int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*)
+ FROM redeem_codes
+ WHERE used_by = $1 AND type = 'admin_balance' AND value = 10
+ `, user.ID).Scan(&auditCount))
+ require.Equal(t, 2, auditCount)
+}
+
+func TestAdminBalanceAuditFailureRollsBackBalance(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ userRepo := NewUserRepository(client, integrationDB)
+ redeemRepo := NewRedeemCodeRepository(client)
+ adminService := service.NewAdminService(
+ userRepo, nil, nil, nil, nil,
+ failingBalanceAuditRepository{RedeemCodeRepository: redeemRepo},
+ nil, nil, nil, nil, nil, nil, client, nil, nil, nil, nil,
+ )
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("admin-balance-rollback-%s@example.com", uuid.NewString()),
+ Username: "admin-balance-rollback",
+ Balance: 5,
+ })
+
+ _, err := adminService.UpdateUserBalance(ctx, user.ID, 10, "add", "must roll back")
+ require.ErrorContains(t, err, "injected balance audit failure")
+
+ unchanged, err := userRepo.GetByID(ctx, user.ID)
+ require.NoError(t, err)
+ require.InDelta(t, 5, unchanged.Balance, 0.000001)
+}
diff --git a/backend/internal/repository/admin_subscription_assign_idempotency_integration_test.go b/backend/internal/repository/admin_subscription_assign_idempotency_integration_test.go
new file mode 100644
index 00000000000..50a75c8ef82
--- /dev/null
+++ b/backend/internal/repository/admin_subscription_assign_idempotency_integration_test.go
@@ -0,0 +1,347 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+type failFirstMarkSucceededIdempotencyRepo struct {
+ service.IdempotencyRepository
+ failed atomic.Bool
+}
+
+func (r *failFirstMarkSucceededIdempotencyRepo) MarkSucceeded(
+ ctx context.Context,
+ id int64,
+ responseStatus int,
+ responseBody string,
+ expiresAt time.Time,
+) error {
+ if r.failed.CompareAndSwap(false, true) {
+ return errors.New("injected crash before idempotency success marker")
+ }
+ return r.IdempotencyRepository.MarkSucceeded(ctx, id, responseStatus, responseBody, expiresAt)
+}
+
+func adminSubscriptionAssignPGOptions(scope, key string, payload any) service.IdempotencyExecuteOptions {
+ return service.IdempotencyExecuteOptions{
+ Scope: scope,
+ ActorScope: "admin:99",
+ Method: "POST",
+ Route: "/api/v1/admin/subscriptions/assign",
+ IdempotencyKey: key,
+ Payload: payload,
+ TTL: time.Hour,
+ RequireKey: true,
+ Persistent: true,
+ }
+}
+
+func executeAdminSubscriptionAssignPGAtomic(
+ ctx context.Context,
+ subscriptionService *service.SubscriptionService,
+ coordinator *service.IdempotencyCoordinator,
+ options service.IdempotencyExecuteOptions,
+ execute func(context.Context) (any, error),
+) (*service.IdempotencyExecuteResult, error) {
+ var result *service.IdempotencyExecuteResult
+ err := subscriptionService.RunAssignmentTransaction(ctx, func(txCtx context.Context) error {
+ var executeErr error
+ result, executeErr = coordinator.Execute(txCtx, options, execute)
+ return executeErr
+ })
+ return result, err
+}
+
+func TestAdminSubscriptionAssignIdempotencyPG_WalletTopupAndAffiliateOnce(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ suffix := uuid.NewString()
+
+ inviter := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("admin-idem-inviter-%s@example.com", suffix),
+ Username: "admin-idem-inviter",
+ })
+ invitee := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("admin-idem-invitee-%s@example.com", suffix),
+ Username: "admin-idem-invitee",
+ })
+ operator := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("admin-idem-operator-%s@example.com", suffix),
+ Username: "admin-idem-operator",
+ Role: service.RoleAdmin,
+ })
+
+ affiliateRepo := NewAffiliateRepository(client, integrationDB)
+ _, err := affiliateRepo.EnsureUserAffiliate(ctx, inviter.ID)
+ require.NoError(t, err)
+ _, err = affiliateRepo.EnsureUserAffiliate(ctx, invitee.ID)
+ require.NoError(t, err)
+ bound, err := affiliateRepo.BindInviter(ctx, invitee.ID, inviter.ID)
+ require.NoError(t, err)
+ require.True(t, bound)
+
+ groupRepo := NewGroupRepository(client, integrationDB)
+ subRepo := NewUserSubscriptionRepository(client)
+ walletRepo := NewWalletRepository(client, integrationDB)
+ walletService := service.NewWalletService(walletRepo)
+ subscriptionService := service.NewSubscriptionService(groupRepo, subRepo, nil, client, nil)
+ subscriptionService.SetWalletTopupService(walletService)
+
+ initial := 100.0
+ seed, err := subscriptionService.AssignSubscription(ctx, &service.AssignSubscriptionInput{
+ UserID: invitee.ID,
+ AssignedBy: operator.ID,
+ Notes: "seed credits wallet",
+ WalletInitialUSD: &initial,
+ PlanType: service.PlanTypeCredits,
+ })
+ require.NoError(t, err)
+
+ cfg := service.DefaultIdempotencyConfig()
+ cfg.ObserveOnly = false
+ idempotencyRepo := NewIdempotencyRepository(client, integrationDB)
+ failingCoordinator := service.NewIdempotencyCoordinator(&failFirstMarkSucceededIdempotencyRepo{
+ IdempotencyRepository: idempotencyRepo,
+ }, cfg)
+ coordinator := service.NewIdempotencyCoordinator(idempotencyRepo, cfg)
+ scope := "admin.subscriptions.assign." + suffix
+ key := "wallet-topup-" + suffix
+ payload := map[string]any{
+ "user_id": invitee.ID,
+ "wallet_initial_usd": 50.0,
+ "notes": "manual topup",
+ }
+ delta := 50.0
+ executeCalls := 0
+ failAffiliate := true
+ execute := func(ctx context.Context) (any, error) {
+ executeCalls++
+ sub, assignErr := subscriptionService.AssignSubscription(ctx, &service.AssignSubscriptionInput{
+ UserID: invitee.ID,
+ AssignedBy: operator.ID,
+ Notes: "manual topup",
+ WalletInitialUSD: &delta,
+ PlanType: service.PlanTypeCredits,
+ })
+ if assignErr != nil {
+ return nil, assignErr
+ }
+ if failAffiliate {
+ return nil, errors.New("injected affiliate accrual failure")
+ }
+ if sub.WalletCreditDeltaUSD == nil || *sub.WalletCreditDeltaUSD <= 0 {
+ return nil, errors.New("wallet assignment did not expose the current credit delta")
+ }
+ rebateAmount := *sub.WalletCreditDeltaUSD * (service.AffiliateRebateCreditsCardRate / 100)
+ applied, rebateErr := affiliateRepo.AccrueQuota(ctx, inviter.ID, invitee.ID, rebateAmount, 0, nil)
+ if rebateErr != nil {
+ return nil, rebateErr
+ }
+ if !applied {
+ return nil, fmt.Errorf("affiliate rebate was not applied")
+ }
+ return map[string]any{"subscription_id": sub.ID}, nil
+ }
+
+ options := adminSubscriptionAssignPGOptions(scope, key, payload)
+ _, err = executeAdminSubscriptionAssignPGAtomic(ctx, subscriptionService, coordinator, options, execute)
+ require.ErrorContains(t, err, "injected affiliate accrual failure")
+ failAffiliate = false
+
+ var walletBalanceAfterAffiliateFailure float64
+ var topupCountAfterAffiliateFailure int
+ var idempotencyCountAfterAffiliateFailure int
+ require.NoError(t, integrationDB.QueryRowContext(ctx,
+ "SELECT wallet_balance_usd::double precision FROM user_subscriptions WHERE id = $1",
+ seed.ID,
+ ).Scan(&walletBalanceAfterAffiliateFailure))
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM subscription_wallet_ledger
+ WHERE subscription_id = $1 AND reason = 'topup'
+ `, seed.ID).Scan(&topupCountAfterAffiliateFailure))
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM idempotency_records
+ WHERE scope = $1 AND idempotency_key_hash = $2
+ `, scope, service.HashIdempotencyKey(key)).Scan(&idempotencyCountAfterAffiliateFailure))
+ require.InDelta(t, 100.0, walletBalanceAfterAffiliateFailure, 0.000001)
+ require.Zero(t, topupCountAfterAffiliateFailure, "affiliate failure must roll back the wallet topup")
+ require.Zero(t, idempotencyCountAfterAffiliateFailure, "affiliate failure must roll back the idempotency claim")
+
+ _, err = executeAdminSubscriptionAssignPGAtomic(ctx, subscriptionService, failingCoordinator, options, execute)
+ require.ErrorContains(t, err, "injected crash before idempotency success marker")
+
+ var walletBalanceAfterFailure float64
+ var topupCountAfterFailure int
+ var affiliateQuotaAfterFailure float64
+ var idempotencyCountAfterFailure int
+ require.NoError(t, integrationDB.QueryRowContext(ctx,
+ "SELECT wallet_balance_usd::double precision FROM user_subscriptions WHERE id = $1",
+ seed.ID,
+ ).Scan(&walletBalanceAfterFailure))
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM subscription_wallet_ledger
+ WHERE subscription_id = $1 AND reason = 'topup'
+ `, seed.ID).Scan(&topupCountAfterFailure))
+ require.NoError(t, integrationDB.QueryRowContext(ctx,
+ "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1",
+ inviter.ID,
+ ).Scan(&affiliateQuotaAfterFailure))
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM idempotency_records
+ WHERE scope = $1 AND idempotency_key_hash = $2
+ `, scope, service.HashIdempotencyKey(key)).Scan(&idempotencyCountAfterFailure))
+ require.InDelta(t, 100.0, walletBalanceAfterFailure, 0.000001, "failed success-marker write must roll back topup")
+ require.Zero(t, topupCountAfterFailure)
+ require.InDelta(t, 0.0, affiliateQuotaAfterFailure, 0.000001, "affiliate ledger must share the rollback")
+ require.Zero(t, idempotencyCountAfterFailure, "failed atomic attempt must not strand a processing claim")
+
+ first, err := executeAdminSubscriptionAssignPGAtomic(ctx, subscriptionService, coordinator, options, execute)
+ require.NoError(t, err)
+ require.False(t, first.Replayed)
+ second, err := executeAdminSubscriptionAssignPGAtomic(ctx, subscriptionService, coordinator, options, execute)
+ require.NoError(t, err)
+ require.True(t, second.Replayed)
+ require.Equal(t, 3, executeCalls, "rolled-back attempts may rerun, but durable effects must commit once")
+
+ var walletBalance float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx,
+ "SELECT wallet_balance_usd::double precision FROM user_subscriptions WHERE id = $1",
+ seed.ID,
+ ).Scan(&walletBalance))
+ require.InDelta(t, 150.0, walletBalance, 0.000001)
+
+ var topupCount int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*)
+ FROM subscription_wallet_ledger
+ WHERE subscription_id = $1 AND reason = 'topup'
+ `, seed.ID).Scan(&topupCount))
+ require.Equal(t, 1, topupCount)
+
+ var affiliateQuota float64
+ var affiliateLedgerCount int
+ require.NoError(t, integrationDB.QueryRowContext(ctx,
+ "SELECT aff_quota::double precision FROM user_affiliates WHERE user_id = $1",
+ inviter.ID,
+ ).Scan(&affiliateQuota))
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*)
+ FROM user_affiliate_ledger
+ WHERE user_id = $1 AND source_user_id = $2 AND action = 'accrue'
+ `, inviter.ID, invitee.ID).Scan(&affiliateLedgerCount))
+ require.InDelta(t, 5.0, affiliateQuota, 0.000001)
+ require.Equal(t, 1, affiliateLedgerCount)
+
+ _, err = integrationDB.ExecContext(ctx, `
+ UPDATE idempotency_records
+ SET expires_at = NOW() - INTERVAL '1 hour'
+ WHERE scope = $1 AND idempotency_key_hash = $2
+ `, scope, service.HashIdempotencyKey(key))
+ require.NoError(t, err)
+ expiredReplay, err := executeAdminSubscriptionAssignPGAtomic(ctx, subscriptionService, coordinator, options, execute)
+ require.NoError(t, err)
+ require.True(t, expiredReplay.Replayed, "persistent financial keys must replay after clock expiry")
+ require.Equal(t, 3, executeCalls)
+
+ _, err = idempotencyRepo.DeleteExpired(ctx, time.Now(), 500)
+ require.NoError(t, err)
+ var persistentRecordCount int
+ var isReclaimable bool
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*), COALESCE(BOOL_AND(is_reclaimable), FALSE)
+ FROM idempotency_records
+ WHERE scope = $1 AND idempotency_key_hash = $2
+ `, scope, service.HashIdempotencyKey(key)).Scan(&persistentRecordCount, &isReclaimable))
+ require.Equal(t, 1, persistentRecordCount, "cleanup must retain persistent financial idempotency rows")
+ require.False(t, isReclaimable)
+
+ _, err = executeAdminSubscriptionAssignPGAtomic(ctx, subscriptionService, coordinator, adminSubscriptionAssignPGOptions(scope, key, map[string]any{
+ "user_id": invitee.ID,
+ "wallet_initial_usd": 75.0,
+ }), execute)
+ require.Error(t, err)
+ require.Equal(t, infraerrors.Code(service.ErrIdempotencyKeyConflict), infraerrors.Code(err))
+ require.Equal(t, 3, executeCalls)
+}
+
+func TestAdminSubscriptionAssignIdempotencyPG_MonthlyAssignmentOnce(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ suffix := uuid.NewString()
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("admin-idem-monthly-%s@example.com", suffix),
+ Username: "admin-idem-monthly",
+ })
+ operator := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("admin-idem-monthly-operator-%s@example.com", suffix),
+ Username: "admin-idem-monthly-operator",
+ Role: service.RoleAdmin,
+ })
+ monthlyLimit := 99.0
+ group := mustCreateGroup(t, client, &service.Group{
+ Name: "admin-idem-monthly-" + suffix,
+ Platform: service.PlatformAnthropic,
+ SubscriptionType: service.SubscriptionTypeSubscription,
+ MonthlyLimitUSD: &monthlyLimit,
+ })
+
+ groupRepo := NewGroupRepository(client, integrationDB)
+ subRepo := NewUserSubscriptionRepository(client)
+ subscriptionService := service.NewSubscriptionService(groupRepo, subRepo, nil, client, nil)
+ cfg := service.DefaultIdempotencyConfig()
+ cfg.ObserveOnly = false
+ coordinator := service.NewIdempotencyCoordinator(NewIdempotencyRepository(client, integrationDB), cfg)
+ scope := "admin.subscriptions.assign.monthly." + suffix
+ key := "monthly-assign-" + suffix
+ payload := map[string]any{
+ "user_id": user.ID,
+ "group_id": group.ID,
+ "validity_days": 30,
+ "notes": "manual monthly",
+ }
+ executeCalls := 0
+ execute := func(ctx context.Context) (any, error) {
+ executeCalls++
+ sub, assignErr := subscriptionService.AssignSubscription(ctx, &service.AssignSubscriptionInput{
+ UserID: user.ID,
+ GroupID: group.ID,
+ ValidityDays: 30,
+ AssignedBy: operator.ID,
+ Notes: "manual monthly",
+ })
+ if assignErr != nil {
+ return nil, assignErr
+ }
+ return map[string]any{"subscription_id": sub.ID}, nil
+ }
+
+ options := adminSubscriptionAssignPGOptions(scope, key, payload)
+ first, err := executeAdminSubscriptionAssignPGAtomic(ctx, subscriptionService, coordinator, options, execute)
+ require.NoError(t, err)
+ require.False(t, first.Replayed)
+ second, err := executeAdminSubscriptionAssignPGAtomic(ctx, subscriptionService, coordinator, options, execute)
+ require.NoError(t, err)
+ require.True(t, second.Replayed)
+ require.Equal(t, 1, executeCalls)
+
+ var subscriptionCount int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*)
+ FROM user_subscriptions
+ WHERE user_id = $1 AND group_id = $2 AND deleted_at IS NULL
+ `, user.ID, group.ID).Scan(&subscriptionCount))
+ require.Equal(t, 1, subscriptionCount)
+}
diff --git a/backend/internal/repository/admin_user_group_atomicity_integration_test.go b/backend/internal/repository/admin_user_group_atomicity_integration_test.go
new file mode 100644
index 00000000000..2c7ab6431d6
--- /dev/null
+++ b/backend/internal/repository/admin_user_group_atomicity_integration_test.go
@@ -0,0 +1,282 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "sort"
+ "testing"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/google/uuid"
+ "github.com/lib/pq"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+func TestAdminUpdateUserGroupPolicyFailureRollsBackAllChanges(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ suffix := uuid.NewString()
+ groupA := mustCreateGroup(t, client, &service.Group{
+ Name: "admin-user-policy-a-" + suffix,
+ Platform: service.PlatformOpenAI,
+ RateMultiplier: 1,
+ })
+ groupB := mustCreateGroup(t, client, &service.Group{
+ Name: "admin-user-policy-b-" + suffix,
+ Platform: service.PlatformOpenAI,
+ RateMultiplier: 1,
+ })
+ userRepo := NewUserRepository(client, integrationDB)
+ user := &service.User{
+ Email: "admin-user-policy-" + suffix + "@example.test",
+ PasswordHash: "test-password-hash",
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ Concurrency: 5,
+ RPMLimit: 10,
+ AllowedGroups: []int64{groupA.ID},
+ }
+ require.NoError(t, userRepo.Create(ctx, user))
+ cleanupAdminUserGroupFixtures(t, user.ID, []int64{groupA.ID, groupB.ID})
+
+ _, err := integrationDB.ExecContext(ctx, `
+ INSERT INTO user_group_rate_multipliers
+ (user_id, group_id, rate_multiplier, created_at, updated_at)
+ VALUES ($1, $2, 1.25, NOW(), NOW())
+ `, user.ID, groupA.ID)
+ require.NoError(t, err)
+
+ invalidator := &adminUserGroupAuthInvalidator{}
+ svc := newAdminUserGroupAtomicityService(client, userRepo, invalidator)
+ newRPM := 60
+ newRate := 2.5
+ allowedGroups := []int64{groupB.ID}
+ missingGroupID := groupB.ID + 9_000_000_000
+
+ _, err = svc.UpdateUser(ctx, user.ID, &service.UpdateUserInput{
+ RPMLimit: &newRPM,
+ AllowedGroups: &allowedGroups,
+ GroupRates: map[int64]*float64{
+ missingGroupID: &newRate,
+ },
+ })
+ require.Error(t, err, "invalid group-rate foreign key must fail the whole admin update")
+ require.Equal(t, []int64{groupA.ID}, loadAdminAllowedGroupIDs(t, ctx, user.ID))
+ require.Equal(t, 10, loadAdminUserRPMLimit(t, ctx, user.ID))
+ require.InDelta(t, 1.25, loadAdminUserGroupRate(t, ctx, user.ID, groupA.ID), 0.000001)
+ require.Empty(t, invalidator.userIDs, "rolled-back changes must not invalidate committed auth state")
+
+ _, err = svc.UpdateUser(ctx, user.ID, &service.UpdateUserInput{
+ RPMLimit: &newRPM,
+ AllowedGroups: &allowedGroups,
+ GroupRates: map[int64]*float64{
+ groupB.ID: &newRate,
+ },
+ })
+ require.NoError(t, err)
+ require.Equal(t, []int64{groupB.ID}, loadAdminAllowedGroupIDs(t, ctx, user.ID))
+ require.Equal(t, 60, loadAdminUserRPMLimit(t, ctx, user.ID))
+ require.InDelta(t, 1.25, loadAdminUserGroupRate(t, ctx, user.ID, groupA.ID), 0.000001,
+ "partial user rate maps must preserve unspecified group rates")
+ require.InDelta(t, newRate, loadAdminUserGroupRate(t, ctx, user.ID, groupB.ID), 0.000001)
+ require.Equal(t, []int64{user.ID}, invalidator.userIDs)
+}
+
+func TestAdminBatchGroupRatesFailureDoesNotPartiallyClearExistingRows(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ suffix := uuid.NewString()
+ group := mustCreateGroup(t, client, &service.Group{
+ Name: "admin-group-rate-atomic-" + suffix,
+ Platform: service.PlatformOpenAI,
+ RateMultiplier: 1,
+ })
+ user := mustCreateUser(t, client, &service.User{Email: "admin-group-rate-" + suffix + "@example.test"})
+ cleanupAdminUserGroupFixtures(t, user.ID, []int64{group.ID})
+
+ _, err := integrationDB.ExecContext(ctx, `
+ INSERT INTO user_group_rate_multipliers
+ (user_id, group_id, rate_multiplier, created_at, updated_at)
+ VALUES ($1, $2, 1.75, NOW(), NOW())
+ `, user.ID, group.ID)
+ require.NoError(t, err)
+
+ invalidator := &adminUserGroupAuthInvalidator{}
+ svc := newAdminUserGroupAtomicityService(client, NewUserRepository(client, integrationDB), invalidator)
+ err = svc.BatchSetGroupRateMultipliers(ctx, group.ID, []service.GroupRateMultiplierInput{
+ {UserID: user.ID + 9_000_000_000, RateMultiplier: 2.5},
+ })
+ require.Error(t, err)
+ require.InDelta(t, 1.75, loadAdminUserGroupRate(t, ctx, user.ID, group.ID), 0.000001,
+ "failed replacement must preserve the previous group rates")
+ require.Empty(t, invalidator.groupIDs, "rolled-back rate changes must not invalidate committed auth state")
+}
+
+func TestAdminBatchGroupRPMFailureDoesNotPartiallyClearExistingRows(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ suffix := uuid.NewString()
+ group := mustCreateGroup(t, client, &service.Group{
+ Name: "admin-group-rpm-atomic-" + suffix,
+ Platform: service.PlatformOpenAI,
+ RateMultiplier: 1,
+ })
+ user := mustCreateUser(t, client, &service.User{Email: "admin-group-rpm-" + suffix + "@example.test"})
+ cleanupAdminUserGroupFixtures(t, user.ID, []int64{group.ID})
+
+ _, err := integrationDB.ExecContext(ctx, `
+ INSERT INTO user_group_rate_multipliers
+ (user_id, group_id, rpm_override, created_at, updated_at)
+ VALUES ($1, $2, 25, NOW(), NOW())
+ `, user.ID, group.ID)
+ require.NoError(t, err)
+
+ invalidator := &adminUserGroupAuthInvalidator{}
+ svc := newAdminUserGroupAtomicityService(client, NewUserRepository(client, integrationDB), invalidator)
+ newRPM := 50
+ err = svc.BatchSetGroupRPMOverrides(ctx, group.ID, []service.GroupRPMOverrideInput{
+ {UserID: user.ID + 9_000_000_000, RPMOverride: &newRPM},
+ })
+ require.Error(t, err)
+ require.Equal(t, 25, loadAdminUserGroupRPM(t, ctx, user.ID, group.ID),
+ "failed replacement must preserve the previous RPM overrides")
+ require.Empty(t, invalidator.groupIDs, "rolled-back RPM changes must not invalidate committed auth state")
+}
+
+func TestAdminClearGroupRatesPreservesRPMOverrideAndInvalidatesAfterCommit(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ suffix := uuid.NewString()
+ group := mustCreateGroup(t, client, &service.Group{
+ Name: "admin-clear-group-rate-" + suffix,
+ Platform: service.PlatformOpenAI,
+ RateMultiplier: 1,
+ })
+ user := mustCreateUser(t, client, &service.User{Email: "admin-clear-group-rate-" + suffix + "@example.test"})
+ cleanupAdminUserGroupFixtures(t, user.ID, []int64{group.ID})
+
+ _, err := integrationDB.ExecContext(ctx, `
+ INSERT INTO user_group_rate_multipliers
+ (user_id, group_id, rate_multiplier, rpm_override, created_at, updated_at)
+ VALUES ($1, $2, 1.75, 25, NOW(), NOW())
+ `, user.ID, group.ID)
+ require.NoError(t, err)
+
+ invalidator := &adminUserGroupAuthInvalidator{}
+ svc := newAdminUserGroupAtomicityService(client, NewUserRepository(client, integrationDB), invalidator)
+ require.NoError(t, svc.ClearGroupRateMultipliers(ctx, group.ID))
+
+ var rate sql.NullFloat64
+ var rpm sql.NullInt64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT rate_multiplier, rpm_override
+ FROM user_group_rate_multipliers
+ WHERE user_id = $1 AND group_id = $2
+ `, user.ID, group.ID).Scan(&rate, &rpm))
+ require.False(t, rate.Valid)
+ require.True(t, rpm.Valid)
+ require.EqualValues(t, 25, rpm.Int64)
+ require.Equal(t, []int64{group.ID}, invalidator.groupIDs)
+}
+
+func newAdminUserGroupAtomicityService(
+ client *dbent.Client,
+ userRepo service.UserRepository,
+ invalidator service.APIKeyAuthCacheInvalidator,
+) service.AdminService {
+ return service.NewAdminService(
+ userRepo,
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ NewUserGroupRateRepository(integrationDB),
+ nil,
+ nil,
+ nil,
+ nil,
+ invalidator,
+ client,
+ nil,
+ nil,
+ nil,
+ nil,
+ )
+}
+
+type adminUserGroupAuthInvalidator struct {
+ userIDs []int64
+ groupIDs []int64
+}
+
+func (*adminUserGroupAuthInvalidator) InvalidateAuthCacheByKey(context.Context, string) {}
+func (s *adminUserGroupAuthInvalidator) InvalidateAuthCacheByGroupID(_ context.Context, groupID int64) {
+ s.groupIDs = append(s.groupIDs, groupID)
+}
+func (s *adminUserGroupAuthInvalidator) InvalidateAuthCacheByUserID(_ context.Context, userID int64) {
+ s.userIDs = append(s.userIDs, userID)
+}
+
+func loadAdminAllowedGroupIDs(t *testing.T, ctx context.Context, userID int64) []int64 {
+ t.Helper()
+ rows, err := integrationDB.QueryContext(ctx, `
+ SELECT group_id FROM user_allowed_groups WHERE user_id = $1 ORDER BY group_id
+ `, userID)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, rows.Close()) }()
+
+ var ids []int64
+ for rows.Next() {
+ var id int64
+ require.NoError(t, rows.Scan(&id))
+ ids = append(ids, id)
+ }
+ require.NoError(t, rows.Err())
+ sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
+ return ids
+}
+
+func loadAdminUserRPMLimit(t *testing.T, ctx context.Context, userID int64) int {
+ t.Helper()
+ var rpm int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `SELECT rpm_limit FROM users WHERE id = $1`, userID).Scan(&rpm))
+ return rpm
+}
+
+func loadAdminUserGroupRate(t *testing.T, ctx context.Context, userID, groupID int64) float64 {
+ t.Helper()
+ var rate float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT rate_multiplier FROM user_group_rate_multipliers WHERE user_id = $1 AND group_id = $2
+ `, userID, groupID).Scan(&rate))
+ return rate
+}
+
+func loadAdminUserGroupRPM(t *testing.T, ctx context.Context, userID, groupID int64) int {
+ t.Helper()
+ var rpm int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT rpm_override FROM user_group_rate_multipliers WHERE user_id = $1 AND group_id = $2
+ `, userID, groupID).Scan(&rpm))
+ return rpm
+}
+
+func cleanupAdminUserGroupFixtures(t *testing.T, userID int64, groupIDs []int64) {
+ t.Helper()
+ t.Cleanup(func() {
+ ctx := context.Background()
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE user_id = $1`, userID)
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM user_allowed_groups WHERE user_id = $1`, userID)
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM auth_identity_channels WHERE identity_id IN (SELECT id FROM auth_identities WHERE user_id = $1)`, userID)
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM auth_identities WHERE user_id = $1`, userID)
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM users WHERE id = $1`, userID)
+ if len(groupIDs) > 0 {
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM groups WHERE id = ANY($1)`, pq.Array(groupIDs))
+ }
+ })
+}
diff --git a/backend/internal/repository/aes_encryptor.go b/backend/internal/repository/aes_encryptor.go
index 924e3698125..1cefba55c48 100644
--- a/backend/internal/repository/aes_encryptor.go
+++ b/backend/internal/repository/aes_encryptor.go
@@ -3,93 +3,290 @@ package repository
import (
"crypto/aes"
"crypto/cipher"
+ "crypto/hmac"
"crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
+ "strings"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
)
+const (
+ legacySecretDomainCiphertextPrefixV2 = "sd:v2:"
+ legacySecretDomainKDFContextV2 = "sub2api/secret-domain/v2\x00"
+ secretDomainCiphertextPrefix = "sd:v3:"
+ secretDomainKDFContext = "sub2api/secret-domain/v3\x00"
+)
+
// AESEncryptor implements SecretEncryptor using AES-256-GCM
type AESEncryptor struct {
- key []byte
+ legacyKey []byte
+ domainKeys map[string][]byte
}
+var _ service.DomainSecretEncryptor = (*AESEncryptor)(nil)
+
// NewAESEncryptor creates a new AES encryptor
func NewAESEncryptor(cfg *config.Config) (service.SecretEncryptor, error) {
- key, err := hex.DecodeString(cfg.Totp.EncryptionKey)
+ if cfg == nil {
+ return nil, fmt.Errorf("secret encryption configuration is required")
+ }
+ domainKeys, err := configuredDomainKeys(cfg)
if err != nil {
- return nil, fmt.Errorf("invalid totp encryption key: %w", err)
+ return nil, err
}
-
- if len(key) != 32 {
- return nil, fmt.Errorf("totp encryption key must be 32 bytes (64 hex chars), got %d bytes", len(key))
+ legacyKey, err := configuredLegacySecretKey(cfg)
+ if err != nil {
+ return nil, err
}
-
- return &AESEncryptor{key: key}, nil
+ if err := rejectReusedSecretRoots(cfg, legacyKey, domainKeys); err != nil {
+ return nil, err
+ }
+ return &AESEncryptor{legacyKey: legacyKey, domainKeys: domainKeys}, nil
}
// Encrypt encrypts plaintext using AES-256-GCM
// Output format: base64(nonce + ciphertext + tag)
func (e *AESEncryptor) Encrypt(plaintext string) (string, error) {
- block, err := aes.NewCipher(e.key)
+ if len(e.legacyKey) == 0 {
+ return "", fmt.Errorf("legacy TOTP_ENCRYPTION_KEY is unavailable")
+ }
+ return encryptAESGCM(e.legacyKey, nil, plaintext)
+}
+
+// Decrypt decrypts ciphertext using AES-256-GCM
+func (e *AESEncryptor) Decrypt(ciphertext string) (string, error) {
+ if isDomainCiphertext(ciphertext) {
+ return "", fmt.Errorf("domain-bound ciphertext requires DecryptForDomain")
+ }
+ if len(e.legacyKey) == 0 {
+ return "", fmt.Errorf("legacy TOTP_ENCRYPTION_KEY is unavailable")
+ }
+ return decryptAESGCM(e.legacyKey, nil, ciphertext)
+}
+
+// EncryptForDomain derives an independent AES key and authenticates the domain
+// as GCM associated data. The expected domain is deliberately not encoded in
+// the envelope, so moving ciphertext cannot select its original decryptor.
+func (e *AESEncryptor) EncryptForDomain(domain, plaintext string) (string, error) {
+ domain, err := validateSecretDomain(domain)
+ if err != nil {
+ return "", err
+ }
+ domainKey, err := e.deriveDomainKey(domain)
+ if err != nil {
+ return "", err
+ }
+ encoded, err := encryptAESGCM(domainKey, secretDomainAAD(domain), plaintext)
if err != nil {
- return "", fmt.Errorf("create cipher: %w", err)
+ return "", err
}
+ return secretDomainCiphertextPrefix + encoded, nil
+}
- gcm, err := cipher.NewGCM(block)
+// DecryptForDomain rejects legacy unbound ciphertext. Legacy data must be
+// migrated before workers and HTTP listeners start.
+func (e *AESEncryptor) DecryptForDomain(domain, ciphertext string) (string, error) {
+ domain, err := validateSecretDomain(domain)
+ if err != nil {
+ return "", err
+ }
+ if !strings.HasPrefix(ciphertext, secretDomainCiphertextPrefix) {
+ if strings.HasPrefix(ciphertext, legacySecretDomainCiphertextPrefixV2) {
+ return "", fmt.Errorf("v2 shared-root ciphertext requires security migration")
+ }
+ return "", fmt.Errorf("legacy unbound ciphertext requires security migration")
+ }
+ encoded := strings.TrimPrefix(ciphertext, secretDomainCiphertextPrefix)
+ domainKey, err := e.deriveDomainKey(domain)
if err != nil {
- return "", fmt.Errorf("create gcm: %w", err)
+ return "", err
}
+ return decryptAESGCM(domainKey, secretDomainAAD(domain), encoded)
+}
- // Generate a random nonce
- nonce := make([]byte, gcm.NonceSize())
- if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
- return "", fmt.Errorf("generate nonce: %w", err)
+func (e *AESEncryptor) deriveDomainKey(domain string) ([]byte, error) {
+ root, ok := e.domainKeys[domain]
+ if !ok || len(root) == 0 {
+ return nil, fmt.Errorf("unsupported or unconfigured secret domain %q", domain)
+ }
+ mac := hmac.New(sha256.New, root)
+ _, _ = mac.Write([]byte(secretDomainKDFContext))
+ _, _ = mac.Write([]byte(domain))
+ return mac.Sum(nil), nil
+}
+
+func (e *AESEncryptor) decryptV2ForMigration(domain, ciphertext string) (string, error) {
+ domain, err := validateSecretDomain(domain)
+ if err != nil {
+ return "", err
+ }
+ if !strings.HasPrefix(ciphertext, legacySecretDomainCiphertextPrefixV2) {
+ return "", fmt.Errorf("not a v2 shared-root ciphertext")
}
+ if len(e.legacyKey) == 0 {
+ return "", fmt.Errorf("legacy TOTP_ENCRYPTION_KEY is unavailable")
+ }
+ encoded := strings.TrimPrefix(ciphertext, legacySecretDomainCiphertextPrefixV2)
+ return decryptAESGCM(deriveLegacyDomainKeyV2(e.legacyKey, domain), legacySecretDomainAADV2(domain), encoded)
+}
- // Encrypt the plaintext
- // Seal appends the ciphertext and tag to the nonce
- ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
+func deriveLegacyDomainKeyV2(root []byte, domain string) []byte {
+ mac := hmac.New(sha256.New, root)
+ _, _ = mac.Write([]byte(legacySecretDomainKDFContextV2))
+ _, _ = mac.Write([]byte(domain))
+ return mac.Sum(nil)
+}
- // Encode as base64
- return base64.StdEncoding.EncodeToString(ciphertext), nil
+func legacySecretDomainAADV2(domain string) []byte {
+ return []byte(legacySecretDomainKDFContextV2 + domain)
}
-// Decrypt decrypts ciphertext using AES-256-GCM
-func (e *AESEncryptor) Decrypt(ciphertext string) (string, error) {
- // Decode from base64
- data, err := base64.StdEncoding.DecodeString(ciphertext)
+func configuredDomainKeys(cfg *config.Config) (map[string][]byte, error) {
+ configured := []struct {
+ domain string
+ name string
+ value string
+ }{
+ {service.SecretDomainTOTP, "SECRET_ENCRYPTION_TOTP_SECRET_KEY", cfg.SecretEncryption.TOTPSecretKey},
+ {service.SecretDomainTOTPCache, "SECRET_ENCRYPTION_TOTP_CACHE_KEY", cfg.SecretEncryption.TOTPCacheKey},
+ {service.SecretDomainAccountCredential, "SECRET_ENCRYPTION_ACCOUNT_CREDENTIAL_KEY", cfg.SecretEncryption.AccountCredentialKey},
+ {service.SecretDomainBackupS3, "SECRET_ENCRYPTION_BACKUP_S3_KEY", cfg.SecretEncryption.BackupS3Key},
+ {service.SecretDomainContentModeration, "SECRET_ENCRYPTION_CONTENT_MODERATION_KEY", cfg.SecretEncryption.ContentModerationKey},
+ {service.SecretDomainChannelMonitor, "SECRET_ENCRYPTION_CHANNEL_MONITOR_KEY", cfg.SecretEncryption.ChannelMonitorKey},
+ {service.SecretDomainPaymentProvider, "SECRET_ENCRYPTION_PAYMENT_PROVIDER_KEY", cfg.SecretEncryption.PaymentProviderKey},
+ {service.SecretDomainProxyCredential, "SECRET_ENCRYPTION_PROXY_CREDENTIAL_KEY", cfg.SecretEncryption.ProxyCredentialKey},
+ {service.SecretDomainSchedulerCache, "SECRET_ENCRYPTION_SCHEDULER_CACHE_KEY", cfg.SecretEncryption.SchedulerCacheKey},
+ {service.SecretDomainOAuthTokenCache, "SECRET_ENCRYPTION_OAUTH_TOKEN_CACHE_KEY", cfg.SecretEncryption.OAuthTokenCacheKey},
+ {service.SecretDomainJWTHMAC, "SECRET_ENCRYPTION_JWT_HMAC_KEY", cfg.SecretEncryption.JWTHMACKey},
+ {service.SecretDomainSettingSecret, "SECRET_ENCRYPTION_SETTING_SECRET_KEY", cfg.SecretEncryption.SettingSecretKey},
+ }
+ keys := make(map[string][]byte, len(configured))
+ for _, item := range configured {
+ key, err := decodeSecretRoot(item.name, item.value)
+ if err != nil {
+ return nil, err
+ }
+ keys[item.domain] = key
+ }
+ return keys, nil
+}
+
+func configuredLegacySecretKey(cfg *config.Config) ([]byte, error) {
+ if !cfg.Totp.EncryptionKeyConfigured {
+ return nil, nil
+ }
+ return decodeSecretRoot("TOTP_ENCRYPTION_KEY", cfg.Totp.EncryptionKey)
+}
+
+func decodeSecretRoot(name, value string) ([]byte, error) {
+ if strings.TrimSpace(value) == "" {
+ return nil, fmt.Errorf("%s must be explicitly configured and stable", name)
+ }
+ key, err := hex.DecodeString(value)
if err != nil {
- return "", fmt.Errorf("decode base64: %w", err)
+ return nil, fmt.Errorf("invalid %s: %w", name, err)
+ }
+ if len(key) != 32 {
+ return nil, fmt.Errorf("%s must be 32 bytes (64 hex chars), got %d bytes", name, len(key))
+ }
+ return key, nil
+}
+
+func rejectReusedSecretRoots(cfg *config.Config, legacyKey []byte, domainKeys map[string][]byte) error {
+ jwtSecret := strings.TrimSpace(cfg.JWT.Secret)
+ if jwtSecret != "" && subtle.ConstantTimeCompare(
+ []byte(jwtSecret),
+ []byte(strings.TrimSpace(cfg.SecretEncryption.JWTHMACKey)),
+ ) == 1 {
+ return fmt.Errorf("SECRET_ENCRYPTION_JWT_HMAC_KEY must be distinct from JWT_SECRET")
+ }
+ seen := make(map[string]string, len(domainKeys)+2)
+ if len(legacyKey) > 0 {
+ seen[string(legacyKey)] = "TOTP_ENCRYPTION_KEY"
+ }
+ if cfg.APIKey.EncryptionKeyConfigured {
+ apiKeyRoot, err := decodeSecretRoot("API_KEY_ENCRYPTION_KEY", cfg.APIKey.EncryptionKey)
+ if err != nil {
+ return err
+ }
+ seen[string(apiKeyRoot)] = "API_KEY_ENCRYPTION_KEY"
}
+ for domain, key := range domainKeys {
+ if previous, ok := seen[string(key)]; ok {
+ return fmt.Errorf("secret root for %s must be distinct from %s", domain, previous)
+ }
+ seen[string(key)] = domain
+ }
+ return nil
+}
- block, err := aes.NewCipher(e.key)
+func isDomainCiphertext(ciphertext string) bool {
+ return strings.HasPrefix(ciphertext, secretDomainCiphertextPrefix) ||
+ strings.HasPrefix(ciphertext, legacySecretDomainCiphertextPrefixV2)
+}
+
+func validateSecretDomain(domain string) (string, error) {
+ trimmed := strings.TrimSpace(domain)
+ if trimmed == "" || trimmed != domain {
+ return "", fmt.Errorf("secret domain must be non-empty and canonical")
+ }
+ return domain, nil
+}
+
+func secretDomainAAD(domain string) []byte {
+ return []byte(secretDomainKDFContext + domain)
+}
+
+func encryptAESGCM(key, aad []byte, plaintext string) (string, error) {
+ gcm, err := newGCM(key)
if err != nil {
- return "", fmt.Errorf("create cipher: %w", err)
+ return "", err
+ }
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return "", fmt.Errorf("generate nonce: %w", err)
}
+ sealed := gcm.Seal(nonce, nonce, []byte(plaintext), aad)
+ return base64.StdEncoding.EncodeToString(sealed), nil
+}
- gcm, err := cipher.NewGCM(block)
+func decryptAESGCM(key, aad []byte, ciphertext string) (string, error) {
+ data, err := base64.StdEncoding.DecodeString(ciphertext)
+ if err != nil {
+ return "", fmt.Errorf("decode base64: %w", err)
+ }
+ gcm, err := newGCM(key)
if err != nil {
- return "", fmt.Errorf("create gcm: %w", err)
+ return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", fmt.Errorf("ciphertext too short")
}
-
- // Extract nonce and ciphertext
nonce, ciphertextData := data[:nonceSize], data[nonceSize:]
-
- // Decrypt
- plaintext, err := gcm.Open(nil, nonce, ciphertextData, nil)
+ plaintext, err := gcm.Open(nil, nonce, ciphertextData, aad)
if err != nil {
return "", fmt.Errorf("decrypt: %w", err)
}
-
return string(plaintext), nil
}
+
+func newGCM(key []byte) (cipher.AEAD, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, fmt.Errorf("create cipher: %w", err)
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, fmt.Errorf("create gcm: %w", err)
+ }
+ return gcm, nil
+}
diff --git a/backend/internal/repository/aes_encryptor_security_test.go b/backend/internal/repository/aes_encryptor_security_test.go
new file mode 100644
index 00000000000..bf763d0c1a2
--- /dev/null
+++ b/backend/internal/repository/aes_encryptor_security_test.go
@@ -0,0 +1,208 @@
+package repository
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewAESEncryptorRejectsAutoGeneratedEphemeralKey(t *testing.T) {
+ cfg := domainKeyTestConfig()
+ cfg.SecretEncryption.ContentModerationKey = ""
+ _, err := NewAESEncryptor(cfg)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "SECRET_ENCRYPTION_CONTENT_MODERATION_KEY")
+}
+
+func TestNewAESEncryptorRejectsMissingProxyCredentialKey(t *testing.T) {
+ cfg := domainKeyTestConfig()
+ cfg.SecretEncryption.ProxyCredentialKey = ""
+
+ _, err := NewAESEncryptor(cfg)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "SECRET_ENCRYPTION_PROXY_CREDENTIAL_KEY")
+}
+
+func TestNewAESEncryptorRejectsMissingSchedulerCacheKey(t *testing.T) {
+ cfg := domainKeyTestConfig()
+ cfg.SecretEncryption.SchedulerCacheKey = ""
+
+ _, err := NewAESEncryptor(cfg)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "SECRET_ENCRYPTION_SCHEDULER_CACHE_KEY")
+}
+
+func TestNewAESEncryptorRejectsMissingOAuthTokenCacheKey(t *testing.T) {
+ cfg := domainKeyTestConfig()
+ cfg.SecretEncryption.OAuthTokenCacheKey = ""
+
+ _, err := NewAESEncryptor(cfg)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "SECRET_ENCRYPTION_OAUTH_TOKEN_CACHE_KEY")
+}
+
+func TestNewAESEncryptorRejectsMissingJWTHMACKey(t *testing.T) {
+ cfg := domainKeyTestConfig()
+ cfg.SecretEncryption.JWTHMACKey = ""
+
+ _, err := NewAESEncryptor(cfg)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "SECRET_ENCRYPTION_JWT_HMAC_KEY")
+}
+
+func TestNewAESEncryptorRejectsMissingSettingSecretKey(t *testing.T) {
+ cfg := domainKeyTestConfig()
+ cfg.SecretEncryption.SettingSecretKey = ""
+
+ _, err := NewAESEncryptor(cfg)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "SECRET_ENCRYPTION_SETTING_SECRET_KEY")
+}
+
+func TestNewAESEncryptorRejectsJWTHMACRootReusedAsSigningSecret(t *testing.T) {
+ cfg := domainKeyTestConfig()
+ cfg.JWT.Secret = cfg.SecretEncryption.JWTHMACKey
+
+ _, err := NewAESEncryptor(cfg)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "distinct from JWT_SECRET")
+}
+
+func TestNewAESEncryptorRejectsReusedOAuthTokenCacheKey(t *testing.T) {
+ cfg := domainKeyTestConfig()
+ cfg.SecretEncryption.OAuthTokenCacheKey = cfg.SecretEncryption.SchedulerCacheKey
+
+ _, err := NewAESEncryptor(cfg)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), service.SecretDomainOAuthTokenCache)
+ require.Contains(t, err.Error(), service.SecretDomainSchedulerCache)
+}
+
+func TestNewAESEncryptorUsesExplicitStableKeyAcrossInstances(t *testing.T) {
+ cfg := domainKeyTestConfig()
+ first, err := NewAESEncryptor(cfg)
+ require.NoError(t, err)
+ second, err := NewAESEncryptor(cfg)
+ require.NoError(t, err)
+
+ ciphertext, err := first.Encrypt("persistent-secret")
+ require.NoError(t, err)
+ plaintext, err := second.Decrypt(ciphertext)
+ require.NoError(t, err)
+ require.Equal(t, "persistent-secret", plaintext)
+}
+
+func TestAESEncryptorBindsCiphertextToSecretDomain(t *testing.T) {
+ cfg := domainKeyTestConfig()
+ firstRaw, err := NewAESEncryptor(cfg)
+ require.NoError(t, err)
+ secondRaw, err := NewAESEncryptor(cfg)
+ require.NoError(t, err)
+ first := firstRaw.(*AESEncryptor)
+ second := secondRaw.(*AESEncryptor)
+
+ ciphertext, err := first.EncryptForDomain(service.SecretDomainTOTP, "domain-secret")
+ require.NoError(t, err)
+ require.True(t, strings.HasPrefix(ciphertext, secretDomainCiphertextPrefix))
+
+ plaintext, err := second.DecryptForDomain(service.SecretDomainTOTP, ciphertext)
+ require.NoError(t, err)
+ require.Equal(t, "domain-secret", plaintext)
+
+ _, err = second.DecryptForDomain(service.SecretDomainContentModeration, ciphertext)
+ require.Error(t, err)
+ _, err = second.Decrypt(ciphertext)
+ require.Error(t, err)
+}
+
+func TestNewAESEncryptorRejectsReusedDomainRoots(t *testing.T) {
+ cfg := domainKeyTestConfig()
+ cfg.SecretEncryption.BackupS3Key = cfg.SecretEncryption.AccountCredentialKey
+
+ _, err := NewAESEncryptor(cfg)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "must be distinct")
+}
+
+func TestNewAESEncryptorRejectsDomainRootReusedFromLegacyRoot(t *testing.T) {
+ cfg := domainKeyTestConfig()
+ cfg.SecretEncryption.TOTPSecretKey = cfg.Totp.EncryptionKey
+
+ _, err := NewAESEncryptor(cfg)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "TOTP_ENCRYPTION_KEY")
+}
+
+func TestAESEncryptorDomainRootsAreIndependentlyReplaceable(t *testing.T) {
+ firstConfig := domainKeyTestConfig()
+ secondConfig := domainKeyTestConfig()
+ secondConfig.SecretEncryption.ContentModerationKey = strings.Repeat("e", 64)
+
+ firstRaw, err := NewAESEncryptor(firstConfig)
+ require.NoError(t, err)
+ secondRaw, err := NewAESEncryptor(secondConfig)
+ require.NoError(t, err)
+ first := firstRaw.(service.DomainSecretEncryptor)
+ second := secondRaw.(service.DomainSecretEncryptor)
+
+ totpCiphertext, err := first.EncryptForDomain(service.SecretDomainTOTP, "totp-value")
+ require.NoError(t, err)
+ moderationCiphertext, err := first.EncryptForDomain(service.SecretDomainContentModeration, "moderation-value")
+ require.NoError(t, err)
+
+ got, err := second.DecryptForDomain(service.SecretDomainTOTP, totpCiphertext)
+ require.NoError(t, err)
+ require.Equal(t, "totp-value", got)
+ _, err = second.DecryptForDomain(service.SecretDomainContentModeration, moderationCiphertext)
+ require.Error(t, err)
+}
+
+func TestAESEncryptorAllowsLegacyRootRetirementAfterV3Cutover(t *testing.T) {
+ cfg := domainKeyTestConfig()
+ cfg.Totp.EncryptionKey = ""
+ cfg.Totp.EncryptionKeyConfigured = false
+
+ raw, err := NewAESEncryptor(cfg)
+ require.NoError(t, err)
+ encryptor := raw.(service.DomainSecretEncryptor)
+ ciphertext, err := encryptor.EncryptForDomain(service.SecretDomainTOTP, "v3-only")
+ require.NoError(t, err)
+ plaintext, err := encryptor.DecryptForDomain(service.SecretDomainTOTP, ciphertext)
+ require.NoError(t, err)
+ require.Equal(t, "v3-only", plaintext)
+ _, err = raw.Decrypt("legacy-ciphertext")
+ require.ErrorContains(t, err, "legacy TOTP_ENCRYPTION_KEY is unavailable")
+}
+
+func domainKeyTestConfig() *config.Config {
+ return &config.Config{
+ Totp: config.TotpConfig{
+ EncryptionKey: strings.Repeat("a", 64),
+ EncryptionKeyConfigured: true,
+ },
+ SecretEncryption: config.SecretEncryptionConfig{
+ TOTPSecretKey: strings.Repeat("1", 64),
+ TOTPCacheKey: strings.Repeat("2", 64),
+ AccountCredentialKey: strings.Repeat("3", 64),
+ BackupS3Key: strings.Repeat("4", 64),
+ ContentModerationKey: strings.Repeat("5", 64),
+ ChannelMonitorKey: strings.Repeat("6", 64),
+ PaymentProviderKey: strings.Repeat("7", 64),
+ ProxyCredentialKey: strings.Repeat("8", 64),
+ SchedulerCacheKey: strings.Repeat("9", 64),
+ OAuthTokenCacheKey: strings.Repeat("b", 64),
+ JWTHMACKey: strings.Repeat("c", 64),
+ SettingSecretKey: strings.Repeat("d", 64),
+ },
+ }
+}
diff --git a/backend/internal/repository/affiliate_repo.go b/backend/internal/repository/affiliate_repo.go
index ef89e5b6a00..df9c00c9221 100644
--- a/backend/internal/repository/affiliate_repo.go
+++ b/backend/internal/repository/affiliate_repo.go
@@ -22,6 +22,34 @@ const (
var affiliateCodeCharset = []byte("ABCDEFGHJKLMNPQRSTUVWXYZ23456789")
+const affiliateUserOverviewSQL = `
+SELECT ua.user_id,
+ COALESCE(u.email, ''),
+ COALESCE(u.username, ''),
+ ua.aff_code,
+ COALESCE(ua.aff_rebate_rate_percent, 0)::double precision,
+ (ua.aff_rebate_rate_percent IS NOT NULL) AS has_custom_rate,
+ ua.aff_count,
+ COALESCE(rebated.rebated_invitee_count, 0),
+ (ua.aff_quota + COALESCE(matured.matured_frozen_quota, 0))::double precision,
+ ua.aff_history_quota::double precision
+FROM user_affiliates ua
+JOIN users u ON u.id = ua.user_id
+LEFT JOIN (
+ SELECT user_id, COUNT(DISTINCT source_user_id)::integer AS rebated_invitee_count
+ FROM user_affiliate_ledger
+ WHERE action = 'accrue' AND source_user_id IS NOT NULL
+ GROUP BY user_id
+) rebated ON rebated.user_id = ua.user_id
+LEFT JOIN (
+ SELECT user_id, COALESCE(SUM(amount), 0)::double precision AS matured_frozen_quota
+ FROM user_affiliate_ledger
+ WHERE action = 'accrue' AND frozen_until IS NOT NULL AND frozen_until <= NOW()
+ GROUP BY user_id
+) matured ON matured.user_id = ua.user_id
+WHERE ua.user_id = $1
+LIMIT 1`
+
type affiliateQueryExecer interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
@@ -86,7 +114,7 @@ func (r *affiliateRepository) BindInviter(ctx context.Context, userID, inviterID
return bound, nil
}
-func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int) (bool, error) {
+func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int, sourceOrderID *int64) (bool, error) {
if amount <= 0 {
return false, nil
}
@@ -112,15 +140,15 @@ func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, invite
if freezeHours > 0 {
if _, err = txClient.ExecContext(txCtx, `
-INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, frozen_until, created_at, updated_at)
-VALUES ($1, 'accrue', $2, $3, NOW() + make_interval(hours => $4), NOW(), NOW())`,
- inviterID, amount, inviteeUserID, freezeHours); err != nil {
+INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, source_order_id, frozen_until, created_at, updated_at)
+VALUES ($1, 'accrue', $2, $3, $4, NOW() + make_interval(hours => $5), NOW(), NOW())`,
+ inviterID, amount, inviteeUserID, nullableInt64Arg(sourceOrderID), freezeHours); err != nil {
return fmt.Errorf("insert affiliate accrue ledger: %w", err)
}
} else {
if _, err = txClient.ExecContext(txCtx, `
-INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, created_at, updated_at)
-VALUES ($1, 'accrue', $2, $3, NOW(), NOW())`, inviterID, amount, inviteeUserID); err != nil {
+INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, source_order_id, created_at, updated_at)
+VALUES ($1, 'accrue', $2, $3, $4, NOW(), NOW())`, inviterID, amount, inviteeUserID, nullableInt64Arg(sourceOrderID)); err != nil {
return fmt.Errorf("insert affiliate accrue ledger: %w", err)
}
}
@@ -275,9 +303,32 @@ FROM cleared`, userID)
return err
}
+ snapshot, err := queryAffiliateTransferSnapshot(txCtx, txClient, userID)
+ if err != nil {
+ return err
+ }
+
if _, err = txClient.ExecContext(txCtx, `
-INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, created_at, updated_at)
-VALUES ($1, 'transfer', $2, NULL, NOW(), NOW())`, userID, transferred); err != nil {
+INSERT INTO user_affiliate_ledger (
+ user_id,
+ action,
+ amount,
+ source_user_id,
+ balance_after,
+ aff_quota_after,
+ aff_frozen_quota_after,
+ aff_history_quota_after,
+ created_at,
+ updated_at
+)
+VALUES ($1, 'transfer', $2, NULL, $3, $4, $5, $6, NOW(), NOW())`,
+ userID,
+ transferred,
+ snapshot.BalanceAfter,
+ snapshot.AvailableQuotaAfter,
+ snapshot.FrozenQuotaAfter,
+ snapshot.HistoryQuotaAfter,
+ ); err != nil {
return fmt.Errorf("insert affiliate transfer ledger: %w", err)
}
@@ -332,6 +383,349 @@ LIMIT $2`, inviterID, limit)
return invitees, nil
}
+func (r *affiliateRepository) ListAffiliateInviteRecords(ctx context.Context, filter service.AffiliateRecordFilter) ([]service.AffiliateInviteRecord, int64, error) {
+ client := clientFromContext(ctx, r.client)
+ where, args := buildAffiliateRecordWhere(filter, "ua.created_at", []string{
+ "inviter.email", "inviter.username", "invitee.email", "invitee.username",
+ "ua.inviter_id::text", "ua.user_id::text", "inviter_aff.aff_code",
+ })
+
+ total, err := queryAffiliateRecordCount(ctx, client, `
+SELECT COUNT(*)
+FROM user_affiliates ua
+JOIN users invitee ON invitee.id = ua.user_id
+JOIN users inviter ON inviter.id = ua.inviter_id
+JOIN user_affiliates inviter_aff ON inviter_aff.user_id = ua.inviter_id
+`+where, args...)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{
+ "inviter": "inviter.email",
+ "invitee": "invitee.email",
+ "aff_code": "inviter_aff.aff_code",
+ "total_rebate": "total_rebate",
+ "created_at": "ua.created_at",
+ }, "ua.created_at")
+ args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
+ rows, err := client.QueryContext(ctx, `
+SELECT ua.inviter_id,
+ COALESCE(inviter.email, ''),
+ COALESCE(inviter.username, ''),
+ ua.user_id,
+ COALESCE(invitee.email, ''),
+ COALESCE(invitee.username, ''),
+ COALESCE(inviter_aff.aff_code, ''),
+ COALESCE(SUM(ual.amount), 0)::double precision AS total_rebate,
+ ua.created_at
+FROM user_affiliates ua
+JOIN users invitee ON invitee.id = ua.user_id
+JOIN users inviter ON inviter.id = ua.inviter_id
+JOIN user_affiliates inviter_aff ON inviter_aff.user_id = ua.inviter_id
+LEFT JOIN user_affiliate_ledger ual
+ ON ual.user_id = ua.inviter_id
+ AND ual.source_user_id = ua.user_id
+ AND ual.action = 'accrue'
+`+where+`
+GROUP BY ua.inviter_id, inviter.email, inviter.username, ua.user_id, invitee.email, invitee.username, inviter_aff.aff_code, ua.created_at
+`+orderBy+`
+LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ items := make([]service.AffiliateInviteRecord, 0)
+ for rows.Next() {
+ var item service.AffiliateInviteRecord
+ if err := rows.Scan(
+ &item.InviterID,
+ &item.InviterEmail,
+ &item.InviterUsername,
+ &item.InviteeID,
+ &item.InviteeEmail,
+ &item.InviteeUsername,
+ &item.AffCode,
+ &item.TotalRebate,
+ &item.CreatedAt,
+ ); err != nil {
+ return nil, 0, err
+ }
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, 0, err
+ }
+ return items, total, nil
+}
+
+func (r *affiliateRepository) ListAffiliateRebateRecords(ctx context.Context, filter service.AffiliateRecordFilter) ([]service.AffiliateRebateRecord, int64, error) {
+ client := clientFromContext(ctx, r.client)
+ where, args := buildAffiliateRecordWhere(filter, "ual.created_at", []string{
+ "inviter.email", "inviter.username", "invitee.email", "invitee.username",
+ "po.id::text", "po.out_trade_no", "po.payment_type", "po.status",
+ })
+ baseJoin := `
+FROM user_affiliate_ledger ual
+JOIN payment_orders po ON po.id = ual.source_order_id
+JOIN users invitee ON invitee.id = ual.source_user_id
+JOIN users inviter ON inviter.id = ual.user_id
+WHERE ual.action = 'accrue'
+ AND ual.source_order_id IS NOT NULL`
+ if where != "" {
+ where = strings.Replace(where, "WHERE ", " AND ", 1)
+ }
+
+ total, err := queryAffiliateRecordCount(ctx, client, "SELECT COUNT(*) "+baseJoin+where, args...)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{
+ "order": "po.id",
+ "inviter": "inviter.email",
+ "invitee": "invitee.email",
+ "order_amount": "po.amount",
+ "pay_amount": "po.pay_amount",
+ "rebate_amount": "ual.amount",
+ "payment_type": "po.payment_type",
+ "order_status": "po.status",
+ "created_at": "ual.created_at",
+ }, "ual.created_at")
+ args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
+ rows, err := client.QueryContext(ctx, `
+SELECT po.id,
+ po.out_trade_no,
+ ual.user_id,
+ COALESCE(inviter.email, ''),
+ COALESCE(inviter.username, ''),
+ ual.source_user_id,
+ COALESCE(invitee.email, ''),
+ COALESCE(invitee.username, ''),
+ po.amount::double precision,
+ po.pay_amount::double precision,
+ ual.amount::double precision,
+ po.payment_type,
+ po.status,
+ ual.created_at
+`+baseJoin+where+`
+`+orderBy+`
+LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ items := make([]service.AffiliateRebateRecord, 0)
+ for rows.Next() {
+ var item service.AffiliateRebateRecord
+ if err := rows.Scan(
+ &item.OrderID,
+ &item.OutTradeNo,
+ &item.InviterID,
+ &item.InviterEmail,
+ &item.InviterUsername,
+ &item.InviteeID,
+ &item.InviteeEmail,
+ &item.InviteeUsername,
+ &item.OrderAmount,
+ &item.PayAmount,
+ &item.RebateAmount,
+ &item.PaymentType,
+ &item.OrderStatus,
+ &item.CreatedAt,
+ ); err != nil {
+ return nil, 0, err
+ }
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, 0, err
+ }
+ return items, total, nil
+}
+
+func (r *affiliateRepository) ListAffiliateTransferRecords(ctx context.Context, filter service.AffiliateRecordFilter) ([]service.AffiliateTransferRecord, int64, error) {
+ client := clientFromContext(ctx, r.client)
+ where, args := buildAffiliateRecordWhere(filter, "ual.created_at", []string{
+ "u.email", "u.username", "u.id::text",
+ })
+ baseJoin := `
+FROM user_affiliate_ledger ual
+JOIN users u ON u.id = ual.user_id
+WHERE ual.action = 'transfer'`
+ if where != "" {
+ where = strings.Replace(where, "WHERE ", " AND ", 1)
+ }
+
+ total, err := queryAffiliateRecordCount(ctx, client, "SELECT COUNT(*) "+baseJoin+where, args...)
+ if err != nil {
+ return nil, 0, err
+ }
+
+ orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{
+ "user": "u.email",
+ "amount": "ual.amount",
+ "balance_after": "ual.balance_after",
+ "available_quota_after": "ual.aff_quota_after",
+ "frozen_quota_after": "ual.aff_frozen_quota_after",
+ "history_quota_after": "ual.aff_history_quota_after",
+ "created_at": "ual.created_at",
+ }, "ual.created_at")
+ args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
+ rows, err := client.QueryContext(ctx, `
+SELECT ual.id,
+ ual.user_id,
+ COALESCE(u.email, ''),
+ COALESCE(u.username, ''),
+ ual.amount::double precision,
+ ual.balance_after::double precision,
+ ual.aff_quota_after::double precision,
+ ual.aff_frozen_quota_after::double precision,
+ ual.aff_history_quota_after::double precision,
+ ual.created_at
+`+baseJoin+where+`
+`+orderBy+`
+LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ items := make([]service.AffiliateTransferRecord, 0)
+ for rows.Next() {
+ var item service.AffiliateTransferRecord
+ var balanceAfter sql.NullFloat64
+ var availableQuotaAfter sql.NullFloat64
+ var frozenQuotaAfter sql.NullFloat64
+ var historyQuotaAfter sql.NullFloat64
+ if err := rows.Scan(
+ &item.LedgerID,
+ &item.UserID,
+ &item.UserEmail,
+ &item.Username,
+ &item.Amount,
+ &balanceAfter,
+ &availableQuotaAfter,
+ &frozenQuotaAfter,
+ &historyQuotaAfter,
+ &item.CreatedAt,
+ ); err != nil {
+ return nil, 0, err
+ }
+ item.BalanceAfter = nullableFloat64Ptr(balanceAfter)
+ item.AvailableQuotaAfter = nullableFloat64Ptr(availableQuotaAfter)
+ item.FrozenQuotaAfter = nullableFloat64Ptr(frozenQuotaAfter)
+ item.HistoryQuotaAfter = nullableFloat64Ptr(historyQuotaAfter)
+ item.SnapshotAvailable = balanceAfter.Valid &&
+ availableQuotaAfter.Valid &&
+ frozenQuotaAfter.Valid &&
+ historyQuotaAfter.Valid
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, 0, err
+ }
+ return items, total, nil
+}
+
+func (r *affiliateRepository) GetAffiliateUserOverview(ctx context.Context, userID int64) (*service.AffiliateUserOverview, error) {
+ if userID <= 0 {
+ return nil, service.ErrUserNotFound
+ }
+ client := clientFromContext(ctx, r.client)
+ rows, err := client.QueryContext(ctx, affiliateUserOverviewSQL, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ if !rows.Next() {
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return nil, service.ErrUserNotFound
+ }
+
+ var overview service.AffiliateUserOverview
+ var customRate float64
+ var hasCustomRate bool
+ if err := rows.Scan(
+ &overview.UserID,
+ &overview.Email,
+ &overview.Username,
+ &overview.AffCode,
+ &customRate,
+ &hasCustomRate,
+ &overview.InvitedCount,
+ &overview.RebatedInviteeCount,
+ &overview.AvailableQuota,
+ &overview.HistoryQuota,
+ ); err != nil {
+ return nil, err
+ }
+ if hasCustomRate {
+ overview.RebateRatePercent = customRate
+ overview.RebateRateCustom = true
+ }
+ return &overview, rows.Err()
+}
+
+func buildAffiliateRecordWhere(filter service.AffiliateRecordFilter, timeColumn string, searchColumns []string) (string, []any) {
+ clauses := make([]string, 0, 3)
+ args := make([]any, 0, 3)
+ if filter.StartAt != nil {
+ args = append(args, *filter.StartAt)
+ clauses = append(clauses, fmt.Sprintf("%s >= $%d", timeColumn, len(args)))
+ }
+ if filter.EndAt != nil {
+ args = append(args, *filter.EndAt)
+ clauses = append(clauses, fmt.Sprintf("%s <= $%d", timeColumn, len(args)))
+ }
+ search := strings.TrimSpace(filter.Search)
+ if search != "" && len(searchColumns) > 0 {
+ args = append(args, "%"+strings.ToLower(search)+"%")
+ parts := make([]string, 0, len(searchColumns))
+ for _, col := range searchColumns {
+ parts = append(parts, fmt.Sprintf("LOWER(%s) LIKE $%d", col, len(args)))
+ }
+ clauses = append(clauses, "("+strings.Join(parts, " OR ")+")")
+ }
+ if len(clauses) == 0 {
+ return "", args
+ }
+ return "WHERE " + strings.Join(clauses, " AND "), args
+}
+
+func buildAffiliateRecordOrderBy(filter service.AffiliateRecordFilter, sortColumns map[string]string, fallbackColumn string) string {
+ column := sortColumns[filter.SortBy]
+ if column == "" {
+ column = fallbackColumn
+ }
+ direction := "DESC"
+ if !filter.SortDesc {
+ direction = "ASC"
+ }
+ return "ORDER BY " + column + " " + direction + " NULLS LAST"
+}
+
+func queryAffiliateRecordCount(ctx context.Context, client affiliateQueryExecer, query string, args ...any) (int64, error) {
+ rows, err := client.QueryContext(ctx, query, args...)
+ if err != nil {
+ return 0, err
+ }
+ defer func() { _ = rows.Close() }()
+ if !rows.Next() {
+ return 0, rows.Err()
+ }
+ var total int64
+ if err := rows.Scan(&total); err != nil {
+ return 0, err
+ }
+ return total, rows.Err()
+}
+
func (r *affiliateRepository) withTx(ctx context.Context, fn func(txCtx context.Context, txClient *dbent.Client) error) error {
if tx := dbent.TxFromContext(ctx); tx != nil {
return fn(ctx, tx.Client())
@@ -516,6 +910,113 @@ func queryUserBalance(ctx context.Context, client affiliateQueryExecer, userID i
return balance, nil
}
+func (r *affiliateRepository) HasInviteeFirstOrderRebate(ctx context.Context, inviteeUserID int64) (bool, error) {
+ client := clientFromContext(ctx, r.client)
+ rows, err := client.QueryContext(ctx,
+ `SELECT EXISTS(SELECT 1 FROM user_affiliate_ledger WHERE source_user_id = $1 AND action = 'invitee_first_order')`,
+ inviteeUserID)
+ if err != nil {
+ return false, fmt.Errorf("check invitee first order rebate: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ if rows.Next() {
+ var exists bool
+ if err := rows.Scan(&exists); err != nil {
+ return false, err
+ }
+ return exists, nil
+ }
+ return false, nil
+}
+
+func (r *affiliateRepository) AccrueInviteeFirstOrderQuota(ctx context.Context, inviteeUserID int64, amount float64) (bool, error) {
+ client := clientFromContext(ctx, r.client)
+ // 原子性写入 ledger + 更新 aff_quota,同时防止重复(UNIQUE 约束或 INSERT ... WHERE NOT EXISTS)
+ rows, err := client.QueryContext(ctx, `
+WITH ins AS (
+ INSERT INTO user_affiliate_ledger (user_id, source_user_id, amount, action, created_at)
+ SELECT $1, $1, $2, 'invitee_first_order', NOW()
+ WHERE NOT EXISTS (
+ SELECT 1 FROM user_affiliate_ledger WHERE source_user_id = $1 AND action = 'invitee_first_order'
+ )
+ RETURNING 1
+)
+UPDATE user_affiliates SET aff_quota = aff_quota + $2, updated_at = NOW()
+WHERE user_id = $1 AND EXISTS (SELECT 1 FROM ins)
+RETURNING TRUE`, inviteeUserID, amount)
+ if err != nil {
+ return false, fmt.Errorf("accrue invitee first order quota: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ applied := rows.Next()
+ return applied, rows.Err()
+}
+
+func (r *affiliateRepository) GetUserSignupIPPrefix(ctx context.Context, userID int64) (string, error) {
+ client := clientFromContext(ctx, r.client)
+ rows, err := client.QueryContext(ctx, `SELECT COALESCE(signup_ip_prefix, '') FROM users WHERE id = $1`, userID)
+ if err != nil {
+ return "", nil
+ }
+ defer func() { _ = rows.Close() }()
+ if rows.Next() {
+ var prefix string
+ if err := rows.Scan(&prefix); err != nil {
+ return "", nil
+ }
+ return prefix, nil
+ }
+ return "", nil
+}
+
+type affiliateTransferSnapshot struct {
+ BalanceAfter float64
+ AvailableQuotaAfter float64
+ FrozenQuotaAfter float64
+ HistoryQuotaAfter float64
+}
+
+func queryAffiliateTransferSnapshot(ctx context.Context, client affiliateQueryExecer, userID int64) (*affiliateTransferSnapshot, error) {
+ rows, err := client.QueryContext(ctx, `
+SELECT u.balance::double precision,
+ ua.aff_quota::double precision,
+ ua.aff_frozen_quota::double precision,
+ ua.aff_history_quota::double precision
+FROM users u
+JOIN user_affiliates ua ON ua.user_id = u.id
+WHERE u.id = $1
+LIMIT 1`, userID)
+ if err != nil {
+ return nil, fmt.Errorf("query affiliate transfer snapshot: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+
+ if !rows.Next() {
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return nil, service.ErrUserNotFound
+ }
+
+ var snapshot affiliateTransferSnapshot
+ if err := rows.Scan(
+ &snapshot.BalanceAfter,
+ &snapshot.AvailableQuotaAfter,
+ &snapshot.FrozenQuotaAfter,
+ &snapshot.HistoryQuotaAfter,
+ ); err != nil {
+ return nil, err
+ }
+ return &snapshot, rows.Err()
+}
+
+func nullableFloat64Ptr(v sql.NullFloat64) *float64 {
+ if !v.Valid {
+ return nil
+ }
+ return &v.Float64
+}
+
func generateAffiliateCode() (string, error) {
buf := make([]byte, affiliateCodeLength)
if _, err := rand.Read(buf); err != nil {
@@ -674,6 +1175,13 @@ func nullableArg(v *float64) any {
return *v
}
+func nullableInt64Arg(v *int64) any {
+ if v == nil {
+ return nil
+ }
+ return *v
+}
+
// ListUsersWithCustomSettings 列出有专属配置(自定义码或专属比例)的用户。
//
// 单一查询同时处理"无搜索"与"按邮箱/用户名模糊搜索":
diff --git a/backend/internal/repository/affiliate_repo_integration_test.go b/backend/internal/repository/affiliate_repo_integration_test.go
index 697a193b5f5..b01ed528a65 100644
--- a/backend/internal/repository/affiliate_repo_integration_test.go
+++ b/backend/internal/repository/affiliate_repo_integration_test.go
@@ -78,6 +78,26 @@ VALUES ($1, $2, $3, $3, NOW(), NOW())`, u.ID, affCode, 12.34)
ledgerCount := querySingleInt(t, txCtx, client,
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'transfer'", u.ID)
require.Equal(t, 1, ledgerCount)
+
+ rows, err := client.QueryContext(txCtx, `
+SELECT amount::double precision,
+ balance_after::double precision,
+ aff_quota_after::double precision,
+ aff_frozen_quota_after::double precision,
+ aff_history_quota_after::double precision
+FROM user_affiliate_ledger
+WHERE user_id = $1 AND action = 'transfer'
+LIMIT 1`, u.ID)
+ require.NoError(t, err)
+ defer func() { _ = rows.Close() }()
+ require.True(t, rows.Next(), "expected transfer ledger")
+ var amount, balanceAfter, quotaAfter, frozenAfter, historyAfter float64
+ require.NoError(t, rows.Scan(&amount, &balanceAfter, "aAfter, &frozenAfter, &historyAfter))
+ require.InDelta(t, 12.34, amount, 1e-9)
+ require.InDelta(t, 17.84, balanceAfter, 1e-9)
+ require.InDelta(t, 0.0, quotaAfter, 1e-9)
+ require.InDelta(t, 0.0, frozenAfter, 1e-9)
+ require.InDelta(t, 12.34, historyAfter, 1e-9)
}
// TestAffiliateRepository_AccrueQuota_ReusesOuterTransaction guards the
@@ -125,7 +145,7 @@ func TestAffiliateRepository_AccrueQuota_ReusesOuterTransaction(t *testing.T) {
require.NoError(t, err)
require.True(t, bound, "invitee must bind to inviter")
- applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 3.5, 0)
+ applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 3.5, 0, nil)
require.NoError(t, err)
require.True(t, applied, "AccrueQuota must report applied=true")
diff --git a/backend/internal/repository/affiliate_repo_test.go b/backend/internal/repository/affiliate_repo_test.go
new file mode 100644
index 00000000000..ccb7bb3daf7
--- /dev/null
+++ b/backend/internal/repository/affiliate_repo_test.go
@@ -0,0 +1,28 @@
+package repository
+
+import (
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestAffiliateUserOverviewSQLIncludesMaturedFrozenQuota(t *testing.T) {
+ query := strings.Join(strings.Fields(affiliateUserOverviewSQL), " ")
+
+ require.Contains(t, query, "ua.aff_quota + COALESCE(matured.matured_frozen_quota, 0)")
+ require.Contains(t, query, "frozen_until <= NOW()")
+}
+
+func TestAffiliateRecordQueriesUseLedgerAuditFields(t *testing.T) {
+ source, err := os.ReadFile("affiliate_repo.go")
+ require.NoError(t, err)
+ content := string(source)
+
+ require.Contains(t, content, "JOIN payment_orders po ON po.id = ual.source_order_id")
+ require.Contains(t, content, "ual.amount::double precision")
+ require.Contains(t, content, "ual.balance_after::double precision")
+ require.NotContains(t, content, "parseAffiliateRebateAmount")
+ require.NotContains(t, content, `"current_balance": "u.balance"`)
+}
diff --git a/backend/internal/repository/allowed_groups_contract_integration_test.go b/backend/internal/repository/allowed_groups_contract_integration_test.go
index b0af0d54b54..f6ccba5ccd6 100644
--- a/backend/internal/repository/allowed_groups_contract_integration_test.go
+++ b/backend/internal/repository/allowed_groups_contract_integration_test.go
@@ -98,7 +98,7 @@ func TestGroupRepository_DeleteCascade_RemovesAllowedGroupsAndClearsApiKeys(t *t
userRepo := newUserRepositoryWithSQL(entClient, tx)
groupRepo := newGroupRepositoryWithSQL(entClient, tx)
- apiKeyRepo := newAPIKeyRepositoryWithSQL(entClient, tx)
+ apiKeyRepo := newAPIKeyRepositoryWithSQL(entClient, tx, strictAPIKeyTestProtector{})
u := &service.User{
Email: uniqueTestValue(t, "cascade-user") + "@example.com",
diff --git a/backend/internal/repository/api_key_cache.go b/backend/internal/repository/api_key_cache.go
index a1072057e8d..80e8e5e375b 100644
--- a/backend/internal/repository/api_key_cache.go
+++ b/backend/internal/repository/api_key_cache.go
@@ -14,7 +14,11 @@ import (
const (
apiKeyRateLimitKeyPrefix = "apikey:ratelimit:"
+ apiKeyStepUpLimitKeyPrefix = "apikey:stepup:ratelimit:"
+ apiKeyCreateLimitKeyPrefix = "apikey:create:ratelimit:"
apiKeyRateLimitDuration = 24 * time.Hour
+ apiKeyStepUpLimitDuration = time.Hour
+ apiKeyCreateLimitDuration = time.Hour
apiKeyAuthCachePrefix = "apikey:auth:"
authCacheInvalidateChannel = "auth:cache:invalidate"
)
@@ -24,6 +28,14 @@ func apiKeyRateLimitKey(userID int64) string {
return fmt.Sprintf("%s%d", apiKeyRateLimitKeyPrefix, userID)
}
+func apiKeyStepUpLimitKey(purpose string, userID int64) string {
+ return fmt.Sprintf("%s%s:%d", apiKeyStepUpLimitKeyPrefix, purpose, userID)
+}
+
+func apiKeyCreateLimitKey(userID int64) string {
+ return fmt.Sprintf("%s%d", apiKeyCreateLimitKeyPrefix, userID)
+}
+
func apiKeyAuthCacheKey(key string) string {
return fmt.Sprintf("%s%s", apiKeyAuthCachePrefix, key)
}
@@ -59,6 +71,33 @@ func (c *apiKeyCache) DeleteCreateAttemptCount(ctx context.Context, userID int64
return c.rdb.Del(ctx, key).Err()
}
+func (c *apiKeyCache) IncrementAPIKeyStepUpAttempt(ctx context.Context, purpose string, userID int64) (int, error) {
+ key := apiKeyStepUpLimitKey(purpose, userID)
+ return c.reserveFixedWindow(ctx, key, apiKeyStepUpLimitDuration)
+}
+
+func (c *apiKeyCache) ReserveAPIKeyCreate(ctx context.Context, userID int64) (int, error) {
+ return c.reserveFixedWindow(ctx, apiKeyCreateLimitKey(userID), apiKeyCreateLimitDuration)
+}
+
+func (c *apiKeyCache) reserveFixedWindow(ctx context.Context, key string, duration time.Duration) (int, error) {
+ const reserveScript = `
+ local count = redis.call('INCR', KEYS[1])
+ if count == 1 then
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
+ end
+ return count`
+ count, err := c.rdb.Eval(ctx, reserveScript, []string{key}, int64(duration/time.Second)).Int()
+ if err != nil {
+ return 0, err
+ }
+ return count, nil
+}
+
+func (c *apiKeyCache) DeleteAPIKeyStepUpAttempts(ctx context.Context, purpose string, userID int64) error {
+ return c.rdb.Del(ctx, apiKeyStepUpLimitKey(purpose, userID)).Err()
+}
+
func (c *apiKeyCache) IncrementDailyUsage(ctx context.Context, apiKey string) error {
return c.rdb.Incr(ctx, apiKey).Err()
}
diff --git a/backend/internal/repository/api_key_cache_integration_test.go b/backend/internal/repository/api_key_cache_integration_test.go
index e93949178f8..8b1fd70b641 100644
--- a/backend/internal/repository/api_key_cache_integration_test.go
+++ b/backend/internal/repository/api_key_cache_integration_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"time"
+ "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
@@ -78,6 +79,64 @@ func (s *ApiKeyCacheSuite) TestCreateAttemptCount() {
}
}
+func (s *ApiKeyCacheSuite) TestRevealAttemptReservationIsAtomic() {
+ rdb := testRedis(s.T())
+ cache := &apiKeyCache{rdb: rdb}
+ ctx := context.Background()
+ const workers = 32
+ counts := make(chan int, workers)
+ errs := make(chan error, workers)
+ for i := 0; i < workers; i++ {
+ go func() {
+ count, err := cache.IncrementAPIKeyStepUpAttempt(ctx, service.APIKeyStepUpPurposeReveal, 77)
+ counts <- count
+ errs <- err
+ }()
+ }
+ seen := make(map[int]bool, workers)
+ for i := 0; i < workers; i++ {
+ require.NoError(s.T(), <-errs)
+ seen[<-counts] = true
+ }
+ for i := 1; i <= workers; i++ {
+ require.True(s.T(), seen[i], "missing atomic reservation %d", i)
+ }
+ ttl, err := rdb.TTL(ctx, apiKeyStepUpLimitKey(service.APIKeyStepUpPurposeReveal, 77)).Result()
+ require.NoError(s.T(), err)
+ s.AssertTTLWithin(ttl, time.Second, apiKeyStepUpLimitDuration)
+}
+
+func (s *ApiKeyCacheSuite) TestSuccessfulCreateReservationIsAtomicAndFixedWindow() {
+ rdb := testRedis(s.T())
+ cache := &apiKeyCache{rdb: rdb}
+ ctx := context.Background()
+ const workers = 32
+ counts := make(chan int, workers)
+ errs := make(chan error, workers)
+ for i := 0; i < workers; i++ {
+ go func() {
+ count, err := cache.ReserveAPIKeyCreate(ctx, 78)
+ counts <- count
+ errs <- err
+ }()
+ }
+ seen := make(map[int]bool, workers)
+ for i := 0; i < workers; i++ {
+ require.NoError(s.T(), <-errs)
+ seen[<-counts] = true
+ }
+ for i := 1; i <= workers; i++ {
+ require.True(s.T(), seen[i], "missing create reservation %d", i)
+ }
+ key := apiKeyCreateLimitKey(78)
+ require.NoError(s.T(), rdb.Expire(ctx, key, 2*time.Second).Err())
+ _, err := cache.ReserveAPIKeyCreate(ctx, 78)
+ require.NoError(s.T(), err)
+ ttl, err := rdb.TTL(ctx, key).Result()
+ require.NoError(s.T(), err)
+ require.LessOrEqual(s.T(), ttl, 2*time.Second, "a retry must not extend the create window")
+}
+
func (s *ApiKeyCacheSuite) TestDailyUsage() {
tests := []struct {
name string
diff --git a/backend/internal/repository/api_key_encryption.go b/backend/internal/repository/api_key_encryption.go
new file mode 100644
index 00000000000..653903ddb5c
--- /dev/null
+++ b/backend/internal/repository/api_key_encryption.go
@@ -0,0 +1,234 @@
+package repository
+
+import (
+ "context"
+ "crypto/subtle"
+ "fmt"
+ "strings"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/apikey"
+ "github.com/Wei-Shaw/sub2api/ent/schema/mixins"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+const (
+ apiKeyEncryptedStoragePrefix = "enc:v1:"
+ apiKeyMigrationBatchSize = 500
+)
+
+func (r *apiKeyRepository) lookupAPIKeyLocator(plaintext string) (string, error) {
+ if r == nil || r.keyProtector == nil {
+ return "", fmt.Errorf("API key protector is not configured")
+ }
+ if strings.TrimSpace(plaintext) == "" {
+ return "", fmt.Errorf("API key plaintext is empty")
+ }
+ locator := strings.ToLower(strings.TrimSpace(r.keyProtector.LookupLocator(plaintext)))
+ if len(locator) != 64 {
+ return "", fmt.Errorf("API key protector returned invalid lookup locator")
+ }
+ return locator, nil
+}
+
+func (r *apiKeyRepository) encryptAPIKeyForStorage(plaintext string, userID int64, purpose, locator string) (string, error) {
+ if r == nil || r.keyProtector == nil {
+ return "", fmt.Errorf("API key protector is not configured")
+ }
+ if strings.TrimSpace(plaintext) == "" {
+ return "", fmt.Errorf("API key plaintext is empty")
+ }
+ ciphertext, err := r.keyProtector.EncryptAPIKey(plaintext, apiKeyAssociatedData(userID, purpose, locator))
+ if err != nil {
+ return "", err
+ }
+ if strings.TrimSpace(ciphertext) == "" {
+ return "", fmt.Errorf("API key encryptor returned empty ciphertext")
+ }
+ return apiKeyEncryptedStoragePrefix + ciphertext, nil
+}
+
+func (r *apiKeyRepository) decryptAPIKeyFromStorage(stored string, userID int64, purpose, locator string) (string, error) {
+ if !strings.HasPrefix(stored, apiKeyEncryptedStoragePrefix) {
+ return "", fmt.Errorf("plaintext API key storage is forbidden")
+ }
+ if r == nil || r.keyProtector == nil {
+ return "", fmt.Errorf("API key protector is not configured")
+ }
+ ciphertext := strings.TrimPrefix(stored, apiKeyEncryptedStoragePrefix)
+ if ciphertext == "" {
+ return "", fmt.Errorf("API key ciphertext is empty")
+ }
+ if strings.TrimSpace(locator) == "" {
+ return "", fmt.Errorf("API key lookup locator is empty")
+ }
+ plaintext, err := r.keyProtector.DecryptAPIKey(ciphertext, apiKeyAssociatedData(userID, purpose, locator))
+ if err != nil {
+ return "", err
+ }
+ if strings.TrimSpace(plaintext) == "" {
+ return "", fmt.Errorf("decrypted API key is empty")
+ }
+ return plaintext, nil
+}
+
+func (r *apiKeyRepository) apiKeyEntityToService(m *dbent.APIKey) (*service.APIKey, error) {
+ out := apiKeyEntityToService(m)
+ if out == nil {
+ return nil, nil
+ }
+ plaintext, err := r.decryptAPIKeyFromStorage(m.Key, m.UserID, m.Purpose, derefString(m.KeyHash))
+ if err != nil {
+ return nil, fmt.Errorf("decrypt API key %d: %w", m.ID, err)
+ }
+ out.Key = plaintext
+ return out, nil
+}
+
+// MigratePlaintextAPIKeysToEncrypted rewrites legacy plaintext API keys before
+// the HTTP server starts. Rows are processed in bounded batches and updated
+// with compare-and-swap so a concurrent change cannot be silently overwritten.
+// Only incomplete storage rows are scanned on normal startup. Complete
+// ciphertext is decrypted and authenticated when that specific key is read,
+// avoiding an O(all historical keys) restart path while remaining fail-closed.
+func (r *apiKeyRepository) MigratePlaintextAPIKeysToEncrypted(ctx context.Context) (int, error) {
+ if r == nil || r.client == nil || r.keyProtector == nil {
+ return 0, fmt.Errorf("API key plaintext migration requires a database client and protector")
+ }
+
+ migrationCtx := mixins.SkipSoftDelete(ctx)
+ lastID := int64(0)
+ migrated := 0
+ for {
+ rows, err := r.client.APIKey.Query().
+ Where(
+ apikey.IDGT(lastID),
+ apikey.Or(
+ apikey.Not(apikey.KeyHasPrefix(apiKeyEncryptedStoragePrefix)),
+ apikey.KeyHashIsNil(),
+ apikey.KeyHashEQ(""),
+ apikey.KeyPrefixEQ(""),
+ ),
+ ).
+ Order(dbent.Asc(apikey.FieldID)).
+ Limit(apiKeyMigrationBatchSize).
+ All(migrationCtx)
+ if err != nil {
+ return migrated, fmt.Errorf("list API keys for encryption migration: %w", err)
+ }
+ if len(rows) == 0 {
+ if r.validateStorageConstraint {
+ if err := r.validateEncryptedStorageConstraint(ctx); err != nil {
+ return migrated, err
+ }
+ }
+ return migrated, nil
+ }
+
+ for _, row := range rows {
+ lastID = row.ID
+ if row.DeletedAt != nil && strings.HasPrefix(row.Key, "__deleted__") {
+ continue
+ }
+ legacyPlaintext := !strings.HasPrefix(row.Key, apiKeyEncryptedStoragePrefix)
+ plaintext := row.Key
+ if !legacyPlaintext {
+ plaintext, err = r.decryptAPIKeyFromStorage(row.Key, row.UserID, row.Purpose, derefString(row.KeyHash))
+ if err != nil {
+ return migrated, fmt.Errorf("API key %d ciphertext is unreadable: %w", row.ID, err)
+ }
+ }
+ expectedHash := r.keyProtector.LookupLocator(plaintext)
+ if !legacyPlaintext && (row.KeyHash == nil || !strings.EqualFold(strings.TrimSpace(*row.KeyHash), expectedHash)) {
+ return migrated, fmt.Errorf("API key %d hash does not match stored secret", row.ID)
+ }
+ expectedPrefix := service.APIKeyPrefixForStorage(plaintext)
+ storedKey := row.Key
+ needsUpdate := !strings.HasPrefix(row.Key, apiKeyEncryptedStoragePrefix) ||
+ row.KeyHash == nil || strings.TrimSpace(*row.KeyHash) == "" ||
+ row.KeyPrefix != expectedPrefix
+ if !needsUpdate {
+ continue
+ }
+ if legacyPlaintext {
+ storedKey, err = r.encryptAPIKeyForStorage(plaintext, row.UserID, row.Purpose, expectedHash)
+ if err != nil {
+ return migrated, fmt.Errorf("encrypt API key %d: %w", row.ID, err)
+ }
+ }
+ updated, err := r.client.APIKey.Update().
+ Where(apikey.IDEQ(row.ID), apikey.KeyEQ(row.Key)).
+ SetKey(storedKey).
+ SetKeyHash(expectedHash).
+ SetKeyPrefix(expectedPrefix).
+ Save(migrationCtx)
+ if err != nil {
+ return migrated, fmt.Errorf("persist encrypted API key %d: %w", row.ID, err)
+ }
+ if updated != 1 {
+ if err := r.verifyConcurrentAPIKeyMigration(migrationCtx, row.ID, plaintext, expectedHash, expectedPrefix); err != nil {
+ return migrated, err
+ }
+ continue
+ }
+ migrated++
+ }
+ }
+}
+
+func (r *apiKeyRepository) validateEncryptedStorageConstraint(ctx context.Context) error {
+ if r.sql == nil {
+ return fmt.Errorf("validate API key encrypted storage: database is unavailable")
+ }
+ if _, err := r.sql.ExecContext(ctx, `ALTER TABLE api_keys VALIDATE CONSTRAINT chk_api_keys_encrypted_storage`); err != nil {
+ return fmt.Errorf("validate API key encrypted storage constraint: %w", err)
+ }
+ rows, err := r.sql.QueryContext(ctx, `
+ SELECT COUNT(*) FROM api_keys
+ WHERE NOT (
+ (deleted_at IS NULL AND key LIKE 'enc:v1:%')
+ OR
+ (deleted_at IS NOT NULL AND (key LIKE 'enc:v1:%' OR key LIKE '__deleted__%'))
+ )`)
+ if err != nil {
+ return fmt.Errorf("verify API key encrypted storage rows: %w", err)
+ }
+ defer rows.Close()
+ var invalid int
+ if !rows.Next() {
+ return fmt.Errorf("verify API key encrypted storage rows: count row is missing")
+ }
+ if err := rows.Scan(&invalid); err != nil {
+ return fmt.Errorf("verify API key encrypted storage rows: %w", err)
+ }
+ if invalid != 0 {
+ return fmt.Errorf("verify API key encrypted storage rows: %d invalid row(s)", invalid)
+ }
+ return nil
+}
+
+func (r *apiKeyRepository) verifyConcurrentAPIKeyMigration(ctx context.Context, id int64, plaintext, expectedHash, expectedPrefix string) error {
+ current, err := r.client.APIKey.Query().Where(apikey.IDEQ(id)).Only(ctx)
+ if err != nil {
+ return fmt.Errorf("API key %d changed concurrently and cannot be reloaded: %w", id, err)
+ }
+ if current.DeletedAt != nil && strings.HasPrefix(current.Key, "__deleted__") {
+ return nil
+ }
+ if !strings.HasPrefix(current.Key, apiKeyEncryptedStoragePrefix) || current.KeyHash == nil ||
+ !strings.EqualFold(strings.TrimSpace(*current.KeyHash), expectedHash) || current.KeyPrefix != expectedPrefix {
+ return fmt.Errorf("API key %d changed concurrently to an unexpected state", id)
+ }
+ decrypted, err := r.decryptAPIKeyFromStorage(current.Key, current.UserID, current.Purpose, derefString(current.KeyHash))
+ if err != nil {
+ return fmt.Errorf("API key %d concurrent ciphertext is unreadable: %w", id, err)
+ }
+ if subtle.ConstantTimeCompare([]byte(decrypted), []byte(plaintext)) != 1 {
+ return fmt.Errorf("API key %d changed concurrently to a different secret", id)
+ }
+ return nil
+}
+
+func apiKeyAssociatedData(userID int64, purpose, locator string) string {
+ return fmt.Sprintf("user=%d\npurpose=%s\nlocator=%s", userID, strings.TrimSpace(purpose), strings.ToLower(strings.TrimSpace(locator)))
+}
diff --git a/backend/internal/repository/api_key_protector.go b/backend/internal/repository/api_key_protector.go
new file mode 100644
index 00000000000..cd4f5906dcc
--- /dev/null
+++ b/backend/internal/repository/api_key_protector.go
@@ -0,0 +1,105 @@
+package repository
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "io"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+const (
+ apiKeyEncryptionDerivationLabel = "sub2api/api-key/aes-gcm/v1"
+ apiKeyLookupDerivationLabel = "sub2api/api-key/lookup-hmac/v1"
+)
+
+type apiKeyProtector struct {
+ encryptionKey []byte
+ lookupKey []byte
+}
+
+var _ service.APIKeyProtector = (*apiKeyProtector)(nil)
+
+func NewAPIKeyProtector(cfg *config.Config) (service.APIKeyProtector, error) {
+ if cfg == nil || !cfg.APIKey.EncryptionKeyConfigured {
+ return nil, fmt.Errorf("API_KEY_ENCRYPTION_KEY must be explicitly configured and stable before API keys can be stored")
+ }
+ masterKey, err := hex.DecodeString(cfg.APIKey.EncryptionKey)
+ if err != nil {
+ return nil, fmt.Errorf("invalid API key encryption key: %w", err)
+ }
+ if len(masterKey) != 32 {
+ return nil, fmt.Errorf("API key encryption key must be 32 bytes (64 hex chars), got %d bytes", len(masterKey))
+ }
+ return &apiKeyProtector{
+ encryptionKey: deriveAPIKeySubkey(masterKey, apiKeyEncryptionDerivationLabel),
+ lookupKey: deriveAPIKeySubkey(masterKey, apiKeyLookupDerivationLabel),
+ }, nil
+}
+
+func deriveAPIKeySubkey(masterKey []byte, label string) []byte {
+ mac := hmac.New(sha256.New, masterKey)
+ _, _ = mac.Write([]byte(label))
+ return mac.Sum(nil)
+}
+
+func (p *apiKeyProtector) EncryptAPIKey(plaintext, associatedData string) (string, error) {
+ gcm, err := p.gcm()
+ if err != nil {
+ return "", err
+ }
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return "", fmt.Errorf("generate API key nonce: %w", err)
+ }
+ sealed := gcm.Seal(nonce, nonce, []byte(plaintext), []byte(associatedData))
+ return base64.RawURLEncoding.EncodeToString(sealed), nil
+}
+
+func (p *apiKeyProtector) DecryptAPIKey(ciphertext, associatedData string) (string, error) {
+ data, err := base64.RawURLEncoding.DecodeString(ciphertext)
+ if err != nil {
+ return "", fmt.Errorf("decode API key ciphertext: %w", err)
+ }
+ gcm, err := p.gcm()
+ if err != nil {
+ return "", err
+ }
+ if len(data) < gcm.NonceSize() {
+ return "", fmt.Errorf("API key ciphertext is too short")
+ }
+ nonce, sealed := data[:gcm.NonceSize()], data[gcm.NonceSize():]
+ plaintext, err := gcm.Open(nil, nonce, sealed, []byte(associatedData))
+ if err != nil {
+ return "", fmt.Errorf("authenticate API key ciphertext: %w", err)
+ }
+ return string(plaintext), nil
+}
+
+func (p *apiKeyProtector) LookupLocator(plaintext string) string {
+ mac := hmac.New(sha256.New, p.lookupKey)
+ _, _ = mac.Write([]byte(plaintext))
+ return hex.EncodeToString(mac.Sum(nil))
+}
+
+func (p *apiKeyProtector) gcm() (cipher.AEAD, error) {
+ if p == nil || len(p.encryptionKey) != 32 {
+ return nil, fmt.Errorf("API key encryption key is unavailable")
+ }
+ block, err := aes.NewCipher(p.encryptionKey)
+ if err != nil {
+ return nil, fmt.Errorf("create API key cipher: %w", err)
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, fmt.Errorf("create API key GCM: %w", err)
+ }
+ return gcm, nil
+}
diff --git a/backend/internal/repository/api_key_protector_security_test.go b/backend/internal/repository/api_key_protector_security_test.go
new file mode 100644
index 00000000000..bdcdaa8d7c9
--- /dev/null
+++ b/backend/internal/repository/api_key_protector_security_test.go
@@ -0,0 +1,43 @@
+package repository
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewAPIKeyProtectorRequiresExplicitIndependentKey(t *testing.T) {
+ _, err := NewAPIKeyProtector(&config.Config{APIKey: config.APIKeyConfig{
+ EncryptionKey: strings.Repeat("11", 32),
+ }})
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "API_KEY_ENCRYPTION_KEY")
+}
+
+func TestAPIKeyProtectorUsesKeyedLocatorAndAuthenticatedContext(t *testing.T) {
+ protector, err := NewAPIKeyProtector(&config.Config{APIKey: config.APIKeyConfig{
+ EncryptionKey: strings.Repeat("22", 32),
+ EncryptionKeyConfigured: true,
+ }})
+ require.NoError(t, err)
+
+ plaintext := "sk-short-but-historical"
+ locator := protector.LookupLocator(plaintext)
+ require.Len(t, locator, 64)
+ require.NotEqual(t, service.HashAPIKey(plaintext), locator, "lookup must use HMAC, not bare SHA-256")
+
+ aad := apiKeyAssociatedData(7, service.APIKeyPurposeStandard, locator)
+ ciphertext, err := protector.EncryptAPIKey(plaintext, aad)
+ require.NoError(t, err)
+ require.NotContains(t, ciphertext, plaintext)
+
+ decrypted, err := protector.DecryptAPIKey(ciphertext, aad)
+ require.NoError(t, err)
+ require.Equal(t, plaintext, decrypted)
+
+ _, err = protector.DecryptAPIKey(ciphertext, apiKeyAssociatedData(8, service.APIKeyPurposeStandard, locator))
+ require.Error(t, err, "ciphertext copied to another user must fail AAD authentication")
+}
diff --git a/backend/internal/repository/api_key_purpose_migration_integration_test.go b/backend/internal/repository/api_key_purpose_migration_integration_test.go
new file mode 100644
index 00000000000..3cbdf71cf52
--- /dev/null
+++ b/backend/internal/repository/api_key_purpose_migration_integration_test.go
@@ -0,0 +1,198 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+
+ "github.com/lib/pq"
+ "github.com/stretchr/testify/require"
+)
+
+const walletUniversalKeyMigrationName = "钱包通用 key(自动路由)"
+
+func TestAPIKeyPurposeMigration181BackfillsOnlyUnambiguousWalletKey(t *testing.T) {
+ tx := testTx(t)
+ createAPIKeyPurposeMigrationTempTables(t, tx)
+ ctx := context.Background()
+
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO user_subscriptions
+ (id, user_id, group_id, wallet_balance_usd, status, expires_at)
+ VALUES (1, 42, NULL, 25, 'active', '2099-12-31 23:59:59+00');
+ INSERT INTO api_keys (id, user_id, name, group_id, deleted_at)
+ VALUES
+ (10, 42, '钱包通用 key(自动路由)', NULL, NULL),
+ (11, 42, 'legacy arbitrary null key', NULL, NULL);
+ `)
+ require.NoError(t, err)
+ _, err = tx.ExecContext(ctx, `CREATE INDEX apikey_user_id_purpose ON api_keys (user_id)`)
+ require.NoError(t, err, "fixture must start with a non-canonical same-name index")
+
+ migration := readMigration(t, "181_api_key_purpose.sql")
+ _, err = tx.ExecContext(ctx, migration)
+ require.NoError(t, err)
+
+ var systemPurpose, legacyPurpose string
+ require.NoError(t, tx.QueryRowContext(ctx, `SELECT purpose FROM api_keys WHERE id = 10`).Scan(&systemPurpose))
+ require.NoError(t, tx.QueryRowContext(ctx, `SELECT purpose FROM api_keys WHERE id = 11`).Scan(&legacyPurpose))
+ require.Equal(t, "wallet_universal", systemPurpose)
+ require.Equal(t, "standard", legacyPurpose)
+
+ var indexDefinition string
+ require.NoError(t, tx.QueryRowContext(ctx, `
+ SELECT pg_get_indexdef('apikey_user_id_purpose'::regclass)
+ `).Scan(&indexDefinition))
+ require.Contains(t, indexDefinition, "CREATE UNIQUE INDEX")
+ require.Contains(t, indexDefinition, "(user_id, purpose)")
+ require.Contains(t, indexDefinition, "deleted_at IS NULL")
+ require.Contains(t, indexDefinition, "'wallet_universal'",
+ "PostgreSQL may render explicit text casts in partial-index predicates")
+
+ _, err = tx.ExecContext(ctx, migration)
+ require.NoError(t, err, "migration 181 must be idempotent")
+}
+
+func TestAPIKeyPurposeMigration181BackfillsHistoricalFiniteWalletKey(t *testing.T) {
+ tx := testTx(t)
+ createAPIKeyPurposeMigrationTempTables(t, tx)
+ ctx := context.Background()
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO user_subscriptions
+ (id, user_id, group_id, wallet_balance_usd, status, expires_at)
+ VALUES (1, 42, NULL, 25, 'active', NOW() + INTERVAL '30 days');
+ INSERT INTO api_keys (id, user_id, name, group_id, deleted_at)
+ VALUES (10, 42, '钱包通用 key(自动路由)', NULL, NULL);
+ `)
+ require.NoError(t, err)
+
+ _, err = tx.ExecContext(ctx, readMigration(t, "181_api_key_purpose.sql"))
+ require.NoError(t, err)
+ var purpose string
+ require.NoError(t, tx.QueryRowContext(ctx, `SELECT purpose FROM api_keys WHERE id = 10`).Scan(&purpose))
+ require.Equal(t, "wallet_universal", purpose)
+}
+
+func TestAPIKeyPurposeMigration181RejectsAmbiguousReservedName(t *testing.T) {
+ tests := []struct {
+ name string
+ setupSQL string
+ wantError string
+ }{
+ {
+ name: "reserved name without wallet evidence",
+ setupSQL: `
+ INSERT INTO api_keys (id, user_id, name, group_id, deleted_at)
+ VALUES (10, 42, '钱包通用 key(自动路由)', NULL, NULL)`,
+ wantError: "reserved wallet key has no historical credits wallet evidence",
+ },
+ {
+ name: "duplicate candidates for one user",
+ setupSQL: `
+ INSERT INTO user_subscriptions
+ (id, user_id, group_id, wallet_balance_usd, status, expires_at)
+ VALUES (1, 42, NULL, 25, 'active', '2099-12-31 23:59:59+00');
+ INSERT INTO api_keys (id, user_id, name, group_id, deleted_at)
+ VALUES
+ (10, 42, '钱包通用 key(自动路由)', NULL, NULL),
+ (11, 42, '钱包通用 key(自动路由)', NULL, NULL)`,
+ wantError: "multiple live wallet universal key candidates",
+ },
+ {
+ name: "reserved name bound to a group",
+ setupSQL: `
+ INSERT INTO user_subscriptions
+ (id, user_id, group_id, wallet_balance_usd, status, expires_at)
+ VALUES (1, 42, NULL, 25, 'active', '2099-12-31 23:59:59+00');
+ INSERT INTO api_keys (id, user_id, name, group_id, deleted_at)
+ VALUES (10, 42, '钱包通用 key(自动路由)', 3, NULL)`,
+ wantError: "reserved wallet key has a non-null group",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tx := testTx(t)
+ createAPIKeyPurposeMigrationTempTables(t, tx)
+ _, err := tx.ExecContext(context.Background(), tt.setupSQL)
+ require.NoError(t, err)
+
+ _, err = tx.ExecContext(context.Background(), readMigration(t, "181_api_key_purpose.sql"))
+ require.ErrorContains(t, err, tt.wantError)
+ })
+ }
+}
+
+func TestAPIKeyPurposeMigration181EnforcesIdentityAtDatabaseBoundary(t *testing.T) {
+ t.Run("purpose is immutable", func(t *testing.T) {
+ tx := migratedAPIKeyPurposeTestTx(t)
+ _, err := tx.ExecContext(context.Background(), `
+ UPDATE api_keys SET purpose = 'wallet_universal' WHERE id = 20
+ `)
+ requirePostgresCode(t, err, pq.ErrorCode("23514"))
+ })
+
+ t.Run("reserved live name requires wallet purpose", func(t *testing.T) {
+ tx := migratedAPIKeyPurposeTestTx(t)
+ _, err := tx.ExecContext(context.Background(), `
+ INSERT INTO api_keys (id, user_id, name, purpose, group_id)
+ VALUES (21, 77, $1, 'standard', NULL)
+ `, walletUniversalKeyMigrationName)
+ requirePostgresCode(t, err, pq.ErrorCode("23514"))
+ })
+
+ t.Run("one live wallet key per user", func(t *testing.T) {
+ tx := migratedAPIKeyPurposeTestTx(t)
+ _, err := tx.ExecContext(context.Background(), `
+ INSERT INTO api_keys (id, user_id, name, purpose, group_id)
+ VALUES (21, 42, $1, 'wallet_universal', NULL)
+ `, walletUniversalKeyMigrationName)
+ requirePostgresCode(t, err, pq.ErrorCode("23505"))
+ })
+}
+
+func migratedAPIKeyPurposeTestTx(t *testing.T) *sql.Tx {
+ t.Helper()
+ tx := testTx(t)
+ createAPIKeyPurposeMigrationTempTables(t, tx)
+ ctx := context.Background()
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO user_subscriptions
+ (id, user_id, group_id, wallet_balance_usd, status, expires_at)
+ VALUES (1, 42, NULL, 25, 'active', '2099-12-31 23:59:59+00');
+ INSERT INTO api_keys (id, user_id, name, group_id)
+ VALUES
+ (10, 42, '钱包通用 key(自动路由)', NULL),
+ (20, 77, 'ordinary key', 3);
+ `)
+ require.NoError(t, err)
+ _, err = tx.ExecContext(ctx, readMigration(t, "181_api_key_purpose.sql"))
+ require.NoError(t, err)
+ return tx
+}
+
+func createAPIKeyPurposeMigrationTempTables(t *testing.T, tx *sql.Tx) {
+ t.Helper()
+ _, err := tx.ExecContext(context.Background(), `
+ CREATE TEMP TABLE user_subscriptions (
+ id BIGINT PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ group_id BIGINT,
+ wallet_balance_usd NUMERIC(20,10),
+ status TEXT NOT NULL,
+ deleted_at TIMESTAMPTZ,
+ expires_at TIMESTAMPTZ NOT NULL
+ );
+ CREATE TEMP TABLE api_keys (
+ id BIGINT PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ name VARCHAR(100) NOT NULL,
+ group_id BIGINT,
+ status VARCHAR(20) NOT NULL DEFAULT 'active',
+ deleted_at TIMESTAMPTZ
+ );
+ `)
+ require.NoError(t, err)
+}
diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go
index 3a52740512d..239665d9018 100644
--- a/backend/internal/repository/api_key_repo.go
+++ b/backend/internal/repository/api_key_repo.go
@@ -3,6 +3,7 @@ package repository
import (
"context"
"database/sql"
+ "encoding/json"
"fmt"
"strings"
"time"
@@ -12,6 +13,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
"github.com/Wei-Shaw/sub2api/ent/user"
+ "github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
@@ -20,16 +22,33 @@ import (
)
type apiKeyRepository struct {
- client *dbent.Client
- sql sqlExecutor
+ client *dbent.Client
+ sql sqlExecutor
+ keyProtector service.APIKeyProtector
+ validateStorageConstraint bool
}
-func NewAPIKeyRepository(client *dbent.Client, sqlDB *sql.DB) service.APIKeyRepository {
- return newAPIKeyRepositoryWithSQL(client, sqlDB)
+var (
+ _ service.APIKeyRepository = (*apiKeyRepository)(nil)
+ _ service.APIKeyPurposeRepository = (*apiKeyRepository)(nil)
+ _ service.APIKeyAuthCacheLocatorProvider = (*apiKeyRepository)(nil)
+)
+
+func (r *apiKeyRepository) APIKeyAuthCacheLocator(plaintext string) string {
+ if r == nil || r.keyProtector == nil {
+ return ""
+ }
+ return r.keyProtector.LookupLocator(plaintext)
}
-func newAPIKeyRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor) *apiKeyRepository {
- return &apiKeyRepository{client: client, sql: sqlq}
+func NewAPIKeyRepository(client *dbent.Client, sqlDB *sql.DB, keyProtector service.APIKeyProtector) service.APIKeyRepository {
+ repo := newAPIKeyRepositoryWithSQL(client, sqlDB, keyProtector)
+ repo.validateStorageConstraint = true
+ return repo
+}
+
+func newAPIKeyRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor, keyProtector service.APIKeyProtector) *apiKeyRepository {
+ return &apiKeyRepository{client: client, sql: sqlq, keyProtector: keyProtector}
}
func (r *apiKeyRepository) activeQuery() *dbent.APIKeyQuery {
@@ -38,10 +57,32 @@ func (r *apiKeyRepository) activeQuery() *dbent.APIKeyQuery {
}
func (r *apiKeyRepository) Create(ctx context.Context, key *service.APIKey) error {
- builder := r.client.APIKey.Create().
+ if key == nil || strings.TrimSpace(key.Key) == "" {
+ return fmt.Errorf("API key plaintext is required")
+ }
+ if r.keyProtector == nil {
+ return fmt.Errorf("API key protector is not configured")
+ }
+ key.KeyHash = r.keyProtector.LookupLocator(key.Key)
+ if key.KeyPrefix == "" {
+ key.KeyPrefix = service.APIKeyPrefixForStorage(key.Key)
+ }
+ if strings.TrimSpace(key.Purpose) == "" {
+ key.Purpose = service.APIKeyPurposeStandard
+ }
+ storedKey, err := r.encryptAPIKeyForStorage(key.Key, key.UserID, key.Purpose, key.KeyHash)
+ if err != nil {
+ return fmt.Errorf("encrypt API key for storage: %w", err)
+ }
+
+ client := clientFromContext(ctx, r.client)
+ builder := client.APIKey.Create().
SetUserID(key.UserID).
- SetKey(key.Key).
+ SetKey(storedKey).
+ SetKeyHash(key.KeyHash).
+ SetKeyPrefix(key.KeyPrefix).
SetName(key.Name).
+ SetPurpose(key.Purpose).
SetStatus(key.Status).
SetNillableGroupID(key.GroupID).
SetNillableLastUsedAt(key.LastUsedAt).
@@ -81,7 +122,7 @@ func (r *apiKeyRepository) GetByID(ctx context.Context, id int64) (*service.APIK
}
return nil, err
}
- return apiKeyEntityToService(m), nil
+ return r.apiKeyEntityToService(m)
}
// GetKeyAndOwnerID 根据 API Key ID 获取其 key 与所有者(用户)ID。
@@ -92,7 +133,7 @@ func (r *apiKeyRepository) GetByID(ctx context.Context, id int64) (*service.APIK
func (r *apiKeyRepository) GetKeyAndOwnerID(ctx context.Context, id int64) (string, int64, error) {
m, err := r.activeQuery().
Where(apikey.IDEQ(id)).
- Select(apikey.FieldKey, apikey.FieldUserID).
+ Select(apikey.FieldKey, apikey.FieldKeyHash, apikey.FieldPurpose, apikey.FieldUserID).
Only(ctx)
if err != nil {
if dbent.IsNotFound(err) {
@@ -100,12 +141,20 @@ func (r *apiKeyRepository) GetKeyAndOwnerID(ctx context.Context, id int64) (stri
}
return "", 0, err
}
- return m.Key, m.UserID, nil
+ key, err := r.decryptAPIKeyFromStorage(m.Key, m.UserID, m.Purpose, derefString(m.KeyHash))
+ if err != nil {
+ return "", 0, fmt.Errorf("decrypt API key %d: %w", id, err)
+ }
+ return key, m.UserID, nil
}
func (r *apiKeyRepository) GetByKey(ctx context.Context, key string) (*service.APIKey, error) {
+ locator, err := r.lookupAPIKeyLocator(key)
+ if err != nil {
+ return nil, err
+ }
m, err := r.activeQuery().
- Where(apikey.KeyEQ(key)).
+ Where(apikey.KeyHashEQ(locator)).
WithUser().
WithGroup().
Only(ctx)
@@ -115,16 +164,42 @@ func (r *apiKeyRepository) GetByKey(ctx context.Context, key string) (*service.A
}
return nil, err
}
- return apiKeyEntityToService(m), nil
+ return r.apiKeyEntityToService(m)
+}
+
+func (r *apiKeyRepository) GetByUserIDAndPurpose(ctx context.Context, userID int64, purpose string) (*service.APIKey, error) {
+ client := clientFromContext(ctx, r.client)
+ m, err := client.APIKey.Query().
+ Where(
+ apikey.UserIDEQ(userID),
+ apikey.PurposeEQ(strings.TrimSpace(purpose)),
+ apikey.DeletedAtIsNil(),
+ ).
+ Only(ctx)
+ if err != nil {
+ if dbent.IsNotFound(err) {
+ return nil, service.ErrAPIKeyNotFound
+ }
+ return nil, err
+ }
+ return r.apiKeyEntityToService(m)
}
func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*service.APIKey, error) {
+ locator, err := r.lookupAPIKeyLocator(key)
+ if err != nil {
+ return nil, err
+ }
m, err := r.activeQuery().
- Where(apikey.KeyEQ(key)).
+ Where(apikey.KeyHashEQ(locator)).
Select(
apikey.FieldID,
apikey.FieldUserID,
+ apikey.FieldKeyHash,
+ apikey.FieldKeyPrefix,
apikey.FieldGroupID,
+ apikey.FieldName,
+ apikey.FieldPurpose,
apikey.FieldStatus,
apikey.FieldIPWhitelist,
apikey.FieldIPBlacklist,
@@ -153,7 +228,10 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
user.FieldLastLoginAt,
user.FieldLastActiveAt,
user.FieldRpmLimit,
- )
+ user.FieldTokenVersion,
+ ).WithUserAllowedGroups(func(q *dbent.UserAllowedGroupQuery) {
+ q.Select(userallowedgroup.FieldUserID, userallowedgroup.FieldGroupID)
+ })
}).
WithGroup(func(q *dbent.GroupQuery) {
q.Select(
@@ -161,11 +239,15 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
group.FieldName,
group.FieldPlatform,
group.FieldStatus,
+ group.FieldIsExclusive,
group.FieldSubscriptionType,
group.FieldRateMultiplier,
group.FieldDailyLimitUsd,
group.FieldWeeklyLimitUsd,
group.FieldMonthlyLimitUsd,
+ group.FieldAllowImageGeneration,
+ group.FieldImageRateIndependent,
+ group.FieldImageRateMultiplier,
group.FieldImagePrice1k,
group.FieldImagePrice2k,
group.FieldImagePrice4k,
@@ -180,6 +262,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
group.FieldDefaultMappedModel,
group.FieldMessagesDispatchModelConfig,
group.FieldRpmLimit,
+ group.FieldUpdatedAt,
)
}).
Only(ctx)
@@ -189,9 +272,281 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
}
return nil, err
}
+ // The authentication query intentionally does not select the encrypted key
+ // column. APIKeyService injects the presented plaintext only after the hash
+ // lookup succeeds.
return apiKeyEntityToService(m), nil
}
+// ValidateAuthCacheSnapshot verifies only the fields that can change whether a
+// cached API key is authorized. It deliberately reads PostgreSQL/SQLite on
+// every positive L1/L2 hit: Redis deletion and pub/sub remain useful for fast
+// convergence, but an outage in either can no longer preserve a revoked grant.
+//
+// Keep this query compact. The full auth query hydrates routing/pricing JSON;
+// this check uses one indexed point read and compares allowed_groups as a set.
+func (r *apiKeyRepository) ValidateAuthCacheSnapshot(ctx context.Context, cacheLocator string, snapshot *service.APIKeyAuthSnapshot) (bool, error) {
+ cacheLocator = strings.ToLower(strings.TrimSpace(cacheLocator))
+ if snapshot == nil || snapshot.APIKeyID <= 0 || snapshot.UserID <= 0 || cacheLocator == "" {
+ return false, nil
+ }
+ if r.sql == nil {
+ return false, fmt.Errorf("auth cache security validator database is unavailable")
+ }
+
+ const authStateQuery = `
+ SELECT
+ ak.id, ak.user_id, ak.key_hash, ak.group_id, ak.name, ak.purpose, ak.status,
+ ak.ip_whitelist, ak.ip_blacklist, ak.quota, ak.quota_used,
+ ak.expires_at, ak.rate_limit_5h, ak.rate_limit_1d, ak.rate_limit_7d,
+ u.status, u.role, u.balance, u.concurrency, u.rpm_limit,
+ g.id, g.name, g.platform, g.status, g.is_exclusive,
+ g.subscription_type, g.rpm_limit, g.updated_at,
+ ugr.rpm_override, uag.group_id
+ FROM api_keys AS ak
+ JOIN users AS u
+ ON u.id = ak.user_id AND u.deleted_at IS NULL
+ LEFT JOIN groups AS g
+ ON g.id = ak.group_id AND g.deleted_at IS NULL
+ LEFT JOIN user_allowed_groups AS uag
+ ON uag.user_id = u.id
+ LEFT JOIN user_group_rate_multipliers AS ugr
+ ON ugr.user_id = u.id AND ugr.group_id = ak.group_id
+ WHERE ak.id = $1 AND ak.deleted_at IS NULL
+ ORDER BY uag.group_id`
+
+ rows, err := r.sql.QueryContext(ctx, authStateQuery, snapshot.APIKeyID)
+ if err != nil {
+ return false, fmt.Errorf("query auth cache security state: %w", err)
+ }
+
+ var (
+ apiKeyID int64
+ userID int64
+ storedKeyHash sql.NullString
+ groupID sql.NullInt64
+ apiKeyName string
+ apiKeyPurpose string
+ apiKeyStatus string
+ ipWhitelistJSON []byte
+ ipBlacklistJSON []byte
+ quota float64
+ quotaUsed float64
+ expiresAt sql.NullTime
+ rateLimit5h float64
+ rateLimit1d float64
+ rateLimit7d float64
+ userStatus string
+ userRole string
+ userBalance float64
+ userConcurrency int
+ userRPMLimit int
+ groupRowID sql.NullInt64
+ groupName sql.NullString
+ groupPlatform sql.NullString
+ groupStatus sql.NullString
+ groupExclusive sql.NullBool
+ groupSubType sql.NullString
+ groupRPMLimit sql.NullInt64
+ groupUpdatedAt sql.NullTime
+ userGroupRPM sql.NullInt64
+ allowedGroups []int64
+ )
+ scanRow := func() error {
+ var allowedGroupID sql.NullInt64
+ if err := rows.Scan(
+ &apiKeyID,
+ &userID,
+ &storedKeyHash,
+ &groupID,
+ &apiKeyName,
+ &apiKeyPurpose,
+ &apiKeyStatus,
+ &ipWhitelistJSON,
+ &ipBlacklistJSON,
+ "a,
+ "aUsed,
+ &expiresAt,
+ &rateLimit5h,
+ &rateLimit1d,
+ &rateLimit7d,
+ &userStatus,
+ &userRole,
+ &userBalance,
+ &userConcurrency,
+ &userRPMLimit,
+ &groupRowID,
+ &groupName,
+ &groupPlatform,
+ &groupStatus,
+ &groupExclusive,
+ &groupSubType,
+ &groupRPMLimit,
+ &groupUpdatedAt,
+ &userGroupRPM,
+ &allowedGroupID,
+ ); err != nil {
+ return err
+ }
+ if allowedGroupID.Valid {
+ allowedGroups = append(allowedGroups, allowedGroupID.Int64)
+ }
+ return nil
+ }
+ if !rows.Next() {
+ if err := rows.Err(); err != nil {
+ _ = rows.Close()
+ return false, fmt.Errorf("read auth cache security state: %w", err)
+ }
+ if err := rows.Close(); err != nil {
+ return false, fmt.Errorf("close auth cache security state rows: %w", err)
+ }
+ return false, nil
+ }
+ if err := scanRow(); err != nil {
+ _ = rows.Close()
+ return false, fmt.Errorf("scan auth cache security state: %w", err)
+ }
+ for rows.Next() {
+ if err := scanRow(); err != nil {
+ _ = rows.Close()
+ return false, fmt.Errorf("scan auth cache security state: %w", err)
+ }
+ }
+ if err := rows.Err(); err != nil {
+ _ = rows.Close()
+ return false, fmt.Errorf("read auth cache security state: %w", err)
+ }
+ if err := rows.Close(); err != nil {
+ return false, fmt.Errorf("close auth cache security state rows: %w", err)
+ }
+ ipWhitelist, err := decodeAuthCacheStringList(ipWhitelistJSON)
+ if err != nil {
+ return false, fmt.Errorf("decode auth cache IP whitelist: %w", err)
+ }
+ ipBlacklist, err := decodeAuthCacheStringList(ipBlacklistJSON)
+ if err != nil {
+ return false, fmt.Errorf("decode auth cache IP blacklist: %w", err)
+ }
+ storedLocator := strings.ToLower(strings.TrimSpace(storedKeyHash.String))
+ locatorMatches := storedKeyHash.Valid && storedLocator == cacheLocator
+
+ if !locatorMatches ||
+ apiKeyID != snapshot.APIKeyID ||
+ userID != snapshot.UserID ||
+ apiKeyName != snapshot.Name ||
+ apiKeyPurpose != snapshot.Purpose ||
+ apiKeyStatus != snapshot.Status ||
+ !equalStringSets(ipWhitelist, snapshot.IPWhitelist) ||
+ !equalStringSets(ipBlacklist, snapshot.IPBlacklist) ||
+ quota != snapshot.Quota ||
+ quotaAuthorizationClass(quota, quotaUsed) != quotaAuthorizationClass(snapshot.Quota, snapshot.QuotaUsed) ||
+ !sameNullableTime(expiresAt, snapshot.ExpiresAt) ||
+ rateLimit5h != snapshot.RateLimit5h ||
+ rateLimit1d != snapshot.RateLimit1d ||
+ rateLimit7d != snapshot.RateLimit7d ||
+ userStatus != snapshot.User.Status ||
+ userRole != snapshot.User.Role ||
+ balanceAuthorizationClass(userBalance) != balanceAuthorizationClass(snapshot.User.Balance) ||
+ userConcurrency != snapshot.User.Concurrency ||
+ userRPMLimit != snapshot.User.RPMLimit ||
+ !sameNullableInt(userGroupRPM, snapshot.User.UserGroupRPMOverride) ||
+ !equalInt64Sets(allowedGroups, snapshot.User.AllowedGroups) ||
+ !sameNullableInt64(groupID, snapshot.GroupID) {
+ return false, nil
+ }
+
+ if snapshot.Group == nil {
+ return !groupRowID.Valid, nil
+ }
+ if !groupRowID.Valid ||
+ groupRowID.Int64 != snapshot.Group.ID ||
+ !groupName.Valid || groupName.String != snapshot.Group.Name ||
+ !groupPlatform.Valid || groupPlatform.String != snapshot.Group.Platform ||
+ !groupStatus.Valid || groupStatus.String != snapshot.Group.Status ||
+ !groupExclusive.Valid || groupExclusive.Bool != snapshot.Group.IsExclusive ||
+ !groupSubType.Valid || groupSubType.String != snapshot.Group.SubscriptionType ||
+ !groupRPMLimit.Valid || int(groupRPMLimit.Int64) != snapshot.Group.RPMLimit ||
+ !groupUpdatedAt.Valid || !groupUpdatedAt.Time.Equal(snapshot.Group.UpdatedAt) {
+ return false, nil
+ }
+ return true, nil
+}
+
+func sameNullableInt64(current sql.NullInt64, cached *int64) bool {
+ if cached == nil {
+ return !current.Valid
+ }
+ return current.Valid && current.Int64 == *cached
+}
+
+func sameNullableInt(current sql.NullInt64, cached *int) bool {
+ if cached == nil {
+ return !current.Valid
+ }
+ return current.Valid && int(current.Int64) == *cached
+}
+
+func equalInt64Sets(left, right []int64) bool {
+ if len(left) != len(right) {
+ return false
+ }
+ counts := make(map[int64]int, len(left))
+ for _, value := range left {
+ counts[value]++
+ }
+ for _, value := range right {
+ counts[value]--
+ if counts[value] < 0 {
+ return false
+ }
+ }
+ return true
+}
+
+func decodeAuthCacheStringList(raw []byte) ([]string, error) {
+ if len(raw) == 0 || string(raw) == "null" {
+ return nil, nil
+ }
+ var values []string
+ if err := json.Unmarshal(raw, &values); err != nil {
+ return nil, err
+ }
+ return values, nil
+}
+
+func equalStringSets(left, right []string) bool {
+ if len(left) != len(right) {
+ return false
+ }
+ counts := make(map[string]int, len(left))
+ for _, value := range left {
+ counts[value]++
+ }
+ for _, value := range right {
+ counts[value]--
+ if counts[value] < 0 {
+ return false
+ }
+ }
+ return true
+}
+
+func sameNullableTime(current sql.NullTime, cached *time.Time) bool {
+ if cached == nil {
+ return !current.Valid
+ }
+ return current.Valid && current.Time.Equal(*cached)
+}
+
+func quotaAuthorizationClass(quota, quotaUsed float64) bool {
+ return quota > 0 && quotaUsed >= quota
+}
+
+func balanceAuthorizationClass(balance float64) bool {
+ return balance > 0
+}
+
func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey) error {
// 使用原子操作:将软删除检查与更新合并到同一语句,避免竞态条件。
// 之前的实现先检查 Exist 再 UpdateOneID,若在两步之间发生软删除,
@@ -306,7 +661,7 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param
if filters.Search != "" {
q = q.Where(apikey.Or(
apikey.NameContainsFold(filters.Search),
- apikey.KeyContainsFold(filters.Search),
+ apikey.KeyPrefixContainsFold(filters.Search),
))
}
if filters.Status != "" {
@@ -366,7 +721,13 @@ func (r *apiKeyRepository) CountByUserID(ctx context.Context, userID int64) (int
}
func (r *apiKeyRepository) ExistsByKey(ctx context.Context, key string) (bool, error) {
- count, err := r.activeQuery().Where(apikey.KeyEQ(key)).Count(ctx)
+ locator, err := r.lookupAPIKeyLocator(key)
+ if err != nil {
+ return false, err
+ }
+ count, err := r.client.APIKey.Query().
+ Where(apikey.KeyHashEQ(locator)).
+ Count(mixins.SkipSoftDelete(ctx))
return count > 0, err
}
@@ -473,26 +834,26 @@ func (r *apiKeyRepository) CountByGroupID(ctx context.Context, groupID int64) (i
return int64(count), err
}
-func (r *apiKeyRepository) ListKeysByUserID(ctx context.Context, userID int64) ([]string, error) {
- keys, err := r.activeQuery().
- Where(apikey.UserIDEQ(userID)).
- Select(apikey.FieldKey).
+func (r *apiKeyRepository) ListAuthCacheLocatorsByUserID(ctx context.Context, userID int64) ([]string, error) {
+ locators, err := r.activeQuery().
+ Where(apikey.UserIDEQ(userID), apikey.KeyHashNotNil()).
+ Select(apikey.FieldKeyHash).
Strings(ctx)
if err != nil {
return nil, err
}
- return keys, nil
+ return locators, nil
}
-func (r *apiKeyRepository) ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error) {
- keys, err := r.activeQuery().
- Where(apikey.GroupIDEQ(groupID)).
- Select(apikey.FieldKey).
+func (r *apiKeyRepository) ListAuthCacheLocatorsByGroupID(ctx context.Context, groupID int64) ([]string, error) {
+ locators, err := r.activeQuery().
+ Where(apikey.GroupIDEQ(groupID), apikey.KeyHashNotNil()).
+ Select(apikey.FieldKeyHash).
Strings(ctx)
if err != nil {
return nil, err
}
- return keys, nil
+ return locators, nil
}
// IncrementQuotaUsed 使用 Ent 原子递增 quota_used 字段并返回新值
@@ -523,11 +884,11 @@ func (r *apiKeyRepository) IncrementQuotaUsedAndGetState(ctx context.Context, id
END,
updated_at = NOW()
WHERE id = $3 AND deleted_at IS NULL
- RETURNING quota_used, quota, key, status
+ RETURNING quota_used, quota, key_hash, status
`
state := &service.APIKeyQuotaUsageState{}
- if err := scanSingleRow(ctx, r.sql, query, []any{amount, service.StatusAPIKeyQuotaExhausted, id}, &state.QuotaUsed, &state.Quota, &state.Key, &state.Status); err != nil {
+ if err := scanSingleRow(ctx, r.sql, query, []any{amount, service.StatusAPIKeyQuotaExhausted, id}, &state.QuotaUsed, &state.Quota, &state.AuthCacheLocator, &state.Status); err != nil {
if err == sql.ErrNoRows {
return nil, service.ErrAPIKeyNotFound
}
@@ -614,10 +975,16 @@ func apiKeyEntityToService(m *dbent.APIKey) *service.APIKey {
return nil
}
out := &service.APIKey{
- ID: m.ID,
- UserID: m.UserID,
- Key: m.Key,
+ ID: m.ID,
+ UserID: m.UserID,
+ // Stored API-key ciphertext must never escape through generic entity
+ // mappers (for example usage-log hydration). API-key repository read
+ // paths explicitly decrypt it for authorized callers.
+ Key: "",
+ KeyHash: derefString(m.KeyHash),
+ KeyPrefix: m.KeyPrefix,
Name: m.Name,
+ Purpose: m.Purpose,
Status: m.Status,
IPWhitelist: m.IPWhitelist,
IPBlacklist: m.IPBlacklist,
@@ -672,6 +1039,8 @@ func userEntityToService(u *dbent.User) *service.User {
BalanceNotifyThreshold: u.BalanceNotifyThreshold,
TotalRecharged: u.TotalRecharged,
RPMLimit: u.RpmLimit,
+ TokenVersion: u.TokenVersion,
+ TokenVersionResolved: true,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
}
@@ -679,6 +1048,14 @@ func userEntityToService(u *dbent.User) *service.User {
if u.BalanceNotifyExtraEmails != "" && u.BalanceNotifyExtraEmails != "[]" {
out.BalanceNotifyExtraEmails = service.ParseNotifyEmails(u.BalanceNotifyExtraEmails)
}
+ if len(u.Edges.UserAllowedGroups) > 0 {
+ out.AllowedGroups = make([]int64, 0, len(u.Edges.UserAllowedGroups))
+ for _, allowed := range u.Edges.UserAllowedGroups {
+ if allowed != nil {
+ out.AllowedGroups = append(out.AllowedGroups, allowed.GroupID)
+ }
+ }
+ }
return out
}
@@ -699,6 +1076,9 @@ func groupEntityToService(g *dbent.Group) *service.Group {
DailyLimitUSD: g.DailyLimitUsd,
WeeklyLimitUSD: g.WeeklyLimitUsd,
MonthlyLimitUSD: g.MonthlyLimitUsd,
+ AllowImageGeneration: g.AllowImageGeneration,
+ ImageRateIndependent: g.ImageRateIndependent,
+ ImageRateMultiplier: g.ImageRateMultiplier,
ImagePrice1K: g.ImagePrice1k,
ImagePrice2K: g.ImagePrice2k,
ImagePrice4K: g.ImagePrice4k,
diff --git a/backend/internal/repository/api_key_repo_encryption_security_test.go b/backend/internal/repository/api_key_repo_encryption_security_test.go
new file mode 100644
index 00000000000..741e9422c0b
--- /dev/null
+++ b/backend/internal/repository/api_key_repo_encryption_security_test.go
@@ -0,0 +1,258 @@
+package repository
+
+import (
+ "context"
+ "encoding/base64"
+ "fmt"
+ "strings"
+ "testing"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/apikey"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+type strictAPIKeyTestProtector struct{}
+
+func (strictAPIKeyTestProtector) EncryptAPIKey(plaintext, associatedData string) (string, error) {
+ if plaintext == "" {
+ return "", fmt.Errorf("empty plaintext")
+ }
+ return "cipher:" + base64.RawURLEncoding.EncodeToString([]byte(associatedData)) + "." + base64.RawURLEncoding.EncodeToString([]byte(plaintext)), nil
+}
+
+func (strictAPIKeyTestProtector) DecryptAPIKey(ciphertext, associatedData string) (string, error) {
+ prefix := "cipher:" + base64.RawURLEncoding.EncodeToString([]byte(associatedData)) + "."
+ if !strings.HasPrefix(ciphertext, prefix) {
+ return "", fmt.Errorf("invalid ciphertext")
+ }
+ decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(ciphertext, prefix))
+ if err != nil {
+ return "", fmt.Errorf("invalid ciphertext: %w", err)
+ }
+ return string(decoded), nil
+}
+
+func (strictAPIKeyTestProtector) LookupLocator(plaintext string) string {
+ return service.HashAPIKey("hmac:" + plaintext)
+}
+
+type countingAPIKeyTestProtector struct {
+ decryptCalls int
+}
+
+func (p *countingAPIKeyTestProtector) EncryptAPIKey(plaintext, associatedData string) (string, error) {
+ return strictAPIKeyTestProtector{}.EncryptAPIKey(plaintext, associatedData)
+}
+
+func (p *countingAPIKeyTestProtector) DecryptAPIKey(ciphertext, associatedData string) (string, error) {
+ p.decryptCalls++
+ return strictAPIKeyTestProtector{}.DecryptAPIKey(ciphertext, associatedData)
+}
+
+func (*countingAPIKeyTestProtector) LookupLocator(plaintext string) string {
+ return strictAPIKeyTestProtector{}.LookupLocator(plaintext)
+}
+
+func TestAPIKeyRepositoryEncryptsKeyAtRestAndAuthenticatesByHash(t *testing.T) {
+ repo, client := newAPIKeyRepoSQLite(t)
+ repo.keyProtector = strictAPIKeyTestProtector{}
+ ctx := context.Background()
+ user := mustCreateAPIKeyRepoUser(t, ctx, client, "api-key-encryption@test.com")
+
+ plaintext := "sk-api-key-encryption"
+ key := &service.APIKey{
+ UserID: user.ID,
+ Key: plaintext,
+ Name: "encrypted key",
+ Status: service.StatusActive,
+ }
+ require.NoError(t, repo.Create(ctx, key))
+
+ stored, err := client.APIKey.Query().Where(apikey.IDEQ(key.ID)).Only(ctx)
+ require.NoError(t, err)
+ require.NotEqual(t, plaintext, stored.Key)
+ require.True(t, strings.HasPrefix(stored.Key, apiKeyEncryptedStoragePrefix))
+ require.Equal(t, repo.APIKeyAuthCacheLocator(plaintext), valueOrEmpty(stored.KeyHash))
+
+ byID, err := repo.GetByID(ctx, key.ID)
+ require.NoError(t, err)
+ require.Equal(t, plaintext, byID.Key)
+
+ byAuth, err := repo.GetByKeyForAuth(ctx, plaintext)
+ require.NoError(t, err)
+ require.Equal(t, key.ID, byAuth.ID)
+ require.Empty(t, byAuth.Key, "auth repository must not load stored ciphertext as an API key")
+}
+
+func TestAPIKeyBulkReadsStayMetadataOnly(t *testing.T) {
+ repo, client := newAPIKeyRepoSQLite(t)
+ repo.keyProtector = strictAPIKeyTestProtector{}
+ ctx := context.Background()
+ user := mustCreateAPIKeyRepoUser(t, ctx, client, "api-key-metadata-only@test.com")
+ key := &service.APIKey{
+ UserID: user.ID, Key: "sk-api-key-metadata-only", Name: service.WalletUniversalAPIKeyName,
+ Purpose: service.APIKeyPurposeWalletUniversal, Status: service.StatusActive,
+ }
+ require.NoError(t, repo.Create(ctx, key))
+ require.NoError(t, repo.Create(ctx, &service.APIKey{
+ UserID: user.ID, Key: "sk-newer-vip-standard", Name: "metadata vip",
+ Purpose: service.APIKeyPurposeStandard, Status: service.StatusActive,
+ }))
+
+ listed, _, err := repo.ListByUserID(ctx, user.ID, pagination.PaginationParams{Page: 1, PageSize: 10}, service.APIKeyListFilters{})
+ require.NoError(t, err)
+ require.Len(t, listed, 2)
+ require.Empty(t, listed[0].Key)
+
+ searched, err := repo.SearchAPIKeys(ctx, user.ID, "metadata", 10)
+ require.NoError(t, err)
+ require.Len(t, searched, 1)
+ require.Empty(t, searched[0].Key)
+
+}
+
+func TestAPIKeyEntityStringRedactsCiphertextAndLocator(t *testing.T) {
+ locator := strings.Repeat("a", 64)
+ entity := &dbent.APIKey{Key: "enc:v1:secret-ciphertext", KeyHash: &locator}
+ printed := entity.String()
+ require.NotContains(t, printed, "secret-ciphertext")
+ require.NotContains(t, printed, locator)
+ require.Contains(t, printed, "key=")
+ require.Contains(t, printed, "key_hash=")
+}
+
+func TestAPIKeyRepositoryCreateFailsClosedWithoutEncryptor(t *testing.T) {
+ repo, client := newAPIKeyRepoSQLite(t)
+ repo.keyProtector = nil
+ ctx := context.Background()
+ user := mustCreateAPIKeyRepoUser(t, ctx, client, "api-key-no-encryptor@test.com")
+
+ err := repo.Create(ctx, &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-api-key-no-encryptor",
+ Name: "must fail",
+ Status: service.StatusActive,
+ })
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "protector")
+}
+
+func TestAPIKeyRepositoryMigratesLegacyPlaintextAndRuntimeRejectsCorruptCiphertext(t *testing.T) {
+ repo, client := newAPIKeyRepoSQLite(t)
+ repo.keyProtector = strictAPIKeyTestProtector{}
+ ctx := context.Background()
+ user := mustCreateAPIKeyRepoUser(t, ctx, client, "api-key-migration@test.com")
+
+ legacyPlaintext := "sk-api-key-legacy"
+ legacy, err := client.APIKey.Create().
+ SetUserID(user.ID).
+ SetKey(legacyPlaintext).
+ SetName("legacy plaintext").
+ SetStatus(service.StatusActive).
+ Save(ctx)
+ require.NoError(t, err)
+
+ migrated, err := repo.MigratePlaintextAPIKeysToEncrypted(ctx)
+ require.NoError(t, err)
+ require.Equal(t, 1, migrated)
+
+ stored, err := client.APIKey.Query().Where(apikey.IDEQ(legacy.ID)).Only(ctx)
+ require.NoError(t, err)
+ require.NotEqual(t, legacyPlaintext, stored.Key)
+ require.Equal(t, repo.APIKeyAuthCacheLocator(legacyPlaintext), valueOrEmpty(stored.KeyHash))
+ require.Equal(t, service.APIKeyPrefixForStorage(legacyPlaintext), stored.KeyPrefix)
+
+ loaded, err := repo.GetByID(ctx, legacy.ID)
+ require.NoError(t, err)
+ require.Equal(t, legacyPlaintext, loaded.Key)
+
+ authenticated, err := repo.GetByKeyForAuth(ctx, legacyPlaintext)
+ require.NoError(t, err)
+ require.Equal(t, legacy.ID, authenticated.ID)
+ require.Equal(t, repo.APIKeyAuthCacheLocator(legacyPlaintext), authenticated.KeyHash)
+
+ _, err = repo.GetByKeyForAuth(ctx, legacyPlaintext+"-wrong")
+ require.ErrorIs(t, err, service.ErrAPIKeyNotFound)
+
+ _, err = client.APIKey.UpdateOneID(legacy.ID).
+ SetKey(apiKeyEncryptedStoragePrefix + "corrupt").
+ Save(ctx)
+ require.NoError(t, err)
+ migrated, err = repo.MigratePlaintextAPIKeysToEncrypted(ctx)
+ require.NoError(t, err)
+ require.Zero(t, migrated, "startup migration only scans incomplete storage rows")
+ _, err = repo.GetByID(ctx, legacy.ID)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "decrypt")
+}
+
+func TestAPIKeyStartupMigrationSkipsAlreadyCompleteEncryptedRows(t *testing.T) {
+ repo, client := newAPIKeyRepoSQLite(t)
+ protector := &countingAPIKeyTestProtector{}
+ repo.keyProtector = protector
+ ctx := context.Background()
+ user := mustCreateAPIKeyRepoUser(t, ctx, client, "api-key-no-full-boot-scan@test.com")
+ require.NoError(t, repo.Create(ctx, &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-already-encrypted-complete",
+ Name: "complete",
+ Status: service.StatusActive,
+ }))
+ protector.decryptCalls = 0
+
+ migrated, err := repo.MigratePlaintextAPIKeysToEncrypted(ctx)
+ require.NoError(t, err)
+ require.Zero(t, migrated)
+ require.Zero(t, protector.decryptCalls, "normal startup must not decrypt every historical API key")
+}
+
+func TestAPIKeyRepositoryRejectsPlaintextFallbackAfterCutover(t *testing.T) {
+ repo, client := newAPIKeyRepoSQLite(t)
+ repo.keyProtector = strictAPIKeyTestProtector{}
+ ctx := context.Background()
+ user := mustCreateAPIKeyRepoUser(t, ctx, client, "api-key-plaintext-rejected@test.com")
+ legacy, err := client.APIKey.Create().
+ SetUserID(user.ID).
+ SetKey("sk-plaintext-must-not-be-read").
+ SetKeyHash(repo.APIKeyAuthCacheLocator("sk-plaintext-must-not-be-read")).
+ SetName("injected plaintext").
+ SetStatus(service.StatusActive).
+ Save(ctx)
+ require.NoError(t, err)
+
+ _, err = repo.GetByID(ctx, legacy.ID)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "plaintext API key storage is forbidden")
+}
+
+func TestAPIKeyMigrationDoesNotMistakeActiveLegacyKeyForDeleteTombstone(t *testing.T) {
+ repo, client := newAPIKeyRepoSQLite(t)
+ repo.keyProtector = strictAPIKeyTestProtector{}
+ ctx := context.Background()
+ user := mustCreateAPIKeyRepoUser(t, ctx, client, "api-key-active-tombstone-prefix@test.com")
+ plaintext := "__deleted__active_legacy_key"
+ legacy, err := client.APIKey.Create().
+ SetUserID(user.ID).
+ SetKey(plaintext).
+ SetName("active legacy prefix").
+ SetStatus(service.StatusActive).
+ Save(ctx)
+ require.NoError(t, err)
+
+ migrated, err := repo.MigratePlaintextAPIKeysToEncrypted(ctx)
+ require.NoError(t, err)
+ require.Equal(t, 1, migrated)
+ loaded, err := repo.GetByID(ctx, legacy.ID)
+ require.NoError(t, err)
+ require.Equal(t, plaintext, loaded.Key)
+}
+
+func valueOrEmpty(value *string) string {
+ if value == nil {
+ return ""
+ }
+ return *value
+}
diff --git a/backend/internal/repository/api_key_repo_integration_test.go b/backend/internal/repository/api_key_repo_integration_test.go
index e926ed86028..2bb0bc19ffa 100644
--- a/backend/internal/repository/api_key_repo_integration_test.go
+++ b/backend/internal/repository/api_key_repo_integration_test.go
@@ -26,7 +26,7 @@ func (s *APIKeyRepoSuite) SetupTest() {
s.ctx = context.Background()
tx := testEntTx(s.T())
s.client = tx.Client()
- s.repo = newAPIKeyRepositoryWithSQL(s.client, tx)
+ s.repo = newAPIKeyRepositoryWithSQL(s.client, tx, strictAPIKeyTestProtector{})
}
func TestAPIKeyRepoSuite(t *testing.T) {
@@ -81,11 +81,49 @@ func (s *APIKeyRepoSuite) TestGetByKey() {
s.Require().Equal(group.ID, got.Group.ID)
}
+func (s *APIKeyRepoSuite) TestGetByKeyForAuthLoadsUserTokenVersion() {
+ user := s.mustCreateUser("getbykey-token-version@test.com")
+ _, err := s.client.User.UpdateOneID(user.ID).SetTokenVersion(11).Save(s.ctx)
+ s.Require().NoError(err)
+
+ key := s.mustCreateApiKey(user.ID, "sk-getbykey-token-version", "Token Version", nil)
+ got, err := s.repo.GetByKeyForAuth(s.ctx, key.Key)
+ s.Require().NoError(err)
+ s.Require().NotNil(got.User)
+ s.Require().True(got.User.TokenVersionResolved)
+ s.Require().Equal(int64(11), got.User.TokenVersion)
+}
+
func (s *APIKeyRepoSuite) TestGetByKey_NotFound() {
_, err := s.repo.GetByKey(s.ctx, "non-existent-key")
s.Require().Error(err, "expected error for non-existent key")
}
+func (s *APIKeyRepoSuite) TestWalletUniversalPurposePersistsAcrossRepositoryReads() {
+ user := s.mustCreateUser("wallet-purpose@test.com")
+ key := &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-wallet-purpose",
+ Name: service.WalletUniversalAPIKeyName,
+ Purpose: service.APIKeyPurposeWalletUniversal,
+ Status: service.StatusDisabled,
+ }
+ s.Require().NoError(s.repo.Create(s.ctx, key))
+
+ byID, err := s.repo.GetByID(s.ctx, key.ID)
+ s.Require().NoError(err)
+ s.Require().Equal(service.APIKeyPurposeWalletUniversal, byID.Purpose)
+
+ byAuth, err := s.repo.GetByKeyForAuth(s.ctx, key.Key)
+ s.Require().NoError(err)
+ s.Require().Equal(service.APIKeyPurposeWalletUniversal, byAuth.Purpose)
+
+ byPurpose, err := s.repo.GetByUserIDAndPurpose(s.ctx, user.ID, service.APIKeyPurposeWalletUniversal)
+ s.Require().NoError(err)
+ s.Require().Equal(key.ID, byPurpose.ID)
+ s.Require().Equal(service.StatusDisabled, byPurpose.Status, "disabled system key must be reused, not replaced")
+}
+
func (s *APIKeyRepoSuite) TestGetByKeyForAuth_PreservesMessagesDispatchModelConfig() {
user := s.mustCreateUser("getbykey-auth-dispatch@test.com")
group, err := s.client.Group.Create().
@@ -494,7 +532,7 @@ func (s *APIKeyRepoSuite) TestIncrementQuotaUsedAndGetState() {
s.Require().Equal(3.5, state.QuotaUsed)
s.Require().Equal(3.0, state.Quota)
s.Require().Equal(service.StatusAPIKeyQuotaExhausted, state.Status)
- s.Require().Equal(key.Key, state.Key)
+ s.Require().Equal(key.KeyHash, state.AuthCacheLocator)
got, err := s.repo.GetByID(s.ctx, key.ID)
s.Require().NoError(err, "GetByID")
@@ -506,7 +544,7 @@ func (s *APIKeyRepoSuite) TestIncrementQuotaUsedAndGetState() {
// 注意:此测试使用 testEntClient(非事务隔离),数据会真正写入数据库。
func TestIncrementQuotaUsed_Concurrent(t *testing.T) {
client := testEntClient(t)
- repo := NewAPIKeyRepository(client, integrationDB).(*apiKeyRepository)
+ repo := NewAPIKeyRepository(client, integrationDB, strictAPIKeyTestProtector{}).(*apiKeyRepository)
ctx := context.Background()
// 创建测试用户和 API Key
diff --git a/backend/internal/repository/api_key_repo_last_used_unit_test.go b/backend/internal/repository/api_key_repo_last_used_unit_test.go
index 7c6e2850e8e..4570fa7774c 100644
--- a/backend/internal/repository/api_key_repo_last_used_unit_test.go
+++ b/backend/internal/repository/api_key_repo_last_used_unit_test.go
@@ -29,8 +29,19 @@ func newAPIKeyRepoSQLite(t *testing.T) (*apiKeyRepository, *dbent.Client) {
drv := entsql.OpenDB(dialect.SQLite, db)
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
t.Cleanup(func() { _ = client.Close() })
+ _, err = db.Exec(`
+ CREATE TABLE IF NOT EXISTS user_group_rate_multipliers (
+ user_id INTEGER NOT NULL,
+ group_id INTEGER NOT NULL,
+ rate_multiplier REAL NULL,
+ rpm_override INTEGER NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (user_id, group_id)
+ )`)
+ require.NoError(t, err)
- return &apiKeyRepository{client: client}, client
+ return &apiKeyRepository{client: client, sql: db, keyProtector: strictAPIKeyTestProtector{}}, client
}
func mustCreateAPIKeyRepoUser(t *testing.T, ctx context.Context, client *dbent.Client, email string) *service.User {
diff --git a/backend/internal/repository/api_key_repo_messages_dispatch_unit_test.go b/backend/internal/repository/api_key_repo_messages_dispatch_unit_test.go
index aba62ead2eb..ac4611c69e6 100644
--- a/backend/internal/repository/api_key_repo_messages_dispatch_unit_test.go
+++ b/backend/internal/repository/api_key_repo_messages_dispatch_unit_test.go
@@ -3,8 +3,10 @@ package repository
import (
"context"
"testing"
+ "time"
dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
@@ -69,6 +71,313 @@ func TestAPIKeyRepository_GetByKeyForAuth_PreservesMessagesDispatchModelConfig_S
got, err := repo.GetByKeyForAuth(ctx, key.Key)
require.NoError(t, err)
+ require.Equal(t, key.Name, got.Name)
require.NotNil(t, got.Group)
require.Equal(t, group.MessagesDispatchModelConfig, got.Group.MessagesDispatchModelConfig)
}
+
+func TestAPIKeyRepository_GetByKeyForAuth_PreservesExplicitAllowedGroupsAndExclusivity_SQLite(t *testing.T) {
+ repo, client := newAPIKeyRepoSQLite(t)
+ ctx := context.Background()
+ user := mustCreateAPIKeyRepoUser(t, ctx, client, "getbykey-auth-wallet-grant@test.com")
+
+ group, err := client.Group.Create().
+ SetName("vip").
+ SetPlatform(service.PlatformAnthropic).
+ SetStatus(service.StatusActive).
+ SetSubscriptionType(service.SubscriptionTypeStandard).
+ SetIsExclusive(true).
+ SetRateMultiplier(1.7).
+ Save(ctx)
+ require.NoError(t, err)
+
+ _, err = client.UserAllowedGroup.Create().
+ SetUserID(user.ID).
+ SetGroupID(group.ID).
+ Save(ctx)
+ require.NoError(t, err)
+
+ key := &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-getbykey-auth-wallet-grant-unit",
+ Name: "VIP Wallet Key",
+ GroupID: &group.ID,
+ Status: service.StatusActive,
+ }
+ require.NoError(t, repo.Create(ctx, key))
+
+ got, err := repo.GetByKeyForAuth(ctx, key.Key)
+ require.NoError(t, err)
+ require.NotNil(t, got.User)
+ require.Contains(t, got.User.AllowedGroups, group.ID)
+ require.NotNil(t, got.Group)
+ require.True(t, got.Group.IsExclusive)
+}
+
+func TestAPIKeyRepository_ValidateAuthCacheSnapshot_DetectsPermissionStateChanges_SQLite(t *testing.T) {
+ repo, client := newAPIKeyRepoSQLite(t)
+ ctx := context.Background()
+ user := mustCreateAPIKeyRepoUser(t, ctx, client, "auth-cache-security-state@test.com")
+
+ group, err := client.Group.Create().
+ SetName("vip").
+ SetPlatform(service.PlatformAnthropic).
+ SetStatus(service.StatusActive).
+ SetSubscriptionType(service.SubscriptionTypeStandard).
+ SetIsExclusive(true).
+ SetRateMultiplier(1.7).
+ Save(ctx)
+ require.NoError(t, err)
+
+ _, err = client.UserAllowedGroup.Create().
+ SetUserID(user.ID).
+ SetGroupID(group.ID).
+ Save(ctx)
+ require.NoError(t, err)
+
+ key := &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-auth-cache-security-state",
+ Name: "VIP key",
+ GroupID: &group.ID,
+ Status: service.StatusActive,
+ }
+ require.NoError(t, repo.Create(ctx, key))
+
+ loaded, err := repo.GetByKeyForAuth(ctx, key.Key)
+ require.NoError(t, err)
+ snapshot := authSnapshotForRepositoryValidation(loaded)
+ cacheLocator := repo.APIKeyAuthCacheLocator(key.Key)
+ rpmOverride := 120
+ _, err = repo.sql.ExecContext(ctx, `
+ INSERT INTO user_group_rate_multipliers (user_id, group_id, rpm_override)
+ VALUES ($1, $2, $3)`, user.ID, group.ID, rpmOverride)
+ require.NoError(t, err)
+ snapshot.User.UserGroupRPMOverride = &rpmOverride
+ refreshSnapshot := func() {
+ current, loadErr := repo.GetByKeyForAuth(ctx, key.Key)
+ require.NoError(t, loadErr)
+ snapshot = authSnapshotForRepositoryValidation(current)
+ snapshot.User.UserGroupRPMOverride = &rpmOverride
+ currentValid, validateErr := repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, validateErr)
+ require.True(t, currentValid)
+ }
+
+ valid, err := repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.True(t, valid)
+
+ _, err = client.UserAllowedGroup.Delete().
+ Where(userallowedgroup.UserIDEQ(user.ID), userallowedgroup.GroupIDEQ(group.ID)).
+ Exec(ctx)
+ require.NoError(t, err)
+ valid, err = repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.False(t, valid, "revoking allowed_groups must invalidate a positive VIP snapshot")
+
+ _, err = client.UserAllowedGroup.Create().SetUserID(user.ID).SetGroupID(group.ID).Save(ctx)
+ require.NoError(t, err)
+ valid, err = repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.True(t, valid)
+
+ _, err = client.Group.UpdateOneID(group.ID).SetStatus(service.StatusDisabled).Save(ctx)
+ require.NoError(t, err)
+ valid, err = repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.False(t, valid, "disabling a group must invalidate its positive auth snapshot")
+ _, err = client.Group.UpdateOneID(group.ID).SetStatus(service.StatusActive).Save(ctx)
+ require.NoError(t, err)
+ refreshSnapshot()
+
+ _, err = client.Group.UpdateOneID(group.ID).SetPlatform(service.PlatformOpenAI).Save(ctx)
+ require.NoError(t, err)
+ valid, err = repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.False(t, valid, "changing platform must invalidate the old routing permission snapshot")
+ _, err = client.Group.UpdateOneID(group.ID).SetPlatform(service.PlatformAnthropic).Save(ctx)
+ require.NoError(t, err)
+ refreshSnapshot()
+
+ _, err = client.Group.UpdateOneID(group.ID).SetIsExclusive(false).Save(ctx)
+ require.NoError(t, err)
+ valid, err = repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.False(t, valid, "changing exclusivity must invalidate the old membership policy snapshot")
+ _, err = client.Group.UpdateOneID(group.ID).SetIsExclusive(true).Save(ctx)
+ require.NoError(t, err)
+ refreshSnapshot()
+
+ _, err = client.Group.UpdateOneID(group.ID).SetRateMultiplier(2.5).Save(ctx)
+ require.NoError(t, err)
+ valid, err = repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.False(t, valid, "a group rate increase must invalidate the older lower-priced snapshot")
+ _, err = client.Group.UpdateOneID(group.ID).SetRateMultiplier(1.7).Save(ctx)
+ require.NoError(t, err)
+ refreshSnapshot()
+
+ _, err = client.Group.UpdateOneID(group.ID).SetDailyLimitUsd(10).Save(ctx)
+ require.NoError(t, err)
+ valid, err = repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.False(t, valid, "a group limit decrease must invalidate the older unlimited snapshot")
+ _, err = client.Group.UpdateOneID(group.ID).ClearDailyLimitUsd().Save(ctx)
+ require.NoError(t, err)
+ refreshSnapshot()
+
+ _, err = repo.sql.ExecContext(ctx, `
+ UPDATE user_group_rate_multipliers
+ SET rpm_override = $3, updated_at = CURRENT_TIMESTAMP
+ WHERE user_id = $1 AND group_id = $2`, user.ID, group.ID, 60)
+ require.NoError(t, err)
+ valid, err = repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.False(t, valid, "a lower user-group RPM override must invalidate the old snapshot")
+ _, err = repo.sql.ExecContext(ctx, `
+ UPDATE user_group_rate_multipliers
+ SET rpm_override = $3, updated_at = CURRENT_TIMESTAMP
+ WHERE user_id = $1 AND group_id = $2`, user.ID, group.ID, rpmOverride)
+ require.NoError(t, err)
+ valid, err = repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.True(t, valid)
+
+ valid, err = repo.ValidateAuthCacheSnapshot(ctx, repo.APIKeyAuthCacheLocator("a different raw key"), snapshot)
+ require.NoError(t, err)
+ require.False(t, valid, "the cache locator must still identify the same database key")
+
+ _, err = client.APIKey.UpdateOneID(key.ID).SetQuota(1).SetQuotaUsed(1).Save(ctx)
+ require.NoError(t, err)
+ valid, err = repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.False(t, valid, "quota exhaustion must invalidate an older usable snapshot")
+ _, err = client.APIKey.UpdateOneID(key.ID).SetQuota(0).SetQuotaUsed(0).Save(ctx)
+ require.NoError(t, err)
+
+ past := time.Now().UTC().Add(-time.Hour).Truncate(time.Second)
+ _, err = client.APIKey.UpdateOneID(key.ID).SetExpiresAt(past).Save(ctx)
+ require.NoError(t, err)
+ valid, err = repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.False(t, valid, "an expiration change must invalidate an unexpired snapshot")
+ _, err = client.APIKey.UpdateOneID(key.ID).ClearExpiresAt().Save(ctx)
+ require.NoError(t, err)
+
+ _, err = client.APIKey.UpdateOneID(key.ID).SetIPWhitelist([]string{"192.0.2.10"}).Save(ctx)
+ require.NoError(t, err)
+ valid, err = repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.False(t, valid, "an IP allow-list change must invalidate the unrestricted snapshot")
+ _, err = client.APIKey.UpdateOneID(key.ID).SetIPWhitelist([]string{}).Save(ctx)
+ require.NoError(t, err)
+
+ replacementKey := "sk-auth-cache-security-state-replaced"
+ _, err = client.APIKey.UpdateOneID(key.ID).
+ SetKey(replacementKey).
+ SetKeyHash(repo.APIKeyAuthCacheLocator(replacementKey)).
+ Save(ctx)
+ require.NoError(t, err)
+ valid, err = repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.False(t, valid, "a replaced key must not leave the old raw key authorized through cache")
+ _, err = client.APIKey.UpdateOneID(key.ID).
+ SetKey(key.Key).
+ SetKeyHash(repo.APIKeyAuthCacheLocator(key.Key)).
+ Save(ctx)
+ require.NoError(t, err)
+ valid, err = repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.True(t, valid)
+
+ _, err = client.User.UpdateOneID(user.ID).SetStatus(service.StatusDisabled).Save(ctx)
+ require.NoError(t, err)
+ valid, err = repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.False(t, valid, "disabling the user must invalidate every cached API key")
+}
+
+func TestAPIKeyRepository_ValidateAuthCacheSnapshot_UniversalWalletComparesAllAllowedGroups_SQLite(t *testing.T) {
+ repo, client := newAPIKeyRepoSQLite(t)
+ ctx := context.Background()
+ user := mustCreateAPIKeyRepoUser(t, ctx, client, "auth-cache-universal-wallet@test.com")
+ group, err := client.Group.Create().
+ SetName("vip").
+ SetPlatform(service.PlatformAnthropic).
+ SetStatus(service.StatusActive).
+ SetSubscriptionType(service.SubscriptionTypeStandard).
+ SetIsExclusive(true).
+ SetRateMultiplier(1).
+ Save(ctx)
+ require.NoError(t, err)
+ _, err = client.UserAllowedGroup.Create().SetUserID(user.ID).SetGroupID(group.ID).Save(ctx)
+ require.NoError(t, err)
+
+ key := &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-auth-cache-universal-wallet",
+ Name: service.WalletUniversalAPIKeyName,
+ Purpose: service.APIKeyPurposeWalletUniversal,
+ Status: service.StatusActive,
+ }
+ require.NoError(t, repo.Create(ctx, key))
+ loaded, err := repo.GetByKeyForAuth(ctx, key.Key)
+ require.NoError(t, err)
+ require.Nil(t, loaded.GroupID)
+ snapshot := authSnapshotForRepositoryValidation(loaded)
+ cacheLocator := repo.APIKeyAuthCacheLocator(key.Key)
+
+ valid, err := repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.True(t, valid)
+ _, err = client.UserAllowedGroup.Delete().
+ Where(userallowedgroup.UserIDEQ(user.ID), userallowedgroup.GroupIDEQ(group.ID)).
+ Exec(ctx)
+ require.NoError(t, err)
+ valid, err = repo.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ require.NoError(t, err)
+ require.False(t, valid, "a NULL-group wallet key must not retain a revoked VIP grant")
+}
+
+func authSnapshotForRepositoryValidation(key *service.APIKey) *service.APIKeyAuthSnapshot {
+ snapshot := &service.APIKeyAuthSnapshot{
+ Version: 11,
+ APIKeyID: key.ID,
+ UserID: key.UserID,
+ GroupID: key.GroupID,
+ Name: key.Name,
+ Purpose: key.Purpose,
+ Status: key.Status,
+ IPWhitelist: append([]string(nil), key.IPWhitelist...),
+ IPBlacklist: append([]string(nil), key.IPBlacklist...),
+ Quota: key.Quota,
+ QuotaUsed: key.QuotaUsed,
+ ExpiresAt: key.ExpiresAt,
+ RateLimit5h: key.RateLimit5h,
+ RateLimit1d: key.RateLimit1d,
+ RateLimit7d: key.RateLimit7d,
+ User: service.APIKeyAuthUserSnapshot{
+ ID: key.User.ID,
+ Status: key.User.Status,
+ Role: key.User.Role,
+ Balance: key.User.Balance,
+ Concurrency: key.User.Concurrency,
+ RPMLimit: key.User.RPMLimit,
+ AllowedGroups: append([]int64(nil), key.User.AllowedGroups...),
+ },
+ }
+ if key.Group != nil {
+ snapshot.Group = &service.APIKeyAuthGroupSnapshot{
+ ID: key.Group.ID,
+ Name: key.Group.Name,
+ Platform: key.Group.Platform,
+ Status: key.Group.Status,
+ IsExclusive: key.Group.IsExclusive,
+ SubscriptionType: key.Group.SubscriptionType,
+ RPMLimit: key.Group.RPMLimit,
+ UpdatedAt: key.Group.UpdatedAt,
+ }
+ }
+ return snapshot
+}
diff --git a/backend/internal/repository/api_key_storage_constraint_integration_test.go b/backend/internal/repository/api_key_storage_constraint_integration_test.go
new file mode 100644
index 00000000000..0cf302c110f
--- /dev/null
+++ b/backend/internal/repository/api_key_storage_constraint_integration_test.go
@@ -0,0 +1,33 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIKeyStartupMigrationValidatesEncryptedStorageConstraint(t *testing.T) {
+ client := testEntClient(t)
+ user := mustCreateUser(t, client, &service.User{Email: "storage-constraint@test.com"})
+ repo := NewAPIKeyRepository(client, integrationDB, strictAPIKeyTestProtector{}).(*apiKeyRepository)
+ _, err := repo.MigratePlaintextAPIKeysToEncrypted(context.Background())
+ require.NoError(t, err)
+
+ var validated bool
+ require.NoError(t, integrationDB.QueryRowContext(context.Background(), `
+ SELECT convalidated FROM pg_constraint
+ WHERE conname = 'chk_api_keys_encrypted_storage'
+ AND conrelid = 'api_keys'::regclass
+ `).Scan(&validated))
+ require.True(t, validated)
+
+ _, err = integrationDB.ExecContext(context.Background(), `
+ INSERT INTO api_keys (user_id, key, key_hash, key_prefix, name, purpose, status)
+ VALUES ($1, 'sk-plaintext-forbidden', $2, 'sk-plain', 'forbidden', 'standard', 'active')
+ `, user.ID, service.HashAPIKey("sk-plaintext-forbidden"))
+ require.Error(t, err, "database must reject plaintext API-key storage after cutover")
+}
diff --git a/backend/internal/repository/bulk_subscription_assignment_atomicity_integration_test.go b/backend/internal/repository/bulk_subscription_assignment_atomicity_integration_test.go
new file mode 100644
index 00000000000..c09dbcc91cd
--- /dev/null
+++ b/backend/internal/repository/bulk_subscription_assignment_atomicity_integration_test.go
@@ -0,0 +1,183 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+func TestBulkAssignSubscriptionPerUserAtomicityAndRetryRepair(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ suffix := uuid.NewString()
+ firstUser := mustCreateUser(t, client, &service.User{
+ Email: "bulk-atomic-first-" + suffix + "@example.test",
+ Username: "bulk-atomic-first-" + suffix,
+ })
+ secondUser := mustCreateUser(t, client, &service.User{
+ Email: "bulk-atomic-second-" + suffix + "@example.test",
+ Username: "bulk-atomic-second-" + suffix,
+ })
+ group := mustCreateGroup(t, client, &service.Group{
+ Name: "bulk-atomic-" + suffix,
+ Platform: service.PlatformOpenAI,
+ SubscriptionType: service.SubscriptionTypeSubscription,
+ Status: service.StatusActive,
+ })
+
+ svc := service.NewSubscriptionService(
+ NewGroupRepository(client, integrationDB),
+ NewUserSubscriptionRepository(client),
+ nil,
+ client,
+ nil,
+ )
+ removeFailure := installBulkAllowedGroupInsertFailure(t, ctx, firstUser.ID, group.ID)
+ input := &service.BulkAssignSubscriptionInput{
+ UserIDs: []int64{firstUser.ID, secondUser.ID},
+ GroupID: group.ID,
+ ValidityDays: 30,
+ AssignedBy: secondUser.ID,
+ Notes: "bulk atomic assignment",
+ }
+
+ first, err := svc.BulkAssignSubscription(ctx, input)
+ require.NoError(t, err)
+ require.Equal(t, 1, first.SuccessCount)
+ require.Equal(t, 1, first.CreatedCount)
+ require.Equal(t, 1, first.FailedCount)
+ require.Equal(t, "failed", first.Statuses[firstUser.ID])
+ require.Equal(t, "created", first.Statuses[secondUser.ID])
+ require.Equal(t, 0, bulkSubscriptionCount(t, ctx, firstUser.ID, group.ID),
+ "a grant failure must roll back the same user's subscription row")
+ require.Equal(t, 0, bulkAllowedGroupCount(t, ctx, firstUser.ID, group.ID))
+ require.Equal(t, 1, bulkSubscriptionCount(t, ctx, secondUser.ID, group.ID),
+ "one user's failure must not roll back another user's committed assignment")
+ require.Equal(t, 1, bulkAllowedGroupCount(t, ctx, secondUser.ID, group.ID))
+
+ removeFailure()
+ retry, err := svc.BulkAssignSubscription(ctx, input)
+ require.NoError(t, err)
+ require.Equal(t, 2, retry.SuccessCount)
+ require.Equal(t, 1, retry.CreatedCount)
+ require.Equal(t, 1, retry.ReusedCount)
+ require.Zero(t, retry.FailedCount)
+ require.Equal(t, "created", retry.Statuses[firstUser.ID])
+ require.Equal(t, "reused", retry.Statuses[secondUser.ID])
+ require.Equal(t, 1, bulkSubscriptionCount(t, ctx, firstUser.ID, group.ID))
+ require.Equal(t, 1, bulkAllowedGroupCount(t, ctx, firstUser.ID, group.ID))
+ require.Equal(t, 1, bulkSubscriptionCount(t, ctx, secondUser.ID, group.ID),
+ "an ACK-loss style retry must reuse, not duplicate, an already committed subscription")
+ require.Equal(t, 1, bulkAllowedGroupCount(t, ctx, secondUser.ID, group.ID))
+}
+
+func TestBulkAssignSubscriptionReusedAssignmentRepairsMissingGrant(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ suffix := uuid.NewString()
+ user := mustCreateUser(t, client, &service.User{
+ Email: "bulk-repair-" + suffix + "@example.test",
+ Username: "bulk-repair-" + suffix,
+ })
+ group := mustCreateGroup(t, client, &service.Group{
+ Name: "bulk-repair-" + suffix,
+ Platform: service.PlatformOpenAI,
+ SubscriptionType: service.SubscriptionTypeSubscription,
+ Status: service.StatusActive,
+ })
+ svc := service.NewSubscriptionService(
+ NewGroupRepository(client, integrationDB),
+ NewUserSubscriptionRepository(client),
+ nil,
+ client,
+ nil,
+ )
+
+ created, err := svc.AssignSubscription(ctx, &service.AssignSubscriptionInput{
+ UserID: user.ID,
+ GroupID: group.ID,
+ ValidityDays: 30,
+ Notes: "repair missing grant",
+ })
+ require.NoError(t, err)
+ require.NotNil(t, created)
+ _, err = integrationDB.ExecContext(ctx, `
+ DELETE FROM user_allowed_groups WHERE user_id = $1 AND group_id = $2
+ `, user.ID, group.ID)
+ require.NoError(t, err)
+ require.Equal(t, 0, bulkAllowedGroupCount(t, ctx, user.ID, group.ID))
+
+ result, err := svc.BulkAssignSubscription(ctx, &service.BulkAssignSubscriptionInput{
+ UserIDs: []int64{user.ID},
+ GroupID: group.ID,
+ ValidityDays: 30,
+ Notes: "repair missing grant",
+ })
+ require.NoError(t, err)
+ require.Equal(t, 1, result.SuccessCount)
+ require.Equal(t, 1, result.ReusedCount)
+ require.Equal(t, "reused", result.Statuses[user.ID])
+ require.Equal(t, 1, bulkSubscriptionCount(t, ctx, user.ID, group.ID))
+ require.Equal(t, 1, bulkAllowedGroupCount(t, ctx, user.ID, group.ID),
+ "the reused branch must idempotently repair the access grant")
+}
+
+func installBulkAllowedGroupInsertFailure(t *testing.T, ctx context.Context, userID, groupID int64) func() {
+ t.Helper()
+ suffix := strings.ReplaceAll(uuid.NewString(), "-", "")
+ functionName := "test_fail_bulk_allowed_group_" + suffix
+ triggerName := "trg_fail_bulk_allowed_group_" + suffix
+ _, err := integrationDB.ExecContext(ctx, fmt.Sprintf(`
+ CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $$
+ BEGIN
+ IF NEW.user_id = %d AND NEW.group_id = %d THEN
+ RAISE EXCEPTION 'injected bulk allowed-group failure';
+ END IF;
+ RETURN NEW;
+ END $$;
+ CREATE TRIGGER %s BEFORE INSERT ON user_allowed_groups
+ FOR EACH ROW EXECUTE FUNCTION %s();
+ `, functionName, userID, groupID, triggerName, functionName))
+ require.NoError(t, err)
+ removed := false
+ remove := func() {
+ if removed {
+ return
+ }
+ removed = true
+ _, dropErr := integrationDB.ExecContext(context.Background(), fmt.Sprintf(`
+ DROP TRIGGER IF EXISTS %s ON user_allowed_groups;
+ DROP FUNCTION IF EXISTS %s();
+ `, triggerName, functionName))
+ require.NoError(t, dropErr)
+ }
+ t.Cleanup(remove)
+ return remove
+}
+
+func bulkSubscriptionCount(t *testing.T, ctx context.Context, userID, groupID int64) int {
+ t.Helper()
+ var count int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM user_subscriptions
+ WHERE user_id = $1 AND group_id = $2 AND deleted_at IS NULL
+ `, userID, groupID).Scan(&count))
+ return count
+}
+
+func bulkAllowedGroupCount(t *testing.T, ctx context.Context, userID, groupID int64) int {
+ t.Helper()
+ var count int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM user_allowed_groups WHERE user_id = $1 AND group_id = $2
+ `, userID, groupID).Scan(&count))
+ return count
+}
diff --git a/backend/internal/repository/channel_monitor_template_repo.go b/backend/internal/repository/channel_monitor_template_repo.go
index 845d186b17a..f08af2f07ae 100644
--- a/backend/internal/repository/channel_monitor_template_repo.go
+++ b/backend/internal/repository/channel_monitor_template_repo.go
@@ -103,7 +103,7 @@ func (r *channelMonitorRequestTemplateRepository) List(ctx context.Context, para
return out, nil
}
-// ApplyToMonitors 把模板当前配置覆盖到 monitorIDs 列表里的关联监控。
+// ApplyToMonitors 把模板当前的密文 envelope 快照覆盖到 monitorIDs 列表里的关联监控。
// WHERE 双重过滤:template_id = id AND id IN (monitorIDs),防止用户传了未关联本模板的 id
// 就被覆盖。走 ent UpdateMany 保留 hooks。
func (r *channelMonitorRequestTemplateRepository) ApplyToMonitors(ctx context.Context, id int64, monitorIDs []int64) (int64, error) {
diff --git a/backend/internal/repository/claude_usage_service.go b/backend/internal/repository/claude_usage_service.go
index b44adde248f..bca34b158ec 100644
--- a/backend/internal/repository/claude_usage_service.go
+++ b/backend/internal/repository/claude_usage_service.go
@@ -15,25 +15,26 @@ import (
const defaultClaudeUsageURL = "https://api.anthropic.com/api/oauth/usage"
-// 默认 User-Agent,与用户抓包的请求一致
-const defaultUsageUserAgent = "claude-code/2.1.7"
+// Use a truthful service identity for the account-management usage endpoint.
+// A caller-supplied Claude Code fingerprint must never influence this request.
+const defaultUsageUserAgent = "sub2api-usage/1"
type claudeUsageService struct {
usageURL string
allowPrivateHosts bool
- httpUpstream service.HTTPUpstream
}
// NewClaudeUsageFetcher 创建 Claude 用量获取服务
-// httpUpstream: 可选,如果提供则支持 TLS 指纹伪装
-func NewClaudeUsageFetcher(httpUpstream service.HTTPUpstream) service.ClaudeUsageFetcher {
+// The HTTPUpstream argument is retained for dependency-injection compatibility.
+// Usage lookups deliberately use the normal validated HTTP client and never a
+// client-fingerprint transport.
+func NewClaudeUsageFetcher(_ service.HTTPUpstream) service.ClaudeUsageFetcher {
return &claudeUsageService{
- usageURL: defaultClaudeUsageURL,
- httpUpstream: httpUpstream,
+ usageURL: defaultClaudeUsageURL,
}
}
-// FetchUsage 简单版本,不支持 TLS 指纹(向后兼容)
+// FetchUsage 获取 Anthropic OAuth 用量数据。
func (s *claudeUsageService) FetchUsage(ctx context.Context, accessToken, proxyURL string) (*service.ClaudeUsageResponse, error) {
return s.FetchUsageWithOptions(ctx, &service.ClaudeUsageFetchOptions{
AccessToken: accessToken,
@@ -41,7 +42,8 @@ func (s *claudeUsageService) FetchUsage(ctx context.Context, accessToken, proxyU
})
}
-// FetchUsageWithOptions 完整版本,支持 TLS 指纹和自定义 User-Agent
+// FetchUsageWithOptions uses only the explicit access token and proxy. It does
+// not claim that sub2api is an official Claude Code client.
func (s *claudeUsageService) FetchUsageWithOptions(ctx context.Context, opts *service.ClaudeUsageFetchOptions) (*service.ClaudeUsageResponse, error) {
if opts == nil {
return nil, fmt.Errorf("options is nil")
@@ -59,37 +61,21 @@ func (s *claudeUsageService) FetchUsageWithOptions(ctx context.Context, opts *se
req.Header.Set("Authorization", "Bearer "+opts.AccessToken)
req.Header.Set("anthropic-beta", "oauth-2025-04-20")
- // 设置 User-Agent(优先使用缓存的 Fingerprint,否则使用默认值)
- userAgent := defaultUsageUserAgent
- if opts.Fingerprint != nil && opts.Fingerprint.UserAgent != "" {
- userAgent = opts.Fingerprint.UserAgent
+ req.Header.Set("User-Agent", defaultUsageUserAgent)
+
+ client, err := httpclient.GetClient(httpclient.Options{
+ ProxyURL: opts.ProxyURL,
+ Timeout: 30 * time.Second,
+ ValidateResolvedIP: true,
+ AllowPrivateHosts: s.allowPrivateHosts,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("create http client failed: %w", err)
}
- req.Header.Set("User-Agent", userAgent)
-
- var resp *http.Response
-
- // 如果有 TLS Profile 且有 HTTPUpstream,使用 DoWithTLS
- if opts.TLSProfile != nil && s.httpUpstream != nil {
- resp, err = s.httpUpstream.DoWithTLS(req, opts.ProxyURL, opts.AccountID, 0, opts.TLSProfile)
- if err != nil {
- return nil, fmt.Errorf("request with TLS fingerprint failed: %w", err)
- }
- } else {
- // 不启用 TLS 指纹,使用普通 HTTP 客户端
- client, err := httpclient.GetClient(httpclient.Options{
- ProxyURL: opts.ProxyURL,
- Timeout: 30 * time.Second,
- ValidateResolvedIP: true,
- AllowPrivateHosts: s.allowPrivateHosts,
- })
- if err != nil {
- return nil, fmt.Errorf("create http client failed: %w", err)
- }
-
- resp, err = client.Do(req)
- if err != nil {
- return nil, fmt.Errorf("request failed: %w", err)
- }
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
diff --git a/backend/internal/repository/claude_usage_service_test.go b/backend/internal/repository/claude_usage_service_test.go
index cbd0b6d3eaa..b269ffe7991 100644
--- a/backend/internal/repository/claude_usage_service_test.go
+++ b/backend/internal/repository/claude_usage_service_test.go
@@ -2,11 +2,14 @@ package repository
import (
"context"
+ "errors"
"io"
"net/http"
"net/http/httptest"
"testing"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
+ "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
@@ -28,6 +31,21 @@ func (s *ClaudeUsageServiceSuite) TearDownTest() {
type usageRequestCapture struct {
authorization string
anthropicBeta string
+ userAgent string
+}
+
+type rejectingUsageHTTPUpstream struct {
+ called bool
+}
+
+func (u *rejectingUsageHTTPUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
+ u.called = true
+ return nil, errors.New("unexpected HTTPUpstream.Do call")
+}
+
+func (u *rejectingUsageHTTPUpstream) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) {
+ u.called = true
+ return nil, errors.New("unexpected HTTPUpstream.DoWithTLS call")
}
func (s *ClaudeUsageServiceSuite) TestFetchUsage_Success() {
@@ -61,6 +79,33 @@ func (s *ClaudeUsageServiceSuite) TestFetchUsage_Success() {
require.Equal(s.T(), "oauth-2025-04-20", captured.anthropicBeta, "anthropic-beta header mismatch")
}
+func (s *ClaudeUsageServiceSuite) TestFetchUsageWithOptions_DoesNotClaimClaudeCodeIdentity() {
+ var captured usageRequestCapture
+ upstream := &rejectingUsageHTTPUpstream{}
+
+ s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ captured.userAgent = r.Header.Get("User-Agent")
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = io.WriteString(w, `{
+ "five_hour": {"utilization": 0, "resets_at": "2025-01-01T00:00:00Z"},
+ "seven_day": {"utilization": 0, "resets_at": "2025-01-08T00:00:00Z"},
+ "seven_day_sonnet": {"utilization": 0, "resets_at": "2025-01-08T00:00:00Z"}
+}`)
+ }))
+
+ s.fetcher = NewClaudeUsageFetcher(upstream).(*claudeUsageService)
+ s.fetcher.usageURL = s.srv.URL
+ s.fetcher.allowPrivateHosts = true
+
+ _, err := s.fetcher.FetchUsageWithOptions(context.Background(), &service.ClaudeUsageFetchOptions{
+ AccessToken: "at",
+ })
+
+ require.NoError(s.T(), err)
+ require.False(s.T(), upstream.called, "usage lookup must not use a fingerprinting transport")
+ require.Equal(s.T(), "sub2api-usage/1", captured.userAgent)
+}
+
func (s *ClaudeUsageServiceSuite) TestFetchUsage_NonOK() {
s.srv = newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
diff --git a/backend/internal/repository/content_moderation_hash_cache.go b/backend/internal/repository/content_moderation_hash_cache.go
new file mode 100644
index 00000000000..782999e7a37
--- /dev/null
+++ b/backend/internal/repository/content_moderation_hash_cache.go
@@ -0,0 +1,71 @@
+package repository
+
+import (
+ "context"
+ "strings"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/redis/go-redis/v9"
+)
+
+const contentModerationFlaggedHashSetKey = "content_moderation:flagged_hashes"
+
+type contentModerationHashCache struct {
+ rdb *redis.Client
+}
+
+func NewContentModerationHashCache(rdb *redis.Client) service.ContentModerationHashCache {
+ return &contentModerationHashCache{rdb: rdb}
+}
+
+func (c *contentModerationHashCache) RecordFlaggedInputHash(ctx context.Context, inputHash string) error {
+ inputHash = strings.TrimSpace(inputHash)
+ if c == nil || c.rdb == nil || inputHash == "" {
+ return nil
+ }
+ return c.rdb.SAdd(ctx, contentModerationFlaggedHashSetKey, inputHash).Err()
+}
+
+func (c *contentModerationHashCache) HasFlaggedInputHash(ctx context.Context, inputHash string) (bool, error) {
+ inputHash = strings.TrimSpace(inputHash)
+ if c == nil || c.rdb == nil || inputHash == "" {
+ return false, nil
+ }
+ return c.rdb.SIsMember(ctx, contentModerationFlaggedHashSetKey, inputHash).Result()
+}
+
+func (c *contentModerationHashCache) DeleteFlaggedInputHash(ctx context.Context, inputHash string) (bool, error) {
+ inputHash = strings.TrimSpace(inputHash)
+ if c == nil || c.rdb == nil || inputHash == "" {
+ return false, nil
+ }
+ deleted, err := c.rdb.SRem(ctx, contentModerationFlaggedHashSetKey, inputHash).Result()
+ if err != nil {
+ return false, err
+ }
+ return deleted > 0, nil
+}
+
+func (c *contentModerationHashCache) ClearFlaggedInputHashes(ctx context.Context) (int64, error) {
+ if c == nil || c.rdb == nil {
+ return 0, nil
+ }
+ deleted, err := c.rdb.SCard(ctx, contentModerationFlaggedHashSetKey).Result()
+ if err != nil {
+ return 0, err
+ }
+ if deleted == 0 {
+ return 0, nil
+ }
+ if err := c.rdb.Del(ctx, contentModerationFlaggedHashSetKey).Err(); err != nil {
+ return 0, err
+ }
+ return deleted, nil
+}
+
+func (c *contentModerationHashCache) CountFlaggedInputHashes(ctx context.Context) (int64, error) {
+ if c == nil || c.rdb == nil {
+ return 0, nil
+ }
+ return c.rdb.SCard(ctx, contentModerationFlaggedHashSetKey).Result()
+}
diff --git a/backend/internal/repository/content_moderation_repo.go b/backend/internal/repository/content_moderation_repo.go
new file mode 100644
index 00000000000..257d8a85650
--- /dev/null
+++ b/backend/internal/repository/content_moderation_repo.go
@@ -0,0 +1,307 @@
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+type contentModerationRepository struct {
+ db *sql.DB
+}
+
+func NewContentModerationRepository(db *sql.DB) service.ContentModerationRepository {
+ return &contentModerationRepository{db: db}
+}
+
+func (r *contentModerationRepository) CreateLog(ctx context.Context, log *service.ContentModerationLog) error {
+ if log == nil {
+ return nil
+ }
+ categoryScores, err := json.Marshal(log.CategoryScores)
+ if err != nil {
+ return fmt.Errorf("marshal moderation category scores: %w", err)
+ }
+ categoryFlags, err := json.Marshal(log.CategoryFlags)
+ if err != nil {
+ return fmt.Errorf("marshal moderation category flags: %w", err)
+ }
+ categoryAppliedInputTypes, err := json.Marshal(log.CategoryAppliedInputTypes)
+ if err != nil {
+ return fmt.Errorf("marshal moderation category applied input types: %w", err)
+ }
+ thresholdSnapshot, err := json.Marshal(log.ThresholdSnapshot)
+ if err != nil {
+ return fmt.Errorf("marshal moderation thresholds: %w", err)
+ }
+ outputHashes, err := json.Marshal(log.OutputHashes)
+ if err != nil {
+ return fmt.Errorf("marshal moderation output hashes: %w", err)
+ }
+ var userID any
+ if log.UserID != nil {
+ userID = *log.UserID
+ }
+ var apiKeyID any
+ if log.APIKeyID != nil {
+ apiKeyID = *log.APIKeyID
+ }
+ var groupID any
+ if log.GroupID != nil {
+ groupID = *log.GroupID
+ }
+ var latency any
+ if log.UpstreamLatencyMS != nil {
+ latency = *log.UpstreamLatencyMS
+ }
+ err = r.db.QueryRowContext(ctx, `
+INSERT INTO content_moderation_logs (
+ request_id, user_id, user_email, api_key_id, api_key_name, group_id, group_name,
+ endpoint, provider, model, stage, mode, action, flagged, highest_category, highest_score,
+ category_scores, category_flags, category_applied_input_types, threshold_snapshot,
+ input_hash, output_hashes, policy_rule, upstream_request_id, safety_identifier,
+ input_excerpt, upstream_latency_ms, error, violation_count, auto_banned, email_sent, queue_delay_ms
+) VALUES (
+ $1, $2, $3, $4, $5, $6, $7,
+ $8, $9, $10, $11, $12, $13, $14, $15, $16,
+ $17::jsonb, $18::jsonb, $19::jsonb, $20::jsonb,
+ $21, $22::jsonb, $23, $24, $25,
+ $26, $27, $28, $29, $30, $31, $32
+) RETURNING id, created_at`,
+ log.RequestID, userID, log.UserEmail, apiKeyID, log.APIKeyName, groupID, log.GroupName,
+ log.Endpoint, log.Provider, log.Model, log.Stage, log.Mode, log.Action, log.Flagged, log.HighestCategory, log.HighestScore,
+ string(categoryScores), string(categoryFlags), string(categoryAppliedInputTypes), string(thresholdSnapshot),
+ log.InputHash, string(outputHashes), log.PolicyRule, log.UpstreamRequestID, log.SafetyIdentifier,
+ log.InputExcerpt, latency, log.Error,
+ log.ViolationCount, log.AutoBanned, log.EmailSent, nullableIntPtr(log.QueueDelayMS),
+ ).Scan(&log.ID, &log.CreatedAt)
+ if err != nil {
+ return fmt.Errorf("insert content moderation log: %w", err)
+ }
+ return nil
+}
+
+func (r *contentModerationRepository) ListLogs(ctx context.Context, filter service.ContentModerationLogFilter) ([]service.ContentModerationLog, *pagination.PaginationResult, error) {
+ where, args := buildContentModerationLogWhere(filter)
+ whereSQL := "WHERE " + strings.Join(where, " AND ")
+
+ var total int64
+ if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM content_moderation_logs l "+whereSQL, args...).Scan(&total); err != nil {
+ return nil, nil, fmt.Errorf("count content moderation logs: %w", err)
+ }
+
+ params := filter.Pagination
+ if params.Page <= 0 {
+ params.Page = 1
+ }
+ if params.PageSize <= 0 {
+ params.PageSize = 20
+ }
+ if params.PageSize > 100 {
+ params.PageSize = 100
+ }
+ queryArgs := append([]any{}, args...)
+ queryArgs = append(queryArgs, params.Limit(), params.Offset())
+ rows, err := r.db.QueryContext(ctx, `
+SELECT
+ l.id, l.request_id, l.user_id, l.user_email, l.api_key_id, l.api_key_name, l.group_id, l.group_name,
+ l.endpoint, l.provider, l.model, COALESCE(l.stage, 'input'), l.mode, l.action, l.flagged, l.highest_category, l.highest_score,
+ l.category_scores, COALESCE(l.category_flags, '{}'::jsonb), COALESCE(l.category_applied_input_types, '{}'::jsonb),
+ l.threshold_snapshot, COALESCE(l.input_hash, ''), COALESCE(l.output_hashes, '[]'::jsonb),
+ COALESCE(l.policy_rule, ''), COALESCE(l.upstream_request_id, ''), COALESCE(l.safety_identifier, ''),
+ l.input_excerpt, l.upstream_latency_ms, l.error,
+ l.violation_count, l.auto_banned, l.email_sent, COALESCE(u.status, ''), l.queue_delay_ms, l.created_at
+FROM content_moderation_logs l
+LEFT JOIN users u ON u.id = l.user_id `+whereSQL+`
+ORDER BY l.created_at DESC, l.id DESC
+LIMIT $`+fmt.Sprint(len(queryArgs)-1)+` OFFSET $`+fmt.Sprint(len(queryArgs)),
+ queryArgs...,
+ )
+ if err != nil {
+ return nil, nil, fmt.Errorf("list content moderation logs: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+
+ items := make([]service.ContentModerationLog, 0)
+ for rows.Next() {
+ var item service.ContentModerationLog
+ var userID, apiKeyID, groupID, latency, queueDelay sql.NullInt64
+ var scoresRaw, flagsRaw, appliedInputTypesRaw, thresholdsRaw, outputHashesRaw []byte
+ if err := rows.Scan(
+ &item.ID,
+ &item.RequestID,
+ &userID,
+ &item.UserEmail,
+ &apiKeyID,
+ &item.APIKeyName,
+ &groupID,
+ &item.GroupName,
+ &item.Endpoint,
+ &item.Provider,
+ &item.Model,
+ &item.Stage,
+ &item.Mode,
+ &item.Action,
+ &item.Flagged,
+ &item.HighestCategory,
+ &item.HighestScore,
+ &scoresRaw,
+ &flagsRaw,
+ &appliedInputTypesRaw,
+ &thresholdsRaw,
+ &item.InputHash,
+ &outputHashesRaw,
+ &item.PolicyRule,
+ &item.UpstreamRequestID,
+ &item.SafetyIdentifier,
+ &item.InputExcerpt,
+ &latency,
+ &item.Error,
+ &item.ViolationCount,
+ &item.AutoBanned,
+ &item.EmailSent,
+ &item.UserStatus,
+ &queueDelay,
+ &item.CreatedAt,
+ ); err != nil {
+ return nil, nil, fmt.Errorf("scan content moderation log: %w", err)
+ }
+ if userID.Valid {
+ v := userID.Int64
+ item.UserID = &v
+ }
+ if apiKeyID.Valid {
+ v := apiKeyID.Int64
+ item.APIKeyID = &v
+ }
+ if groupID.Valid {
+ v := groupID.Int64
+ item.GroupID = &v
+ }
+ if latency.Valid {
+ v := int(latency.Int64)
+ item.UpstreamLatencyMS = &v
+ }
+ if queueDelay.Valid {
+ v := int(queueDelay.Int64)
+ item.QueueDelayMS = &v
+ }
+ item.CategoryScores = map[string]float64{}
+ _ = json.Unmarshal(scoresRaw, &item.CategoryScores)
+ item.CategoryFlags = map[string]bool{}
+ _ = json.Unmarshal(flagsRaw, &item.CategoryFlags)
+ item.CategoryAppliedInputTypes = map[string][]string{}
+ _ = json.Unmarshal(appliedInputTypesRaw, &item.CategoryAppliedInputTypes)
+ item.ThresholdSnapshot = map[string]float64{}
+ _ = json.Unmarshal(thresholdsRaw, &item.ThresholdSnapshot)
+ item.OutputHashes = []string{}
+ _ = json.Unmarshal(outputHashesRaw, &item.OutputHashes)
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, nil, fmt.Errorf("iterate content moderation logs: %w", err)
+ }
+ return items, paginationResultFromTotal(total, params), nil
+}
+
+func (r *contentModerationRepository) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time) (int, error) {
+ if userID <= 0 {
+ return 0, nil
+ }
+ var count int
+ err := r.db.QueryRowContext(ctx, `
+WITH last_auto_ban AS (
+ SELECT MAX(created_at) AS at
+ FROM content_moderation_logs
+ WHERE user_id = $1 AND auto_banned = TRUE
+)
+SELECT COUNT(*)
+FROM content_moderation_logs
+WHERE user_id = $1
+ AND flagged = TRUE
+ AND created_at >= $2
+ AND created_at > COALESCE((SELECT at FROM last_auto_ban), '-infinity'::timestamptz)
+`, userID, since).Scan(&count)
+ if err != nil {
+ return 0, fmt.Errorf("count user content moderation flagged logs: %w", err)
+ }
+ return count, nil
+}
+
+func (r *contentModerationRepository) CleanupExpiredLogs(ctx context.Context, hitBefore time.Time, nonHitBefore time.Time) (*service.ContentModerationCleanupResult, error) {
+ result := &service.ContentModerationCleanupResult{FinishedAt: time.Now()}
+ if r == nil || r.db == nil {
+ return result, nil
+ }
+ hitExec, err := r.db.ExecContext(ctx, `
+DELETE FROM content_moderation_logs
+WHERE flagged = TRUE AND created_at < $1
+`, hitBefore)
+ if err != nil {
+ return nil, fmt.Errorf("delete expired hit content moderation logs: %w", err)
+ }
+ result.DeletedHit, _ = hitExec.RowsAffected()
+
+ nonHitExec, err := r.db.ExecContext(ctx, `
+DELETE FROM content_moderation_logs
+WHERE flagged = FALSE AND created_at < $1
+`, nonHitBefore)
+ if err != nil {
+ return nil, fmt.Errorf("delete expired non-hit content moderation logs: %w", err)
+ }
+ result.DeletedNonHit, _ = nonHitExec.RowsAffected()
+
+ result.FinishedAt = time.Now()
+ return result, nil
+}
+
+func nullableIntPtr(value *int) any {
+ if value == nil {
+ return nil
+ }
+ return *value
+}
+
+func buildContentModerationLogWhere(filter service.ContentModerationLogFilter) ([]string, []any) {
+ where := []string{"l.id IS NOT NULL"}
+ args := make([]any, 0)
+ add := func(expr string, value any) {
+ args = append(args, value)
+ where = append(where, fmt.Sprintf(expr, len(args)))
+ }
+ switch strings.ToLower(strings.TrimSpace(filter.Result)) {
+ case "hit", "flagged":
+ where = append(where, "l.flagged = TRUE")
+ case "blocked", "block":
+ where = append(where, "l.action = 'block'")
+ case "pass", "allow":
+ where = append(where, "l.flagged = FALSE AND l.error = ''")
+ case "error":
+ where = append(where, "l.error <> ''")
+ }
+ if filter.GroupID != nil {
+ add("l.group_id = $%d", *filter.GroupID)
+ }
+ if endpoint := strings.TrimSpace(filter.Endpoint); endpoint != "" {
+ add("l.endpoint = $%d", endpoint)
+ }
+ if search := strings.TrimSpace(filter.Search); search != "" {
+ like := "%" + search + "%"
+ args = append(args, like, like, like, like, like)
+ idx := len(args) - 4
+ where = append(where, fmt.Sprintf("(l.request_id ILIKE $%d OR l.user_email ILIKE $%d OR l.api_key_name ILIKE $%d OR l.model ILIKE $%d OR l.input_excerpt ILIKE $%d)", idx, idx+1, idx+2, idx+3, idx+4))
+ }
+ if filter.From != nil && !filter.From.IsZero() {
+ add("l.created_at >= $%d", *filter.From)
+ }
+ if filter.To != nil && !filter.To.IsZero() {
+ add("l.created_at <= $%d", *filter.To)
+ }
+ return where, args
+}
diff --git a/backend/internal/repository/credential_encryption.go b/backend/internal/repository/credential_encryption.go
new file mode 100644
index 00000000000..58dded4094b
--- /dev/null
+++ b/backend/internal/repository/credential_encryption.go
@@ -0,0 +1,233 @@
+package repository
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+const (
+ encryptedCredentialMarkerKey = "__sub2api_encrypted"
+ encryptedCredentialAlgKey = "alg"
+ encryptedCredentialValueKey = "ciphertext"
+ encryptedCredentialAlgV1 = "aes-256-gcm-json-v1"
+ encryptedCredentialAlgV2 = "aes-256-gcm-json-domain-v2"
+ encryptedCredentialAlgV3 = "aes-256-gcm-json-domain-v3"
+)
+
+var sensitiveCredentialKeys = map[string]struct{}{
+ "access_token": {},
+ "api_key": {},
+ "apikey": {},
+ "auth_token": {},
+ "bearer_token": {},
+ "client_secret": {},
+ "cookie": {},
+ "cookies": {},
+ "credentials": {},
+ "id_token": {},
+ "password": {},
+ "private_key": {},
+ "refresh_token": {},
+ "secret": {},
+ "secret_access_key": {},
+ "service_account_json": {},
+ "session_key": {},
+ "token": {},
+}
+
+func encryptAccountCredentials(in map[string]any, encryptor service.SecretEncryptor) (map[string]any, error) {
+ if in == nil {
+ return nil, nil
+ }
+ out, err := encryptCredentialMap(in, encryptor)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func decryptAccountCredentials(in map[string]any, encryptor service.SecretEncryptor) (map[string]any, error) {
+ if in == nil {
+ return nil, nil
+ }
+ out, err := decryptCredentialMap(in, encryptor)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func encryptCredentialMap(in map[string]any, encryptor service.SecretEncryptor) (map[string]any, error) {
+ out := make(map[string]any, len(in))
+ for key, value := range in {
+ encrypted, err := encryptCredentialValue(key, value, encryptor)
+ if err != nil {
+ return nil, fmt.Errorf("encrypt credential %q: %w", key, err)
+ }
+ out[key] = encrypted
+ }
+ return out, nil
+}
+
+func decryptCredentialMap(in map[string]any, encryptor service.SecretEncryptor) (map[string]any, error) {
+ out := make(map[string]any, len(in))
+ for key, value := range in {
+ decrypted, err := decryptCredentialValue(value, encryptor)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt credential %q: %w", key, err)
+ }
+ out[key] = decrypted
+ }
+ return out, nil
+}
+
+func encryptCredentialValue(key string, value any, encryptor service.SecretEncryptor) (any, error) {
+ if value == nil {
+ return nil, nil
+ }
+ alg, ciphertext, envelope, err := parseEncryptedCredentialEnvelope(value)
+ if err != nil {
+ return nil, err
+ }
+ if envelope {
+ return preserveDomainCredentialEnvelope(value, alg, ciphertext, encryptor)
+ }
+ if isSensitiveCredentialKey(key) {
+ return encryptCredentialJSON(value, encryptor)
+ }
+
+ switch typed := value.(type) {
+ case map[string]any:
+ return encryptCredentialMap(typed, encryptor)
+ case []any:
+ out := make([]any, len(typed))
+ for i, item := range typed {
+ encrypted, err := encryptCredentialValue("", item, encryptor)
+ if err != nil {
+ return nil, err
+ }
+ out[i] = encrypted
+ }
+ return out, nil
+ default:
+ return copyJSONValue(value), nil
+ }
+}
+
+func preserveDomainCredentialEnvelope(value any, alg, ciphertext string, encryptor service.SecretEncryptor) (any, error) {
+ if alg != encryptedCredentialAlgV3 {
+ return nil, fmt.Errorf("legacy credential envelope requires security migration")
+ }
+ plaintext, err := service.DecryptForSecretDomain(encryptor, service.SecretDomainAccountCredential, ciphertext)
+ if err != nil {
+ return nil, fmt.Errorf("validate domain-bound credential: %w", err)
+ }
+ var decoded any
+ if err := json.Unmarshal([]byte(plaintext), &decoded); err != nil {
+ return nil, fmt.Errorf("validate domain-bound credential payload: %w", err)
+ }
+ return copyJSONValue(value), nil
+}
+
+func encryptCredentialJSON(value any, encryptor service.SecretEncryptor) (any, error) {
+ payload, err := json.Marshal(value)
+ if err != nil {
+ return nil, err
+ }
+ ciphertext, err := service.EncryptForSecretDomain(encryptor, service.SecretDomainAccountCredential, string(payload))
+ if err != nil {
+ return nil, err
+ }
+ return encryptedCredentialEnvelope(encryptedCredentialAlgV3, ciphertext), nil
+}
+
+func decryptCredentialValue(value any, encryptor service.SecretEncryptor) (any, error) {
+ alg, ciphertext, envelope, err := parseEncryptedCredentialEnvelope(value)
+ if err != nil {
+ return nil, err
+ }
+ if envelope {
+ if alg != encryptedCredentialAlgV3 {
+ return nil, fmt.Errorf("legacy credential envelope requires security migration")
+ }
+ plaintext, err := service.DecryptForSecretDomain(encryptor, service.SecretDomainAccountCredential, ciphertext)
+ if err != nil {
+ return nil, err
+ }
+ var out any
+ if err := json.Unmarshal([]byte(plaintext), &out); err != nil {
+ return nil, err
+ }
+ return out, nil
+ }
+
+ switch typed := value.(type) {
+ case map[string]any:
+ return decryptCredentialMap(typed, encryptor)
+ case []any:
+ out := make([]any, len(typed))
+ for i, item := range typed {
+ decrypted, err := decryptCredentialValue(item, encryptor)
+ if err != nil {
+ return nil, err
+ }
+ out[i] = decrypted
+ }
+ return out, nil
+ default:
+ return copyJSONValue(value), nil
+ }
+}
+
+func isSensitiveCredentialKey(key string) bool {
+ normalized := strings.ToLower(strings.TrimSpace(key))
+ _, ok := sensitiveCredentialKeys[normalized]
+ return ok
+}
+
+func encryptedCredentialEnvelope(alg, ciphertext string) map[string]any {
+ return map[string]any{
+ encryptedCredentialMarkerKey: true,
+ encryptedCredentialAlgKey: alg,
+ encryptedCredentialValueKey: ciphertext,
+ }
+}
+
+func parseEncryptedCredentialEnvelope(value any) (alg, ciphertext string, isEnvelope bool, err error) {
+ envelopeMap, ok := value.(map[string]any)
+ if !ok {
+ return "", "", false, nil
+ }
+ marker, _ := envelopeMap[encryptedCredentialMarkerKey].(bool)
+ if !marker {
+ return "", "", false, nil
+ }
+ alg, _ = envelopeMap[encryptedCredentialAlgKey].(string)
+ ciphertext, _ = envelopeMap[encryptedCredentialValueKey].(string)
+ if (alg != encryptedCredentialAlgV1 && alg != encryptedCredentialAlgV2 && alg != encryptedCredentialAlgV3) || ciphertext == "" {
+ return "", "", true, fmt.Errorf("invalid encrypted credential envelope")
+ }
+ return alg, ciphertext, true, nil
+}
+
+func copyJSONValue(value any) any {
+ switch typed := value.(type) {
+ case map[string]any:
+ out := make(map[string]any, len(typed))
+ for k, v := range typed {
+ out[k] = copyJSONValue(v)
+ }
+ return out
+ case []any:
+ out := make([]any, len(typed))
+ for i, v := range typed {
+ out[i] = copyJSONValue(v)
+ }
+ return out
+ default:
+ return value
+ }
+}
diff --git a/backend/internal/repository/credential_encryption_test.go b/backend/internal/repository/credential_encryption_test.go
new file mode 100644
index 00000000000..a6c6baf466b
--- /dev/null
+++ b/backend/internal/repository/credential_encryption_test.go
@@ -0,0 +1,278 @@
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/enttest"
+ "github.com/Wei-Shaw/sub2api/ent/schema/mixins"
+
+ "entgo.io/ent/dialect"
+ entsql "entgo.io/ent/dialect/sql"
+ _ "modernc.org/sqlite"
+)
+
+type fakeCredentialEncryptor struct{}
+
+func (fakeCredentialEncryptor) Encrypt(plaintext string) (string, error) {
+ return "enc:" + plaintext, nil
+}
+
+func (fakeCredentialEncryptor) Decrypt(ciphertext string) (string, error) {
+ return strings.TrimPrefix(ciphertext, "enc:"), nil
+}
+
+func (fakeCredentialEncryptor) EncryptForDomain(domain, plaintext string) (string, error) {
+ return "domain:" + domain + ":" + plaintext, nil
+}
+
+func (fakeCredentialEncryptor) DecryptForDomain(domain, ciphertext string) (string, error) {
+ prefix := "domain:" + domain + ":"
+ if !strings.HasPrefix(ciphertext, prefix) {
+ return "", errors.New("ciphertext domain mismatch")
+ }
+ return strings.TrimPrefix(ciphertext, prefix), nil
+}
+
+func TestEncryptAccountCredentialsProtectsSensitiveKeysOnly(t *testing.T) {
+ input := map[string]any{
+ "api_key": "sk-secret",
+ "access_token": "at-secret",
+ "model_mapping": map[string]any{"gpt-5": "upstream-model"},
+ "nested": map[string]any{
+ "refresh_token": "rt-secret",
+ "region": "us-east-1",
+ },
+ }
+
+ stored, err := encryptAccountCredentials(input, fakeCredentialEncryptor{})
+ if err != nil {
+ t.Fatalf("encryptAccountCredentials() error = %v", err)
+ }
+
+ if stored["api_key"] == "sk-secret" {
+ t.Fatal("api_key was stored as plaintext")
+ }
+ if stored["access_token"] == "at-secret" {
+ t.Fatal("access_token was stored as plaintext")
+ }
+ nested, ok := stored["nested"].(map[string]any)
+ if !ok {
+ t.Fatalf("nested credentials = %#v, want map[string]any", stored["nested"])
+ }
+ if nested["refresh_token"] == "rt-secret" {
+ t.Fatal("nested refresh_token was stored as plaintext")
+ }
+ if got := stored["model_mapping"]; !reflect.DeepEqual(got, input["model_mapping"]) {
+ t.Fatalf("model_mapping = %#v, want %#v", got, input["model_mapping"])
+ }
+
+ decrypted, err := decryptAccountCredentials(stored, fakeCredentialEncryptor{})
+ if err != nil {
+ t.Fatalf("decryptAccountCredentials() error = %v", err)
+ }
+ if !reflect.DeepEqual(decrypted, input) {
+ t.Fatalf("decrypted = %#v, want %#v", decrypted, input)
+ }
+}
+
+func TestEncryptAccountCredentialsIsIdempotentForEncryptedValues(t *testing.T) {
+ input := map[string]any{"api_key": "sk-secret"}
+
+ stored, err := encryptAccountCredentials(input, fakeCredentialEncryptor{})
+ if err != nil {
+ t.Fatalf("first encrypt error = %v", err)
+ }
+ storedAgain, err := encryptAccountCredentials(stored, fakeCredentialEncryptor{})
+ if err != nil {
+ t.Fatalf("second encrypt error = %v", err)
+ }
+
+ if !reflect.DeepEqual(storedAgain, stored) {
+ t.Fatalf("storedAgain = %#v, want %#v", storedAgain, stored)
+ }
+}
+
+func TestEncryptAccountCredentialsWithoutDomainEncryptorFailsClosed(t *testing.T) {
+ input := map[string]any{"api_key": "sk-secret"}
+
+ if _, err := encryptAccountCredentials(input, nil); err == nil {
+ t.Fatal("encryptAccountCredentials() accepted a nil domain encryptor")
+ }
+}
+
+func TestMigrateLegacyAccountCredentialEnvelopeToDomainBoundV2(t *testing.T) {
+ ctx := context.Background()
+ client := newCredentialEncryptionTestClient(t)
+ encryptor := fakeCredentialEncryptor{}
+
+ legacyCiphertext, err := encryptor.Encrypt(`"legacy-secret"`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ legacy := map[string]any{
+ "api_key": map[string]any{
+ encryptedCredentialMarkerKey: true,
+ encryptedCredentialAlgKey: encryptedCredentialAlgV1,
+ encryptedCredentialValueKey: legacyCiphertext,
+ },
+ }
+ account, err := client.Account.Create().
+ SetName("legacy").
+ SetPlatform("openai").
+ SetType("api_key").
+ SetCredentials(legacy).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ softDeleted, err := client.Account.Create().
+ SetName("legacy-soft-deleted").
+ SetPlatform("openai").
+ SetType("api_key").
+ SetCredentials(legacy).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.Account.UpdateOneID(softDeleted.ID).SetDeletedAt(time.Now()).Save(ctx); err != nil {
+ t.Fatal(err)
+ }
+
+ result, err := MigrateDomainBoundSecrets(ctx, client, encryptor)
+ if err != nil {
+ t.Fatalf("MigrateDomainBoundSecrets() error = %v", err)
+ }
+ if result.AccountCredentials != 2 {
+ t.Fatalf("AccountCredentials = %d, want 2 including soft-deleted data", result.AccountCredentials)
+ }
+
+ refreshed, err := client.Account.Get(ctx, account.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope, ok := refreshed.Credentials["api_key"].(map[string]any)
+ if !ok || envelope[encryptedCredentialAlgKey] != encryptedCredentialAlgV3 {
+ t.Fatalf("migrated envelope = %#v", refreshed.Credentials["api_key"])
+ }
+ decrypted, err := decryptAccountCredentials(refreshed.Credentials, encryptor)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if decrypted["api_key"] != "legacy-secret" {
+ t.Fatalf("decrypted api_key = %#v", decrypted["api_key"])
+ }
+ refreshedSoftDeleted, err := client.Account.Get(mixins.SkipSoftDelete(ctx), softDeleted.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ softEnvelope, ok := refreshedSoftDeleted.Credentials["api_key"].(map[string]any)
+ if !ok || softEnvelope[encryptedCredentialAlgKey] != encryptedCredentialAlgV3 {
+ t.Fatalf("soft-deleted account was not migrated: %#v", refreshedSoftDeleted.Credentials)
+ }
+
+ again, err := MigrateDomainBoundSecrets(ctx, client, encryptor)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if again.AccountCredentials != 0 {
+ t.Fatalf("idempotent migration updated %d account(s)", again.AccountCredentials)
+ }
+}
+
+func TestEncryptPlaintextAccountCredentialsBackfillsOnlyPlaintext(t *testing.T) {
+ ctx := context.Background()
+ client := newCredentialEncryptionTestClient(t)
+ encryptor := fakeCredentialEncryptor{}
+
+ plain, err := client.Account.Create().
+ SetName("plain").
+ SetPlatform("openai").
+ SetType("api_key").
+ SetCredentials(map[string]any{
+ "api_key": "sk-secret",
+ "base_url": "https://api.example.test",
+ }).
+ Save(ctx)
+ if err != nil {
+ t.Fatalf("create plain account: %v", err)
+ }
+
+ alreadyEncryptedCredentials, err := encryptAccountCredentials(map[string]any{"api_key": "already-encrypted"}, encryptor)
+ if err != nil {
+ t.Fatalf("encrypt fixture credentials: %v", err)
+ }
+ alreadyEncrypted, err := client.Account.Create().
+ SetName("encrypted").
+ SetPlatform("openai").
+ SetType("api_key").
+ SetCredentials(alreadyEncryptedCredentials).
+ Save(ctx)
+ if err != nil {
+ t.Fatalf("create encrypted account: %v", err)
+ }
+
+ dryRun, err := EncryptPlaintextAccountCredentials(ctx, client, encryptor, true)
+ if err != nil {
+ t.Fatalf("dry run backfill: %v", err)
+ }
+ if dryRun.Scanned != 2 || dryRun.NeedsEncryption != 1 || dryRun.Updated != 0 {
+ t.Fatalf("dry run result = %#v", dryRun)
+ }
+
+ result, err := EncryptPlaintextAccountCredentials(ctx, client, encryptor, false)
+ if err != nil {
+ t.Fatalf("backfill: %v", err)
+ }
+ if result.Scanned != 2 || result.NeedsEncryption != 1 || result.Updated != 1 {
+ t.Fatalf("backfill result = %#v", result)
+ }
+
+ refreshedPlain, err := client.Account.Get(ctx, plain.ID)
+ if err != nil {
+ t.Fatalf("get plain account: %v", err)
+ }
+ if refreshedPlain.Credentials["api_key"] == "sk-secret" {
+ t.Fatal("plaintext credential was not encrypted")
+ }
+ decrypted, err := decryptAccountCredentials(refreshedPlain.Credentials, encryptor)
+ if err != nil {
+ t.Fatalf("decrypt backfilled credentials: %v", err)
+ }
+ if decrypted["api_key"] != "sk-secret" {
+ t.Fatalf("decrypted api_key = %#v", decrypted["api_key"])
+ }
+
+ refreshedEncrypted, err := client.Account.Get(ctx, alreadyEncrypted.ID)
+ if err != nil {
+ t.Fatalf("get encrypted account: %v", err)
+ }
+ if !reflect.DeepEqual(refreshedEncrypted.Credentials, alreadyEncryptedCredentials) {
+ t.Fatalf("already encrypted credentials changed: %#v", refreshedEncrypted.Credentials)
+ }
+}
+
+func newCredentialEncryptionTestClient(t *testing.T) *dbent.Client {
+ t.Helper()
+
+ db, err := sql.Open("sqlite", "file:credential_encryption?mode=memory&cache=shared")
+ if err != nil {
+ t.Fatalf("open sqlite: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+
+ if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
+ t.Fatalf("enable foreign keys: %v", err)
+ }
+
+ drv := entsql.OpenDB(dialect.SQLite, db)
+ client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
+ t.Cleanup(func() { _ = client.Close() })
+ return client
+}
diff --git a/backend/internal/repository/domain_secret_migration.go b/backend/internal/repository/domain_secret_migration.go
new file mode 100644
index 00000000000..a46ee640878
--- /dev/null
+++ b/backend/internal/repository/domain_secret_migration.go
@@ -0,0 +1,573 @@
+package repository
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "reflect"
+ "sort"
+ "strings"
+
+ "entgo.io/ent/dialect"
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/channelmonitor"
+ "github.com/Wei-Shaw/sub2api/ent/proxy"
+ "github.com/Wei-Shaw/sub2api/ent/schema/mixins"
+ "github.com/Wei-Shaw/sub2api/ent/setting"
+ "github.com/Wei-Shaw/sub2api/ent/user"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+const backupS3ConfigSettingKey = "backup_s3_config"
+const domainSecretMigrationAdvisoryLockID int64 = 694208311321144028
+
+// MigrateDomainBoundSecrets rewrites every persistent value that historically
+// shared TOTP_ENCRYPTION_KEY, including v2 shared-root domain ciphertext, in one
+// transaction. Callers must run it after old binaries are drained and before
+// constructing workers or HTTP listeners.
+func MigrateDomainBoundSecrets(ctx context.Context, client *dbent.Client, encryptor service.SecretEncryptor) (result DomainSecretMigrationResult, err error) {
+ if client == nil {
+ return result, errors.New("nil ent client")
+ }
+ if _, ok := encryptor.(service.DomainSecretEncryptor); !ok {
+ return result, errors.New("domain secret encryptor is unavailable")
+ }
+
+ tx, err := client.Tx(ctx)
+ if err != nil {
+ return result, fmt.Errorf("begin domain secret migration: %w", err)
+ }
+ defer func() {
+ if err != nil {
+ _ = tx.Rollback()
+ }
+ }()
+ txClient := tx.Client()
+ exec, _ := txClient.Driver().(sqlQueryExecutor)
+ if err = acquireDomainSecretMigrationLock(ctx, client.Driver().Dialect(), exec); err != nil {
+ return result, err
+ }
+
+ accountResult, err := EncryptPlaintextAccountCredentials(ctx, txClient, encryptor, false)
+ if err != nil {
+ return result, fmt.Errorf("migrate account credentials: %w", err)
+ }
+ result.AccountCredentials = accountResult.Updated
+ if result.TOTPSecrets, err = migrateTOTPSecrets(ctx, txClient, encryptor); err != nil {
+ return result, err
+ }
+ if result.ChannelMonitorKeys, err = migrateChannelMonitorKeys(ctx, txClient, encryptor); err != nil {
+ return result, err
+ }
+ if result.ChannelMonitorPayloads, err = migrateChannelMonitorRequestPayloads(ctx, txClient, encryptor); err != nil {
+ return result, err
+ }
+ if result.BackupS3Configs, err = migrateBackupS3Config(ctx, txClient, encryptor); err != nil {
+ return result, err
+ }
+ if result.ContentModerationConfigs, err = migrateContentModerationConfig(ctx, txClient, encryptor); err != nil {
+ return result, err
+ }
+ if result.SettingSecrets, err = migrateSensitiveSettingSecrets(ctx, txClient, encryptor); err != nil {
+ return result, err
+ }
+ if result.ProxyCredentials, err = migrateProxyCredentials(ctx, txClient, encryptor); err != nil {
+ return result, err
+ }
+ if err = validateProxyCredentialStorageConstraint(ctx, client.Driver().Dialect(), exec); err != nil {
+ return result, err
+ }
+ if err = tx.Commit(); err != nil {
+ return result, fmt.Errorf("commit domain secret migration: %w", err)
+ }
+ return result, nil
+}
+
+func migrateSensitiveSettingSecrets(ctx context.Context, client *dbent.Client, encryptor service.SecretEncryptor) (int, error) {
+ keys := make([]string, 0, len(sensitiveSettingKeys))
+ for key := range sensitiveSettingKeys {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ rows, err := client.Setting.Query().Where(setting.KeyIn(keys...)).All(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("query sensitive settings: %w", err)
+ }
+ updated := 0
+ for _, row := range rows {
+ if row.Value == "" {
+ continue
+ }
+ bound, changed, err := migratePlaintextSettingSecret(encryptor, row.Value)
+ if err != nil {
+ return updated, fmt.Errorf("migrate sensitive setting %q: %w", row.Key, err)
+ }
+ if !changed {
+ continue
+ }
+ if _, err := client.Setting.UpdateOneID(row.ID).
+ Where(setting.ValueEQ(row.Value)).
+ SetValue(bound).
+ Save(ctx); err != nil {
+ return updated, fmt.Errorf("update sensitive setting %q with concurrency guard: %w", row.Key, err)
+ }
+ updated++
+ }
+ return updated, nil
+}
+
+func migratePlaintextSettingSecret(encryptor service.SecretEncryptor, value string) (string, bool, error) {
+ if strings.HasPrefix(value, secretDomainCiphertextPrefix) {
+ if _, err := service.DecryptForSecretDomain(encryptor, service.SecretDomainSettingSecret, value); err != nil {
+ return "", false, err
+ }
+ return value, false, nil
+ }
+ if strings.HasPrefix(value, "sd:") {
+ return "", false, errors.New("unsupported or malformed setting secret ciphertext")
+ }
+ bound, err := service.EncryptForSecretDomain(encryptor, service.SecretDomainSettingSecret, value)
+ return bound, true, err
+}
+
+func acquireDomainSecretMigrationLock(ctx context.Context, dialectName string, exec sqlQueryExecutor) error {
+ if dialectName != dialect.Postgres {
+ return nil
+ }
+ if exec == nil {
+ return errors.New("domain secret migration SQL executor is unavailable")
+ }
+ rows, err := exec.QueryContext(ctx, "SELECT pg_advisory_xact_lock($1)", domainSecretMigrationAdvisoryLockID)
+ if err != nil {
+ return fmt.Errorf("acquire domain secret migration lock: %w", err)
+ }
+ if err := rows.Close(); err != nil {
+ return fmt.Errorf("close domain secret migration lock result: %w", err)
+ }
+ return nil
+}
+
+func validateProxyCredentialStorageConstraint(ctx context.Context, dialectName string, exec sqlQueryExecutor) error {
+ if dialectName != dialect.Postgres {
+ return nil
+ }
+ if exec == nil {
+ return errors.New("domain secret migration SQL executor is unavailable")
+ }
+ if _, err := exec.ExecContext(ctx, "ALTER TABLE proxies VALIDATE CONSTRAINT chk_proxies_password_encrypted_storage"); err != nil {
+ return fmt.Errorf("validate encrypted proxy credential storage: %w", err)
+ }
+ return nil
+}
+
+func migrateProxyCredentials(ctx context.Context, client *dbent.Client, encryptor service.SecretEncryptor) (int, error) {
+ migrationCtx := mixins.SkipSoftDelete(ctx)
+ rows, err := client.Proxy.Query().Where(proxy.PasswordNotNil()).All(migrationCtx)
+ if err != nil {
+ return 0, fmt.Errorf("query proxy credentials: %w", err)
+ }
+ updated := 0
+ for _, row := range rows {
+ if row.Password == nil {
+ continue
+ }
+ if *row.Password == "" {
+ if _, err := client.Proxy.UpdateOneID(row.ID).
+ Where(proxy.PasswordEQ("")).
+ ClearPassword().
+ Save(migrationCtx); err != nil {
+ return updated, fmt.Errorf("clear empty proxy %d credential with concurrency guard: %w", row.ID, err)
+ }
+ updated++
+ continue
+ }
+ bound, changed, err := migrateLegacyOrPlaintextSecret(encryptor, service.SecretDomainProxyCredential, *row.Password)
+ if err != nil {
+ return updated, fmt.Errorf("migrate proxy %d credential: %w", row.ID, err)
+ }
+ if !changed {
+ continue
+ }
+ if _, err := client.Proxy.UpdateOneID(row.ID).
+ Where(proxy.PasswordEQ(*row.Password)).
+ SetPassword(bound).
+ Save(migrationCtx); err != nil {
+ return updated, fmt.Errorf("update proxy %d credential with concurrency guard: %w", row.ID, err)
+ }
+ updated++
+ }
+ return updated, nil
+}
+
+func migrateTOTPSecrets(ctx context.Context, client *dbent.Client, encryptor service.SecretEncryptor) (int, error) {
+ migrationCtx := mixins.SkipSoftDelete(ctx)
+ rows, err := client.User.Query().Where(user.TotpSecretEncryptedNotNil()).All(migrationCtx)
+ if err != nil {
+ return 0, fmt.Errorf("query TOTP secrets: %w", err)
+ }
+ updated := 0
+ for _, row := range rows {
+ if row.TotpSecretEncrypted == nil || strings.TrimSpace(*row.TotpSecretEncrypted) == "" {
+ continue
+ }
+ bound, changed, err := migrateLegacyCiphertext(encryptor, service.SecretDomainTOTP, *row.TotpSecretEncrypted)
+ if err != nil {
+ return updated, fmt.Errorf("migrate user %d TOTP secret: %w", row.ID, err)
+ }
+ if !changed {
+ continue
+ }
+ if _, err := client.User.UpdateOneID(row.ID).
+ Where(user.TotpSecretEncryptedEQ(*row.TotpSecretEncrypted)).
+ SetTotpSecretEncrypted(bound).
+ Save(migrationCtx); err != nil {
+ return updated, fmt.Errorf("update user %d TOTP secret with concurrency guard: %w", row.ID, err)
+ }
+ updated++
+ }
+ return updated, nil
+}
+
+func migrateChannelMonitorKeys(ctx context.Context, client *dbent.Client, encryptor service.SecretEncryptor) (int, error) {
+ rows, err := client.ChannelMonitor.Query().All(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("query channel monitor keys: %w", err)
+ }
+ updated := 0
+ for _, row := range rows {
+ bound, changed, err := migrateLegacyCiphertext(encryptor, service.SecretDomainChannelMonitor, row.APIKeyEncrypted)
+ if err != nil {
+ return updated, fmt.Errorf("migrate channel monitor %d key: %w", row.ID, err)
+ }
+ if !changed {
+ continue
+ }
+ if _, err := client.ChannelMonitor.UpdateOneID(row.ID).
+ Where(channelmonitor.APIKeyEncryptedEQ(row.APIKeyEncrypted)).
+ SetAPIKeyEncrypted(bound).
+ Save(ctx); err != nil {
+ return updated, fmt.Errorf("update channel monitor %d key with concurrency guard: %w", row.ID, err)
+ }
+ updated++
+ }
+ return updated, nil
+}
+
+// migrateChannelMonitorRequestPayloads encrypts both reusable templates and
+// monitor snapshots. Counting is per updated row, even when both JSON fields in
+// that row are rewritten.
+func migrateChannelMonitorRequestPayloads(ctx context.Context, client *dbent.Client, encryptor service.SecretEncryptor) (int, error) {
+ updatedTemplates, err := migrateChannelMonitorTemplatePayloads(ctx, client, encryptor)
+ if err != nil {
+ return 0, err
+ }
+ updatedMonitors, err := migrateChannelMonitorSnapshotPayloads(ctx, client, encryptor)
+ if err != nil {
+ return updatedTemplates, err
+ }
+ return updatedTemplates + updatedMonitors, nil
+}
+
+func migrateChannelMonitorTemplatePayloads(ctx context.Context, client *dbent.Client, encryptor service.SecretEncryptor) (int, error) {
+ rows, err := client.ChannelMonitorRequestTemplate.Query().All(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("query channel monitor template request payloads: %w", err)
+ }
+ updated := 0
+ for _, row := range rows {
+ headers, body, changed, retiredAttribution, err := migrateChannelMonitorPayloadMaps(encryptor, row.ExtraHeaders, row.BodyOverride)
+ if err != nil {
+ return updated, fmt.Errorf("migrate channel monitor template %d request payload: %w", row.ID, err)
+ }
+ if !changed {
+ continue
+ }
+ updater := client.ChannelMonitorRequestTemplate.UpdateOneID(row.ID).SetExtraHeaders(headers)
+ if retiredAttribution {
+ updater = updater.SetBodyOverrideMode(service.MonitorBodyOverrideModeOff)
+ }
+ if body == nil {
+ updater = updater.ClearBodyOverride()
+ } else {
+ updater = updater.SetBodyOverride(body)
+ }
+ if _, err := updater.Save(ctx); err != nil {
+ return updated, fmt.Errorf("update channel monitor template %d request payload: %w", row.ID, err)
+ }
+ updated++
+ }
+ return updated, nil
+}
+
+func migrateChannelMonitorSnapshotPayloads(ctx context.Context, client *dbent.Client, encryptor service.SecretEncryptor) (int, error) {
+ rows, err := client.ChannelMonitor.Query().All(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("query channel monitor snapshot request payloads: %w", err)
+ }
+ updated := 0
+ for _, row := range rows {
+ headers, body, changed, retiredAttribution, err := migrateChannelMonitorPayloadMaps(encryptor, row.ExtraHeaders, row.BodyOverride)
+ if err != nil {
+ return updated, fmt.Errorf("migrate channel monitor %d request payload: %w", row.ID, err)
+ }
+ if !changed {
+ continue
+ }
+ updater := client.ChannelMonitor.UpdateOneID(row.ID).SetExtraHeaders(headers)
+ if retiredAttribution {
+ updater = updater.SetBodyOverrideMode(service.MonitorBodyOverrideModeOff)
+ }
+ if body == nil {
+ updater = updater.ClearBodyOverride()
+ } else {
+ updater = updater.SetBodyOverride(body)
+ }
+ if _, err := updater.Save(ctx); err != nil {
+ return updated, fmt.Errorf("update channel monitor %d request payload: %w", row.ID, err)
+ }
+ updated++
+ }
+ return updated, nil
+}
+
+func migrateChannelMonitorPayloadMaps(encryptor service.SecretEncryptor, headers map[string]string, body map[string]any) (map[string]string, map[string]any, bool, bool, error) {
+ sealedHeaders := headers
+ headersChanged := false
+ retiredAttribution := false
+ if service.HasChannelMonitorExtraHeadersEnvelopeMarker(headers) {
+ if _, err := service.OpenChannelMonitorExtraHeaders(encryptor, headers); err != nil {
+ if !errors.Is(err, service.ErrChannelMonitorTemplateOfficialClientAttribution) {
+ return nil, nil, false, false, err
+ }
+ sealedHeaders, err = service.SealChannelMonitorExtraHeaders(encryptor, map[string]string{})
+ if err != nil {
+ return nil, nil, false, false, err
+ }
+ headersChanged = true
+ retiredAttribution = true
+ }
+ } else {
+ var err error
+ sealedHeaders, err = service.SealChannelMonitorExtraHeaders(encryptor, headers)
+ if err != nil {
+ if !errors.Is(err, service.ErrChannelMonitorTemplateOfficialClientAttribution) {
+ return nil, nil, false, false, err
+ }
+ sealedHeaders, err = service.SealChannelMonitorExtraHeaders(encryptor, map[string]string{})
+ if err != nil {
+ return nil, nil, false, false, err
+ }
+ retiredAttribution = true
+ }
+ headersChanged = true
+ }
+
+ sealedBody := body
+ bodyChanged := false
+ if body != nil {
+ if service.HasChannelMonitorBodyOverrideEnvelopeMarker(body) {
+ if _, err := service.OpenChannelMonitorBodyOverride(encryptor, body); err != nil {
+ if !errors.Is(err, service.ErrChannelMonitorTemplateOfficialClientAttribution) {
+ return nil, nil, false, false, err
+ }
+ sealedBody = nil
+ bodyChanged = true
+ retiredAttribution = true
+ }
+ } else {
+ var err error
+ sealedBody, err = service.SealChannelMonitorBodyOverride(encryptor, body)
+ if err != nil {
+ if !errors.Is(err, service.ErrChannelMonitorTemplateOfficialClientAttribution) {
+ return nil, nil, false, false, err
+ }
+ sealedBody = nil
+ retiredAttribution = true
+ }
+ bodyChanged = true
+ }
+ }
+ return sealedHeaders, sealedBody, headersChanged || bodyChanged, retiredAttribution, nil
+}
+
+func migrateBackupS3Config(ctx context.Context, client *dbent.Client, encryptor service.SecretEncryptor) (int, error) {
+ row, err := client.Setting.Query().Where(setting.KeyEQ(backupS3ConfigSettingKey)).Only(ctx)
+ if dbent.IsNotFound(err) {
+ return 0, nil
+ }
+ if err != nil {
+ return 0, fmt.Errorf("query backup S3 config: %w", err)
+ }
+ if strings.TrimSpace(row.Value) == "" {
+ return 0, nil
+ }
+ var cfg service.BackupS3Config
+ if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil {
+ return 0, fmt.Errorf("decode backup S3 config: %w", err)
+ }
+ if strings.TrimSpace(cfg.SecretAccessKey) == "" {
+ return 0, nil
+ }
+ bound, changed, err := migrateLegacyOrPlaintextSecret(encryptor, service.SecretDomainBackupS3, cfg.SecretAccessKey)
+ if err != nil {
+ return 0, fmt.Errorf("migrate backup S3 secret: %w", err)
+ }
+ if !changed {
+ return 0, nil
+ }
+ cfg.SecretAccessKey = bound
+ raw, err := json.Marshal(&cfg)
+ if err != nil {
+ return 0, fmt.Errorf("encode backup S3 config: %w", err)
+ }
+ if _, err := client.Setting.UpdateOneID(row.ID).
+ Where(setting.ValueEQ(row.Value)).
+ SetValue(string(raw)).
+ Save(ctx); err != nil {
+ return 0, fmt.Errorf("update backup S3 config with concurrency guard: %w", err)
+ }
+ return 1, nil
+}
+
+func migrateContentModerationConfig(ctx context.Context, client *dbent.Client, encryptor service.SecretEncryptor) (int, error) {
+ row, err := client.Setting.Query().Where(setting.KeyEQ(service.SettingKeyContentModerationConfig)).Only(ctx)
+ if dbent.IsNotFound(err) {
+ return 0, nil
+ }
+ if err != nil {
+ return 0, fmt.Errorf("query content moderation config: %w", err)
+ }
+ if strings.TrimSpace(row.Value) == "" {
+ return 0, nil
+ }
+ var cfg service.ContentModerationConfig
+ if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil {
+ return 0, fmt.Errorf("decode content moderation config: %w", err)
+ }
+ changed, err := migrateContentModerationKeys(&cfg, encryptor)
+ if err != nil {
+ return 0, err
+ }
+ if !changed {
+ return 0, nil
+ }
+ raw, err := json.Marshal(&cfg)
+ if err != nil {
+ return 0, fmt.Errorf("encode content moderation config: %w", err)
+ }
+ if _, err := client.Setting.UpdateOneID(row.ID).
+ Where(setting.ValueEQ(row.Value)).
+ SetValue(string(raw)).
+ Save(ctx); err != nil {
+ return 0, fmt.Errorf("update content moderation config with concurrency guard: %w", err)
+ }
+ return 1, nil
+}
+
+func migrateContentModerationKeys(cfg *service.ContentModerationConfig, encryptor service.SecretEncryptor) (bool, error) {
+ plaintextKeys := normalizeSecretList(append(append([]string{}, cfg.APIKeys...), cfg.APIKey))
+ decryptedKeys := make([]string, 0, len(cfg.EncryptedAPIKeys))
+ changed := len(plaintextKeys) > 0
+ for i, ciphertext := range cfg.EncryptedAPIKeys {
+ bound, plaintext, itemChanged, err := migrateLegacyCiphertextWithPlaintext(encryptor, service.SecretDomainContentModeration, ciphertext)
+ if err != nil {
+ return false, fmt.Errorf("migrate content moderation key %d: %w", i, err)
+ }
+ decryptedKeys = append(decryptedKeys, plaintext)
+ cfg.EncryptedAPIKeys[i] = bound
+ changed = changed || itemChanged
+ }
+ decryptedKeys = normalizeSecretList(decryptedKeys)
+ if len(decryptedKeys) > 0 && len(plaintextKeys) > 0 && !reflect.DeepEqual(decryptedKeys, plaintextKeys) {
+ return false, errors.New("content moderation plaintext compatibility keys do not match encrypted keys")
+ }
+ if len(decryptedKeys) == 0 && len(plaintextKeys) > 0 {
+ cfg.EncryptedAPIKeys = make([]string, 0, len(plaintextKeys))
+ for i, plaintext := range plaintextKeys {
+ bound, err := service.EncryptForSecretDomain(encryptor, service.SecretDomainContentModeration, plaintext)
+ if err != nil {
+ return false, fmt.Errorf("encrypt plaintext content moderation key %d: %w", i, err)
+ }
+ cfg.EncryptedAPIKeys = append(cfg.EncryptedAPIKeys, bound)
+ }
+ }
+ if changed {
+ cfg.APIKey = ""
+ cfg.APIKeys = nil
+ }
+ return changed, nil
+}
+
+func migrateLegacyCiphertext(encryptor service.SecretEncryptor, domain, ciphertext string) (string, bool, error) {
+ bound, _, changed, err := migrateLegacyCiphertextWithPlaintext(encryptor, domain, ciphertext)
+ return bound, changed, err
+}
+
+func migrateLegacyCiphertextWithPlaintext(encryptor service.SecretEncryptor, domain, ciphertext string) (bound, plaintext string, changed bool, err error) {
+ if strings.TrimSpace(ciphertext) == "" {
+ return "", "", false, errors.New("empty ciphertext")
+ }
+ if strings.HasPrefix(ciphertext, secretDomainCiphertextPrefix) {
+ plaintext, err = service.DecryptForSecretDomain(encryptor, domain, ciphertext)
+ return ciphertext, plaintext, false, err
+ }
+ plaintext, err = decryptLegacySecretForMigration(encryptor, domain, ciphertext)
+ if err != nil {
+ return "", "", false, err
+ }
+ if plaintext == "" {
+ return "", "", false, errors.New("legacy ciphertext decrypted to an empty secret")
+ }
+ bound, err = service.EncryptForSecretDomain(encryptor, domain, plaintext)
+ return bound, plaintext, true, err
+}
+
+func migrateLegacyOrPlaintextSecret(encryptor service.SecretEncryptor, domain, value string) (string, bool, error) {
+ if strings.HasPrefix(value, secretDomainCiphertextPrefix) {
+ if _, err := service.DecryptForSecretDomain(encryptor, domain, value); err != nil {
+ return "", false, err
+ }
+ return value, false, nil
+ }
+ plaintext, err := decryptLegacySecretForMigration(encryptor, domain, value)
+ if err != nil {
+ // backup_s3_config historically stored unmarked plaintext. Treat only this
+ // schema's non-v2 value as plaintext, then remove the ambiguity forever.
+ plaintext = value
+ }
+ bound, err := service.EncryptForSecretDomain(encryptor, domain, plaintext)
+ return bound, true, err
+}
+
+type v2DomainMigrationDecryptor interface {
+ decryptV2ForMigration(domain, ciphertext string) (string, error)
+}
+
+func decryptLegacySecretForMigration(encryptor service.SecretEncryptor, domain, ciphertext string) (string, error) {
+ if strings.HasPrefix(ciphertext, legacySecretDomainCiphertextPrefixV2) {
+ migrationDecryptor, ok := encryptor.(v2DomainMigrationDecryptor)
+ if !ok {
+ return "", errors.New("v2 domain migration decryptor is unavailable")
+ }
+ return migrationDecryptor.decryptV2ForMigration(domain, ciphertext)
+ }
+ return encryptor.Decrypt(ciphertext)
+}
+
+func normalizeSecretList(values []string) []string {
+ seen := make(map[string]struct{}, len(values))
+ out := make([]string, 0, len(values))
+ for _, value := range values {
+ value = strings.TrimSpace(value)
+ if value == "" {
+ continue
+ }
+ if _, ok := seen[value]; ok {
+ continue
+ }
+ seen[value] = struct{}{}
+ out = append(out, value)
+ }
+ return out
+}
diff --git a/backend/internal/repository/domain_secret_migration_test.go b/backend/internal/repository/domain_secret_migration_test.go
new file mode 100644
index 00000000000..d7902da922b
--- /dev/null
+++ b/backend/internal/repository/domain_secret_migration_test.go
@@ -0,0 +1,690 @@
+package repository
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "entgo.io/ent/dialect"
+ "github.com/DATA-DOG/go-sqlmock"
+ "github.com/Wei-Shaw/sub2api/ent/schema/mixins"
+ "github.com/Wei-Shaw/sub2api/ent/setting"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+func TestMigrateDomainBoundSecretsCoversPersistentSecretStores(t *testing.T) {
+ ctx := context.Background()
+ client := newCredentialEncryptionTestClient(t)
+ encryptor := newDomainMigrationTestEncryptor(t)
+ domainEncryptor := encryptor.(service.DomainSecretEncryptor)
+ aesEncryptor := encryptor.(*AESEncryptor)
+ v2AccountCiphertext := mustV2DomainEncrypt(
+ t,
+ aesEncryptor,
+ service.SecretDomainAccountCredential,
+ `"account-secret"`,
+ )
+ account, err := client.Account.Create().
+ SetName("v2-domain-account").
+ SetPlatform("openai").
+ SetType("api_key").
+ SetCredentials(map[string]any{
+ "api_key": encryptedCredentialEnvelope(encryptedCredentialAlgV2, v2AccountCiphertext),
+ }).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ legacyTOTP := mustLegacyEncrypt(t, encryptor, "JBSWY3DPEHPK3PXP")
+ user, err := client.User.Create().
+ SetEmail("domain-migration@example.test").
+ SetPasswordHash("password-hash").
+ SetTotpSecretEncrypted(legacyTOTP).
+ SetTotpEnabled(true).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ softDeletedUser, err := client.User.Create().
+ SetEmail("domain-migration-deleted@example.test").
+ SetPasswordHash("password-hash").
+ SetTotpSecretEncrypted(legacyTOTP).
+ SetTotpEnabled(true).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.User.UpdateOneID(softDeletedUser.ID).SetDeletedAt(time.Now()).Save(ctx); err != nil {
+ t.Fatal(err)
+ }
+
+ legacyMonitorKey := mustLegacyEncrypt(t, encryptor, "monitor-secret")
+ monitor, err := client.ChannelMonitor.Create().
+ SetName("legacy-monitor").
+ SetProvider("openai").
+ SetEndpoint("https://api.example.test").
+ SetAPIKeyEncrypted(legacyMonitorKey).
+ SetPrimaryModel("gpt-test").
+ SetIntervalSeconds(60).
+ SetCreatedBy(1).
+ SetExtraHeaders(map[string]string{"User-Agent": "legacy-monitor-client"}).
+ SetBodyOverrideMode(service.MonitorBodyOverrideModeReplace).
+ SetBodyOverride(map[string]any{"metadata": map[string]any{"token": "monitor-body-secret"}}).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ template, err := client.ChannelMonitorRequestTemplate.Create().
+ SetName("legacy-template").
+ SetProvider("openai").
+ SetExtraHeaders(map[string]string{"User-Agent": "legacy-template-client"}).
+ SetBodyOverrideMode(service.MonitorBodyOverrideModeReplace).
+ SetBodyOverride(map[string]any{"metadata": map[string]any{"token": "template-body-secret"}}).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ backup := service.BackupS3Config{
+ Endpoint: "https://s3.example.test",
+ Bucket: "backup",
+ AccessKeyID: "access",
+ SecretAccessKey: mustLegacyEncrypt(t, encryptor, "backup-secret"),
+ }
+ backupJSON, err := json.Marshal(backup)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.Setting.Create().SetKey("backup_s3_config").SetValue(string(backupJSON)).Save(ctx); err != nil {
+ t.Fatal(err)
+ }
+
+ moderation := service.ContentModerationConfig{
+ Enabled: true,
+ EncryptedAPIKeys: []string{mustLegacyEncrypt(t, encryptor, "moderation-secret")},
+ }
+ moderationJSON, err := json.Marshal(moderation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.Setting.Create().SetKey(service.SettingKeyContentModerationConfig).SetValue(string(moderationJSON)).Save(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.Setting.Create().SetKey(service.SettingKeyAdminAPIKey).SetValue("plaintext-admin-api-key").Save(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.Setting.Create().SetKey(service.SettingKeySiteName).SetValue("public-site-name").Save(ctx); err != nil {
+ t.Fatal(err)
+ }
+ proxyRow, err := client.Proxy.Create().
+ SetName("legacy-proxy").
+ SetProtocol("http").
+ SetHost("proxy.example.test").
+ SetPort(8080).
+ SetPassword("proxy-secret").
+ SetStatus(service.StatusActive).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ softDeletedProxy, err := client.Proxy.Create().
+ SetName("legacy-deleted-proxy").
+ SetProtocol("http").
+ SetHost("deleted-proxy.example.test").
+ SetPort(8081).
+ SetPassword("deleted-proxy-secret").
+ SetStatus(service.StatusActive).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.Proxy.UpdateOneID(softDeletedProxy.ID).SetDeletedAt(time.Now()).Save(ctx); err != nil {
+ t.Fatal(err)
+ }
+ emptyProxy, err := client.Proxy.Create().
+ SetName("legacy-empty-proxy").
+ SetProtocol("http").
+ SetHost("empty-proxy.example.test").
+ SetPort(8082).
+ SetPassword("").
+ SetStatus(service.StatusActive).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ legacyProxyCiphertext := mustLegacyEncrypt(t, encryptor, "legacy-encrypted-proxy-secret")
+ legacyEncryptedProxy, err := client.Proxy.Create().
+ SetName("legacy-encrypted-proxy").
+ SetProtocol("http").
+ SetHost("legacy-encrypted-proxy.example.test").
+ SetPort(8083).
+ SetPassword(legacyProxyCiphertext).
+ SetStatus(service.StatusActive).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ v2ProxyCiphertext := mustV2DomainEncrypt(t, aesEncryptor, service.SecretDomainProxyCredential, "v2-proxy-secret")
+ v2Proxy, err := client.Proxy.Create().
+ SetName("v2-proxy").
+ SetProtocol("http").
+ SetHost("v2-proxy.example.test").
+ SetPort(8084).
+ SetPassword(v2ProxyCiphertext).
+ SetStatus(service.StatusActive).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ boundProxyCiphertext, err := domainEncryptor.EncryptForDomain(service.SecretDomainProxyCredential, "already-bound-proxy-secret")
+ if err != nil {
+ t.Fatal(err)
+ }
+ boundProxy, err := client.Proxy.Create().
+ SetName("already-bound-proxy").
+ SetProtocol("http").
+ SetHost("already-bound-proxy.example.test").
+ SetPort(8085).
+ SetPassword(boundProxyCiphertext).
+ SetStatus(service.StatusActive).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, err := MigrateDomainBoundSecrets(ctx, client, encryptor)
+ if err != nil {
+ t.Fatalf("MigrateDomainBoundSecrets() error = %v", err)
+ }
+ if result.AccountCredentials != 1 || result.TOTPSecrets != 2 || result.ChannelMonitorKeys != 1 || result.ChannelMonitorPayloads != 2 || result.BackupS3Configs != 1 || result.ContentModerationConfigs != 1 || result.SettingSecrets != 1 || result.ProxyCredentials != 5 {
+ t.Fatalf("migration result = %#v", result)
+ }
+ refreshedAccount, err := client.Account.Get(ctx, account.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ accountEnvelope, ok := refreshedAccount.Credentials["api_key"].(map[string]any)
+ if !ok || accountEnvelope[encryptedCredentialAlgKey] != encryptedCredentialAlgV3 {
+ t.Fatalf("migrated account envelope = %#v", refreshedAccount.Credentials)
+ }
+ decryptedAccount, err := decryptAccountCredentials(refreshedAccount.Credentials, encryptor)
+ if err != nil || decryptedAccount["api_key"] != "account-secret" {
+ t.Fatalf("migrated account secret = %#v, err=%v", decryptedAccount, err)
+ }
+
+ refreshedUser, err := client.User.Get(ctx, user.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, err := domainEncryptor.DecryptForDomain(service.SecretDomainTOTP, *refreshedUser.TotpSecretEncrypted); err != nil || got != "JBSWY3DPEHPK3PXP" {
+ t.Fatalf("migrated TOTP = %q, err=%v", got, err)
+ }
+ if _, err := domainEncryptor.DecryptForDomain(service.SecretDomainContentModeration, *refreshedUser.TotpSecretEncrypted); err == nil {
+ t.Fatal("TOTP ciphertext decrypted in content moderation domain")
+ }
+ refreshedSoftDeletedUser, err := client.User.Get(mixins.SkipSoftDelete(ctx), softDeletedUser.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, err := domainEncryptor.DecryptForDomain(service.SecretDomainTOTP, *refreshedSoftDeletedUser.TotpSecretEncrypted); err != nil || got != "JBSWY3DPEHPK3PXP" {
+ t.Fatalf("migrated soft-deleted TOTP = %q, err=%v", got, err)
+ }
+
+ refreshedMonitor, err := client.ChannelMonitor.Get(ctx, monitor.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, err := domainEncryptor.DecryptForDomain(service.SecretDomainChannelMonitor, refreshedMonitor.APIKeyEncrypted); err != nil || got != "monitor-secret" {
+ t.Fatalf("migrated monitor key = %q, err=%v", got, err)
+ }
+ monitorHeaders, err := service.OpenChannelMonitorExtraHeaders(encryptor, refreshedMonitor.ExtraHeaders)
+ if err != nil || monitorHeaders["User-Agent"] != "legacy-monitor-client" {
+ t.Fatalf("migrated monitor headers = %#v, err=%v", monitorHeaders, err)
+ }
+ monitorBody, err := service.OpenChannelMonitorBodyOverride(encryptor, refreshedMonitor.BodyOverride)
+ if err != nil || monitorBody["metadata"].(map[string]any)["token"] != "monitor-body-secret" {
+ t.Fatalf("migrated monitor body = %#v, err=%v", monitorBody, err)
+ }
+ refreshedTemplate, err := client.ChannelMonitorRequestTemplate.Get(ctx, template.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ templateHeaders, err := service.OpenChannelMonitorExtraHeaders(encryptor, refreshedTemplate.ExtraHeaders)
+ if err != nil || templateHeaders["User-Agent"] != "legacy-template-client" {
+ t.Fatalf("migrated template headers = %#v, err=%v", templateHeaders, err)
+ }
+ templateBody, err := service.OpenChannelMonitorBodyOverride(encryptor, refreshedTemplate.BodyOverride)
+ if err != nil || templateBody["metadata"].(map[string]any)["token"] != "template-body-secret" {
+ t.Fatalf("migrated template body = %#v, err=%v", templateBody, err)
+ }
+
+ backupSetting, err := client.Setting.Query().Where(setting.KeyEQ("backup_s3_config")).Only(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var migratedBackup service.BackupS3Config
+ if err := json.Unmarshal([]byte(backupSetting.Value), &migratedBackup); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := domainEncryptor.DecryptForDomain(service.SecretDomainBackupS3, migratedBackup.SecretAccessKey); err != nil || got != "backup-secret" {
+ t.Fatalf("migrated backup secret = %q, err=%v", got, err)
+ }
+
+ moderationSetting, err := client.Setting.Query().Where(setting.KeyEQ(service.SettingKeyContentModerationConfig)).Only(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var migratedModeration service.ContentModerationConfig
+ if err := json.Unmarshal([]byte(moderationSetting.Value), &migratedModeration); err != nil {
+ t.Fatal(err)
+ }
+ if got, err := domainEncryptor.DecryptForDomain(service.SecretDomainContentModeration, migratedModeration.EncryptedAPIKeys[0]); err != nil || got != "moderation-secret" {
+ t.Fatalf("migrated moderation secret = %q, err=%v", got, err)
+ }
+ sensitiveSetting, err := client.Setting.Query().Where(setting.KeyEQ(service.SettingKeyAdminAPIKey)).Only(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, err := domainEncryptor.DecryptForDomain(service.SecretDomainSettingSecret, sensitiveSetting.Value); err != nil || got != "plaintext-admin-api-key" {
+ t.Fatalf("migrated setting secret = %q, err=%v", got, err)
+ }
+ publicSetting, err := client.Setting.Query().Where(setting.KeyEQ(service.SettingKeySiteName)).Only(ctx)
+ if err != nil || publicSetting.Value != "public-site-name" {
+ t.Fatalf("public setting changed = %#v, err=%v", publicSetting, err)
+ }
+ refreshedProxy, err := client.Proxy.Get(ctx, proxyRow.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if refreshedProxy.Password == nil {
+ t.Fatal("migrated proxy password is nil")
+ }
+ if got, err := domainEncryptor.DecryptForDomain(service.SecretDomainProxyCredential, *refreshedProxy.Password); err != nil || got != "proxy-secret" {
+ t.Fatalf("migrated proxy credential = %q, err=%v", got, err)
+ }
+ refreshedSoftDeletedProxy, err := client.Proxy.Get(mixins.SkipSoftDelete(ctx), softDeletedProxy.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if refreshedSoftDeletedProxy.Password == nil {
+ t.Fatal("migrated soft-deleted proxy password is nil")
+ }
+ if got, err := domainEncryptor.DecryptForDomain(service.SecretDomainProxyCredential, *refreshedSoftDeletedProxy.Password); err != nil || got != "deleted-proxy-secret" {
+ t.Fatalf("migrated soft-deleted proxy credential = %q, err=%v", got, err)
+ }
+ refreshedEmptyProxy, err := client.Proxy.Get(ctx, emptyProxy.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if refreshedEmptyProxy.Password != nil {
+ t.Fatalf("empty proxy credential was not normalized to NULL: %q", *refreshedEmptyProxy.Password)
+ }
+ for _, tc := range []struct {
+ id int64
+ want string
+ }{
+ {id: legacyEncryptedProxy.ID, want: "legacy-encrypted-proxy-secret"},
+ {id: v2Proxy.ID, want: "v2-proxy-secret"},
+ } {
+ refreshed, err := client.Proxy.Get(ctx, tc.id)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if refreshed.Password == nil {
+ t.Fatalf("migrated proxy %d password is nil", tc.id)
+ }
+ if got, err := domainEncryptor.DecryptForDomain(service.SecretDomainProxyCredential, *refreshed.Password); err != nil || got != tc.want {
+ t.Fatalf("migrated proxy %d credential = %q, err=%v", tc.id, got, err)
+ }
+ }
+ refreshedBoundProxy, err := client.Proxy.Get(ctx, boundProxy.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if refreshedBoundProxy.Password == nil || *refreshedBoundProxy.Password != boundProxyCiphertext {
+ t.Fatalf("already-bound proxy ciphertext changed: got=%v", refreshedBoundProxy.Password)
+ }
+
+ again, err := MigrateDomainBoundSecrets(ctx, client, encryptor)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if again.TOTPSecrets != 0 || again.ChannelMonitorKeys != 0 || again.ChannelMonitorPayloads != 0 || again.BackupS3Configs != 0 || again.ContentModerationConfigs != 0 || again.SettingSecrets != 0 || again.ProxyCredentials != 0 {
+ t.Fatalf("idempotent migration result = %#v", again)
+ }
+}
+
+func TestMigrateDomainBoundSecretsRejectsUnreadableProxyCiphertextAndRollsBack(t *testing.T) {
+ ctx := context.Background()
+ client := newCredentialEncryptionTestClient(t)
+ encryptor := newDomainMigrationTestEncryptor(t)
+ account, err := client.Account.Create().
+ SetName("proxy-rollback-account").
+ SetPlatform("openai").
+ SetType("api_key").
+ SetCredentials(map[string]any{"api_key": "plaintext-account-secret"}).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = client.Proxy.Create().
+ SetName("corrupt-proxy-migration").
+ SetProtocol("http").
+ SetHost("corrupt-proxy.example.test").
+ SetPort(8090).
+ SetPassword("sd:v3:not-valid").
+ SetStatus(service.StatusActive).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := MigrateDomainBoundSecrets(ctx, client, encryptor); err == nil {
+ t.Fatal("migration accepted unreadable proxy ciphertext")
+ }
+ refreshed, err := client.Account.Get(ctx, account.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if refreshed.Credentials["api_key"] != "plaintext-account-secret" {
+ t.Fatalf("failed proxy migration did not roll back prior account rewrite: %#v", refreshed.Credentials)
+ }
+}
+
+func TestMigrateDomainBoundSecretsRejectsUnsafeOrUnreadableMonitorPayloadAndRollsBack(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ headers func(t *testing.T, encryptor service.SecretEncryptor) map[string]string
+ }{
+ {
+ name: "wrong domain envelope",
+ headers: func(t *testing.T, encryptor service.SecretEncryptor) map[string]string {
+ t.Helper()
+ ciphertext, err := encryptor.(service.DomainSecretEncryptor).EncryptForDomain(
+ service.SecretDomainSettingSecret,
+ `{"version":1,"kind":"extra_headers","headers":{"User-Agent":"client"}}`,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ return map[string]string{service.ChannelMonitorSecretEnvelopeKey: ciphertext}
+ },
+ },
+ {
+ name: "credential header",
+ headers: func(_ *testing.T, _ service.SecretEncryptor) map[string]string {
+ return map[string]string{"Authorization": "Bearer stored-secret"}
+ },
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := context.Background()
+ client := newCredentialEncryptionTestClient(t)
+ encryptor := newDomainMigrationTestEncryptor(t)
+ legacyKey := mustLegacyEncrypt(t, encryptor, "monitor-key-before-rollback")
+ monitor, err := client.ChannelMonitor.Create().
+ SetName("rollback-monitor").
+ SetProvider("openai").
+ SetEndpoint("https://api.example.test").
+ SetAPIKeyEncrypted(legacyKey).
+ SetPrimaryModel("gpt-test").
+ SetIntervalSeconds(60).
+ SetCreatedBy(1).
+ SetExtraHeaders(tc.headers(t, encryptor)).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := MigrateDomainBoundSecrets(ctx, client, encryptor); err == nil {
+ t.Fatal("migration accepted unsafe channel monitor request payload")
+ }
+ refreshed, err := client.ChannelMonitor.Get(ctx, monitor.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if refreshed.APIKeyEncrypted != legacyKey {
+ t.Fatal("failed payload migration did not roll back the prior API key rewrite")
+ }
+ })
+ }
+}
+
+func TestMigrateDomainBoundSecretsRetiresEncryptedOfficialClientAttribution(t *testing.T) {
+ ctx := context.Background()
+ client := newCredentialEncryptionTestClient(t)
+ encryptor := newDomainMigrationTestEncryptor(t)
+ domainEncryptor := encryptor.(service.DomainSecretEncryptor)
+
+ headerCiphertext, err := domainEncryptor.EncryptForDomain(
+ service.SecretDomainChannelMonitor,
+ `{"version":1,"kind":"extra_headers","headers":{"User-Agent":"claude-cli/2.1.92 (external, cli)","X-App":"cli"}}`,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ bodyCiphertext, err := domainEncryptor.EncryptForDomain(
+ service.SecretDomainChannelMonitor,
+ `{"version":1,"kind":"body_override","body":{"system":"You are Claude Code, Anthropic's official CLI for Claude."}}`,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ monitor, err := client.ChannelMonitor.Create().
+ SetName("legacy-encrypted-attribution").
+ SetProvider("anthropic").
+ SetEndpoint("https://api.anthropic.com").
+ SetAPIKeyEncrypted(mustLegacyEncrypt(t, encryptor, "monitor-key")).
+ SetPrimaryModel("claude-test").
+ SetIntervalSeconds(60).
+ SetCreatedBy(1).
+ SetExtraHeaders(map[string]string{service.ChannelMonitorSecretEnvelopeKey: headerCiphertext}).
+ SetBodyOverrideMode(service.MonitorBodyOverrideModeMerge).
+ SetBodyOverride(map[string]any{service.ChannelMonitorSecretEnvelopeKey: bodyCiphertext}).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := MigrateDomainBoundSecrets(ctx, client, encryptor); err != nil {
+ t.Fatal(err)
+ }
+
+ refreshed, err := client.ChannelMonitor.Get(ctx, monitor.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ headers, err := service.OpenChannelMonitorExtraHeaders(encryptor, refreshed.ExtraHeaders)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(headers) != 0 || refreshed.BodyOverride != nil || refreshed.BodyOverrideMode != service.MonitorBodyOverrideModeOff {
+ t.Fatalf("official-client attribution was not retired: headers=%#v body=%#v mode=%q", headers, refreshed.BodyOverride, refreshed.BodyOverrideMode)
+ }
+}
+
+func TestMigrateDomainBoundSecretsRejectsMalformedSettingCiphertextAndRollsBack(t *testing.T) {
+ ctx := context.Background()
+ client := newCredentialEncryptionTestClient(t)
+ encryptor := newDomainMigrationTestEncryptor(t)
+ if _, err := client.Setting.Create().
+ SetKey(service.SettingKeyAdminAPIKey).
+ SetValue("plaintext-admin-before-rollback").
+ Save(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.Setting.Create().
+ SetKey(service.SettingKeySMTPPassword).
+ SetValue("sd:v3:not-valid").
+ Save(ctx); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := MigrateDomainBoundSecrets(ctx, client, encryptor); err == nil {
+ t.Fatal("expected malformed setting ciphertext migration failure")
+ }
+ adminSetting, err := client.Setting.Query().Where(setting.KeyEQ(service.SettingKeyAdminAPIKey)).Only(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if adminSetting.Value != "plaintext-admin-before-rollback" {
+ t.Fatalf("transaction did not roll back prior setting migration: %q", adminSetting.Value)
+ }
+}
+
+func TestDomainSecretMigrationPostgresGuards(t *testing.T) {
+ t.Run("lock", func(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = db.Close() }()
+ mock.ExpectQuery("SELECT pg_advisory_xact_lock\\(\\$1\\)").
+ WithArgs(domainSecretMigrationAdvisoryLockID).
+ WillReturnRows(sqlmock.NewRows([]string{"pg_advisory_xact_lock"}).AddRow(nil))
+
+ if err := acquireDomainSecretMigrationLock(context.Background(), dialect.Postgres, db); err != nil {
+ t.Fatal(err)
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Fatal(err)
+ }
+ })
+
+ t.Run("validate proxy constraint", func(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = db.Close() }()
+ mock.ExpectExec("ALTER TABLE proxies VALIDATE CONSTRAINT chk_proxies_password_encrypted_storage").
+ WillReturnResult(sqlmock.NewResult(0, 0))
+
+ if err := validateProxyCredentialStorageConstraint(context.Background(), dialect.Postgres, db); err != nil {
+ t.Fatal(err)
+ }
+ if err := mock.ExpectationsWereMet(); err != nil {
+ t.Fatal(err)
+ }
+ })
+
+ if err := acquireDomainSecretMigrationLock(context.Background(), dialect.SQLite, nil); err != nil {
+ t.Fatalf("non-Postgres lock guard returned error: %v", err)
+ }
+ if err := validateProxyCredentialStorageConstraint(context.Background(), dialect.SQLite, nil); err != nil {
+ t.Fatalf("non-Postgres constraint guard returned error: %v", err)
+ }
+}
+
+func TestMigrateDomainBoundSecretsRejectsUnreadableLegacyCiphertext(t *testing.T) {
+ ctx := context.Background()
+ client := newCredentialEncryptionTestClient(t)
+ encryptor := newDomainMigrationTestEncryptor(t)
+ legacyAccountCiphertext := mustLegacyEncrypt(t, encryptor, `"account-secret"`)
+ account, err := client.Account.Create().
+ SetName("rollback-account").
+ SetPlatform("openai").
+ SetType("api_key").
+ SetCredentials(map[string]any{
+ "api_key": encryptedCredentialEnvelope(encryptedCredentialAlgV1, legacyAccountCiphertext),
+ }).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _, err = client.User.Create().
+ SetEmail("corrupt-domain-migration@example.test").
+ SetPasswordHash("password-hash").
+ SetTotpSecretEncrypted("not-valid-ciphertext").
+ SetTotpEnabled(true).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := MigrateDomainBoundSecrets(ctx, client, encryptor); err == nil {
+ t.Fatal("migration accepted unreadable legacy TOTP ciphertext")
+ }
+ refreshed, err := client.Account.Get(ctx, account.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ envelope, ok := refreshed.Credentials["api_key"].(map[string]any)
+ if !ok || envelope[encryptedCredentialAlgKey] != encryptedCredentialAlgV1 {
+ t.Fatalf("failed migration did not roll back prior account rewrite: %#v", refreshed.Credentials)
+ }
+}
+
+func TestMigrateDomainBoundSecretsUpgradesV2SharedRootCiphertext(t *testing.T) {
+ ctx := context.Background()
+ client := newCredentialEncryptionTestClient(t)
+ encryptor := newDomainMigrationTestEncryptor(t)
+ aesEncryptor := encryptor.(*AESEncryptor)
+ v2Ciphertext := mustV2DomainEncrypt(t, aesEncryptor, service.SecretDomainTOTP, "v2-totp-secret")
+ user, err := client.User.Create().
+ SetEmail("v2-domain-migration@example.test").
+ SetPasswordHash("password-hash").
+ SetTotpSecretEncrypted(v2Ciphertext).
+ SetTotpEnabled(true).
+ Save(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ result, err := MigrateDomainBoundSecrets(ctx, client, encryptor)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if result.TOTPSecrets != 1 {
+ t.Fatalf("migration result = %#v", result)
+ }
+ refreshed, err := client.User.Get(ctx, user.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(*refreshed.TotpSecretEncrypted, secretDomainCiphertextPrefix) {
+ t.Fatalf("migrated ciphertext = %q", *refreshed.TotpSecretEncrypted)
+ }
+ got, err := service.DecryptForSecretDomain(encryptor, service.SecretDomainTOTP, *refreshed.TotpSecretEncrypted)
+ if err != nil || got != "v2-totp-secret" {
+ t.Fatalf("migrated v2 secret = %q, err=%v", got, err)
+ }
+}
+
+func newDomainMigrationTestEncryptor(t *testing.T) service.SecretEncryptor {
+ t.Helper()
+ cfg := domainKeyTestConfig()
+ cfg.Totp.EncryptionKey = strings.Repeat("f", 64)
+ encryptor, err := NewAESEncryptor(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return encryptor
+}
+
+func mustV2DomainEncrypt(t *testing.T, encryptor *AESEncryptor, domain, plaintext string) string {
+ t.Helper()
+ encoded, err := encryptAESGCM(
+ deriveLegacyDomainKeyV2(encryptor.legacyKey, domain),
+ legacySecretDomainAADV2(domain),
+ plaintext,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ return legacySecretDomainCiphertextPrefixV2 + encoded
+}
+
+func mustLegacyEncrypt(t *testing.T, encryptor service.SecretEncryptor, plaintext string) string {
+ t.Helper()
+ ciphertext, err := encryptor.Encrypt(plaintext)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return ciphertext
+}
diff --git a/backend/internal/repository/durable_usage_billing_outbox_repo.go b/backend/internal/repository/durable_usage_billing_outbox_repo.go
new file mode 100644
index 00000000000..e92e819c9f6
--- /dev/null
+++ b/backend/internal/repository/durable_usage_billing_outbox_repo.go
@@ -0,0 +1,533 @@
+package repository
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+const defaultDurableUsageBillingAdmissionTimeout = 250 * time.Millisecond
+
+type durableUsageBillingOutboxOptions struct {
+ AdmissionTimeout time.Duration
+}
+
+type durableUsageBillingOutboxRepository struct {
+ inner service.UsageBillingOutboxRepository
+ bindingValidator service.UsageBillingBindingValidator
+ spool *usageBillingOutboxSpool
+ admissionTimeout time.Duration
+ drainMu sync.Mutex
+}
+
+func NewDurableUsageBillingOutboxRepository(db *sql.DB) (*durableUsageBillingOutboxRepository, error) {
+ inner := newDurableUsageBillingOutboxInner(db)
+ return newDurableUsageBillingOutboxRepository(
+ inner,
+ inner,
+ defaultUsageBillingOutboxSpoolDir(),
+ durableUsageBillingOutboxOptions{},
+ )
+}
+
+func newDurableUsageBillingOutboxInner(db *sql.DB) *usageBillingOutboxRepository {
+ inner := NewUsageBillingOutboxRepository(db)
+ // Rollout safety: producers already wired for pre-dispatch admission consume
+ // their hold atomically. Legacy producers continue durable postpaid billing
+ // instead of failing after a successful upstream response. Flip
+ // requireAdmission only after every producer and reconciliation path passes.
+ inner.useAdmissionIfPresent = true
+ inner.requireAdmission = false
+ return inner
+}
+
+func newDurableUsageBillingOutboxRepository(
+ inner service.UsageBillingOutboxRepository,
+ bindingValidator service.UsageBillingBindingValidator,
+ spoolDir string,
+ options durableUsageBillingOutboxOptions,
+) (*durableUsageBillingOutboxRepository, error) {
+ if inner == nil || bindingValidator == nil {
+ return nil, errors.New("usage billing durable outbox inner repository is nil")
+ }
+ spool, err := newUsageBillingOutboxSpool(spoolDir)
+ if err != nil {
+ return nil, fmt.Errorf("initialize usage billing durable spool: %w", err)
+ }
+ if options.AdmissionTimeout <= 0 {
+ options.AdmissionTimeout = defaultDurableUsageBillingAdmissionTimeout
+ }
+ return &durableUsageBillingOutboxRepository{
+ inner: inner, bindingValidator: bindingValidator, spool: spool,
+ admissionTimeout: options.AdmissionTimeout,
+ }, nil
+}
+
+func (r *durableUsageBillingOutboxRepository) Enqueue(ctx context.Context, envelope service.UsageBillingEnvelope) (*service.UsageBillingOutboxEvent, bool, error) {
+ if r == nil || r.inner == nil || r.spool == nil {
+ return nil, false, service.ErrUsageBillingOutboxUnavailable
+ }
+ if err := envelope.Validate(); err != nil {
+ return nil, false, err
+ }
+ baseCtx := context.Background()
+ if ctx != nil {
+ baseCtx = context.WithoutCancel(ctx)
+ }
+ dbCtx, cancel := context.WithTimeout(baseCtx, r.admissionTimeout)
+ event, inserted, err := r.inner.Enqueue(dbCtx, envelope)
+ cancel()
+ if err == nil {
+ return event, inserted, nil
+ }
+ if !errors.Is(err, service.ErrUsageBillingOutboxAdmissionRetryable) {
+ return nil, false, err
+ }
+ if spoolErr := r.spool.Put(envelope); spoolErr != nil {
+ return nil, false, fmt.Errorf("%w: database: %v; durable spool: %w", service.ErrUsageBillingOutboxAdmissionRetryable, err, spoolErr)
+ }
+ return &service.UsageBillingOutboxEvent{
+ Envelope: envelope, Status: service.UsageBillingOutboxStatusPending,
+ MaxAttempts: service.UsageBillingOutboxDefaultMaxAttempts,
+ CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
+ }, true, nil
+}
+
+func (r *durableUsageBillingOutboxRepository) Claim(ctx context.Context, owner string, limit int, lease time.Duration) ([]service.UsageBillingOutboxEvent, error) {
+ if r == nil || r.inner == nil {
+ return nil, service.ErrUsageBillingOutboxUnavailable
+ }
+ if err := r.drainSpool(ctx, limit); err != nil {
+ return nil, err
+ }
+ return r.inner.Claim(ctx, owner, limit, lease)
+}
+
+func (r *durableUsageBillingOutboxRepository) drainSpool(ctx context.Context, limit int) error {
+ if r == nil || r.spool == nil {
+ return service.ErrUsageBillingOutboxUnavailable
+ }
+ if limit <= 0 || limit > 100 {
+ limit = 100
+ }
+ r.drainMu.Lock()
+ defer r.drainMu.Unlock()
+ entries, err := r.spool.List(limit)
+ if err != nil {
+ return err
+ }
+ for _, entry := range entries {
+ if ctx != nil {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+ }
+ envelope, err := r.spool.Read(entry)
+ if err != nil {
+ if shouldQuarantineUsageBillingSpoolError(err) {
+ if quarantineErr := r.spool.Quarantine(entry); quarantineErr != nil {
+ return fmt.Errorf("quarantine unreadable usage billing spool entry %s: %w", entry, quarantineErr)
+ }
+ slog.Error("quarantined unreadable usage billing spool entry", "entry", entry, "error", err)
+ continue
+ }
+ return fmt.Errorf("read usage billing spool entry %s: %w", entry, err)
+ }
+ if _, _, err := r.inner.Enqueue(ctx, envelope); err != nil {
+ if shouldQuarantineUsageBillingSpoolError(err) {
+ if quarantineErr := r.spool.Quarantine(entry); quarantineErr != nil {
+ return fmt.Errorf("quarantine rejected usage billing spool entry %s: %w", entry, quarantineErr)
+ }
+ slog.Error("quarantined rejected usage billing spool entry", "entry", entry, "error", err)
+ continue
+ }
+ return fmt.Errorf("import usage billing spool entry %s: %w", entry, err)
+ }
+ if err := r.spool.Remove(entry); err != nil {
+ return fmt.Errorf("remove imported usage billing spool entry %s: %w", entry, err)
+ }
+ }
+ return nil
+}
+
+func (r *durableUsageBillingOutboxRepository) Complete(ctx context.Context, id int64, owner, leaseToken, resultCode string) error {
+ return r.inner.Complete(ctx, id, owner, leaseToken, resultCode)
+}
+
+func (r *durableUsageBillingOutboxRepository) Retry(ctx context.Context, id int64, owner, leaseToken string, availableAt time.Time, errorCode, errorMessage string) error {
+ return r.inner.Retry(ctx, id, owner, leaseToken, availableAt, errorCode, errorMessage)
+}
+
+func (r *durableUsageBillingOutboxRepository) DeadLetter(ctx context.Context, id int64, owner, leaseToken, errorCode, errorMessage string) error {
+ return r.inner.DeadLetter(ctx, id, owner, leaseToken, errorCode, errorMessage)
+}
+
+func (r *durableUsageBillingOutboxRepository) ValidateBindings(ctx context.Context, envelope service.UsageBillingEnvelope) error {
+ return r.bindingValidator.ValidateBindings(ctx, envelope)
+}
+
+func (r *durableUsageBillingOutboxRepository) Admit(ctx context.Context, admission service.UsageBillingAdmission) error {
+ intentRepo, ok := r.inner.(service.UsageBillingAdmissionRepository)
+ if !ok {
+ return service.ErrUsageBillingOutboxUnavailable
+ }
+ return intentRepo.Admit(ctx, admission)
+}
+
+func (r *durableUsageBillingOutboxRepository) MarkDispatched(ctx context.Context, ref service.UsageBillingAdmissionAttemptRef) error {
+ intentRepo, ok := r.inner.(service.UsageBillingAdmissionRepository)
+ if !ok {
+ return service.ErrUsageBillingOutboxUnavailable
+ }
+ return intentRepo.MarkDispatched(ctx, ref)
+}
+
+func (r *durableUsageBillingOutboxRepository) MarkAttemptFailed(ctx context.Context, ref service.UsageBillingAdmissionAttemptRef) error {
+ intentRepo, ok := r.inner.(service.UsageBillingAdmissionRepository)
+ if !ok {
+ return service.ErrUsageBillingOutboxUnavailable
+ }
+ return intentRepo.MarkAttemptFailed(ctx, ref)
+}
+
+func (r *durableUsageBillingOutboxRepository) MarkOrphaned(ctx context.Context, ref service.UsageBillingAdmissionAttemptRef) error {
+ intentRepo, ok := r.inner.(service.UsageBillingAdmissionRepository)
+ if !ok {
+ return service.ErrUsageBillingOutboxUnavailable
+ }
+ return intentRepo.MarkOrphaned(ctx, ref)
+}
+
+func (r *durableUsageBillingOutboxRepository) Heartbeat(ctx context.Context, ref service.UsageBillingAdmissionAttemptRef, lease time.Duration) error {
+ intentRepo, ok := r.inner.(service.UsageBillingAdmissionRepository)
+ if !ok {
+ return service.ErrUsageBillingOutboxUnavailable
+ }
+ return intentRepo.Heartbeat(ctx, ref, lease)
+}
+
+func (r *durableUsageBillingOutboxRepository) Abandon(ctx context.Context, ref service.UsageBillingAdmissionAttemptRef) error {
+ intentRepo, ok := r.inner.(service.UsageBillingAdmissionRepository)
+ if !ok {
+ return service.ErrUsageBillingOutboxUnavailable
+ }
+ return intentRepo.Abandon(ctx, ref)
+}
+
+func (r *durableUsageBillingOutboxRepository) WaitSettled(ctx context.Context, ref service.UsageBillingAdmissionAttemptRef) error {
+ intentRepo, ok := r.inner.(service.UsageBillingAdmissionRepository)
+ if !ok {
+ return service.ErrUsageBillingOutboxUnavailable
+ }
+ return intentRepo.WaitSettled(ctx, ref)
+}
+
+func (r *durableUsageBillingOutboxRepository) ReconcileStaleAdmissions(
+ ctx context.Context,
+ preparedGrace time.Duration,
+ dispatchedGrace time.Duration,
+ limit int,
+) (service.UsageBillingAdmissionReconcileResult, error) {
+ reconciler, ok := r.inner.(service.UsageBillingAdmissionReconciler)
+ if !ok {
+ return service.UsageBillingAdmissionReconcileResult{}, service.ErrUsageBillingOutboxUnavailable
+ }
+ return reconciler.ReconcileStaleAdmissions(ctx, preparedGrace, dispatchedGrace, limit)
+}
+
+func (r *durableUsageBillingOutboxRepository) ListUsageBillingReconciliationCases(
+ ctx context.Context,
+ limit int,
+) ([]service.UsageBillingReconciliationCase, error) {
+ repo, ok := r.inner.(service.UsageBillingReconciliationRepository)
+ if !ok {
+ return nil, service.ErrUsageBillingReconciliationUnavailable
+ }
+ return repo.ListUsageBillingReconciliationCases(ctx, limit)
+}
+
+func (r *durableUsageBillingOutboxRepository) ResolveUsageBillingReconciliation(
+ ctx context.Context,
+ input service.UsageBillingReconciliationResolveInput,
+) error {
+ repo, ok := r.inner.(service.UsageBillingReconciliationRepository)
+ if !ok {
+ return service.ErrUsageBillingReconciliationUnavailable
+ }
+ r.drainMu.Lock()
+ defer r.drainMu.Unlock()
+ stored, found, err := r.spool.GetByIdentity(input.RequestID, input.APIKeyID)
+ if err != nil {
+ return err
+ }
+ if found {
+ switch input.Action {
+ case service.UsageBillingReconciliationActionSettleDelivered:
+ if input.Envelope.Validate() != nil ||
+ input.Envelope.RequestFingerprint() != stored.RequestFingerprint() {
+ return service.ErrUsageBillingReconciliationConflict
+ }
+ input.Envelope = stored
+ case service.UsageBillingReconciliationActionReleaseUndelivered,
+ service.UsageBillingReconciliationActionRetryDeadLetter:
+ return service.ErrUsageBillingReconciliationConflict
+ }
+ }
+ if err := repo.ResolveUsageBillingReconciliation(ctx, input); err != nil {
+ return err
+ }
+ if found && input.Action == service.UsageBillingReconciliationActionSettleDelivered {
+ name := usageBillingSpoolFileNameForIdentity(input.RequestID, input.APIKeyID)
+ if err := r.spool.Remove(name); err != nil {
+ slog.Error("remove reconciled usage billing spool entry failed", "entry", name, "error", err)
+ }
+ }
+ return nil
+}
+
+type usageBillingOutboxSpool struct {
+ dir string
+ mu sync.Mutex
+}
+
+func newUsageBillingOutboxSpool(dir string) (*usageBillingOutboxSpool, error) {
+ dir = filepath.Clean(strings.TrimSpace(dir))
+ if dir == "." || dir == "" {
+ return nil, errors.New("usage billing spool directory is empty")
+ }
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return nil, err
+ }
+ if err := syncDirectory(dir); err != nil {
+ return nil, err
+ }
+ return &usageBillingOutboxSpool{dir: dir}, nil
+}
+
+func (s *usageBillingOutboxSpool) Put(envelope service.UsageBillingEnvelope) error {
+ if s == nil {
+ return errors.New("usage billing spool is nil")
+ }
+ if err := envelope.Validate(); err != nil {
+ return err
+ }
+ raw, err := envelope.MarshalJSON()
+ if err != nil {
+ return err
+ }
+ if len(raw) > service.UsageBillingEnvelopeMaxBytes {
+ return service.ErrUsageBillingEnvelopeInvalid
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ name := usageBillingSpoolFileName(envelope)
+ finalPath := filepath.Join(s.dir, name)
+ if existing, err := os.ReadFile(finalPath); err == nil {
+ stored, decodeErr := service.DecodeUsageBillingEnvelope(existing)
+ if decodeErr != nil {
+ return decodeErr
+ }
+ if stored.RequestFingerprint() != envelope.RequestFingerprint() {
+ return service.ErrUsageBillingRequestConflict
+ }
+ return nil
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+
+ temp, err := os.CreateTemp(s.dir, ".pending-*")
+ if err != nil {
+ return err
+ }
+ tempPath := temp.Name()
+ cleanup := func() { _ = os.Remove(tempPath) }
+ defer cleanup()
+ if err := temp.Chmod(0o600); err != nil {
+ _ = temp.Close()
+ return err
+ }
+ if _, err := temp.Write(raw); err != nil {
+ _ = temp.Close()
+ return err
+ }
+ if err := temp.Sync(); err != nil {
+ _ = temp.Close()
+ return err
+ }
+ if err := temp.Close(); err != nil {
+ return err
+ }
+ if err := os.Link(tempPath, finalPath); err != nil {
+ if !errors.Is(err, os.ErrExist) {
+ return err
+ }
+ existing, readErr := os.ReadFile(finalPath)
+ if readErr != nil {
+ return readErr
+ }
+ stored, decodeErr := service.DecodeUsageBillingEnvelope(existing)
+ if decodeErr != nil || stored.RequestFingerprint() != envelope.RequestFingerprint() {
+ return service.ErrUsageBillingRequestConflict
+ }
+ }
+ if err := syncDirectory(s.dir); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (s *usageBillingOutboxSpool) List(limit int) ([]string, error) {
+ entries, err := os.ReadDir(s.dir)
+ if err != nil {
+ return nil, err
+ }
+ files := make([]string, 0, len(entries))
+ for _, entry := range entries {
+ if entry.Type().IsRegular() && strings.HasSuffix(entry.Name(), ".json") {
+ files = append(files, entry.Name())
+ }
+ }
+ sort.Strings(files)
+ if limit > 0 && len(files) > limit {
+ files = files[:limit]
+ }
+ return files, nil
+}
+
+func (s *usageBillingOutboxSpool) Read(name string) (service.UsageBillingEnvelope, error) {
+ if filepath.Base(name) != name || !strings.HasSuffix(name, ".json") {
+ return service.UsageBillingEnvelope{}, errors.New("invalid usage billing spool file name")
+ }
+ file, err := os.Open(filepath.Join(s.dir, name))
+ if err != nil {
+ return service.UsageBillingEnvelope{}, err
+ }
+ defer func() { _ = file.Close() }()
+ raw, err := io.ReadAll(io.LimitReader(file, service.UsageBillingEnvelopeMaxBytes+1))
+ if err != nil {
+ return service.UsageBillingEnvelope{}, err
+ }
+ return service.DecodeUsageBillingEnvelope(raw)
+}
+
+func (s *usageBillingOutboxSpool) GetByIdentity(
+ requestID string,
+ apiKeyID int64,
+) (service.UsageBillingEnvelope, bool, error) {
+ name := usageBillingSpoolFileNameForIdentity(requestID, apiKeyID)
+ envelope, err := s.Read(name)
+ if errors.Is(err, os.ErrNotExist) {
+ return service.UsageBillingEnvelope{}, false, nil
+ }
+ if err != nil {
+ return service.UsageBillingEnvelope{}, false, err
+ }
+ return envelope, true, nil
+}
+
+func (s *usageBillingOutboxSpool) Quarantine(name string) error {
+ if s == nil || filepath.Base(name) != name || !strings.HasSuffix(name, ".json") {
+ return errors.New("invalid usage billing spool quarantine entry")
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ quarantineDir := filepath.Join(s.dir, "quarantine")
+ if err := os.MkdirAll(quarantineDir, 0o700); err != nil {
+ return err
+ }
+ base := strings.TrimSuffix(name, ".json")
+ target := filepath.Join(quarantineDir, fmt.Sprintf("%s.%d.json", base, time.Now().UTC().UnixNano()))
+ if err := os.Rename(filepath.Join(s.dir, name), target); err != nil {
+ return err
+ }
+ if err := syncDirectory(quarantineDir); err != nil {
+ return err
+ }
+ return syncDirectory(s.dir)
+}
+
+func (s *usageBillingOutboxSpool) Remove(name string) error {
+ if filepath.Base(name) != name || !strings.HasSuffix(name, ".json") {
+ return errors.New("invalid usage billing spool file name")
+ }
+ if err := os.Remove(filepath.Join(s.dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ return syncDirectory(s.dir)
+}
+
+func usageBillingSpoolFileName(envelope service.UsageBillingEnvelope) string {
+ return usageBillingSpoolFileNameForIdentity(envelope.RequestID(), envelope.APIKeyID())
+}
+
+func usageBillingSpoolFileNameForIdentity(requestID string, apiKeyID int64) string {
+ sum := sha256.Sum256([]byte(strings.TrimSpace(requestID) + "\x00" + strconv.FormatInt(apiKeyID, 10)))
+ return hex.EncodeToString(sum[:]) + ".json"
+}
+
+func shouldQuarantineUsageBillingSpoolError(err error) bool {
+ for _, permanent := range []error{
+ service.ErrUsageBillingEnvelopeInvalid,
+ service.ErrUsageBillingEnvelopeVersion,
+ service.ErrUsageBillingEnvelopeFingerprintMismatch,
+ service.ErrUsageBillingRequestConflict,
+ service.ErrUsageBillingCrossTenant,
+ service.ErrUsageBillingOutboxTargetNotFound,
+ service.ErrUsageBillingAdmissionInvalid,
+ service.ErrUsageBillingAdmissionMissing,
+ service.ErrUsageBillingAdmissionFinalized,
+ service.ErrUsageBillingAdmissionLeaseLost,
+ } {
+ if errors.Is(err, permanent) {
+ return true
+ }
+ }
+ return false
+}
+
+func syncDirectory(dir string) error {
+ file, err := os.Open(dir)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = file.Close() }()
+ if err := file.Sync(); err != nil {
+ // Some development filesystems do not support syncing directories. The
+ // production Linux volume does; refuse startup there instead of claiming
+ // durability that the filesystem cannot provide.
+ slog.Error("usage billing spool directory sync failed", "directory", dir, "error", err)
+ return err
+ }
+ return nil
+}
+
+func defaultUsageBillingOutboxSpoolDir() string {
+ if dataDir := strings.TrimSpace(os.Getenv("DATA_DIR")); dataDir != "" {
+ return filepath.Join(dataDir, "usage-billing-outbox-spool")
+ }
+ if info, err := os.Stat("/app/data"); err == nil && info.IsDir() {
+ return filepath.Join("/app/data", "usage-billing-outbox-spool")
+ }
+ return filepath.Join("data", "usage-billing-outbox-spool")
+}
+
+var _ service.UsageBillingOutboxRepository = (*durableUsageBillingOutboxRepository)(nil)
+var _ service.UsageBillingBindingValidator = (*durableUsageBillingOutboxRepository)(nil)
+var _ service.UsageBillingAdmissionRepository = (*durableUsageBillingOutboxRepository)(nil)
+var _ service.UsageBillingReconciliationRepository = (*durableUsageBillingOutboxRepository)(nil)
diff --git a/backend/internal/repository/durable_usage_billing_outbox_repo_unit_test.go b/backend/internal/repository/durable_usage_billing_outbox_repo_unit_test.go
new file mode 100644
index 00000000000..d2f8bd2f4d2
--- /dev/null
+++ b/backend/internal/repository/durable_usage_billing_outbox_repo_unit_test.go
@@ -0,0 +1,212 @@
+package repository
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+type durableUsageOutboxInnerStub struct {
+ enqueueErr error
+ envelopes []service.UsageBillingEnvelope
+ claimed []service.UsageBillingOutboxEvent
+ claimCalls int
+ resolved []service.UsageBillingReconciliationResolveInput
+}
+
+func (s *durableUsageOutboxInnerStub) Enqueue(ctx context.Context, envelope service.UsageBillingEnvelope) (*service.UsageBillingOutboxEvent, bool, error) {
+ if s.enqueueErr != nil {
+ return nil, false, s.enqueueErr
+ }
+ s.envelopes = append(s.envelopes, envelope)
+ return &service.UsageBillingOutboxEvent{ID: int64(len(s.envelopes)), Envelope: envelope}, true, nil
+}
+func (s *durableUsageOutboxInnerStub) Claim(context.Context, string, int, time.Duration) ([]service.UsageBillingOutboxEvent, error) {
+ s.claimCalls++
+ return s.claimed, nil
+}
+func (s *durableUsageOutboxInnerStub) Complete(context.Context, int64, string, string, string) error {
+ return nil
+}
+func (s *durableUsageOutboxInnerStub) Retry(context.Context, int64, string, string, time.Time, string, string) error {
+ return nil
+}
+func (s *durableUsageOutboxInnerStub) DeadLetter(context.Context, int64, string, string, string, string) error {
+ return nil
+}
+func (s *durableUsageOutboxInnerStub) ValidateBindings(context.Context, service.UsageBillingEnvelope) error {
+ return nil
+}
+func (s *durableUsageOutboxInnerStub) ListUsageBillingReconciliationCases(context.Context, int) ([]service.UsageBillingReconciliationCase, error) {
+ return nil, nil
+}
+func (s *durableUsageOutboxInnerStub) ResolveUsageBillingReconciliation(_ context.Context, input service.UsageBillingReconciliationResolveInput) error {
+ s.resolved = append(s.resolved, input)
+ return nil
+}
+
+func durableUsageOutboxTestEnvelope(t *testing.T) service.UsageBillingEnvelope {
+ t.Helper()
+ groupID := int64(4)
+ envelope, err := service.NewUsageBillingEnvelope(service.UsageBillingEnvelopeInput{
+ RequestID: "durable-spool-test", RequestPayloadHash: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+ APIKeyID: 1, UserID: 2, AccountID: 3,
+ GroupID: &groupID, AccountType: service.AccountTypeOAuth,
+ BillingModel: "claude-sonnet-4", ExecutionModel: "claude-sonnet-4",
+ BillingType: service.BillingTypeBalance, InputTokens: 1,
+ OccurredAtUnixMs: time.Now().UnixMilli(),
+ PricingSource: service.PricingSourceBuiltinFallback, PricingRevision: "fallback-v1",
+ PricingHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ RateMultiplier: 1, AccountRateMultiplier: 1,
+ })
+ require.NoError(t, err)
+ return envelope
+}
+
+func TestDurableUsageBillingOutboxRepository_SpoolsRetryableAdmissionAndReplaysAfterRestart(t *testing.T) {
+ spoolDir := t.TempDir()
+ sentinel := errors.New("postgres unavailable")
+ firstInner := &durableUsageOutboxInnerStub{enqueueErr: service.MarkUsageBillingOutboxAdmissionRetryable(sentinel)}
+ first, err := newDurableUsageBillingOutboxRepository(firstInner, firstInner, spoolDir, durableUsageBillingOutboxOptions{AdmissionTimeout: 25 * time.Millisecond})
+ require.NoError(t, err)
+ envelope := durableUsageOutboxTestEnvelope(t)
+
+ _, inserted, err := first.Enqueue(context.Background(), envelope)
+ require.NoError(t, err)
+ require.True(t, inserted)
+ require.Empty(t, firstInner.envelopes)
+ files, err := filepath.Glob(filepath.Join(spoolDir, "*.json"))
+ require.NoError(t, err)
+ require.Len(t, files, 1, "successful admission must be fsync-backed before returning")
+
+ recoveredInner := &durableUsageOutboxInnerStub{}
+ restarted, err := newDurableUsageBillingOutboxRepository(recoveredInner, recoveredInner, spoolDir, durableUsageBillingOutboxOptions{AdmissionTimeout: 25 * time.Millisecond})
+ require.NoError(t, err)
+ _, err = restarted.Claim(context.Background(), "restart-worker", 10, time.Second)
+ require.NoError(t, err)
+ require.Len(t, recoveredInner.envelopes, 1)
+ require.Equal(t, envelope.RequestFingerprint(), recoveredInner.envelopes[0].RequestFingerprint())
+ files, err = filepath.Glob(filepath.Join(spoolDir, "*.json"))
+ require.NoError(t, err)
+ require.Empty(t, files, "spool file is deleted only after idempotent DB enqueue succeeds")
+}
+
+func TestDurableUsageBillingOutboxRepository_BoundsHungDatabaseBeforeSpooling(t *testing.T) {
+ inner := &blockingUsageBillingOutboxInnerStub{}
+ repo, err := newDurableUsageBillingOutboxRepository(inner, inner, t.TempDir(), durableUsageBillingOutboxOptions{AdmissionTimeout: 20 * time.Millisecond})
+ require.NoError(t, err)
+
+ started := time.Now()
+ _, _, err = repo.Enqueue(context.Background(), durableUsageOutboxTestEnvelope(t))
+ require.NoError(t, err)
+ require.Less(t, time.Since(started), 250*time.Millisecond)
+}
+
+type blockingUsageBillingOutboxInnerStub struct{ durableUsageOutboxInnerStub }
+
+func (s *blockingUsageBillingOutboxInnerStub) Enqueue(ctx context.Context, envelope service.UsageBillingEnvelope) (*service.UsageBillingOutboxEvent, bool, error) {
+ <-ctx.Done()
+ return nil, false, service.MarkUsageBillingOutboxAdmissionRetryable(ctx.Err())
+}
+
+func TestUsageBillingOutboxSpool_DoesNotOverwriteConflictingIdentity(t *testing.T) {
+ dir := t.TempDir()
+ spool, err := newUsageBillingOutboxSpool(dir)
+ require.NoError(t, err)
+ first := durableUsageOutboxTestEnvelope(t)
+ require.NoError(t, spool.Put(first))
+
+ raw, err := first.MarshalJSON()
+ require.NoError(t, err)
+ files, err := filepath.Glob(filepath.Join(dir, "*.json"))
+ require.NoError(t, err)
+ require.Len(t, files, 1)
+ require.NoError(t, os.WriteFile(files[0], append(raw, '\n'), 0o600))
+ require.NoError(t, spool.Put(first), "same immutable envelope is idempotent")
+}
+
+func TestDurableUsageBillingReconciliationRefusesReleaseWhenLocalSpoolExists(t *testing.T) {
+ inner := &durableUsageOutboxInnerStub{}
+ repo, err := newDurableUsageBillingOutboxRepository(inner, inner, t.TempDir(), durableUsageBillingOutboxOptions{})
+ require.NoError(t, err)
+ envelope := durableUsageOutboxTestEnvelope(t)
+ require.NoError(t, repo.spool.Put(envelope))
+
+ err = repo.ResolveUsageBillingReconciliation(context.Background(), service.UsageBillingReconciliationResolveInput{
+ RequestID: envelope.RequestID(), APIKeyID: envelope.APIKeyID(),
+ Action: service.UsageBillingReconciliationActionReleaseUndelivered,
+ })
+
+ require.ErrorIs(t, err, service.ErrUsageBillingReconciliationConflict)
+ require.Empty(t, inner.resolved)
+}
+
+func TestDurableUsageBillingReconciliationConsumesMatchingLocalSpoolForDeliveredReplay(t *testing.T) {
+ spoolDir := t.TempDir()
+ inner := &durableUsageOutboxInnerStub{}
+ repo, err := newDurableUsageBillingOutboxRepository(inner, inner, spoolDir, durableUsageBillingOutboxOptions{})
+ require.NoError(t, err)
+ envelope := durableUsageOutboxTestEnvelope(t)
+ require.NoError(t, repo.spool.Put(envelope))
+
+ err = repo.ResolveUsageBillingReconciliation(context.Background(), service.UsageBillingReconciliationResolveInput{
+ RequestID: envelope.RequestID(), APIKeyID: envelope.APIKeyID(),
+ Action: service.UsageBillingReconciliationActionSettleDelivered,
+ Envelope: envelope,
+ })
+
+ require.NoError(t, err)
+ require.Len(t, inner.resolved, 1)
+ require.Equal(t, envelope.RequestFingerprint(), inner.resolved[0].Envelope.RequestFingerprint())
+ files, err := filepath.Glob(filepath.Join(spoolDir, "*.json"))
+ require.NoError(t, err)
+ require.Empty(t, files)
+}
+
+func TestDurableUsageBillingOutboxQuarantinesPermanentPoisonAndContinuesClaim(t *testing.T) {
+ spoolDir := t.TempDir()
+ inner := &durableUsageOutboxInnerStub{enqueueErr: service.ErrUsageBillingAdmissionLeaseLost}
+ repo, err := newDurableUsageBillingOutboxRepository(inner, inner, spoolDir, durableUsageBillingOutboxOptions{})
+ require.NoError(t, err)
+ require.NoError(t, repo.spool.Put(durableUsageOutboxTestEnvelope(t)))
+
+ _, err = repo.Claim(context.Background(), "worker", 10, time.Second)
+
+ require.NoError(t, err)
+ rootFiles, err := filepath.Glob(filepath.Join(spoolDir, "*.json"))
+ require.NoError(t, err)
+ require.Empty(t, rootFiles)
+ quarantined, err := filepath.Glob(filepath.Join(spoolDir, "quarantine", "*.json"))
+ require.NoError(t, err)
+ require.Len(t, quarantined, 1)
+}
+
+func TestDurableUsageBillingOutboxQuarantinesMissingTargetAndContinuesClaim(t *testing.T) {
+ spoolDir := t.TempDir()
+ claimed := service.UsageBillingOutboxEvent{ID: 42}
+ inner := &durableUsageOutboxInnerStub{
+ enqueueErr: service.ErrUsageBillingOutboxTargetNotFound,
+ claimed: []service.UsageBillingOutboxEvent{claimed},
+ }
+ repo, err := newDurableUsageBillingOutboxRepository(inner, inner, spoolDir, durableUsageBillingOutboxOptions{})
+ require.NoError(t, err)
+ require.NoError(t, repo.spool.Put(durableUsageOutboxTestEnvelope(t)))
+
+ events, err := repo.Claim(context.Background(), "worker", 10, time.Second)
+
+ require.NoError(t, err)
+ require.Equal(t, []service.UsageBillingOutboxEvent{claimed}, events)
+ require.Equal(t, 1, inner.claimCalls, "a permanent spool poison must not block database claims")
+ rootFiles, err := filepath.Glob(filepath.Join(spoolDir, "*.json"))
+ require.NoError(t, err)
+ require.Empty(t, rootFiles)
+ quarantined, err := filepath.Glob(filepath.Join(spoolDir, "quarantine", "*.json"))
+ require.NoError(t, err)
+ require.Len(t, quarantined, 1)
+}
diff --git a/backend/internal/repository/ent.go b/backend/internal/repository/ent.go
index 64d321924d5..ea05d121fdb 100644
--- a/backend/internal/repository/ent.go
+++ b/backend/internal/repository/ent.go
@@ -67,8 +67,14 @@ func InitEnt(cfg *config.Config) (*ent.Client, *sql.DB, error) {
// 创建 Ent 客户端,绑定到已配置的数据库驱动。
client := ent.NewClient(ent.Driver(drv))
- // 启动阶段:从配置或数据库中确保系统密钥可用。
- if err := ensureBootstrapSecrets(migrationCtx, client, cfg); err != nil {
+ // JWT signing material must be opened or migrated before configuration
+ // validation and before any worker or listener can observe it.
+ secretEncryptor, err := NewAESEncryptor(cfg)
+ if err != nil {
+ _ = client.Close()
+ return nil, nil, fmt.Errorf("initialize bootstrap secret encryption: %w", err)
+ }
+ if err := ensureBootstrapSecrets(migrationCtx, client, cfg, secretEncryptor); err != nil {
_ = client.Close()
return nil, nil, err
}
diff --git a/backend/internal/repository/fixtures_integration_test.go b/backend/internal/repository/fixtures_integration_test.go
index 80b9cab6abc..f1f7b1a49e8 100644
--- a/backend/internal/repository/fixtures_integration_test.go
+++ b/backend/internal/repository/fixtures_integration_test.go
@@ -4,10 +4,13 @@ package repository
import (
"context"
+ "encoding/base64"
+ "strings"
"testing"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
+ entgroup "github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
@@ -117,6 +120,64 @@ func mustCreateGroup(t *testing.T, client *dbent.Client, g *service.Group) *serv
return g
}
+func mustGetOrCreateWalletBusinessGroup(
+ t *testing.T,
+ client *dbent.Client,
+ name, platform string,
+ isExclusive bool,
+ rateMultiplier float64,
+) *service.Group {
+ t.Helper()
+ ctx := context.Background()
+ entity, err := client.Group.Query().
+ Where(entgroup.NameEQ(name), entgroup.DeletedAtIsNil()).
+ Only(ctx)
+ if dbent.IsNotFound(err) {
+ return mustCreateGroup(t, client, &service.Group{
+ Name: name,
+ Platform: platform,
+ Status: service.StatusActive,
+ SubscriptionType: service.SubscriptionTypeStandard,
+ IsExclusive: isExclusive,
+ RateMultiplier: rateMultiplier,
+ })
+ }
+ require.NoError(t, err)
+ group := groupEntityToService(entity)
+ require.Equal(t, platform, group.Platform)
+ require.Equal(t, service.StatusActive, group.Status)
+ require.Equal(t, service.SubscriptionTypeStandard, group.SubscriptionType)
+ require.Equal(t, isExclusive, group.IsExclusive)
+ require.InDelta(t, rateMultiplier, group.RateMultiplier, 0.000001)
+ return group
+}
+
+func mustCreateCreditsWallet(t *testing.T, client *dbent.Client, userID int64, balance float64) *dbent.UserSubscription {
+ t.Helper()
+ ctx := context.Background()
+ tx, err := client.Tx(ctx)
+ require.NoError(t, err)
+ defer func() { _ = tx.Rollback() }()
+ wallet, err := tx.Client().UserSubscription.Create().
+ SetUserID(userID).
+ SetStartsAt(time.Now().UTC().Add(-time.Hour)).
+ SetExpiresAt(service.MaxExpiresAt).
+ SetStatus(service.SubscriptionStatusActive).
+ SetWalletBalanceUsd(balance).
+ SetWalletInitialUsd(balance).
+ SetAssignedAt(time.Now().UTC()).
+ Save(ctx)
+ require.NoError(t, err)
+ _, err = tx.Client().ExecContext(ctx, `
+ INSERT INTO subscription_wallet_ledger
+ (subscription_id, delta_usd, balance_after, reason, notes)
+ VALUES ($1, $2, $2, 'activation', 'integration wallet baseline')
+ `, wallet.ID, balance)
+ require.NoError(t, err)
+ require.NoError(t, tx.Commit())
+ return wallet
+}
+
func mustCreateProxy(t *testing.T, client *dbent.Client, p *service.Proxy) *service.Proxy {
t.Helper()
ctx := context.Background()
@@ -256,12 +317,27 @@ func mustCreateApiKey(t *testing.T, client *dbent.Client, k *service.APIKey) *se
if k.Name == "" {
k.Name = "default"
}
+ if k.KeyHash == "" {
+ k.KeyHash = service.HashAPIKey(k.Key)
+ }
+ if k.KeyPrefix == "" {
+ k.KeyPrefix = service.APIKeyPrefixForStorage(k.Key)
+ }
+ storedKey := k.Key
+ if !strings.HasPrefix(storedKey, apiKeyEncryptedStoragePrefix) {
+ storedKey = apiKeyEncryptedStoragePrefix + "fixture." + base64.RawURLEncoding.EncodeToString([]byte(k.Key))
+ }
create := client.APIKey.Create().
SetUserID(k.UserID).
- SetKey(k.Key).
+ SetKey(storedKey).
+ SetKeyHash(k.KeyHash).
+ SetKeyPrefix(k.KeyPrefix).
SetName(k.Name).
SetStatus(k.Status)
+ if k.Purpose != "" {
+ create.SetPurpose(k.Purpose)
+ }
if k.Quota != 0 {
create.SetQuota(k.Quota)
}
@@ -385,7 +461,7 @@ func mustCreateSubscription(t *testing.T, client *dbent.Client, s *service.UserS
create := client.UserSubscription.Create().
SetUserID(s.UserID).
- SetGroupID(s.GroupID).
+ SetNillableGroupID(s.GroupID).
SetStartsAt(s.StartsAt).
SetExpiresAt(s.ExpiresAt).
SetStatus(s.Status).
diff --git a/backend/internal/repository/gemini_token_cache.go b/backend/internal/repository/gemini_token_cache.go
index d4f552bc4f3..da8d0cf858d 100644
--- a/backend/internal/repository/gemini_token_cache.go
+++ b/backend/internal/repository/gemini_token_cache.go
@@ -2,7 +2,10 @@ package repository
import (
"context"
+ "crypto/rand"
+ "encoding/hex"
"fmt"
+ "strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -11,39 +14,150 @@ import (
)
const (
- oauthTokenKeyPrefix = "oauth:token:"
+ oauthLegacyTokenKeyPrefix = "oauth:token:"
+ oauthTokenKeyPrefix = "oauth:v2:token:"
oauthRefreshLockKeyPrefix = "oauth:refresh_lock:"
)
+var oauthRefreshLockReleaseScript = redis.NewScript(`
+if redis.call('GET', KEYS[1]) == ARGV[1] then
+ return redis.call('DEL', KEYS[1])
+end
+return 0
+`)
+
type geminiTokenCache struct {
- rdb *redis.Client
+ rdb *redis.Client
+ encryptor service.SecretEncryptor
}
-func NewGeminiTokenCache(rdb *redis.Client) service.GeminiTokenCache {
- return &geminiTokenCache{rdb: rdb}
+func NewGeminiTokenCache(rdb *redis.Client, encryptor service.SecretEncryptor) (service.GeminiTokenCache, error) {
+ if rdb == nil {
+ return nil, fmt.Errorf("oauth token cache Redis client is required")
+ }
+ if domainEncryptor, ok := encryptor.(service.DomainSecretEncryptor); !ok || domainEncryptor == nil {
+ return nil, fmt.Errorf("domain secret encryptor is required for oauth token cache")
+ }
+ return &geminiTokenCache{rdb: rdb, encryptor: encryptor}, nil
}
func (c *geminiTokenCache) GetAccessToken(ctx context.Context, cacheKey string) (string, error) {
- key := fmt.Sprintf("%s%s", oauthTokenKeyPrefix, cacheKey)
- return c.rdb.Get(ctx, key).Result()
+ encoded, err := c.rdb.Get(ctx, oauthTokenKey(cacheKey)).Result()
+ if err != nil {
+ return "", err
+ }
+ return decodeOAuthAccessToken(encoded, c.encryptor)
}
func (c *geminiTokenCache) SetAccessToken(ctx context.Context, cacheKey string, token string, ttl time.Duration) error {
- key := fmt.Sprintf("%s%s", oauthTokenKeyPrefix, cacheKey)
- return c.rdb.Set(ctx, key, token, ttl).Err()
+ encoded, err := encodeOAuthAccessToken(token, c.encryptor)
+ if err != nil {
+ return fmt.Errorf("encrypt oauth access token: %w", err)
+ }
+ if err := c.rdb.Set(ctx, oauthTokenKey(cacheKey), encoded, ttl).Err(); err != nil {
+ return err
+ }
+ if err := c.rdb.Del(ctx, oauthLegacyTokenKey(cacheKey)).Err(); err != nil {
+ return fmt.Errorf("remove legacy plaintext oauth access token: %w", err)
+ }
+ return nil
}
func (c *geminiTokenCache) DeleteAccessToken(ctx context.Context, cacheKey string) error {
- key := fmt.Sprintf("%s%s", oauthTokenKeyPrefix, cacheKey)
- return c.rdb.Del(ctx, key).Err()
+ return c.rdb.Del(ctx, oauthTokenKey(cacheKey), oauthLegacyTokenKey(cacheKey)).Err()
}
-func (c *geminiTokenCache) AcquireRefreshLock(ctx context.Context, cacheKey string, ttl time.Duration) (bool, error) {
+func (c *geminiTokenCache) AcquireRefreshLock(ctx context.Context, cacheKey string, ttl time.Duration) (bool, string, error) {
+ ownershipToken, err := newOAuthRefreshLockToken()
+ if err != nil {
+ return false, "", fmt.Errorf("generate oauth refresh lock token: %w", err)
+ }
key := fmt.Sprintf("%s%s", oauthRefreshLockKeyPrefix, cacheKey)
- return c.rdb.SetNX(ctx, key, 1, ttl).Result()
+ acquired, err := c.rdb.SetNX(ctx, key, ownershipToken, ttl).Result()
+ if err != nil || !acquired {
+ return acquired, "", err
+ }
+ return true, ownershipToken, nil
}
-func (c *geminiTokenCache) ReleaseRefreshLock(ctx context.Context, cacheKey string) error {
+func (c *geminiTokenCache) ReleaseRefreshLock(ctx context.Context, cacheKey string, ownershipToken string) error {
+ if ownershipToken == "" {
+ return nil
+ }
key := fmt.Sprintf("%s%s", oauthRefreshLockKeyPrefix, cacheKey)
- return c.rdb.Del(ctx, key).Err()
+ if _, err := oauthRefreshLockReleaseScript.Run(ctx, c.rdb, []string{key}, ownershipToken).Result(); err != nil {
+ return fmt.Errorf("release oauth refresh lock: %w", err)
+ }
+ return nil
+}
+
+func newOAuthRefreshLockToken() (string, error) {
+ var token [16]byte
+ if _, err := rand.Read(token[:]); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(token[:]), nil
+}
+
+func oauthTokenKey(cacheKey string) string {
+ return fmt.Sprintf("%s%s", oauthTokenKeyPrefix, cacheKey)
+}
+
+func oauthLegacyTokenKey(cacheKey string) string {
+ return fmt.Sprintf("%s%s", oauthLegacyTokenKeyPrefix, cacheKey)
+}
+
+func encodeOAuthAccessToken(token string, encryptor service.SecretEncryptor) (string, error) {
+ encoded, err := service.EncryptForSecretDomain(encryptor, service.SecretDomainOAuthTokenCache, token)
+ if err != nil {
+ return "", err
+ }
+ if encoded == "" || encoded == token || !strings.HasPrefix(encoded, secretDomainCiphertextPrefix) {
+ return "", fmt.Errorf("oauth access token encryption did not produce protected ciphertext")
+ }
+ return encoded, nil
+}
+
+func decodeOAuthAccessToken(encoded string, encryptor service.SecretEncryptor) (string, error) {
+ return service.DecryptForSecretDomain(encryptor, service.SecretDomainOAuthTokenCache, encoded)
+}
+
+// PurgeLegacyOAuthTokenCacheSecrets removes only the pre-v2 plaintext access
+// token namespace. New encrypted tokens and refresh locks use non-overlapping
+// prefixes and are never touched. Old application nodes must be drained before
+// this startup preflight runs, otherwise they can recreate plaintext keys.
+func PurgeLegacyOAuthTokenCacheSecrets(ctx context.Context, rdb *redis.Client) (int64, error) {
+ if rdb == nil {
+ return 0, fmt.Errorf("oauth token cache Redis client is required")
+ }
+ pattern := oauthLegacyTokenKeyPrefix + "*"
+ var removed int64
+ for pass := 0; pass < 3; pass++ {
+ var cursor uint64
+ for {
+ keys, next, err := rdb.Scan(ctx, cursor, pattern, 256).Result()
+ if err != nil {
+ return removed, fmt.Errorf("scan legacy oauth token cache: %w", err)
+ }
+ if len(keys) > 0 {
+ count, err := rdb.Unlink(ctx, keys...).Result()
+ if err != nil {
+ return removed, fmt.Errorf("remove legacy oauth token cache: %w", err)
+ }
+ removed += count
+ }
+ cursor = next
+ if cursor == 0 {
+ break
+ }
+ }
+ remaining, _, err := rdb.Scan(ctx, 0, pattern, 1).Result()
+ if err != nil {
+ return removed, fmt.Errorf("verify legacy oauth token cache: %w", err)
+ }
+ if len(remaining) == 0 {
+ return removed, nil
+ }
+ }
+ return removed, fmt.Errorf("legacy oauth token cache keys remain")
}
diff --git a/backend/internal/repository/gemini_token_cache_integration_test.go b/backend/internal/repository/gemini_token_cache_integration_test.go
index 4fe898656af..e14209cb11d 100644
--- a/backend/internal/repository/gemini_token_cache_integration_test.go
+++ b/backend/internal/repository/gemini_token_cache_integration_test.go
@@ -4,9 +4,11 @@ package repository
import (
"errors"
+ "strings"
"testing"
"time"
+ "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
@@ -20,7 +22,11 @@ type GeminiTokenCacheSuite struct {
func (s *GeminiTokenCacheSuite) SetupTest() {
s.IntegrationRedisSuite.SetupTest()
- s.cache = NewGeminiTokenCache(s.rdb)
+ encryptor, err := NewAESEncryptor(geminiTokenCacheIntegrationConfig())
+ require.NoError(s.T(), err)
+ cache, err := NewGeminiTokenCache(s.rdb, encryptor)
+ require.NoError(s.T(), err)
+ s.cache = cache
}
func (s *GeminiTokenCacheSuite) TestDeleteAccessToken() {
@@ -38,10 +44,106 @@ func (s *GeminiTokenCacheSuite) TestDeleteAccessToken() {
require.True(s.T(), errors.Is(err, redis.Nil), "expected redis.Nil after delete")
}
+func (s *GeminiTokenCacheSuite) TestAccessTokenIsEncryptedAtRest() {
+ cacheKey := "encrypted-project"
+ token := "ya29.integration-plaintext-token"
+ require.NoError(s.T(), s.cache.SetAccessToken(s.ctx, cacheKey, token, time.Minute))
+
+ raw, err := s.rdb.Get(s.ctx, oauthTokenKey(cacheKey)).Result()
+ require.NoError(s.T(), err)
+ require.NotEqual(s.T(), token, raw)
+ require.True(s.T(), strings.HasPrefix(raw, secretDomainCiphertextPrefix))
+ _, err = s.rdb.Get(s.ctx, oauthLegacyTokenKey(cacheKey)).Result()
+ require.ErrorIs(s.T(), err, redis.Nil)
+
+ got, err := s.cache.GetAccessToken(s.ctx, cacheKey)
+ require.NoError(s.T(), err)
+ require.Equal(s.T(), token, got)
+}
+
+func (s *GeminiTokenCacheSuite) TestGetAccessTokenDoesNotReadLegacyPlaintext() {
+ cacheKey := "legacy-only"
+ require.NoError(s.T(), s.rdb.Set(s.ctx, oauthLegacyTokenKey(cacheKey), "ya29.legacy-plaintext", time.Minute).Err())
+
+ got, err := s.cache.GetAccessToken(s.ctx, cacheKey)
+ require.Empty(s.T(), got)
+ require.ErrorIs(s.T(), err, redis.Nil)
+}
+
+func (s *GeminiTokenCacheSuite) TestGetAccessTokenRejectsCorruptV2Ciphertext() {
+ cacheKey := "corrupt-v2"
+ require.NoError(s.T(), s.rdb.Set(s.ctx, oauthTokenKey(cacheKey), "not-domain-ciphertext", time.Minute).Err())
+
+ got, err := s.cache.GetAccessToken(s.ctx, cacheKey)
+ require.Empty(s.T(), got)
+ require.Error(s.T(), err)
+}
+
+func (s *GeminiTokenCacheSuite) TestPurgeLegacyOAuthTokenCachePreservesV2AndLockNamespaces() {
+ require.NoError(s.T(), s.rdb.Set(s.ctx, oauthLegacyTokenKey("legacy"), "plaintext", time.Minute).Err())
+ require.NoError(s.T(), s.rdb.Set(s.ctx, oauthTokenKey("encrypted"), "protected", time.Minute).Err())
+ require.NoError(s.T(), s.rdb.Set(s.ctx, oauthRefreshLockKeyPrefix+"lock", "owner", time.Minute).Err())
+
+ removed, err := PurgeLegacyOAuthTokenCacheSecrets(s.ctx, s.rdb)
+ require.NoError(s.T(), err)
+ require.EqualValues(s.T(), 1, removed)
+ _, err = s.rdb.Get(s.ctx, oauthLegacyTokenKey("legacy")).Result()
+ require.ErrorIs(s.T(), err, redis.Nil)
+ require.Equal(s.T(), "protected", s.rdb.Get(s.ctx, oauthTokenKey("encrypted")).Val())
+ require.Equal(s.T(), "owner", s.rdb.Get(s.ctx, oauthRefreshLockKeyPrefix+"lock").Val())
+}
+
func (s *GeminiTokenCacheSuite) TestDeleteAccessToken_MissingKey() {
require.NoError(s.T(), s.cache.DeleteAccessToken(s.ctx, "missing-key"))
}
+func (s *GeminiTokenCacheSuite) TestRefreshLockReleaseDoesNotDeleteForeignOwner() {
+ cacheKey := "ownership-takeover"
+ acquired, ownerA, err := s.cache.AcquireRefreshLock(s.ctx, cacheKey, time.Minute)
+ require.NoError(s.T(), err)
+ require.True(s.T(), acquired)
+ require.NotEmpty(s.T(), ownerA)
+
+ lockKey := oauthRefreshLockKeyPrefix + cacheKey
+ require.NoError(s.T(), s.rdb.Set(s.ctx, lockKey, "owner-b", time.Minute).Err())
+ require.NoError(s.T(), s.cache.ReleaseRefreshLock(s.ctx, cacheKey, ownerA))
+
+ currentOwner, err := s.rdb.Get(s.ctx, lockKey).Result()
+ require.NoError(s.T(), err)
+ require.Equal(s.T(), "owner-b", currentOwner)
+}
+
+func (s *GeminiTokenCacheSuite) TestRefreshLockOwnerCanReleaseOwnLock() {
+ cacheKey := "ownership-release"
+ acquired, owner, err := s.cache.AcquireRefreshLock(s.ctx, cacheKey, time.Minute)
+ require.NoError(s.T(), err)
+ require.True(s.T(), acquired)
+ require.NotEmpty(s.T(), owner)
+
+ require.NoError(s.T(), s.cache.ReleaseRefreshLock(s.ctx, cacheKey, owner))
+ _, err = s.rdb.Get(s.ctx, oauthRefreshLockKeyPrefix+cacheKey).Result()
+ require.ErrorIs(s.T(), err, redis.Nil)
+}
+
func TestGeminiTokenCacheSuite(t *testing.T) {
suite.Run(t, new(GeminiTokenCacheSuite))
}
+
+func geminiTokenCacheIntegrationConfig() *config.Config {
+ return &config.Config{
+ SecretEncryption: config.SecretEncryptionConfig{
+ TOTPSecretKey: strings.Repeat("1", 64),
+ TOTPCacheKey: strings.Repeat("2", 64),
+ AccountCredentialKey: strings.Repeat("3", 64),
+ BackupS3Key: strings.Repeat("4", 64),
+ ContentModerationKey: strings.Repeat("5", 64),
+ ChannelMonitorKey: strings.Repeat("6", 64),
+ PaymentProviderKey: strings.Repeat("7", 64),
+ ProxyCredentialKey: strings.Repeat("8", 64),
+ SchedulerCacheKey: strings.Repeat("9", 64),
+ OAuthTokenCacheKey: strings.Repeat("b", 64),
+ JWTHMACKey: strings.Repeat("c", 64),
+ SettingSecretKey: strings.Repeat("d", 64),
+ },
+ }
+}
diff --git a/backend/internal/repository/gemini_token_cache_test.go b/backend/internal/repository/gemini_token_cache_test.go
index 4fcebfdd391..cb8f18aac03 100644
--- a/backend/internal/repository/gemini_token_cache_test.go
+++ b/backend/internal/repository/gemini_token_cache_test.go
@@ -4,9 +4,11 @@ package repository
import (
"context"
+ "strings"
"testing"
"time"
+ "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
@@ -22,7 +24,84 @@ func TestGeminiTokenCache_DeleteAccessToken_RedisError(t *testing.T) {
_ = rdb.Close()
})
- cache := NewGeminiTokenCache(rdb)
- err := cache.DeleteAccessToken(context.Background(), "broken")
+ encryptor, err := NewAESEncryptor(domainKeyTestConfig())
+ require.NoError(t, err)
+ cache, err := NewGeminiTokenCache(rdb, encryptor)
+ require.NoError(t, err)
+ err = cache.DeleteAccessToken(context.Background(), "broken")
require.Error(t, err)
}
+
+func TestNewOAuthRefreshLockTokenIsOpaqueAndUnique(t *testing.T) {
+ first, err := newOAuthRefreshLockToken()
+ require.NoError(t, err)
+ second, err := newOAuthRefreshLockToken()
+ require.NoError(t, err)
+
+ require.Len(t, first, 32)
+ require.Len(t, second, 32)
+ require.NotEqual(t, first, second)
+}
+
+func TestGeminiTokenCache_ReleaseRefreshLockEmptyOwnerIsNoop(t *testing.T) {
+ cache := &geminiTokenCache{}
+ require.NoError(t, cache.ReleaseRefreshLock(context.Background(), "key", ""))
+}
+
+func TestOAuthAccessTokenCodecEncryptsAtRest(t *testing.T) {
+ encryptor, err := NewAESEncryptor(domainKeyTestConfig())
+ require.NoError(t, err)
+
+ encoded, err := encodeOAuthAccessToken("ya29.plaintext-token", encryptor)
+ require.NoError(t, err)
+ require.NotEqual(t, "ya29.plaintext-token", encoded)
+ require.True(t, strings.HasPrefix(encoded, secretDomainCiphertextPrefix))
+
+ decoded, err := decodeOAuthAccessToken(encoded, encryptor)
+ require.NoError(t, err)
+ require.Equal(t, "ya29.plaintext-token", decoded)
+
+ _, err = service.DecryptForSecretDomain(encryptor, service.SecretDomainSchedulerCache, encoded)
+ require.Error(t, err)
+}
+
+func TestNewGeminiTokenCacheRequiresDomainEncryptor(t *testing.T) {
+ rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"})
+ t.Cleanup(func() { _ = rdb.Close() })
+
+ _, err := NewGeminiTokenCache(rdb, nil)
+ require.Error(t, err)
+}
+
+func TestNewGeminiTokenCacheRequiresRedisClient(t *testing.T) {
+ encryptor, err := NewAESEncryptor(domainKeyTestConfig())
+ require.NoError(t, err)
+
+ _, err = NewGeminiTokenCache(nil, encryptor)
+ require.Error(t, err)
+}
+
+func TestOAuthAccessTokenCodecRejectsLegacyPlaintext(t *testing.T) {
+ encryptor, err := NewAESEncryptor(domainKeyTestConfig())
+ require.NoError(t, err)
+
+ _, err = decodeOAuthAccessToken("ya29.legacy-plaintext-token", encryptor)
+ require.Error(t, err)
+}
+
+func TestPurgeLegacyOAuthTokenCacheRequiresRedisClient(t *testing.T) {
+ _, err := PurgeLegacyOAuthTokenCacheSecrets(context.Background(), nil)
+ require.Error(t, err)
+}
+
+func TestOAuthTokenCacheNamespacesDoNotOverlap(t *testing.T) {
+ newKey := oauthTokenKey("account-1")
+ legacyKey := oauthLegacyTokenKey("account-1")
+ lockKey := oauthRefreshLockKeyPrefix + "account-1"
+
+ require.True(t, strings.HasPrefix(legacyKey, oauthLegacyTokenKeyPrefix))
+ require.False(t, strings.HasPrefix(newKey, oauthLegacyTokenKeyPrefix))
+ require.False(t, strings.HasPrefix(lockKey, oauthLegacyTokenKeyPrefix))
+ require.NotEqual(t, newKey, legacyKey)
+ require.NotEqual(t, newKey, lockKey)
+}
diff --git a/backend/internal/repository/group_payment_atomicity_integration_test.go b/backend/internal/repository/group_payment_atomicity_integration_test.go
new file mode 100644
index 00000000000..16471a3f2dc
--- /dev/null
+++ b/backend/internal/repository/group_payment_atomicity_integration_test.go
@@ -0,0 +1,318 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+func TestGroupFulfillmentCompletionFailureRollsBackNewSubscriptionAndRetryDeliversOnce(t *testing.T) {
+ ctx := context.Background()
+ fixture := newGroupPaymentAtomicityFixture(t, ctx, "new")
+ removeFailure := installPaymentCompletionFailure(t, ctx, fixture.orderID)
+
+ err := fixture.paymentService().ExecuteSubscriptionFulfillment(ctx, fixture.orderID)
+ require.ErrorContains(t, err, "injected payment completion failure")
+ require.Equal(t, 0, groupSubscriptionCount(t, ctx, fixture.user.ID, fixture.group.ID), "failed order completion must roll back the new monthly entitlement")
+ require.Equal(t, 0, paymentAuditCount(t, ctx, fixture.orderID, "SUBSCRIPTION_SUCCESS"))
+ requirePaymentOrderStatus(t, ctx, fixture.orderID, service.OrderStatusFailed)
+
+ removeFailure()
+ require.NoError(t, fixture.paymentService().ExecuteSubscriptionFulfillment(ctx, fixture.orderID))
+ require.Equal(t, 1, groupSubscriptionCount(t, ctx, fixture.user.ID, fixture.group.ID))
+ require.Equal(t, 1, paymentAuditCount(t, ctx, fixture.orderID, "SUBSCRIPTION_SUCCESS"))
+ requirePaymentOrderStatus(t, ctx, fixture.orderID, service.OrderStatusCompleted)
+}
+
+func TestGroupFulfillmentCompletionFailureRollsBackExtensionAndRetryExtendsOnce(t *testing.T) {
+ ctx := context.Background()
+ fixture := newGroupPaymentAtomicityFixture(t, ctx, "extend")
+ initialExpiry := time.Now().UTC().Add(10 * 24 * time.Hour).Truncate(time.Microsecond)
+ existing := mustCreateSubscription(t, fixture.client, &service.UserSubscription{
+ UserID: fixture.user.ID,
+ GroupID: &fixture.group.ID,
+ StartsAt: time.Now().UTC().Add(-time.Hour),
+ ExpiresAt: initialExpiry,
+ Status: service.SubscriptionStatusActive,
+ })
+ removeFailure := installPaymentCompletionFailure(t, ctx, fixture.orderID)
+
+ err := fixture.paymentService().ExecuteSubscriptionFulfillment(ctx, fixture.orderID)
+ require.ErrorContains(t, err, "injected payment completion failure")
+ require.WithinDuration(t, initialExpiry, groupSubscriptionExpiry(t, ctx, existing.ID), time.Millisecond,
+ "failed order completion must roll back the monthly extension")
+ require.Equal(t, 0, paymentAuditCount(t, ctx, fixture.orderID, "SUBSCRIPTION_SUCCESS"))
+ requirePaymentOrderStatus(t, ctx, fixture.orderID, service.OrderStatusFailed)
+
+ removeFailure()
+ require.NoError(t, fixture.paymentService().ExecuteSubscriptionFulfillment(ctx, fixture.orderID))
+ wantExpiry := initialExpiry.AddDate(0, 0, fixture.days)
+ require.WithinDuration(t, wantExpiry, groupSubscriptionExpiry(t, ctx, existing.ID), time.Millisecond,
+ "retry must extend exactly once")
+ require.Equal(t, 1, paymentAuditCount(t, ctx, fixture.orderID, "SUBSCRIPTION_SUCCESS"))
+ requirePaymentOrderStatus(t, ctx, fixture.orderID, service.OrderStatusCompleted)
+}
+
+func TestConcurrentGroupFulfillmentsForSameUserAndGroupAccumulateBothExtensions(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+
+ fixture := newGroupPaymentAtomicityFixture(t, ctx, "concurrent-extension")
+ secondOrderID := fixture.createPaidOrder(t, ctx)
+ initialExpiry := time.Now().UTC().Add(10 * 24 * time.Hour).Truncate(time.Microsecond)
+ mustCreateSubscription(t, fixture.client, &service.UserSubscription{
+ UserID: fixture.user.ID,
+ GroupID: &fixture.group.ID,
+ StartsAt: time.Now().UTC().Add(-time.Hour),
+ ExpiresAt: initialExpiry,
+ Status: service.SubscriptionStatusActive,
+ })
+
+ barrier := newSubscriptionReadBarrier(500 * time.Millisecond)
+ baseRepo := NewUserSubscriptionRepository(fixture.client)
+ paymentService := fixture.paymentServiceWithSubscriptionRepo(&barrierUserSubscriptionRepository{
+ UserSubscriptionRepository: baseRepo,
+ barrier: barrier,
+ })
+
+ start := make(chan struct{})
+ errs := make(chan error, 2)
+ for _, orderID := range []int64{fixture.orderID, secondOrderID} {
+ orderID := orderID
+ go func() {
+ <-start
+ errs <- paymentService.ExecuteSubscriptionFulfillment(ctx, orderID)
+ }()
+ }
+ close(start)
+ for range 2 {
+ require.NoError(t, <-errs)
+ }
+
+ wantExpiry := initialExpiry.AddDate(0, 0, fixture.days*2)
+ require.WithinDuration(t, wantExpiry, groupSubscriptionExpiry(t, ctx, barrier.subscriptionID()), time.Millisecond,
+ "two independently paid orders must extend the monthly entitlement twice")
+ requirePaymentOrderStatus(t, ctx, fixture.orderID, service.OrderStatusCompleted)
+ requirePaymentOrderStatus(t, ctx, secondOrderID, service.OrderStatusCompleted)
+ require.Equal(t, 1, paymentAuditCount(t, ctx, fixture.orderID, "SUBSCRIPTION_SUCCESS"))
+ require.Equal(t, 1, paymentAuditCount(t, ctx, secondOrderID, "SUBSCRIPTION_SUCCESS"))
+}
+
+type subscriptionReadBarrier struct {
+ mu sync.Mutex
+ reads int
+ seenSubID int64
+ release chan struct{}
+ releaseOnce sync.Once
+ fallbackAfter time.Duration
+}
+
+func newSubscriptionReadBarrier(fallbackAfter time.Duration) *subscriptionReadBarrier {
+ return &subscriptionReadBarrier{
+ release: make(chan struct{}),
+ fallbackAfter: fallbackAfter,
+ }
+}
+
+func (b *subscriptionReadBarrier) wait(subscriptionID int64) {
+ b.mu.Lock()
+ b.reads++
+ b.seenSubID = subscriptionID
+ reads := b.reads
+ b.mu.Unlock()
+ if reads >= 2 {
+ b.releaseOnce.Do(func() { close(b.release) })
+ }
+
+ timer := time.NewTimer(b.fallbackAfter)
+ defer timer.Stop()
+ select {
+ case <-b.release:
+ case <-timer.C:
+ // Once the production row lock exists, the second transaction cannot
+ // reach this read until the first commits. Let the first continue after
+ // a bounded wait; the second will then observe the committed expiry.
+ b.releaseOnce.Do(func() { close(b.release) })
+ }
+}
+
+func (b *subscriptionReadBarrier) subscriptionID() int64 {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.seenSubID
+}
+
+type barrierUserSubscriptionRepository struct {
+ service.UserSubscriptionRepository
+ barrier *subscriptionReadBarrier
+}
+
+func (r *barrierUserSubscriptionRepository) LockUserForSubscriptionAssignment(ctx context.Context, userID int64) error {
+ locker, ok := r.UserSubscriptionRepository.(interface {
+ LockUserForSubscriptionAssignment(context.Context, int64) error
+ })
+ if !ok {
+ return nil
+ }
+ return locker.LockUserForSubscriptionAssignment(ctx, userID)
+}
+
+func (r *barrierUserSubscriptionRepository) GetByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
+ sub, err := r.UserSubscriptionRepository.GetByUserIDAndGroupID(ctx, userID, groupID)
+ if err == nil && sub != nil {
+ r.barrier.wait(sub.ID)
+ }
+ return sub, err
+}
+
+type groupPaymentAtomicityFixture struct {
+ client *dbent.Client
+ user *service.User
+ group *service.Group
+ orderID int64
+ days int
+}
+
+func newGroupPaymentAtomicityFixture(t *testing.T, ctx context.Context, label string) *groupPaymentAtomicityFixture {
+ t.Helper()
+ client := testEntClient(t)
+ suffix := uuid.NewString()
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("group-payment-atomicity-%s-%s@example.com", label, suffix),
+ Username: "group-payment-atomicity-" + label,
+ PasswordHash: "hash",
+ })
+ group := mustCreateGroup(t, client, &service.Group{
+ Name: "group-payment-atomicity-" + label + "-" + suffix,
+ Platform: service.PlatformOpenAI,
+ SubscriptionType: service.SubscriptionTypeSubscription,
+ Status: service.StatusActive,
+ })
+ const days = 30
+ orderUUID := uuid.NewString()
+ order, err := client.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(30).
+ SetPayAmount(30).
+ SetFeeRate(0).
+ SetRechargeCode("GRP-" + orderUUID).
+ SetOutTradeNo("grp-" + orderUUID).
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("tr-grp-" + orderUUID).
+ SetOrderType(payment.OrderTypeSubscription).
+ SetSubscriptionGroupID(group.ID).
+ SetSubscriptionDays(days).
+ SetStatus(service.OrderStatusPaid).
+ SetPaidAt(time.Now()).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+ return &groupPaymentAtomicityFixture{client: client, user: user, group: group, orderID: order.ID, days: days}
+}
+
+func (f *groupPaymentAtomicityFixture) paymentService() *service.PaymentService {
+ subRepo := NewUserSubscriptionRepository(f.client)
+ return f.paymentServiceWithSubscriptionRepo(subRepo)
+}
+
+func (f *groupPaymentAtomicityFixture) paymentServiceWithSubscriptionRepo(subRepo service.UserSubscriptionRepository) *service.PaymentService {
+ groupRepo := NewGroupRepository(f.client, integrationDB)
+ userRepo := NewUserRepository(f.client, integrationDB)
+ subSvc := service.NewSubscriptionService(groupRepo, subRepo, nil, f.client, nil)
+ return service.NewPaymentService(f.client, nil, nil, nil, subSvc, nil, userRepo, groupRepo, nil)
+}
+
+func (f *groupPaymentAtomicityFixture) createPaidOrder(t *testing.T, ctx context.Context) int64 {
+ t.Helper()
+ orderUUID := uuid.NewString()
+ order, err := f.client.PaymentOrder.Create().
+ SetUserID(f.user.ID).
+ SetUserEmail(f.user.Email).
+ SetUserName(f.user.Username).
+ SetAmount(30).
+ SetPayAmount(30).
+ SetFeeRate(0).
+ SetRechargeCode("GRP-" + orderUUID).
+ SetOutTradeNo("grp-" + orderUUID).
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("tr-grp-" + orderUUID).
+ SetOrderType(payment.OrderTypeSubscription).
+ SetSubscriptionGroupID(f.group.ID).
+ SetSubscriptionDays(f.days).
+ SetStatus(service.OrderStatusPaid).
+ SetPaidAt(time.Now()).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+ return order.ID
+}
+
+func installPaymentCompletionFailure(t *testing.T, ctx context.Context, orderID int64) func() {
+ t.Helper()
+ suffix := strings.ReplaceAll(uuid.NewString(), "-", "")
+ functionName := "test_fail_payment_completed_" + suffix
+ triggerName := "trg_fail_payment_completed_" + suffix
+ _, err := integrationDB.ExecContext(ctx, fmt.Sprintf(`
+ CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $$
+ BEGIN
+ IF NEW.id = %d AND NEW.status = 'COMPLETED' THEN
+ RAISE EXCEPTION 'injected payment completion failure';
+ END IF;
+ RETURN NEW;
+ END $$;
+ CREATE TRIGGER %s BEFORE UPDATE ON payment_orders
+ FOR EACH ROW EXECUTE FUNCTION %s();
+ `, functionName, orderID, triggerName, functionName))
+ require.NoError(t, err)
+ removed := false
+ remove := func() {
+ if removed {
+ return
+ }
+ removed = true
+ _, dropErr := integrationDB.ExecContext(context.Background(), fmt.Sprintf(`
+ DROP TRIGGER IF EXISTS %s ON payment_orders;
+ DROP FUNCTION IF EXISTS %s();
+ `, triggerName, functionName))
+ require.NoError(t, dropErr)
+ }
+ t.Cleanup(remove)
+ return remove
+}
+
+func groupSubscriptionCount(t *testing.T, ctx context.Context, userID, groupID int64) int {
+ t.Helper()
+ var count int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM user_subscriptions
+ WHERE user_id=$1 AND group_id=$2 AND deleted_at IS NULL
+ `, userID, groupID).Scan(&count))
+ return count
+}
+
+func groupSubscriptionExpiry(t *testing.T, ctx context.Context, subscriptionID int64) time.Time {
+ t.Helper()
+ var expiresAt time.Time
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT expires_at FROM user_subscriptions WHERE id=$1
+ `, subscriptionID).Scan(&expiresAt))
+ return expiresAt
+}
diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go
index 5e16475a3a1..3037c70b29d 100644
--- a/backend/internal/repository/group_repo.go
+++ b/backend/internal/repository/group_repo.go
@@ -38,7 +38,9 @@ func newGroupRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor) *groupRep
}
func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) error {
- builder := r.client.Group.Create().
+ client := clientFromContext(ctx, r.client)
+ exec := txAwareSQLExecutor(ctx, r.sql, r.client)
+ builder := client.Group.Create().
SetName(groupIn.Name).
SetDescription(groupIn.Description).
SetPlatform(groupIn.Platform).
@@ -50,6 +52,9 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
SetNillableDailyLimitUsd(groupIn.DailyLimitUSD).
SetNillableWeeklyLimitUsd(groupIn.WeeklyLimitUSD).
SetNillableMonthlyLimitUsd(groupIn.MonthlyLimitUSD).
+ SetAllowImageGeneration(groupIn.AllowImageGeneration).
+ SetImageRateIndependent(groupIn.ImageRateIndependent).
+ SetImageRateMultiplier(groupIn.ImageRateMultiplier).
SetNillableImagePrice1k(groupIn.ImagePrice1K).
SetNillableImagePrice2k(groupIn.ImagePrice2K).
SetNillableImagePrice4k(groupIn.ImagePrice4K).
@@ -79,7 +84,7 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
groupIn.ID = created.ID
groupIn.CreatedAt = created.CreatedAt
groupIn.UpdatedAt = created.UpdatedAt
- if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil {
+ if err := enqueueSchedulerOutbox(ctx, exec, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil {
logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group create failed: group=%d err=%v", groupIn.ID, err)
}
}
@@ -99,7 +104,8 @@ func (r *groupRepository) GetByID(ctx context.Context, id int64) (*service.Group
func (r *groupRepository) GetByIDLite(ctx context.Context, id int64) (*service.Group, error) {
// AccountCount is intentionally not loaded here; use GetByID when needed.
- m, err := r.client.Group.Query().
+ client := clientFromContext(ctx, r.client)
+ m, err := client.Group.Query().
Where(group.IDEQ(id)).
Only(ctx)
if err != nil {
@@ -109,7 +115,9 @@ func (r *groupRepository) GetByIDLite(ctx context.Context, id int64) (*service.G
}
func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) error {
- builder := r.client.Group.UpdateOneID(groupIn.ID).
+ client := clientFromContext(ctx, r.client)
+ exec := txAwareSQLExecutor(ctx, r.sql, r.client)
+ builder := client.Group.UpdateOneID(groupIn.ID).
SetName(groupIn.Name).
SetDescription(groupIn.Description).
SetPlatform(groupIn.Platform).
@@ -120,6 +128,9 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
SetNillableDailyLimitUsd(groupIn.DailyLimitUSD).
SetNillableWeeklyLimitUsd(groupIn.WeeklyLimitUSD).
SetNillableMonthlyLimitUsd(groupIn.MonthlyLimitUSD).
+ SetAllowImageGeneration(groupIn.AllowImageGeneration).
+ SetImageRateIndependent(groupIn.ImageRateIndependent).
+ SetImageRateMultiplier(groupIn.ImageRateMultiplier).
SetNillableImagePrice1k(groupIn.ImagePrice1K).
SetNillableImagePrice2k(groupIn.ImagePrice2K).
SetNillableImagePrice4k(groupIn.ImagePrice4K).
@@ -194,7 +205,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
return translatePersistenceError(err, service.ErrGroupNotFound, service.ErrGroupExists)
}
groupIn.UpdatedAt = updated.UpdatedAt
- if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil {
+ if err := enqueueSchedulerOutbox(ctx, exec, service.SchedulerOutboxEventGroupChanged, nil, &groupIn.ID, nil); err != nil {
logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group update failed: group=%d err=%v", groupIn.ID, err)
}
return nil
@@ -488,7 +499,8 @@ func (r *groupRepository) ExistsByIDs(ctx context.Context, ids []int64) (map[int
func (r *groupRepository) GetAccountCount(ctx context.Context, groupID int64) (total int64, active int64, err error) {
var rateLimited int64
- err = scanSingleRow(ctx, r.sql,
+ exec := txAwareSQLExecutor(ctx, r.sql, r.client)
+ err = scanSingleRow(ctx, exec,
`SELECT COUNT(*),
COUNT(*) FILTER (WHERE a.status = 'active' AND a.schedulable = true),
COUNT(*) FILTER (WHERE a.status = 'active' AND (
@@ -503,12 +515,13 @@ func (r *groupRepository) GetAccountCount(ctx context.Context, groupID int64) (t
}
func (r *groupRepository) DeleteAccountGroupsByGroupID(ctx context.Context, groupID int64) (int64, error) {
- res, err := r.sql.ExecContext(ctx, "DELETE FROM account_groups WHERE group_id = $1", groupID)
+ exec := txAwareSQLExecutor(ctx, r.sql, r.client)
+ res, err := exec.ExecContext(ctx, "DELETE FROM account_groups WHERE group_id = $1", groupID)
if err != nil {
return 0, err
}
affected, _ := res.RowsAffected()
- if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupID, nil); err != nil {
+ if err := enqueueSchedulerOutbox(ctx, exec, service.SchedulerOutboxEventGroupChanged, nil, &groupID, nil); err != nil {
logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue group account clear failed: group=%d err=%v", groupID, err)
}
return affected, nil
@@ -684,7 +697,8 @@ func (r *groupRepository) GetAccountIDsByGroupIDs(ctx context.Context, groupIDs
return nil, nil
}
- rows, err := r.sql.QueryContext(
+ exec := txAwareSQLExecutor(ctx, r.sql, r.client)
+ rows, err := exec.QueryContext(
ctx,
"SELECT DISTINCT account_id FROM account_groups WHERE group_id = ANY($1) ORDER BY account_id",
pq.Array(groupIDs),
@@ -716,7 +730,8 @@ func (r *groupRepository) BindAccountsToGroup(ctx context.Context, groupID int64
}
// 使用 INSERT ... ON CONFLICT DO NOTHING 忽略已存在的绑定
- _, err := r.sql.ExecContext(
+ exec := txAwareSQLExecutor(ctx, r.sql, r.client)
+ _, err := exec.ExecContext(
ctx,
`INSERT INTO account_groups (account_id, group_id, priority, created_at)
SELECT unnest($1::bigint[]), $2, 50, NOW()
@@ -729,7 +744,7 @@ func (r *groupRepository) BindAccountsToGroup(ctx context.Context, groupID int64
}
// 发送调度器事件
- if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupID, nil); err != nil {
+ if err := enqueueSchedulerOutbox(ctx, exec, service.SchedulerOutboxEventGroupChanged, nil, &groupID, nil); err != nil {
logger.LegacyPrintf("repository.group", "[SchedulerOutbox] enqueue bind accounts to group failed: group=%d err=%v", groupID, err)
}
diff --git a/backend/internal/repository/group_repo_integration_test.go b/backend/internal/repository/group_repo_integration_test.go
index f91dae43eb6..1bfc6593599 100644
--- a/backend/internal/repository/group_repo_integration_test.go
+++ b/backend/internal/repository/group_repo_integration_test.go
@@ -534,6 +534,8 @@ func (s *GroupRepoSuite) TestListActive() {
}
func (s *GroupRepoSuite) TestListActiveByPlatform() {
+ baseGroups, err := s.repo.ListActiveByPlatform(s.ctx, service.PlatformAnthropic)
+ s.Require().NoError(err, "ListActiveByPlatform base")
s.Require().NoError(s.repo.Create(s.ctx, &service.Group{
Name: "g1",
Platform: service.PlatformAnthropic,
@@ -561,8 +563,7 @@ func (s *GroupRepoSuite) TestListActiveByPlatform() {
groups, err := s.repo.ListActiveByPlatform(s.ctx, service.PlatformAnthropic)
s.Require().NoError(err, "ListActiveByPlatform")
- // 1 default anthropic group + 1 test active anthropic group = 2 total
- s.Require().Len(groups, 2)
+ s.Require().Len(groups, len(baseGroups)+1)
// Verify our test group is in the results
var found bool
for _, g := range groups {
diff --git a/backend/internal/repository/group_write_atomicity_integration_test.go b/backend/internal/repository/group_write_atomicity_integration_test.go
new file mode 100644
index 00000000000..9737264aa71
--- /dev/null
+++ b/backend/internal/repository/group_write_atomicity_integration_test.go
@@ -0,0 +1,335 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/lib/pq"
+ "github.com/stretchr/testify/require"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+func TestAdminCreateGroupCopyFailureRollsBackGroupAndRetryCreatesOnce(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ suffix := uuid.NewString()
+ source := mustCreateGroup(t, client, &service.Group{
+ Name: "group-copy-source-" + suffix,
+ Platform: service.PlatformOpenAI,
+ RateMultiplier: 1,
+ })
+ account := mustCreateAccount(t, client, &service.Account{
+ Name: "group-copy-account-" + suffix,
+ Platform: service.PlatformOpenAI,
+ Type: service.AccountTypeOAuth,
+ })
+ mustBindAccountToGroup(t, client, account.ID, source.ID, 50)
+ targetName := "group-copy-target-" + suffix
+ cleanupGroupWriteFixtures(t, []int64{source.ID}, []int64{account.ID}, targetName)
+
+ removeFailure := installGroupAccountBindFailure(t, ctx, "name", targetName)
+ svc := newGroupWriteAtomicityAdminService(client, nil)
+
+ _, err := svc.CreateGroup(ctx, &service.CreateGroupInput{
+ Name: targetName,
+ Platform: service.PlatformOpenAI,
+ RateMultiplier: 1,
+ CopyAccountsFromGroupIDs: []int64{source.ID},
+ MessagesDispatchModelConfig: service.OpenAIMessagesDispatchModelConfig{},
+ })
+ require.ErrorContains(t, err, "injected group account bind failure")
+ require.Equal(t, 0, groupCountByName(t, ctx, targetName),
+ "a failed account copy must not leave a committed group")
+
+ removeFailure()
+ created, err := svc.CreateGroup(ctx, &service.CreateGroupInput{
+ Name: targetName,
+ Platform: service.PlatformOpenAI,
+ RateMultiplier: 1,
+ CopyAccountsFromGroupIDs: []int64{source.ID},
+ })
+ require.NoError(t, err)
+ require.NotNil(t, created)
+ require.Equal(t, 1, groupCountByName(t, ctx, targetName))
+ require.Equal(t, []int64{account.ID}, groupAccountIDs(t, ctx, created.ID),
+ "retry must create exactly one group with the requested bindings")
+}
+
+func TestAdminUpdateGroupCopyFailureRollsBackFieldsBindingsAndCacheInvalidation(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ suffix := uuid.NewString()
+ target := mustCreateGroup(t, client, &service.Group{
+ Name: "group-update-target-" + suffix,
+ Description: "original description",
+ Platform: service.PlatformOpenAI,
+ RateMultiplier: 1,
+ })
+ source := mustCreateGroup(t, client, &service.Group{
+ Name: "group-update-source-" + suffix,
+ Platform: service.PlatformOpenAI,
+ RateMultiplier: 1,
+ })
+ originalAccount := mustCreateAccount(t, client, &service.Account{
+ Name: "group-update-original-account-" + suffix,
+ Platform: service.PlatformOpenAI,
+ Type: service.AccountTypeOAuth,
+ })
+ replacementAccount := mustCreateAccount(t, client, &service.Account{
+ Name: "group-update-replacement-account-" + suffix,
+ Platform: service.PlatformOpenAI,
+ Type: service.AccountTypeOAuth,
+ })
+ mustBindAccountToGroup(t, client, originalAccount.ID, target.ID, 50)
+ mustBindAccountToGroup(t, client, replacementAccount.ID, source.ID, 50)
+ cleanupGroupWriteFixtures(t, []int64{target.ID, source.ID}, []int64{originalAccount.ID, replacementAccount.ID}, "")
+
+ removeFailure := installGroupAccountBindFailure(t, ctx, "id", target.ID)
+ invalidator := &groupWriteAuthInvalidator{}
+ svc := newGroupWriteAtomicityAdminService(client, invalidator)
+ newRate := 2.5
+
+ _, err := svc.UpdateGroup(ctx, target.ID, &service.UpdateGroupInput{
+ Description: "new description",
+ RateMultiplier: &newRate,
+ CopyAccountsFromGroupIDs: []int64{source.ID},
+ })
+ require.ErrorContains(t, err, "injected group account bind failure")
+ persisted := loadGroupWriteState(t, ctx, target.ID)
+ require.Equal(t, "original description", persisted.description)
+ require.InDelta(t, 1, persisted.rateMultiplier, 0.0001)
+ require.Equal(t, []int64{originalAccount.ID}, groupAccountIDs(t, ctx, target.ID),
+ "failed replacement must preserve the original account bindings")
+ require.Empty(t, invalidator.groupIDs,
+ "auth cache must not be invalidated for a transaction that rolls back")
+
+ removeFailure()
+ updated, err := svc.UpdateGroup(ctx, target.ID, &service.UpdateGroupInput{
+ Description: "new description",
+ RateMultiplier: &newRate,
+ CopyAccountsFromGroupIDs: []int64{source.ID},
+ })
+ require.NoError(t, err)
+ require.Equal(t, "new description", updated.Description)
+ require.InDelta(t, newRate, updated.RateMultiplier, 0.0001)
+ require.Equal(t, []int64{replacementAccount.ID}, groupAccountIDs(t, ctx, target.ID))
+ require.Equal(t, []int64{target.ID}, invalidator.groupIDs,
+ "auth cache invalidation must happen once, after the successful commit")
+}
+
+func TestAdminUpdateReservedGroupCopyFailurePreservesPolicyAndBindings(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ suffix := uuid.NewString()
+ reserved := mustCreateGroup(t, client, &service.Group{
+ Name: service.WalletDefaultOpenAIGroupName,
+ Description: "reserved original",
+ Platform: service.PlatformOpenAI,
+ RateMultiplier: 1,
+ SubscriptionType: service.SubscriptionTypeStandard,
+ Status: service.StatusActive,
+ IsExclusive: false,
+ })
+ source := mustCreateGroup(t, client, &service.Group{
+ Name: "reserved-copy-source-" + suffix,
+ Platform: service.PlatformOpenAI,
+ RateMultiplier: 1,
+ })
+ originalAccount := mustCreateAccount(t, client, &service.Account{
+ Name: "reserved-original-account-" + suffix,
+ Platform: service.PlatformOpenAI,
+ Type: service.AccountTypeOAuth,
+ })
+ replacementAccount := mustCreateAccount(t, client, &service.Account{
+ Name: "reserved-replacement-account-" + suffix,
+ Platform: service.PlatformOpenAI,
+ Type: service.AccountTypeOAuth,
+ })
+ mustBindAccountToGroup(t, client, originalAccount.ID, reserved.ID, 50)
+ mustBindAccountToGroup(t, client, replacementAccount.ID, source.ID, 50)
+ cleanupGroupWriteFixtures(t, []int64{reserved.ID, source.ID}, []int64{originalAccount.ID, replacementAccount.ID}, "")
+
+ removeFailure := installGroupAccountBindFailure(t, ctx, "id", reserved.ID)
+ svc := newGroupWriteAtomicityAdminService(client, &groupWriteAuthInvalidator{})
+ newRate := 1.25
+
+ _, err := svc.UpdateGroup(ctx, reserved.ID, &service.UpdateGroupInput{
+ Description: "reserved changed",
+ RateMultiplier: &newRate,
+ CopyAccountsFromGroupIDs: []int64{source.ID},
+ })
+ require.ErrorContains(t, err, "injected group account bind failure")
+ persisted := loadGroupWriteState(t, ctx, reserved.ID)
+ require.Equal(t, service.WalletDefaultOpenAIGroupName, persisted.name)
+ require.Equal(t, service.PlatformOpenAI, persisted.platform)
+ require.Equal(t, service.StatusActive, persisted.status)
+ require.Equal(t, service.SubscriptionTypeStandard, persisted.subscriptionType)
+ require.False(t, persisted.isExclusive)
+ require.Equal(t, "reserved original", persisted.description)
+ require.InDelta(t, 1, persisted.rateMultiplier, 0.0001)
+ require.Equal(t, []int64{originalAccount.ID}, groupAccountIDs(t, ctx, reserved.ID))
+
+ removeFailure()
+}
+
+func newGroupWriteAtomicityAdminService(client *dbent.Client, invalidator service.APIKeyAuthCacheInvalidator) service.AdminService {
+ return service.NewAdminService(
+ nil,
+ NewGroupRepository(client, integrationDB),
+ NewAccountRepository(client, integrationDB, nil, nil),
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ invalidator,
+ client,
+ nil,
+ nil,
+ nil,
+ nil,
+ )
+}
+
+type groupWriteAuthInvalidator struct {
+ groupIDs []int64
+}
+
+func (*groupWriteAuthInvalidator) InvalidateAuthCacheByKey(context.Context, string) {}
+func (*groupWriteAuthInvalidator) InvalidateAuthCacheByUserID(context.Context, int64) {}
+func (s *groupWriteAuthInvalidator) InvalidateAuthCacheByGroupID(_ context.Context, groupID int64) {
+ s.groupIDs = append(s.groupIDs, groupID)
+}
+
+func installGroupAccountBindFailure(t *testing.T, ctx context.Context, selector string, value any) func() {
+ t.Helper()
+ suffix := strings.ReplaceAll(uuid.NewString(), "-", "")
+ functionName := "test_fail_group_bind_" + suffix
+ triggerName := "trg_fail_group_bind_" + suffix
+
+ var predicate string
+ switch selector {
+ case "id":
+ predicate = fmt.Sprintf("NEW.group_id = %d", value)
+ case "name":
+ predicate = fmt.Sprintf("EXISTS (SELECT 1 FROM groups WHERE id = NEW.group_id AND name = %s)", quoteSQLLiteral(fmt.Sprint(value)))
+ default:
+ t.Fatalf("unsupported bind failure selector %q", selector)
+ }
+
+ _, err := integrationDB.ExecContext(ctx, fmt.Sprintf(`
+ CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $$
+ BEGIN
+ IF %s THEN
+ RAISE EXCEPTION 'injected group account bind failure';
+ END IF;
+ RETURN NEW;
+ END $$;
+ CREATE TRIGGER %s BEFORE INSERT ON account_groups
+ FOR EACH ROW EXECUTE FUNCTION %s();
+ `, functionName, predicate, triggerName, functionName))
+ require.NoError(t, err)
+
+ removed := false
+ remove := func() {
+ if removed {
+ return
+ }
+ removed = true
+ _, dropErr := integrationDB.ExecContext(context.Background(), fmt.Sprintf(`
+ DROP TRIGGER IF EXISTS %s ON account_groups;
+ DROP FUNCTION IF EXISTS %s();
+ `, triggerName, functionName))
+ require.NoError(t, dropErr)
+ }
+ t.Cleanup(remove)
+ return remove
+}
+
+func quoteSQLLiteral(value string) string {
+ return "'" + strings.ReplaceAll(value, "'", "''") + "'"
+}
+
+func groupCountByName(t *testing.T, ctx context.Context, name string) int {
+ t.Helper()
+ var count int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM groups WHERE name = $1 AND deleted_at IS NULL
+ `, name).Scan(&count))
+ return count
+}
+
+func groupAccountIDs(t *testing.T, ctx context.Context, groupID int64) []int64 {
+ t.Helper()
+ rows, err := integrationDB.QueryContext(ctx, `
+ SELECT account_id FROM account_groups WHERE group_id = $1 ORDER BY account_id
+ `, groupID)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, rows.Close()) }()
+
+ var ids []int64
+ for rows.Next() {
+ var id int64
+ require.NoError(t, rows.Scan(&id))
+ ids = append(ids, id)
+ }
+ require.NoError(t, rows.Err())
+ return ids
+}
+
+type groupWriteState struct {
+ name string
+ description string
+ platform string
+ status string
+ subscriptionType string
+ rateMultiplier float64
+ isExclusive bool
+}
+
+func loadGroupWriteState(t *testing.T, ctx context.Context, groupID int64) groupWriteState {
+ t.Helper()
+ var state groupWriteState
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT name, description, platform, status, subscription_type, rate_multiplier, is_exclusive
+ FROM groups WHERE id = $1 AND deleted_at IS NULL
+ `, groupID).Scan(
+ &state.name,
+ &state.description,
+ &state.platform,
+ &state.status,
+ &state.subscriptionType,
+ &state.rateMultiplier,
+ &state.isExclusive,
+ ))
+ return state
+}
+
+func cleanupGroupWriteFixtures(t *testing.T, groupIDs, accountIDs []int64, targetName string) {
+ t.Helper()
+ t.Cleanup(func() {
+ ctx := context.Background()
+ if targetName != "" {
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM account_groups WHERE group_id IN (SELECT id FROM groups WHERE name = $1)`, targetName)
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM groups WHERE name = $1`, targetName)
+ }
+ if len(groupIDs) > 0 {
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM account_groups WHERE group_id = ANY($1)`, pq.Array(groupIDs))
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM groups WHERE id = ANY($1)`, pq.Array(groupIDs))
+ }
+ if len(accountIDs) > 0 {
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM account_groups WHERE account_id = ANY($1)`, pq.Array(accountIDs))
+ _, _ = integrationDB.ExecContext(ctx, `DELETE FROM accounts WHERE id = ANY($1)`, pq.Array(accountIDs))
+ }
+ })
+}
diff --git a/backend/internal/repository/hfc_abuse_risk_repo.go b/backend/internal/repository/hfc_abuse_risk_repo.go
new file mode 100644
index 00000000000..77ed789a8f8
--- /dev/null
+++ b/backend/internal/repository/hfc_abuse_risk_repo.go
@@ -0,0 +1,300 @@
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+type hfcAbuseRiskRepository struct {
+ db *sql.DB
+}
+
+func NewHFCAbuseRiskRepository(db *sql.DB) service.HFCAbuseRiskRepository {
+ return &hfcAbuseRiskRepository{db: db}
+}
+
+func (r *hfcAbuseRiskRepository) CreateEvent(ctx context.Context, event *service.HFCAbuseRiskEvent) error {
+ if event == nil {
+ return nil
+ }
+ evidence, err := json.Marshal(event.Evidence)
+ if err != nil {
+ return fmt.Errorf("marshal hfc abuse risk evidence: %w", err)
+ }
+ err = r.db.QueryRowContext(ctx, `
+INSERT INTO hfc_abuse_risk_events (
+ source, severity, status, user_id, user_email, api_key_id, api_key_name,
+ group_id, group_name, risk_score, signup_ip_prefix, device_fingerprint_hash,
+ signup_user_agent_hash, payment_order_id, redeem_code_id, referral_user_id,
+ content_moderation_log_id, summary, evidence
+) VALUES (
+ $1, $2, $3, $4, $5, $6, $7,
+ $8, $9, $10, $11, $12,
+ $13, $14, $15, $16,
+ $17, $18, $19::jsonb
+) RETURNING id, created_at, updated_at`,
+ event.Source,
+ event.Severity,
+ event.Status,
+ nullableInt64Ptr(event.UserID),
+ event.UserEmail,
+ nullableInt64Ptr(event.APIKeyID),
+ event.APIKeyName,
+ nullableInt64Ptr(event.GroupID),
+ event.GroupName,
+ event.RiskScore,
+ event.SignupIPPrefix,
+ event.DeviceFingerprintHash,
+ event.SignupUserAgentHash,
+ nullableInt64Ptr(event.PaymentOrderID),
+ nullableInt64Ptr(event.RedeemCodeID),
+ nullableInt64Ptr(event.ReferralUserID),
+ nullableInt64Ptr(event.ContentModerationLogID),
+ event.Summary,
+ string(evidence),
+ ).Scan(&event.ID, &event.CreatedAt, &event.UpdatedAt)
+ if err != nil {
+ return fmt.Errorf("insert hfc abuse risk event: %w", err)
+ }
+ return nil
+}
+
+func (r *hfcAbuseRiskRepository) ListEvents(ctx context.Context, filter service.HFCAbuseRiskEventFilter) ([]service.HFCAbuseRiskEvent, *pagination.PaginationResult, error) {
+ where, args := buildHFCAbuseRiskEventWhere(filter)
+ whereSQL := "WHERE " + strings.Join(where, " AND ")
+ params := defaultHFCAbuseRiskPagination(filter.Pagination)
+
+ var total int64
+ if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM hfc_abuse_risk_events e "+whereSQL, args...).Scan(&total); err != nil {
+ if isHFCAbuseRiskEventsTableMissing(err) {
+ return []service.HFCAbuseRiskEvent{}, paginationResultFromTotal(0, params), nil
+ }
+ return nil, nil, fmt.Errorf("count hfc abuse risk events: %w", err)
+ }
+
+ queryArgs := append([]any{}, args...)
+ queryArgs = append(queryArgs, params.Limit(), params.Offset())
+ rows, err := r.db.QueryContext(ctx, hfcAbuseRiskEventSelectSQL+`
+FROM hfc_abuse_risk_events e `+whereSQL+`
+ORDER BY e.created_at DESC, e.id DESC
+LIMIT $`+fmt.Sprint(len(queryArgs)-1)+` OFFSET $`+fmt.Sprint(len(queryArgs)),
+ queryArgs...,
+ )
+ if err != nil {
+ if isHFCAbuseRiskEventsTableMissing(err) {
+ return []service.HFCAbuseRiskEvent{}, paginationResultFromTotal(0, params), nil
+ }
+ return nil, nil, fmt.Errorf("list hfc abuse risk events: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+
+ items := make([]service.HFCAbuseRiskEvent, 0)
+ for rows.Next() {
+ item, err := scanHFCAbuseRiskEvent(rows)
+ if err != nil {
+ return nil, nil, err
+ }
+ items = append(items, *item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, nil, fmt.Errorf("iterate hfc abuse risk events: %w", err)
+ }
+ return items, paginationResultFromTotal(total, params), nil
+}
+
+func (r *hfcAbuseRiskRepository) GetEvent(ctx context.Context, id int64) (*service.HFCAbuseRiskEvent, error) {
+ row := r.db.QueryRowContext(ctx, hfcAbuseRiskEventSelectSQL+`
+FROM hfc_abuse_risk_events e
+WHERE e.id = $1`, id)
+ event, err := scanHFCAbuseRiskEvent(row)
+ if err != nil {
+ if err == sql.ErrNoRows || isHFCAbuseRiskEventsTableMissing(err) {
+ return nil, service.ErrHFCAbuseRiskEventNotFound
+ }
+ return nil, err
+ }
+ return event, nil
+}
+
+func (r *hfcAbuseRiskRepository) UpdateReview(ctx context.Context, update service.HFCAbuseRiskReviewUpdate) error {
+ if update.EventID <= 0 {
+ return service.ErrHFCAbuseRiskEventNotFound
+ }
+ res, err := r.db.ExecContext(ctx, `
+UPDATE hfc_abuse_risk_events
+SET status = $2,
+ action_taken = $3,
+ action_note = $4,
+ reviewed_by = $5,
+ reviewed_at = $6,
+ updated_at = NOW()
+WHERE id = $1`,
+ update.EventID,
+ update.Status,
+ update.ActionTaken,
+ update.ActionNote,
+ nullableInt64Ptr(update.ReviewedBy),
+ update.ReviewedAt,
+ )
+ if err != nil {
+ if isHFCAbuseRiskEventsTableMissing(err) {
+ return service.ErrHFCAbuseRiskEventNotFound
+ }
+ return fmt.Errorf("update hfc abuse risk event review: %w", err)
+ }
+ affected, _ := res.RowsAffected()
+ if affected == 0 {
+ return service.ErrHFCAbuseRiskEventNotFound
+ }
+ return nil
+}
+
+const hfcAbuseRiskEventSelectSQL = `
+SELECT
+ e.id, e.source, e.severity, e.status, e.user_id, e.user_email,
+ e.api_key_id, e.api_key_name, e.group_id, e.group_name, e.risk_score,
+ e.signup_ip_prefix, e.device_fingerprint_hash, e.signup_user_agent_hash,
+ e.payment_order_id, e.redeem_code_id, e.referral_user_id,
+ e.content_moderation_log_id, e.summary, e.evidence,
+ e.action_taken, e.action_note, e.reviewed_by, e.reviewed_at,
+ e.telegram_sent, e.telegram_sent_at, e.telegram_error,
+ e.created_at, e.updated_at
+`
+
+type hfcAbuseRiskEventScanner interface {
+ Scan(dest ...any) error
+}
+
+func scanHFCAbuseRiskEvent(scanner hfcAbuseRiskEventScanner) (*service.HFCAbuseRiskEvent, error) {
+ var event service.HFCAbuseRiskEvent
+ var userID, apiKeyID, groupID, paymentOrderID, redeemCodeID, referralUserID, moderationLogID, reviewedBy sql.NullInt64
+ var reviewedAt, telegramSentAt sql.NullTime
+ var evidenceRaw []byte
+ if err := scanner.Scan(
+ &event.ID,
+ &event.Source,
+ &event.Severity,
+ &event.Status,
+ &userID,
+ &event.UserEmail,
+ &apiKeyID,
+ &event.APIKeyName,
+ &groupID,
+ &event.GroupName,
+ &event.RiskScore,
+ &event.SignupIPPrefix,
+ &event.DeviceFingerprintHash,
+ &event.SignupUserAgentHash,
+ &paymentOrderID,
+ &redeemCodeID,
+ &referralUserID,
+ &moderationLogID,
+ &event.Summary,
+ &evidenceRaw,
+ &event.ActionTaken,
+ &event.ActionNote,
+ &reviewedBy,
+ &reviewedAt,
+ &event.TelegramSent,
+ &telegramSentAt,
+ &event.TelegramError,
+ &event.CreatedAt,
+ &event.UpdatedAt,
+ ); err != nil {
+ if err == sql.ErrNoRows {
+ return nil, err
+ }
+ return nil, fmt.Errorf("scan hfc abuse risk event: %w", err)
+ }
+ event.UserID = int64PtrFromNull(userID)
+ event.APIKeyID = int64PtrFromNull(apiKeyID)
+ event.GroupID = int64PtrFromNull(groupID)
+ event.PaymentOrderID = int64PtrFromNull(paymentOrderID)
+ event.RedeemCodeID = int64PtrFromNull(redeemCodeID)
+ event.ReferralUserID = int64PtrFromNull(referralUserID)
+ event.ContentModerationLogID = int64PtrFromNull(moderationLogID)
+ event.ReviewedBy = int64PtrFromNull(reviewedBy)
+ if reviewedAt.Valid {
+ v := reviewedAt.Time
+ event.ReviewedAt = &v
+ }
+ if telegramSentAt.Valid {
+ v := telegramSentAt.Time
+ event.TelegramSentAt = &v
+ }
+ event.Evidence = []service.HFCAbuseRiskEvidence{}
+ _ = json.Unmarshal(evidenceRaw, &event.Evidence)
+ return &event, nil
+}
+
+func buildHFCAbuseRiskEventWhere(filter service.HFCAbuseRiskEventFilter) ([]string, []any) {
+ where := []string{"e.id IS NOT NULL"}
+ args := make([]any, 0)
+ add := func(expr string, value any) {
+ args = append(args, value)
+ where = append(where, fmt.Sprintf(expr, len(args)))
+ }
+ if filter.Source != "" {
+ add("e.source = $%d", filter.Source)
+ }
+ if filter.Severity != "" {
+ add("e.severity = $%d", filter.Severity)
+ }
+ if filter.Status != "" {
+ add("e.status = $%d", filter.Status)
+ }
+ if search := strings.TrimSpace(filter.Search); search != "" {
+ like := "%" + search + "%"
+ args = append(args, like, like, like, like, like, like)
+ idx := len(args) - 5
+ where = append(where, fmt.Sprintf("(e.user_email ILIKE $%d OR e.api_key_name ILIKE $%d OR e.group_name ILIKE $%d OR e.summary ILIKE $%d OR e.signup_ip_prefix ILIKE $%d OR e.device_fingerprint_hash ILIKE $%d)", idx, idx+1, idx+2, idx+3, idx+4, idx+5))
+ }
+ if filter.From != nil && !filter.From.IsZero() {
+ add("e.created_at >= $%d", *filter.From)
+ }
+ if filter.To != nil && !filter.To.IsZero() {
+ add("e.created_at <= $%d", *filter.To)
+ }
+ return where, args
+}
+
+func defaultHFCAbuseRiskPagination(params pagination.PaginationParams) pagination.PaginationParams {
+ if params.Page <= 0 {
+ params.Page = 1
+ }
+ if params.PageSize <= 0 {
+ params.PageSize = 20
+ }
+ if params.PageSize > 100 {
+ params.PageSize = 100
+ }
+ return params
+}
+
+func isHFCAbuseRiskEventsTableMissing(err error) bool {
+ if err == nil {
+ return false
+ }
+ return strings.Contains(err.Error(), `relation "hfc_abuse_risk_events" does not exist`)
+}
+
+func nullableInt64Ptr(value *int64) any {
+ if value == nil {
+ return nil
+ }
+ return *value
+}
+
+func int64PtrFromNull(value sql.NullInt64) *int64 {
+ if !value.Valid {
+ return nil
+ }
+ v := value.Int64
+ return &v
+}
diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go
index 4309e997f4f..abbef912929 100644
--- a/backend/internal/repository/http_upstream.go
+++ b/backend/internal/repository/http_upstream.go
@@ -3,6 +3,7 @@ package repository
import (
"compress/flate"
"compress/gzip"
+ "context"
"errors"
"fmt"
"io"
@@ -173,10 +174,7 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
if req != nil && req.URL != nil {
targetHost = req.URL.Host
}
- proxyInfo := "direct"
- if proxyURL != "" {
- proxyInfo = proxyURL
- }
+ proxyInfo := proxyurl.ForLog(proxyURL)
slog.Debug("tls_fingerprint_enabled", "account_id", accountID, "target", targetHost, "proxy", proxyInfo, "profile", profile.Name)
if err := s.validateRequestHost(req); err != nil {
@@ -216,6 +214,7 @@ func (s *httpUpstreamService) acquireClientWithTLS(proxyURL string, accountID in
// TLS 指纹客户端使用独立的缓存键,与普通客户端隔离
func (s *httpUpstreamService) getClientEntryWithTLS(proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile, markInFlight bool, enforceLimit bool) (*upstreamClientEntry, error) {
isolation := s.getIsolationMode()
+ proxyLog := proxyurl.ForLog(proxyURL)
proxyKey, parsedProxy, err := normalizeProxyURL(proxyURL)
if err != nil {
return nil, err
@@ -235,7 +234,7 @@ func (s *httpUpstreamService) getClientEntryWithTLS(proxyURL string, accountID i
atomic.AddInt64(&entry.inFlight, 1)
}
s.mu.RUnlock()
- slog.Debug("tls_fingerprint_reusing_client", "account_id", accountID, "cache_key", cacheKey)
+ slog.Debug("tls_fingerprint_reusing_client", "account_id", accountID, "proxy", proxyLog)
return entry, nil
}
s.mu.RUnlock()
@@ -249,12 +248,12 @@ func (s *httpUpstreamService) getClientEntryWithTLS(proxyURL string, accountID i
atomic.AddInt64(&entry.inFlight, 1)
}
s.mu.Unlock()
- slog.Debug("tls_fingerprint_reusing_client", "account_id", accountID, "cache_key", cacheKey)
+ slog.Debug("tls_fingerprint_reusing_client", "account_id", accountID, "proxy", proxyLog)
return entry, nil
}
slog.Debug("tls_fingerprint_evicting_stale_client",
"account_id", accountID,
- "cache_key", cacheKey,
+ "proxy", proxyLog,
"proxy_changed", entry.proxyKey != proxyKey,
"pool_changed", entry.poolKey != poolKey)
s.removeClientLocked(cacheKey, entry)
@@ -272,9 +271,13 @@ func (s *httpUpstreamService) getClientEntryWithTLS(proxyURL string, accountID i
}
// 创建带 TLS 指纹的 Transport
- slog.Debug("tls_fingerprint_creating_new_client", "account_id", accountID, "cache_key", cacheKey, "proxy", proxyKey)
+ slog.Debug("tls_fingerprint_creating_new_client", "account_id", accountID, "proxy", proxyLog)
settings := s.resolvePoolSettings(isolation, accountConcurrency)
- transport, err := buildUpstreamTransportWithTLSFingerprint(settings, parsedProxy, profile)
+ var safeDial func(context.Context, string, string) (net.Conn, error)
+ if s.shouldValidateResolvedIP() {
+ safeDial = urlvalidator.NewSafeDialContext(false)
+ }
+ transport, err := buildUpstreamTransportWithTLSFingerprint(settings, parsedProxy, profile, safeDial)
if err != nil {
s.mu.Unlock()
return nil, fmt.Errorf("build TLS fingerprint transport: %w", err)
@@ -304,10 +307,7 @@ func (s *httpUpstreamService) getClientEntryWithTLS(proxyURL string, accountID i
func (s *httpUpstreamService) shouldValidateResolvedIP() bool {
if s.cfg == nil {
- return false
- }
- if !s.cfg.Security.URLAllowlist.Enabled {
- return false
+ return true
}
return !s.cfg.Security.URLAllowlist.AllowPrivateHosts
}
@@ -424,6 +424,9 @@ func (s *httpUpstreamService) getClientEntry(proxyURL string, accountID int64, a
s.mu.Unlock()
return nil, fmt.Errorf("build transport: %w", err)
}
+ if s.shouldValidateResolvedIP() {
+ transport.DialContext = urlvalidator.NewSafeDialContext(false)
+ }
client := &http.Client{Transport: transport}
if s.shouldValidateResolvedIP() {
client.CheckRedirect = s.redirectChecker
@@ -697,7 +700,7 @@ func normalizeProxyURL(raw string) (string, *url.URL, error) {
parsed.Host = hostname
}
}
- return parsed.String(), parsed, nil
+ return proxyurl.TransportCacheKey(parsed), parsed, nil
}
// defaultPoolSettings 获取默认连接池配置
@@ -789,7 +792,12 @@ func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL) (*http.Tra
// - nil/空: 直连,使用 TLSFingerprintDialer
// - http/https: HTTP 代理,使用 HTTPProxyDialer(CONNECT 隧道 + utls 握手)
// - socks5: SOCKS5 代理,使用 SOCKS5ProxyDialer(SOCKS5 隧道 + utls 握手)
-func buildUpstreamTransportWithTLSFingerprint(settings poolSettings, proxyURL *url.URL, profile *tlsfingerprint.Profile) (*http.Transport, error) {
+func buildUpstreamTransportWithTLSFingerprint(
+ settings poolSettings,
+ proxyURL *url.URL,
+ profile *tlsfingerprint.Profile,
+ safeDial func(context.Context, string, string) (net.Conn, error),
+) (*http.Transport, error) {
transport := &http.Transport{
MaxIdleConns: settings.maxIdleConns,
MaxIdleConnsPerHost: settings.maxIdleConnsPerHost,
@@ -804,7 +812,7 @@ func buildUpstreamTransportWithTLSFingerprint(settings poolSettings, proxyURL *u
if proxyURL == nil {
// 直连:使用 TLSFingerprintDialer
slog.Debug("tls_fingerprint_transport_direct")
- dialer := tlsfingerprint.NewDialer(profile, nil)
+ dialer := tlsfingerprint.NewDialer(profile, safeDial)
transport.DialTLSContext = dialer.DialTLSContext
} else {
scheme := strings.ToLower(proxyURL.Scheme)
@@ -825,6 +833,7 @@ func buildUpstreamTransportWithTLSFingerprint(settings poolSettings, proxyURL *u
if err := proxyutil.ConfigureTransportProxy(transport, proxyURL); err != nil {
return nil, err
}
+ transport.DialContext = safeDial
}
}
diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go
index b3268463ad9..13081097850 100644
--- a/backend/internal/repository/http_upstream_test.go
+++ b/backend/internal/repository/http_upstream_test.go
@@ -79,6 +79,21 @@ func (s *HTTPUpstreamSuite) TestNormalizeProxyURL_Canonicalizes() {
require.Equal(s.T(), key1, key2, "expected normalized proxy keys to match")
}
+func (s *HTTPUpstreamSuite) TestNormalizeProxyURL_CredentialKeyIsNonReversible() {
+ firstKey, firstURL, err := normalizeProxyURL("http://alice:first-secret@proxy.local:8080/path?token=ignored")
+ require.NoError(s.T(), err)
+ secondKey, _, err := normalizeProxyURL("http://alice:second-secret@proxy.local:8080")
+ require.NoError(s.T(), err)
+ require.NotEqual(s.T(), firstKey, secondKey)
+ require.NotContains(s.T(), firstKey, "alice")
+ require.NotContains(s.T(), firstKey, "first-secret")
+ require.NotContains(s.T(), firstKey, "token=ignored")
+ require.NotNil(s.T(), firstURL.User, "transport URL must retain proxy credentials")
+ password, ok := firstURL.User.Password()
+ require.True(s.T(), ok)
+ require.Equal(s.T(), "first-secret", password)
+}
+
// TestAcquireClient_OverLimitReturnsError 测试连接池缓存上限保护
// 验证超限且无可淘汰条目时返回错误
func (s *HTTPUpstreamSuite) TestAcquireClient_OverLimitReturnsError() {
@@ -116,6 +131,35 @@ func (s *HTTPUpstreamSuite) TestDo_WithoutProxy_GoesDirect() {
require.Equal(s.T(), "direct", string(b), "unexpected body")
}
+func (s *HTTPUpstreamSuite) TestDo_RejectsPrivateDestinationWithoutAllowlistMode() {
+ var calls atomic.Int32
+ upstream := newLocalTestServer(s.T(), http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ calls.Add(1)
+ _, _ = io.WriteString(w, "must-not-run")
+ }))
+ s.T().Cleanup(upstream.Close)
+
+ s.cfg.Security.URLAllowlist.Enabled = false
+ s.cfg.Security.URLAllowlist.AllowPrivateHosts = false
+ up := NewHTTPUpstream(s.cfg)
+ req, err := http.NewRequest(http.MethodGet, upstream.URL+"/private", nil)
+ require.NoError(s.T(), err)
+
+ resp, err := up.Do(req, "", 1, 1)
+ require.Error(s.T(), err)
+ require.Nil(s.T(), resp)
+ require.Zero(s.T(), calls.Load(), "private upstream handler must not be reached")
+}
+
+func (s *HTTPUpstreamSuite) TestRedirectCheckerRejectsPrivateDestinationWhenAllowlistDisabled() {
+ s.cfg.Security.URLAllowlist.Enabled = false
+ s.cfg.Security.URLAllowlist.AllowPrivateHosts = false
+ svc := s.newService()
+ req, err := http.NewRequest(http.MethodGet, "https://127.0.0.1/internal", nil)
+ require.NoError(s.T(), err)
+ require.Error(s.T(), svc.redirectChecker(req, nil))
+}
+
// TestDo_WithHTTPProxy_UsesProxy 测试 HTTP 代理功能
// 验证请求通过代理服务器转发,使用绝对 URI 格式
func (s *HTTPUpstreamSuite) TestDo_WithHTTPProxy_UsesProxy() {
diff --git a/backend/internal/repository/idempotency_persistence_guard_integration_test.go b/backend/internal/repository/idempotency_persistence_guard_integration_test.go
new file mode 100644
index 00000000000..46559a13690
--- /dev/null
+++ b/backend/internal/repository/idempotency_persistence_guard_integration_test.go
@@ -0,0 +1,52 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPersistentFinancialIdempotencyDatabaseGuard(t *testing.T) {
+ ctx := context.Background()
+ scope := "admin.users.balance.update"
+ fingerprint := uuid.NewString()
+ keyHash := uuid.NewString()
+ expiresAt := time.Now().Add(time.Hour)
+
+ _, err := integrationDB.ExecContext(ctx, `
+ INSERT INTO idempotency_records (
+ scope, idempotency_key_hash, request_fingerprint, status, expires_at, is_reclaimable
+ ) VALUES ($1, $2, $3, 'processing', $4, TRUE)
+ `, scope, uuid.NewString(), fingerprint, expiresAt)
+ require.Error(t, err, "financial claims must fail closed when marked reclaimable")
+
+ var recordID int64
+ err = integrationDB.QueryRowContext(ctx, `
+ INSERT INTO idempotency_records (
+ scope, idempotency_key_hash, request_fingerprint, status, expires_at, is_reclaimable
+ ) VALUES ($1, $2, $3, 'processing', $4, FALSE)
+ RETURNING id
+ `, scope, keyHash, fingerprint, expiresAt).Scan(&recordID)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM idempotency_records WHERE id = $1", recordID)
+ })
+
+ _, err = integrationDB.ExecContext(ctx,
+ "UPDATE idempotency_records SET is_reclaimable = TRUE WHERE id = $1",
+ recordID,
+ )
+ require.Error(t, err, "persistent claims must never be downgraded")
+
+ _, err = integrationDB.ExecContext(ctx,
+ "UPDATE idempotency_records SET idempotency_key_hash = $2 WHERE id = $1",
+ recordID,
+ uuid.NewString(),
+ )
+ require.Error(t, err, "executed operation identity must be immutable")
+}
diff --git a/backend/internal/repository/idempotency_repo.go b/backend/internal/repository/idempotency_repo.go
index 32f2faaed4f..2cda122c402 100644
--- a/backend/internal/repository/idempotency_repo.go
+++ b/backend/internal/repository/idempotency_repo.go
@@ -11,11 +11,16 @@ import (
)
type idempotencyRepository struct {
- sql sqlExecutor
+ client *dbent.Client
+ sql sqlExecutor
}
-func NewIdempotencyRepository(_ *dbent.Client, sqlDB *sql.DB) service.IdempotencyRepository {
- return &idempotencyRepository{sql: sqlDB}
+func NewIdempotencyRepository(client *dbent.Client, sqlDB *sql.DB) service.IdempotencyRepository {
+ return &idempotencyRepository{client: client, sql: sqlDB}
+}
+
+func (r *idempotencyRepository) executor(ctx context.Context) sqlQueryExecutor {
+ return txAwareSQLExecutor(ctx, r.sql, r.client)
}
func (r *idempotencyRepository) CreateProcessing(ctx context.Context, record *service.IdempotencyRecord) (bool, error) {
@@ -24,20 +29,21 @@ func (r *idempotencyRepository) CreateProcessing(ctx context.Context, record *se
}
query := `
INSERT INTO idempotency_records (
- scope, idempotency_key_hash, request_fingerprint, status, locked_until, expires_at
- ) VALUES ($1, $2, $3, $4, $5, $6)
+ scope, idempotency_key_hash, request_fingerprint, status, locked_until, expires_at, is_reclaimable
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (scope, idempotency_key_hash) DO NOTHING
RETURNING id, created_at, updated_at
`
var createdAt time.Time
var updatedAt time.Time
- err := scanSingleRow(ctx, r.sql, query, []any{
+ err := scanSingleRow(ctx, r.executor(ctx), query, []any{
record.Scope,
record.IdempotencyKeyHash,
record.RequestFingerprint,
record.Status,
record.LockedUntil,
record.ExpiresAt,
+ !record.Persistent,
}, &record.ID, &createdAt, &updatedAt)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
@@ -54,7 +60,7 @@ func (r *idempotencyRepository) GetByScopeAndKeyHash(ctx context.Context, scope,
query := `
SELECT
id, scope, idempotency_key_hash, request_fingerprint, status, response_status,
- response_body, error_reason, locked_until, expires_at, created_at, updated_at
+ response_body, error_reason, locked_until, expires_at, is_reclaimable, created_at, updated_at
FROM idempotency_records
WHERE scope = $1 AND idempotency_key_hash = $2
`
@@ -63,7 +69,8 @@ func (r *idempotencyRepository) GetByScopeAndKeyHash(ctx context.Context, scope,
var responseBody sql.NullString
var errorReason sql.NullString
var lockedUntil sql.NullTime
- err := scanSingleRow(ctx, r.sql, query, []any{scope, keyHash},
+ var isReclaimable bool
+ err := scanSingleRow(ctx, r.executor(ctx), query, []any{scope, keyHash},
&record.ID,
&record.Scope,
&record.IdempotencyKeyHash,
@@ -74,6 +81,7 @@ func (r *idempotencyRepository) GetByScopeAndKeyHash(ctx context.Context, scope,
&errorReason,
&lockedUntil,
&record.ExpiresAt,
+ &isReclaimable,
&record.CreatedAt,
&record.UpdatedAt,
)
@@ -99,6 +107,7 @@ func (r *idempotencyRepository) GetByScopeAndKeyHash(ctx context.Context, scope,
v := lockedUntil.Time
record.LockedUntil = &v
}
+ record.Persistent = !isReclaimable
return record, nil
}
@@ -117,9 +126,10 @@ func (r *idempotencyRepository) TryReclaim(
expires_at = $4
WHERE id = $1
AND status = $5
+ AND is_reclaimable = TRUE
AND (locked_until IS NULL OR locked_until <= $6)
`
- res, err := r.sql.ExecContext(ctx, query,
+ res, err := r.executor(ctx).ExecContext(ctx, query,
id,
service.IdempotencyStatusProcessing,
newLockedUntil,
@@ -153,7 +163,7 @@ func (r *idempotencyRepository) ExtendProcessingLock(
AND status = $4
AND request_fingerprint = $5
`
- res, err := r.sql.ExecContext(
+ res, err := r.executor(ctx).ExecContext(
ctx,
query,
id,
@@ -184,7 +194,7 @@ func (r *idempotencyRepository) MarkSucceeded(ctx context.Context, id int64, res
updated_at = NOW()
WHERE id = $1
`
- _, err := r.sql.ExecContext(ctx, query,
+ _, err := r.executor(ctx).ExecContext(ctx, query,
id,
service.IdempotencyStatusSucceeded,
responseStatus,
@@ -204,7 +214,7 @@ func (r *idempotencyRepository) MarkFailedRetryable(ctx context.Context, id int6
updated_at = NOW()
WHERE id = $1
`
- _, err := r.sql.ExecContext(ctx, query,
+ _, err := r.executor(ctx).ExecContext(ctx, query,
id,
service.IdempotencyStatusFailedRetryable,
errorReason,
@@ -223,13 +233,14 @@ func (r *idempotencyRepository) DeleteExpired(ctx context.Context, now time.Time
SELECT id
FROM idempotency_records
WHERE expires_at <= $1
+ AND is_reclaimable = TRUE
ORDER BY expires_at ASC
LIMIT $2
)
DELETE FROM idempotency_records
WHERE id IN (SELECT id FROM victims)
`
- res, err := r.sql.ExecContext(ctx, query, now, limit)
+ res, err := r.executor(ctx).ExecContext(ctx, query, now, limit)
if err != nil {
return 0, err
}
diff --git a/backend/internal/repository/integration_harness_test.go b/backend/internal/repository/integration_harness_test.go
index 5857fbcb2ac..34a256b19b2 100644
--- a/backend/internal/repository/integration_harness_test.go
+++ b/backend/internal/repository/integration_harness_test.go
@@ -43,21 +43,25 @@ var (
)
func TestMain(m *testing.M) {
+ os.Exit(runIntegrationTests(m))
+}
+
+func runIntegrationTests(m *testing.M) int {
ctx := context.Background()
if err := timezone.Init("UTC"); err != nil {
log.Printf("failed to init timezone: %v", err)
- os.Exit(1)
+ return 1
}
if !dockerIsAvailable(ctx) {
// In CI we expect Docker to be available so integration tests should fail loudly.
if os.Getenv("CI") != "" {
log.Printf("docker is not available (CI=true); failing integration tests")
- os.Exit(1)
+ return 1
}
log.Printf("docker is not available; skipping integration tests (start Docker to enable)")
- os.Exit(0)
+ return 0
}
postgresImage := selectDockerImage(ctx, postgresImageTag)
@@ -71,7 +75,7 @@ func TestMain(m *testing.M) {
)
if err != nil {
log.Printf("failed to start postgres container: %v", err)
- os.Exit(1)
+ return 1
}
defer func() { _ = pgContainer.Terminate(ctx) }()
@@ -81,24 +85,24 @@ func TestMain(m *testing.M) {
)
if err != nil {
log.Printf("failed to start redis container: %v", err)
- os.Exit(1)
+ return 1
}
defer func() { _ = redisContainer.Terminate(ctx) }()
dsn, err := pgContainer.ConnectionString(ctx, "sslmode=disable", "TimeZone=UTC")
if err != nil {
log.Printf("failed to get postgres dsn: %v", err)
- os.Exit(1)
+ return 1
}
integrationDB, err = openSQLWithRetry(ctx, dsn, 30*time.Second)
if err != nil {
log.Printf("failed to open sql db: %v", err)
- os.Exit(1)
+ return 1
}
if err := ApplyMigrations(ctx, integrationDB); err != nil {
log.Printf("failed to apply db migrations: %v", err)
- os.Exit(1)
+ return 1
}
// 创建 ent client 用于集成测试
@@ -108,12 +112,12 @@ func TestMain(m *testing.M) {
redisHost, err := redisContainer.Host(ctx)
if err != nil {
log.Printf("failed to get redis host: %v", err)
- os.Exit(1)
+ return 1
}
redisPort, err := redisContainer.MappedPort(ctx, "6379/tcp")
if err != nil {
log.Printf("failed to get redis port: %v", err)
- os.Exit(1)
+ return 1
}
integrationRedis = redisclient.NewClient(&redisclient.Options{
@@ -122,7 +126,7 @@ func TestMain(m *testing.M) {
})
if err := integrationRedis.Ping(ctx).Err(); err != nil {
log.Printf("failed to ping redis: %v", err)
- os.Exit(1)
+ return 1
}
code := m.Run()
@@ -131,7 +135,7 @@ func TestMain(m *testing.M) {
_ = integrationRedis.Close()
_ = integrationDB.Close()
- os.Exit(code)
+ return code
}
func dockerIsAvailable(ctx context.Context) bool {
@@ -328,7 +332,7 @@ func (h prefixHook) prefixCmd(cmd redisclient.Cmder) {
}
switch strings.ToLower(cmd.Name()) {
- case "get", "set", "setnx", "setex", "psetex", "incr", "decr", "incrby", "expire", "pexpire", "ttl", "pttl",
+ case "get", "getdel", "set", "setnx", "setex", "psetex", "incr", "decr", "incrby", "expire", "pexpire", "ttl", "pttl",
"hgetall", "hget", "hset", "hdel", "hincrbyfloat", "exists",
"zadd", "zcard", "zrange", "zrangebyscore", "zrem", "zremrangebyscore", "zrevrange", "zrevrangebyscore", "zscore":
prefixOne(1)
diff --git a/backend/internal/repository/migrations_runner.go b/backend/internal/repository/migrations_runner.go
index 6dbb9fbd7c0..6aee8a4f594 100644
--- a/backend/internal/repository/migrations_runner.go
+++ b/backend/internal/repository/migrations_runner.go
@@ -4,10 +4,12 @@ import (
"context"
"crypto/sha256"
"database/sql"
+ "database/sql/driver"
"encoding/hex"
"errors"
"fmt"
"io/fs"
+ "regexp"
"sort"
"strings"
"time"
@@ -50,9 +52,57 @@ CREATE TABLE IF NOT EXISTS atlas_schema_revisions (
// 任何稳定的 int64 值都可以,只要不与同一数据库中的其他锁冲突即可。
const migrationsAdvisoryLockID int64 = 694208311321144027
const migrationsLockRetryInterval = 500 * time.Millisecond
+const migrationsUnlockTimeout = 5 * time.Second
+const nonTransactionalMigrationTimeout = 10 * time.Minute
const nonTransactionalMigrationSuffix = "_notx.sql"
const paymentOrdersOutTradeNoUniqueMigration = "120_enforce_payment_orders_out_trade_no_unique_notx.sql"
const paymentOrdersOutTradeNoUniqueIndex = "paymentorder_out_trade_no_unique"
+const apiKeyHashIndexMigration = "138_add_api_key_hash_indexes_notx.sql"
+const walletIntegrityIndexesMigration = "178_wallet_integrity_indexes_notx.sql"
+
+type requiredIndexSpec struct {
+ name string
+ table string
+ keyColumn string
+ predicate string
+}
+
+var paymentOrdersOutTradeNoIndexSpec = requiredIndexSpec{
+ name: paymentOrdersOutTradeNoUniqueIndex,
+ table: "payment_orders",
+ keyColumn: "out_trade_no",
+ predicate: "out_trade_no <> ''",
+}
+
+var apiKeyHashIndexSpec = requiredIndexSpec{
+ name: "apikey_key_hash",
+ table: "api_keys",
+ keyColumn: "key_hash",
+ predicate: "deleted_at IS NULL AND key_hash IS NOT NULL",
+}
+
+var walletIntegrityIndexes = []requiredIndexSpec{
+ {
+ name: "idx_wallet_ledger_one_activation",
+ table: "subscription_wallet_ledger",
+ keyColumn: "subscription_id",
+ predicate: "reason = 'activation'",
+ },
+ {
+ name: "idx_user_subscriptions_one_active_credits_wallet",
+ table: "user_subscriptions",
+ keyColumn: "user_id",
+ predicate: "wallet_balance_usd IS NOT NULL AND status = 'active' AND deleted_at IS NULL AND expires_at >= '2099-12-30 23:59:59+00'",
+ },
+}
+
+var postgresTimestampWithOffsetPattern = regexp.MustCompile(
+ `'[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?[+-][0-9]{2}(:[0-9]{2}|[0-9]{2})?'`,
+)
+
+var concurrentCreateIndexNamePattern = regexp.MustCompile(
+ `(?i)\bCREATE\s+(UNIQUE\s+)?INDEX\s+CONCURRENTLY\s+IF\s+NOT\s+EXISTS\s+([a-z_][a-z0-9_$]*)`,
+)
type migrationChecksumCompatibilityRule struct {
fileChecksum string
@@ -60,9 +110,16 @@ type migrationChecksumCompatibilityRule struct {
acceptedChecksums map[string]struct{}
}
+type migrationExecutor interface {
+ ExecContext(context.Context, string, ...any) (sql.Result, error)
+ QueryContext(context.Context, string, ...any) (*sql.Rows, error)
+ QueryRowContext(context.Context, string, ...any) *sql.Row
+}
+
// migrationChecksumCompatibilityRules 仅用于兼容历史上误修改过的迁移文件 checksum。
// 规则必须同时匹配「迁移名 + 数据库 checksum + 当前文件 checksum」且两者都落在该迁移的已知版本集合内才会放行,
// 避免放宽全局校验,也允许将误改的历史 migration 回滚为已发布版本而不要求人工修 checksum。
+// 含数据写入的 migration 不允许走该白名单;checksum mismatch 必须人工 review 数据状态并另写补偿迁移。
var migrationChecksumCompatibilityRules = map[string]migrationChecksumCompatibilityRule{
"054_drop_legacy_cache_columns.sql": newMigrationChecksumCompatibilityRule("82de761156e03876653e7a6a4eee883cd927847036f779b0b9f34c42a8af7a7d", "182c193f3359946cf094090cd9e57d5c3fd9abaffbc1e8fc378646b8a6fa12b4"),
"061_add_usage_log_request_type.sql": newMigrationChecksumCompatibilityRule("66207e7aa5dd0429c2e2c0fabdaf79783ff157fa0af2e81adff2ee03790ec65c", "08a248652cbab7cfde147fc6ef8cda464f2477674e20b718312faa252e0481c0", "222b4a09c797c22e5922b6b172327c824f5463aaa8760e4f621bc5c22e2be0f3"),
@@ -115,30 +172,57 @@ func ApplyMigrations(ctx context.Context, db *sql.DB) error {
// - ctx: 上下文
// - db: 数据库连接
// - fsys: 包含迁移文件的文件系统(通常是 embed.FS)
-func applyMigrationsFS(ctx context.Context, db *sql.DB, fsys fs.FS) error {
+func applyMigrationsFS(ctx context.Context, db *sql.DB, fsys fs.FS) (retErr error) {
if db == nil {
return errors.New("nil sql db")
}
+ // PostgreSQL advisory locks are session-scoped. Pin one physical database
+ // session for lock acquisition, every migration statement (including
+ // *_notx.sql), and unlock. Running these calls through sql.DB would allow the
+ // pool to switch sessions and silently make the lock ineffective.
+ conn, err := db.Conn(ctx)
+ if err != nil {
+ return fmt.Errorf("acquire migrations connection: %w", err)
+ }
+ defer func() {
+ _ = conn.Close()
+ }()
+
// 获取分布式锁,确保多实例部署时只有一个实例执行迁移。
// 这是 PostgreSQL 特有的 Advisory Lock 机制。
- if err := pgAdvisoryLock(ctx, db); err != nil {
+ if err := pgAdvisoryLock(ctx, conn); err != nil {
+ discardSQLConn(conn)
return err
}
defer func() {
// 无论迁移是否成功,都要释放锁。
- // 使用 context.Background() 确保即使原 ctx 已取消也能释放锁。
- _ = pgAdvisoryUnlock(context.Background(), db)
+ // 使用独立且有界的 context,确保原 ctx 取消后仍尝试释放,同时
+ // 避免数据库故障让启动流程永久卡在清理路径。
+ unlockCtx, cancel := context.WithTimeout(context.Background(), migrationsUnlockTimeout)
+ defer cancel()
+ if err := pgAdvisoryUnlock(unlockCtx, conn); err != nil {
+ retErr = errors.Join(retErr, err)
+ // Returning a session that may still own the advisory lock to the
+ // pool can deadlock every later startup. Mark it bad so database/sql
+ // closes the physical session and PostgreSQL releases session locks.
+ discardSQLConn(conn)
+ }
}()
+ return applyMigrationsFSOnConn(ctx, conn, fsys)
+}
+
+func applyMigrationsFSOnConn(ctx context.Context, conn *sql.Conn, fsys fs.FS) error {
+
// 创建迁移记录表(如果不存在)。
// 该表记录所有已应用的迁移及其校验和。
- if _, err := db.ExecContext(ctx, schemaMigrationsTableDDL); err != nil {
+ if _, err := conn.ExecContext(ctx, schemaMigrationsTableDDL); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
// 自动对齐 Atlas 基线(如果检测到 legacy schema_migrations 且缺失 atlas_schema_revisions)。
- if err := ensureAtlasBaselineAligned(ctx, db, fsys); err != nil {
+ if err := ensureAtlasBaselineAligned(ctx, conn, fsys); err != nil {
return err
}
@@ -169,12 +253,21 @@ func applyMigrationsFS(ctx context.Context, db *sql.DB, fsys fs.FS) error {
// 检查该迁移是否已经应用
var existing string
- rowErr := db.QueryRowContext(ctx, "SELECT checksum FROM schema_migrations WHERE filename = $1", name).Scan(&existing)
+ rowErr := conn.QueryRowContext(ctx, "SELECT checksum FROM schema_migrations WHERE filename = $1", name).Scan(&existing)
if rowErr == nil {
// 迁移已应用,验证校验和是否匹配
if existing != checksum {
+ if migrationContainsDataMutation(content) {
+ return fmt.Errorf(
+ "migration %s checksum mismatch (db=%s file=%s)\n"+
+ "This migration contains data-changing SQL (UPDATE/INSERT/DELETE/MERGE/COPY), so checksum compatibility is disabled and manual review is required.\n"+
+ "Review the applied production state, then either create a new remediation migration or explicitly document why no data backfill is needed.\n"+
+ "Do not silently whitelist checksum mismatches for data migrations",
+ name, existing, checksum,
+ )
+ }
// 兼容特定历史误改场景(仅白名单规则),其余仍保持严格不可变约束。
- if isMigrationChecksumCompatible(name, existing, checksum) {
+ if isMigrationChecksumCompatible(name, existing, checksum, content) {
continue
}
// 校验和不匹配意味着迁移文件在应用后被修改,这是危险的。
@@ -201,36 +294,25 @@ func applyMigrationsFS(ctx context.Context, db *sql.DB, fsys fs.FS) error {
}
if nonTx {
- if err := prepareNonTransactionalMigration(ctx, db, name); err != nil {
- return fmt.Errorf("prepare migration %s: %w", name, err)
- }
-
- // *_notx.sql:用于 CREATE/DROP INDEX CONCURRENTLY 场景,必须非事务执行。
- // 逐条语句执行,避免将多条 CONCURRENTLY 语句放入同一个隐式事务块。
- statements := splitSQLStatements(content)
- for i, stmt := range statements {
- trimmed := strings.TrimSpace(stmt)
- if trimmed == "" {
- continue
- }
- if stripSQLLineComment(trimmed) == "" {
- continue
- }
- if _, err := db.ExecContext(ctx, trimmed); err != nil {
- return fmt.Errorf("apply migration %s (non-tx statement %d): %w", name, i+1, err)
- }
- }
- if _, err := db.ExecContext(ctx, "INSERT INTO schema_migrations (filename, checksum) VALUES ($1, $2)", name, checksum); err != nil {
- return fmt.Errorf("record migration %s (non-tx): %w", name, err)
+ if err := applyNonTransactionalMigration(ctx, conn, name, checksum, content); err != nil {
+ return err
}
continue
}
// 默认迁移在事务中执行,确保原子性:要么完全成功,要么完全回滚。
- tx, err := db.BeginTx(ctx, nil)
+ tx, err := conn.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %s: %w", name, err)
}
+ if _, err := tx.ExecContext(ctx, "SET LOCAL lock_timeout = '5s'"); err != nil {
+ _ = tx.Rollback()
+ return fmt.Errorf("set lock timeout for migration %s: %w", name, err)
+ }
+ if _, err := tx.ExecContext(ctx, "SET LOCAL statement_timeout = '10min'"); err != nil {
+ _ = tx.Rollback()
+ return fmt.Errorf("set statement timeout for migration %s: %w", name, err)
+ }
// 执行迁移 SQL
if _, err := tx.ExecContext(ctx, content); err != nil {
@@ -254,43 +336,281 @@ func applyMigrationsFS(ctx context.Context, db *sql.DB, fsys fs.FS) error {
return nil
}
-func prepareNonTransactionalMigration(ctx context.Context, db *sql.DB, name string) error {
+func applyNonTransactionalMigration(ctx context.Context, conn *sql.Conn, name, checksum, content string) error {
+ nonTxCtx, cancel := context.WithTimeout(ctx, nonTransactionalMigrationTimeout)
+ defer cancel()
+
+ if err := prepareNonTransactionalMigration(nonTxCtx, conn, name); err != nil {
+ return fmt.Errorf("prepare migration %s: %w", name, err)
+ }
+ if err := prepareConcurrentIndexArtifacts(nonTxCtx, conn, content); err != nil {
+ return fmt.Errorf("prepare migration %s concurrent indexes: %w", name, err)
+ }
+
+ // *_notx.sql:用于 CREATE/DROP INDEX CONCURRENTLY 场景,必须在持有
+ // advisory lock 的同一 pinned session 上逐条非事务执行。
+ statements := splitSQLStatements(content)
+ for i, stmt := range statements {
+ trimmed := strings.TrimSpace(stmt)
+ if trimmed == "" || stripSQLLineComment(trimmed) == "" {
+ continue
+ }
+ if _, err := conn.ExecContext(nonTxCtx, trimmed); err != nil {
+ return fmt.Errorf("apply migration %s (non-tx statement %d): %w", name, i+1, err)
+ }
+ }
+ if _, err := conn.ExecContext(nonTxCtx, "INSERT INTO schema_migrations (filename, checksum) VALUES ($1, $2)", name, checksum); err != nil {
+ return fmt.Errorf("record migration %s (non-tx): %w", name, err)
+ }
+ return nil
+}
+
+func prepareNonTransactionalMigration(ctx context.Context, db migrationExecutor, name string) error {
switch name {
case paymentOrdersOutTradeNoUniqueMigration:
return preparePaymentOrdersOutTradeNoUniqueMigration(ctx, db)
+ case apiKeyHashIndexMigration:
+ return prepareRequiredIndex(ctx, db, apiKeyHashIndexSpec)
+ case walletIntegrityIndexesMigration:
+ return prepareWalletIntegrityIndexesMigration(ctx, db)
default:
return nil
}
}
-func preparePaymentOrdersOutTradeNoUniqueMigration(ctx context.Context, db *sql.DB) error {
- duplicates, err := findDuplicatePaymentOrderOutTradeNos(ctx, db)
+func prepareWalletIntegrityIndexesMigration(ctx context.Context, db migrationExecutor) error {
+ for _, spec := range walletIntegrityIndexes {
+ if err := prepareRequiredIndex(ctx, db, spec); err != nil {
+ if strings.Contains(err.Error(), "does not match required definition") {
+ return fmt.Errorf("prepare wallet integrity index %s: existing index does not match required wallet integrity definition: %w", spec.name, err)
+ }
+ return fmt.Errorf("prepare wallet integrity index %s: %w", spec.name, err)
+ }
+ }
+ return nil
+}
+
+func prepareRequiredIndex(ctx context.Context, db migrationExecutor, spec requiredIndexSpec) error {
+ state, err := inspectIndexDefinition(ctx, db, spec.name)
if err != nil {
- return fmt.Errorf("precheck duplicate out_trade_no: %w", err)
+ return fmt.Errorf("inspect required index: %w", err)
}
- if len(duplicates) > 0 {
+ if !state.exists {
+ return nil
+ }
+ if !state.valid {
+ if _, err := db.ExecContext(ctx, fmt.Sprintf("DROP INDEX CONCURRENTLY IF EXISTS %s", spec.name)); err != nil {
+ return fmt.Errorf("drop invalid index: %w", err)
+ }
+ return nil
+ }
+ if !state.matches(spec) {
return fmt.Errorf(
- "duplicate out_trade_no values block %s; remediate duplicates before retrying: %s",
- paymentOrdersOutTradeNoUniqueMigration,
- strings.Join(duplicates, ", "),
+ "existing valid index does not match required definition (table=%s unique=%t keys=%d key=%s predicate=%q)",
+ state.table, state.unique, state.keyCount, state.keyColumn, state.predicate,
)
}
+ return nil
+}
+
+type indexDefinitionState struct {
+ exists bool
+ valid bool
+ unique bool
+ table string
+ keyCount int
+ keyColumn string
+ predicate string
+}
- invalid, err := indexIsInvalid(ctx, db, paymentOrdersOutTradeNoUniqueIndex)
+func (s indexDefinitionState) matches(spec requiredIndexSpec) bool {
+ return s.valid &&
+ s.unique &&
+ s.table == spec.table &&
+ s.keyCount == 1 &&
+ strings.EqualFold(strings.TrimSpace(s.keyColumn), spec.keyColumn) &&
+ normalizeIndexPredicate(s.predicate) == normalizeIndexPredicate(spec.predicate)
+}
+
+func inspectIndexDefinition(ctx context.Context, db migrationExecutor, indexName string) (indexDefinitionState, error) {
+ state := indexDefinitionState{}
+ err := db.QueryRowContext(ctx, `
+ SELECT
+ i.indisvalid,
+ i.indisunique,
+ tbl.relname,
+ i.indnkeyatts,
+ pg_get_indexdef(i.indexrelid, 1, TRUE),
+ COALESCE(pg_get_expr(i.indpred, i.indrelid, TRUE), '')
+ FROM pg_class idx
+ JOIN pg_namespace ns ON ns.oid = idx.relnamespace
+ JOIN pg_index i ON i.indexrelid = idx.oid
+ JOIN pg_class tbl ON tbl.oid = i.indrelid
+ WHERE ns.nspname = 'public'
+ AND idx.relname = $1
+ `, indexName).Scan(
+ &state.valid,
+ &state.unique,
+ &state.table,
+ &state.keyCount,
+ &state.keyColumn,
+ &state.predicate,
+ )
+ if errors.Is(err, sql.ErrNoRows) {
+ return state, nil
+ }
if err != nil {
- return fmt.Errorf("check invalid index %s: %w", paymentOrdersOutTradeNoUniqueIndex, err)
+ return state, err
}
- if !invalid {
- return nil
+ state.exists = true
+ return state, nil
+}
+
+func normalizeIndexPredicate(predicate string) string {
+ normalized := postgresTimestampWithOffsetPattern.ReplaceAllStringFunc(predicate, normalizePostgresTimestampLiteral)
+ return stripIndexPredicateCasts(normalizeSQLOutsideLiterals(normalized))
+}
+
+func normalizePostgresTimestampLiteral(literal string) string {
+ value := strings.Trim(literal, "'")
+ for _, layout := range []string{
+ "2006-01-02 15:04:05Z07:00",
+ "2006-01-02 15:04:05Z0700",
+ "2006-01-02 15:04:05Z07",
+ } {
+ parsed, err := time.Parse(layout, value)
+ if err == nil {
+ return "'" + parsed.UTC().Format("2006-01-02 15:04:05.999999999") + "+00'"
+ }
+ }
+ return literal
+}
+
+func normalizeSQLOutsideLiterals(input string) string {
+ var out strings.Builder
+ out.Grow(len(input))
+ inLiteral := false
+ for i := 0; i < len(input); i++ {
+ ch := input[i]
+ if ch == '\'' {
+ out.WriteByte(ch)
+ if inLiteral && i+1 < len(input) && input[i+1] == '\'' {
+ out.WriteByte(input[i+1])
+ i++
+ continue
+ }
+ inLiteral = !inLiteral
+ continue
+ }
+ if inLiteral {
+ out.WriteByte(ch)
+ continue
+ }
+ if ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' || ch == '(' || ch == ')' {
+ continue
+ }
+ if ch >= 'A' && ch <= 'Z' {
+ ch += 'a' - 'A'
+ }
+ out.WriteByte(ch)
+ }
+ return out.String()
+}
+
+func stripIndexPredicateCasts(input string) string {
+ casts := []string{"::charactervarying", "::timestampwithtimezone", "::text"}
+ var out strings.Builder
+ out.Grow(len(input))
+ inLiteral := false
+ for i := 0; i < len(input); {
+ if input[i] == '\'' {
+ out.WriteByte(input[i])
+ if inLiteral && i+1 < len(input) && input[i+1] == '\'' {
+ out.WriteByte(input[i+1])
+ i += 2
+ continue
+ }
+ inLiteral = !inLiteral
+ i++
+ continue
+ }
+ if !inLiteral {
+ removed := false
+ for _, cast := range casts {
+ if strings.HasPrefix(input[i:], cast) {
+ i += len(cast)
+ removed = true
+ break
+ }
+ }
+ if removed {
+ continue
+ }
+ }
+ out.WriteByte(input[i])
+ i++
}
+ return out.String()
+}
- if _, err := db.ExecContext(ctx, fmt.Sprintf("DROP INDEX CONCURRENTLY IF EXISTS %s", paymentOrdersOutTradeNoUniqueIndex)); err != nil {
- return fmt.Errorf("drop invalid index %s: %w", paymentOrdersOutTradeNoUniqueIndex, err)
+func prepareConcurrentIndexArtifacts(ctx context.Context, db migrationExecutor, content string) error {
+ for _, statement := range splitSQLStatements(content) {
+ cleanStatement := stripSQLLineComment(statement)
+ match := concurrentCreateIndexNamePattern.FindStringSubmatch(cleanStatement)
+ if len(match) != 3 {
+ upper := strings.ToUpper(cleanStatement)
+ if strings.Contains(upper, "CREATE") && strings.Contains(upper, "INDEX") && strings.Contains(upper, "CONCURRENTLY") {
+ return errors.New("unsupported CREATE INDEX CONCURRENTLY syntax; index name must be an unquoted, non-qualified SQL identifier")
+ }
+ continue
+ }
+ indexName := match[2]
+ if isCanonicalRequiredIndex(indexName) {
+ continue
+ }
+ invalid, err := indexIsInvalid(ctx, db, indexName)
+ if err != nil {
+ return fmt.Errorf("check invalid index %s: %w", indexName, err)
+ }
+ if !invalid {
+ continue
+ }
+ if _, err := db.ExecContext(ctx, fmt.Sprintf("DROP INDEX CONCURRENTLY IF EXISTS %s", indexName)); err != nil {
+ return fmt.Errorf("drop invalid index %s: %w", indexName, err)
+ }
}
return nil
}
-func findDuplicatePaymentOrderOutTradeNos(ctx context.Context, db *sql.DB) ([]string, error) {
+func isCanonicalRequiredIndex(indexName string) bool {
+ if indexName == paymentOrdersOutTradeNoIndexSpec.name || indexName == apiKeyHashIndexSpec.name {
+ return true
+ }
+ for _, spec := range walletIntegrityIndexes {
+ if indexName == spec.name {
+ return true
+ }
+ }
+ return false
+}
+
+func preparePaymentOrdersOutTradeNoUniqueMigration(ctx context.Context, db migrationExecutor) error {
+ duplicates, err := findDuplicatePaymentOrderOutTradeNos(ctx, db)
+ if err != nil {
+ return fmt.Errorf("precheck duplicate out_trade_no: %w", err)
+ }
+ if len(duplicates) > 0 {
+ return fmt.Errorf(
+ "duplicate out_trade_no values block %s; remediate duplicates before retrying: %s",
+ paymentOrdersOutTradeNoUniqueMigration,
+ strings.Join(duplicates, ", "),
+ )
+ }
+
+ return prepareRequiredIndex(ctx, db, paymentOrdersOutTradeNoIndexSpec)
+}
+
+func findDuplicatePaymentOrderOutTradeNos(ctx context.Context, db migrationExecutor) ([]string, error) {
rows, err := db.QueryContext(ctx, `
SELECT out_trade_no, COUNT(*) AS duplicate_count
FROM payment_orders
@@ -322,7 +642,7 @@ func findDuplicatePaymentOrderOutTradeNos(ctx context.Context, db *sql.DB) ([]st
return duplicates, nil
}
-func indexIsInvalid(ctx context.Context, db *sql.DB, indexName string) (bool, error) {
+func indexIsInvalid(ctx context.Context, db migrationExecutor, indexName string) (bool, error) {
var invalid bool
err := db.QueryRowContext(ctx, `
SELECT EXISTS (
@@ -338,7 +658,7 @@ func indexIsInvalid(ctx context.Context, db *sql.DB, indexName string) (bool, er
return invalid, err
}
-func ensureAtlasBaselineAligned(ctx context.Context, db *sql.DB, fsys fs.FS) error {
+func ensureAtlasBaselineAligned(ctx context.Context, db migrationExecutor, fsys fs.FS) error {
hasLegacy, err := tableExists(ctx, db, "schema_migrations")
if err != nil {
return fmt.Errorf("check schema_migrations: %w", err)
@@ -365,10 +685,16 @@ func ensureAtlasBaselineAligned(ctx context.Context, db *sql.DB, fsys fs.FS) err
return nil
}
- version, description, hash, err := latestMigrationBaseline(fsys)
+ version, description, hash, ok, err := latestAppliedMigrationBaseline(ctx, db, fsys)
if err != nil {
return fmt.Errorf("atlas baseline version: %w", err)
}
+ if !ok {
+ // A fresh or partially migrated database must not advertise the newest
+ // embedded migration to a second runner. The next startup can align Atlas
+ // after this runner has recorded a contiguous applied prefix.
+ return nil
+ }
if _, err := db.ExecContext(ctx, `
INSERT INTO atlas_schema_revisions (version, description, type, applied, total, executed_at, execution_time, hash)
@@ -379,7 +705,65 @@ func ensureAtlasBaselineAligned(ctx context.Context, db *sql.DB, fsys fs.FS) err
return nil
}
-func tableExists(ctx context.Context, db *sql.DB, tableName string) (bool, error) {
+func latestAppliedMigrationBaseline(ctx context.Context, db migrationExecutor, fsys fs.FS) (string, string, string, bool, error) {
+ rows, err := db.QueryContext(ctx, "SELECT filename, checksum FROM schema_migrations")
+ if err != nil {
+ return "", "", "", false, fmt.Errorf("list applied migrations: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+
+ applied := make(map[string]string)
+ for rows.Next() {
+ var name, checksum string
+ if err := rows.Scan(&name, &checksum); err != nil {
+ return "", "", "", false, fmt.Errorf("scan applied migration: %w", err)
+ }
+ applied[name] = checksum
+ }
+ if err := rows.Err(); err != nil {
+ return "", "", "", false, fmt.Errorf("iterate applied migrations: %w", err)
+ }
+
+ files, err := fs.Glob(fsys, "*.sql")
+ if err != nil {
+ return "", "", "", false, err
+ }
+ sort.Strings(files)
+
+ var version, hash string
+ for _, name := range files {
+ contentBytes, err := fs.ReadFile(fsys, name)
+ if err != nil {
+ return "", "", "", false, err
+ }
+ content := strings.TrimSpace(string(contentBytes))
+ if content == "" {
+ continue
+ }
+
+ dbChecksum, exists := applied[name]
+ if !exists {
+ break
+ }
+ sum := sha256.Sum256([]byte(content))
+ fileChecksum := hex.EncodeToString(sum[:])
+ if dbChecksum != fileChecksum && !isMigrationChecksumCompatible(name, dbChecksum, fileChecksum, content) {
+ return "", "", "", false, fmt.Errorf(
+ "applied migration %s checksum mismatch (db=%s file=%s)",
+ name, dbChecksum, fileChecksum,
+ )
+ }
+ version = strings.TrimSuffix(name, ".sql")
+ hash = fileChecksum
+ }
+
+ if version == "" {
+ return "", "", "", false, nil
+ }
+ return version, version, hash, true, nil
+}
+
+func tableExists(ctx context.Context, db migrationExecutor, tableName string) (bool, error) {
var exists bool
err := db.QueryRowContext(ctx, `
SELECT EXISTS (
@@ -428,7 +812,10 @@ func newMigrationChecksumCompatibilityRule(fileChecksum string, acceptedDBChecks
}
}
-func isMigrationChecksumCompatible(name, dbChecksum, fileChecksum string) bool {
+func isMigrationChecksumCompatible(name, dbChecksum, fileChecksum, content string) bool {
+ if migrationContainsDataMutation(content) {
+ return false
+ }
rule, ok := migrationChecksumCompatibilityRules[name]
if !ok {
return false
@@ -441,6 +828,41 @@ func isMigrationChecksumCompatible(name, dbChecksum, fileChecksum string) bool {
return fileOK
}
+func migrationContainsDataMutation(content string) bool {
+ normalized := strings.ToUpper(stripSQLLineComment(content))
+ for _, keyword := range []string{"UPDATE", "INSERT", "DELETE", "MERGE", "COPY"} {
+ if containsSQLKeyword(normalized, keyword) {
+ return true
+ }
+ }
+ return false
+}
+
+func containsSQLKeyword(content, keyword string) bool {
+ searchStart := 0
+ for {
+ idx := strings.Index(content[searchStart:], keyword)
+ if idx < 0 {
+ return false
+ }
+ idx += searchStart
+ beforeOK := idx == 0 || !isSQLIdentifierByte(content[idx-1])
+ after := idx + len(keyword)
+ afterOK := after >= len(content) || !isSQLIdentifierByte(content[after])
+ if beforeOK && afterOK {
+ return true
+ }
+ searchStart = after
+ }
+}
+
+func isSQLIdentifierByte(b byte) bool {
+ return b == '_' || b == '$' ||
+ (b >= '0' && b <= '9') ||
+ (b >= 'A' && b <= 'Z') ||
+ (b >= 'a' && b <= 'z')
+}
+
func validateMigrationExecutionMode(name, content string) (bool, error) {
normalizedName := strings.ToLower(strings.TrimSpace(name))
upperContent := strings.ToUpper(content)
@@ -510,7 +932,7 @@ func stripSQLLineComment(s string) string {
// pgAdvisoryLock 获取 PostgreSQL Advisory Lock。
// Advisory Lock 是一种轻量级的锁机制,不与任何特定的数据库对象关联。
// 它非常适合用于应用层面的分布式锁场景,如迁移序列化。
-func pgAdvisoryLock(ctx context.Context, db *sql.DB) error {
+func pgAdvisoryLock(ctx context.Context, db migrationExecutor) error {
ticker := time.NewTicker(migrationsLockRetryInterval)
defer ticker.Stop()
@@ -532,10 +954,19 @@ func pgAdvisoryLock(ctx context.Context, db *sql.DB) error {
// pgAdvisoryUnlock 释放 PostgreSQL Advisory Lock。
// 必须在获取锁后确保释放,否则会阻塞其他实例的迁移操作。
-func pgAdvisoryUnlock(ctx context.Context, db *sql.DB) error {
- _, err := db.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", migrationsAdvisoryLockID)
- if err != nil {
+func pgAdvisoryUnlock(ctx context.Context, db migrationExecutor) error {
+ var unlocked bool
+ if err := db.QueryRowContext(ctx, "SELECT pg_advisory_unlock($1)", migrationsAdvisoryLockID).Scan(&unlocked); err != nil {
return fmt.Errorf("release migrations lock: %w", err)
}
+ if !unlocked {
+ return errors.New("release migrations lock: current database session does not own the lock")
+ }
return nil
}
+
+func discardSQLConn(conn *sql.Conn) {
+ _ = conn.Raw(func(any) error {
+ return driver.ErrBadConn
+ })
+}
diff --git a/backend/internal/repository/migrations_runner_checksum_test.go b/backend/internal/repository/migrations_runner_checksum_test.go
index 1fcb3be1e70..721669aba36 100644
--- a/backend/internal/repository/migrations_runner_checksum_test.go
+++ b/backend/internal/repository/migrations_runner_checksum_test.go
@@ -6,9 +6,13 @@ import (
"github.com/stretchr/testify/require"
)
+func isMigrationChecksumCompatibleForDDL(name, dbChecksum, fileChecksum string) bool {
+ return isMigrationChecksumCompatible(name, dbChecksum, fileChecksum, "CREATE TABLE t(id int);")
+}
+
func TestIsMigrationChecksumCompatible(t *testing.T) {
t.Run("054历史checksum可兼容", func(t *testing.T) {
- ok := isMigrationChecksumCompatible(
+ ok := isMigrationChecksumCompatibleForDDL(
"054_drop_legacy_cache_columns.sql",
"182c193f3359946cf094090cd9e57d5c3fd9abaffbc1e8fc378646b8a6fa12b4",
"82de761156e03876653e7a6a4eee883cd927847036f779b0b9f34c42a8af7a7d",
@@ -17,7 +21,7 @@ func TestIsMigrationChecksumCompatible(t *testing.T) {
})
t.Run("054在未知文件checksum下不兼容", func(t *testing.T) {
- ok := isMigrationChecksumCompatible(
+ ok := isMigrationChecksumCompatibleForDDL(
"054_drop_legacy_cache_columns.sql",
"182c193f3359946cf094090cd9e57d5c3fd9abaffbc1e8fc378646b8a6fa12b4",
"0000000000000000000000000000000000000000000000000000000000000000",
@@ -26,7 +30,7 @@ func TestIsMigrationChecksumCompatible(t *testing.T) {
})
t.Run("061历史checksum可兼容", func(t *testing.T) {
- ok := isMigrationChecksumCompatible(
+ ok := isMigrationChecksumCompatibleForDDL(
"061_add_usage_log_request_type.sql",
"08a248652cbab7cfde147fc6ef8cda464f2477674e20b718312faa252e0481c0",
"66207e7aa5dd0429c2e2c0fabdaf79783ff157fa0af2e81adff2ee03790ec65c",
@@ -35,7 +39,7 @@ func TestIsMigrationChecksumCompatible(t *testing.T) {
})
t.Run("061第二个历史checksum可兼容", func(t *testing.T) {
- ok := isMigrationChecksumCompatible(
+ ok := isMigrationChecksumCompatibleForDDL(
"061_add_usage_log_request_type.sql",
"222b4a09c797c22e5922b6b172327c824f5463aaa8760e4f621bc5c22e2be0f3",
"66207e7aa5dd0429c2e2c0fabdaf79783ff157fa0af2e81adff2ee03790ec65c",
@@ -44,7 +48,7 @@ func TestIsMigrationChecksumCompatible(t *testing.T) {
})
t.Run("非白名单迁移不兼容", func(t *testing.T) {
- ok := isMigrationChecksumCompatible(
+ ok := isMigrationChecksumCompatibleForDDL(
"001_init.sql",
"182c193f3359946cf094090cd9e57d5c3fd9abaffbc1e8fc378646b8a6fa12b4",
"82de761156e03876653e7a6a4eee883cd927847036f779b0b9f34c42a8af7a7d",
@@ -53,7 +57,7 @@ func TestIsMigrationChecksumCompatible(t *testing.T) {
})
t.Run("109历史checksum可兼容", func(t *testing.T) {
- ok := isMigrationChecksumCompatible(
+ ok := isMigrationChecksumCompatibleForDDL(
"109_auth_identity_compat_backfill.sql",
"551e498aa5616d2d91096e9d72cf9fb36e418ee22eacc557f8811cadbc9e20ee",
"0580b4602d85435edf9aca1633db580bb3932f26517f75134106f80275ec2ace",
@@ -62,7 +66,7 @@ func TestIsMigrationChecksumCompatible(t *testing.T) {
})
t.Run("109当前checksum可兼容历史checksum", func(t *testing.T) {
- ok := isMigrationChecksumCompatible(
+ ok := isMigrationChecksumCompatibleForDDL(
"109_auth_identity_compat_backfill.sql",
"551e498aa5616d2d91096e9d72cf9fb36e418ee22eacc557f8811cadbc9e20ee",
"0580b4602d85435edf9aca1633db580bb3932f26517f75134106f80275ec2ace",
@@ -71,7 +75,7 @@ func TestIsMigrationChecksumCompatible(t *testing.T) {
})
t.Run("109回滚到历史文件后仍兼容已应用的新checksum", func(t *testing.T) {
- ok := isMigrationChecksumCompatible(
+ ok := isMigrationChecksumCompatibleForDDL(
"109_auth_identity_compat_backfill.sql",
"0580b4602d85435edf9aca1633db580bb3932f26517f75134106f80275ec2ace",
"551e498aa5616d2d91096e9d72cf9fb36e418ee22eacc557f8811cadbc9e20ee",
@@ -80,7 +84,7 @@ func TestIsMigrationChecksumCompatible(t *testing.T) {
})
t.Run("110历史checksum可兼容", func(t *testing.T) {
- ok := isMigrationChecksumCompatible(
+ ok := isMigrationChecksumCompatibleForDDL(
"110_pending_auth_and_provider_default_grants.sql",
"e3d1f433be2b564cfbdc549adf98fce13c5c7b363ebc20fd05b765d0563b0925",
"32cf87ee787b1bb36b5c691367c96eee37518fa3eed6f3322cf68795e3745279",
@@ -89,7 +93,7 @@ func TestIsMigrationChecksumCompatible(t *testing.T) {
})
t.Run("112历史checksum可兼容", func(t *testing.T) {
- ok := isMigrationChecksumCompatible(
+ ok := isMigrationChecksumCompatibleForDDL(
"112_add_payment_order_provider_key_snapshot.sql",
"ffd3e8a2c9295fa9cbefefd629a78268877e5b51bc970a82d9b3f46ec4ebd15e",
"b75f8f56d39455682787696a3d92ad25b055444ca328fb7fca9a460a15d68d99",
@@ -98,7 +102,7 @@ func TestIsMigrationChecksumCompatible(t *testing.T) {
})
t.Run("115历史checksum可兼容修复后的legacy external backfill", func(t *testing.T) {
- ok := isMigrationChecksumCompatible(
+ ok := isMigrationChecksumCompatibleForDDL(
"115_auth_identity_legacy_external_backfill.sql",
"4cf39e508be9fd1a5aa41610cbbebeb80385c9adda45bf78a706de9db4f1385f",
"022aadd97bb53e755f0cf7a3a957e0cb1a1353b0c39ec4de3234acd2871fd04f",
@@ -107,7 +111,7 @@ func TestIsMigrationChecksumCompatible(t *testing.T) {
})
t.Run("116历史checksum可兼容修复后的legacy external safety reports", func(t *testing.T) {
- ok := isMigrationChecksumCompatible(
+ ok := isMigrationChecksumCompatibleForDDL(
"116_auth_identity_legacy_external_safety_reports.sql",
"f7757bd929ac67ffb08ce69fa4cf20fad39dbff9d5a5085fb2adabb7607e5877",
"07edb09fa8d04ffb172b0621e3c22f4d1757d20a24ae267b3b36b087ab72d488",
@@ -116,7 +120,7 @@ func TestIsMigrationChecksumCompatible(t *testing.T) {
})
t.Run("119历史checksum可兼容占位文件", func(t *testing.T) {
- ok := isMigrationChecksumCompatible(
+ ok := isMigrationChecksumCompatibleForDDL(
"119_enforce_payment_orders_out_trade_no_unique.sql",
"ebd2c67cce0116393fb4f1b5d5116a67c6aceb73820dfb5133d1ff6f36d72d34",
"0bbe809ae48a9d811dabda1ba1c74955bd71c4a9cc610f9128816818dfa6c11e",
@@ -129,7 +133,7 @@ func TestIsMigrationChecksumCompatible(t *testing.T) {
"a38243ca0a72c3a01c0a92b7986423054d6133c0399441f853b99802852720fb",
"e0cdf835d6c688d64100f483d31bc02ac9ebad414bf1837af239a84bf75b8227",
} {
- ok := isMigrationChecksumCompatible(
+ ok := isMigrationChecksumCompatibleForDDL(
"118_wechat_dual_mode_and_auth_source_defaults.sql",
dbChecksum,
"b54194d7a3e4fbf710e0a3590d22a2fe7966804c487052a356e0b55f53ef96b0",
@@ -144,7 +148,7 @@ func TestIsMigrationChecksumCompatible(t *testing.T) {
"707431450603e70a43ce9fbd61e0c12fa67da4875158ccefabacea069587ab22",
"04b082b5a239c525154fe9185d324ee2b05ff90da9297e10dba19f9be79aa59a",
} {
- ok := isMigrationChecksumCompatible(
+ ok := isMigrationChecksumCompatibleForDDL(
"120_enforce_payment_orders_out_trade_no_unique_notx.sql",
dbChecksum,
"34aadc0db59a4e390f92a12b73bd74642d9724f33124f73638ae00089ea5e074",
@@ -154,7 +158,7 @@ func TestIsMigrationChecksumCompatible(t *testing.T) {
})
t.Run("119未知checksum不兼容", func(t *testing.T) {
- ok := isMigrationChecksumCompatible(
+ ok := isMigrationChecksumCompatibleForDDL(
"119_enforce_payment_orders_out_trade_no_unique.sql",
"ebd2c67cce0116393fb4f1b5d5116a67c6aceb73820dfb5133d1ff6f36d72d34",
"ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
diff --git a/backend/internal/repository/migrations_runner_extra_test.go b/backend/internal/repository/migrations_runner_extra_test.go
index 5d67665ed45..6d27291c9f6 100644
--- a/backend/internal/repository/migrations_runner_extra_test.go
+++ b/backend/internal/repository/migrations_runner_extra_test.go
@@ -69,7 +69,7 @@ func TestLatestMigrationBaseline(t *testing.T) {
}
func TestIsMigrationChecksumCompatible_AdditionalCases(t *testing.T) {
- require.False(t, isMigrationChecksumCompatible("unknown.sql", "db", "file"))
+ require.False(t, isMigrationChecksumCompatible("unknown.sql", "db", "file", "CREATE TABLE t(id int);"))
var (
name string
@@ -82,8 +82,8 @@ func TestIsMigrationChecksumCompatible_AdditionalCases(t *testing.T) {
}
require.NotEmpty(t, name)
- require.False(t, isMigrationChecksumCompatible(name, "db-not-accepted", "file-not-match"))
- require.False(t, isMigrationChecksumCompatible(name, "db-not-accepted", rule.fileChecksum))
+ require.False(t, isMigrationChecksumCompatible(name, "db-not-accepted", "file-not-match", "CREATE TABLE t(id int);"))
+ require.False(t, isMigrationChecksumCompatible(name, "db-not-accepted", rule.fileChecksum, "CREATE TABLE t(id int);"))
var accepted string
for checksum := range rule.acceptedDBChecksum {
@@ -91,7 +91,19 @@ func TestIsMigrationChecksumCompatible_AdditionalCases(t *testing.T) {
break
}
require.NotEmpty(t, accepted)
- require.True(t, isMigrationChecksumCompatible(name, accepted, rule.fileChecksum))
+ require.True(t, isMigrationChecksumCompatible(name, accepted, rule.fileChecksum, "CREATE TABLE t(id int);"))
+ require.False(t, isMigrationChecksumCompatible(name, accepted, rule.fileChecksum, "UPDATE groups SET allow_image_generation = true;"))
+}
+
+func TestMigrationContainsDataMutation(t *testing.T) {
+ require.False(t, migrationContainsDataMutation("CREATE TABLE t(id int);"))
+ require.False(t, migrationContainsDataMutation("-- UPDATE t SET id = 1\nCREATE TABLE t(id int);"))
+ require.True(t, migrationContainsDataMutation("UPDATE groups SET allow_image_generation = true;"))
+ require.True(t, migrationContainsDataMutation("INSERT INTO settings (key, value) VALUES ('k', 'v');"))
+ require.True(t, migrationContainsDataMutation("DELETE FROM payment_audit_logs WHERE id = 1;"))
+ require.True(t, migrationContainsDataMutation("MERGE INTO target USING source ON target.id = source.id WHEN MATCHED THEN UPDATE SET value = source.value;"))
+ require.True(t, migrationContainsDataMutation("COPY users FROM STDIN;"))
+ require.False(t, migrationContainsDataMutation("CREATE TABLE insert_history(id int);"))
}
func TestMigrationChecksumCompatibilityRules_CoverEditedUpgradeCompatibilityMigrations(t *testing.T) {
@@ -142,6 +154,10 @@ func TestEnsureAtlasBaselineAligned(t *testing.T) {
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM atlas_schema_revisions").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
+ mock.ExpectQuery("SELECT filename, checksum FROM schema_migrations").
+ WillReturnRows(sqlmock.NewRows([]string{"filename", "checksum"}).
+ AddRow("001_init.sql", migrationChecksum("CREATE TABLE t1(id int); ")).
+ AddRow("002_next.sql", migrationChecksum("CREATE TABLE t2(id int); ")))
mock.ExpectExec("INSERT INTO atlas_schema_revisions").
WithArgs("002_next", "002_next", 1, sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
@@ -223,6 +239,9 @@ func TestEnsureAtlasBaselineAligned(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM atlas_schema_revisions").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
+ mock.ExpectQuery("SELECT filename, checksum FROM schema_migrations").
+ WillReturnRows(sqlmock.NewRows([]string{"filename", "checksum"}).
+ AddRow("001_init.sql", migrationChecksum("CREATE TABLE t(id int); ")))
mock.ExpectExec("INSERT INTO atlas_schema_revisions").
WithArgs("001_init", "001_init", 1, sqlmock.AnyArg()).
WillReturnError(errors.New("insert failed"))
@@ -235,6 +254,61 @@ func TestEnsureAtlasBaselineAligned(t *testing.T) {
require.Contains(t, err.Error(), "insert atlas baseline")
require.NoError(t, mock.ExpectationsWereMet())
})
+
+ t.Run("baseline_stops_at_contiguous_applied_prefix", func(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectQuery("SELECT EXISTS \\(").
+ WithArgs("schema_migrations").
+ WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
+ mock.ExpectQuery("SELECT EXISTS \\(").
+ WithArgs("atlas_schema_revisions").
+ WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
+ mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM atlas_schema_revisions").
+ WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
+ mock.ExpectQuery("SELECT filename, checksum FROM schema_migrations").
+ WillReturnRows(sqlmock.NewRows([]string{"filename", "checksum"}).
+ AddRow("001_init.sql", migrationChecksum("CREATE TABLE t1(id int); ")).
+ AddRow("003_later.sql", migrationChecksum("CREATE TABLE t3(id int); ")))
+ mock.ExpectExec("INSERT INTO atlas_schema_revisions").
+ WithArgs("001_init", "001_init", 1, sqlmock.AnyArg()).
+ WillReturnResult(sqlmock.NewResult(1, 1))
+
+ fsys := fstest.MapFS{
+ "001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE t1(id int);")},
+ "002_missing.sql": &fstest.MapFile{Data: []byte("CREATE TABLE t2(id int);")},
+ "003_later.sql": &fstest.MapFile{Data: []byte("CREATE TABLE t3(id int);")},
+ }
+ err = ensureAtlasBaselineAligned(context.Background(), db, fsys)
+ require.NoError(t, err)
+ require.NoError(t, mock.ExpectationsWereMet())
+ })
+
+ t.Run("skip_baseline_when_no_embedded_migration_is_applied", func(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectQuery("SELECT EXISTS \\(").
+ WithArgs("schema_migrations").
+ WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
+ mock.ExpectQuery("SELECT EXISTS \\(").
+ WithArgs("atlas_schema_revisions").
+ WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
+ mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM atlas_schema_revisions").
+ WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
+ mock.ExpectQuery("SELECT filename, checksum FROM schema_migrations").
+ WillReturnRows(sqlmock.NewRows([]string{"filename", "checksum"}))
+
+ fsys := fstest.MapFS{
+ "001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE t1(id int);")},
+ }
+ err = ensureAtlasBaselineAligned(context.Background(), db, fsys)
+ require.NoError(t, err)
+ require.NoError(t, mock.ExpectationsWereMet())
+ })
}
func TestApplyMigrationsFS_ChecksumMismatchRejected(t *testing.T) {
@@ -246,9 +320,7 @@ func TestApplyMigrationsFS_ChecksumMismatchRejected(t *testing.T) {
mock.ExpectQuery("SELECT checksum FROM schema_migrations WHERE filename = \\$1").
WithArgs("001_init.sql").
WillReturnRows(sqlmock.NewRows([]string{"checksum"}).AddRow("mismatched-checksum"))
- mock.ExpectExec("SELECT pg_advisory_unlock\\(\\$1\\)").
- WithArgs(migrationsAdvisoryLockID).
- WillReturnResult(sqlmock.NewResult(0, 1))
+ expectMigrationsUnlock(mock)
fsys := fstest.MapFS{
"001_init.sql": &fstest.MapFile{Data: []byte("CREATE TABLE t(id int);")},
@@ -259,6 +331,28 @@ func TestApplyMigrationsFS_ChecksumMismatchRejected(t *testing.T) {
require.NoError(t, mock.ExpectationsWereMet())
}
+func TestApplyMigrationsFS_ChecksumMismatchForDataMigrationRequiresManualReview(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+
+ prepareMigrationsBootstrapExpectations(mock)
+ mock.ExpectQuery("SELECT checksum FROM schema_migrations WHERE filename = \\$1").
+ WithArgs("054_drop_legacy_cache_columns.sql").
+ WillReturnRows(sqlmock.NewRows([]string{"checksum"}).AddRow("182c193f3359946cf094090cd9e57d5c3fd9abaffbc1e8fc378646b8a6fa12b4"))
+ expectMigrationsUnlock(mock)
+
+ fsys := fstest.MapFS{
+ "054_drop_legacy_cache_columns.sql": &fstest.MapFile{Data: []byte("UPDATE groups SET allow_image_generation = true;")},
+ }
+ err = applyMigrationsFS(context.Background(), db, fsys)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "checksum mismatch")
+ require.Contains(t, err.Error(), "data-changing SQL")
+ require.Contains(t, err.Error(), "manual review")
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
func TestApplyMigrationsFS_CheckMigrationQueryError(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
@@ -268,9 +362,7 @@ func TestApplyMigrationsFS_CheckMigrationQueryError(t *testing.T) {
mock.ExpectQuery("SELECT checksum FROM schema_migrations WHERE filename = \\$1").
WithArgs("001_err.sql").
WillReturnError(errors.New("query failed"))
- mock.ExpectExec("SELECT pg_advisory_unlock\\(\\$1\\)").
- WithArgs(migrationsAdvisoryLockID).
- WillReturnResult(sqlmock.NewResult(0, 1))
+ expectMigrationsUnlock(mock)
fsys := fstest.MapFS{
"001_err.sql": &fstest.MapFile{Data: []byte("SELECT 1;")},
@@ -293,9 +385,7 @@ func TestApplyMigrationsFS_SkipEmptyAndAlreadyApplied(t *testing.T) {
mock.ExpectQuery("SELECT checksum FROM schema_migrations WHERE filename = \\$1").
WithArgs("001_already.sql").
WillReturnRows(sqlmock.NewRows([]string{"checksum"}).AddRow(checksum))
- mock.ExpectExec("SELECT pg_advisory_unlock\\(\\$1\\)").
- WithArgs(migrationsAdvisoryLockID).
- WillReturnResult(sqlmock.NewResult(0, 1))
+ expectMigrationsUnlock(mock)
fsys := fstest.MapFS{
"000_empty.sql": &fstest.MapFile{Data: []byte(" \n\t ")},
@@ -312,9 +402,7 @@ func TestApplyMigrationsFS_ReadMigrationError(t *testing.T) {
defer func() { _ = db.Close() }()
prepareMigrationsBootstrapExpectations(mock)
- mock.ExpectExec("SELECT pg_advisory_unlock\\(\\$1\\)").
- WithArgs(migrationsAdvisoryLockID).
- WillReturnResult(sqlmock.NewResult(0, 1))
+ expectMigrationsUnlock(mock)
fsys := fstest.MapFS{
"001_bad.sql": &fstest.MapFile{Mode: fs.ModeDir},
@@ -343,12 +431,12 @@ func TestPgAdvisoryLockAndUnlock_ErrorBranches(t *testing.T) {
require.NoError(t, mock.ExpectationsWereMet())
})
- t.Run("unlock_exec_error", func(t *testing.T) {
+ t.Run("unlock_query_error", func(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
defer func() { _ = db.Close() }()
- mock.ExpectExec("SELECT pg_advisory_unlock\\(\\$1\\)").
+ mock.ExpectQuery("SELECT pg_advisory_unlock\\(\\$1\\)").
WithArgs(migrationsAdvisoryLockID).
WillReturnError(errors.New("unlock failed"))
diff --git a/backend/internal/repository/migrations_runner_lock_integration_test.go b/backend/internal/repository/migrations_runner_lock_integration_test.go
new file mode 100644
index 00000000000..870097f6e70
--- /dev/null
+++ b/backend/internal/repository/migrations_runner_lock_integration_test.go
@@ -0,0 +1,72 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "testing"
+ "testing/fstest"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+// This regression test disables idle pooling so a DB-level advisory-lock query
+// immediately gives its session back. A correct runner pins one *sql.Conn from
+// lock acquisition through both transactional and non-transactional migration
+// execution; otherwise both runners can observe the migration as missing and
+// race the same DDL.
+func TestApplyMigrationsFS_AdvisoryLockStaysBoundToMigrationSession(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ suffix := time.Now().UnixNano()
+ filename := fmt.Sprintf("900_lock_binding_%d.sql", suffix)
+ tableName := fmt.Sprintf("migration_lock_binding_%d", suffix)
+ fsys := fstest.MapFS{
+ filename: &fstest.MapFile{Data: []byte(fmt.Sprintf(`
+SELECT pg_sleep(0.75);
+CREATE TABLE %s (id BIGINT PRIMARY KEY);
+`, tableName))},
+ }
+
+ _, err := integrationDB.ExecContext(ctx, fmt.Sprintf("DROP TABLE IF EXISTS %s", tableName))
+ require.NoError(t, err)
+ _, err = integrationDB.ExecContext(ctx, "DELETE FROM schema_migrations WHERE filename = $1", filename)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ _, _ = integrationDB.ExecContext(context.Background(), fmt.Sprintf("DROP TABLE IF EXISTS %s", tableName))
+ _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM schema_migrations WHERE filename = $1", filename)
+ })
+
+ integrationDB.SetMaxIdleConns(0)
+ t.Cleanup(func() { integrationDB.SetMaxIdleConns(2) })
+
+ start := make(chan struct{})
+ errs := make(chan error, 2)
+ var wg sync.WaitGroup
+ for range 2 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-start
+ errs <- applyMigrationsFS(ctx, integrationDB, fsys)
+ }()
+ }
+ close(start)
+ wg.Wait()
+ close(errs)
+
+ for runErr := range errs {
+ require.NoError(t, runErr)
+ }
+
+ var applied int
+ err = integrationDB.QueryRowContext(ctx,
+ "SELECT COUNT(*) FROM schema_migrations WHERE filename = $1", filename,
+ ).Scan(&applied)
+ require.NoError(t, err)
+ require.Equal(t, 1, applied)
+}
diff --git a/backend/internal/repository/migrations_runner_lock_test.go b/backend/internal/repository/migrations_runner_lock_test.go
new file mode 100644
index 00000000000..cb543d56472
--- /dev/null
+++ b/backend/internal/repository/migrations_runner_lock_test.go
@@ -0,0 +1,228 @@
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "database/sql/driver"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "testing/fstest"
+
+ sqlmock "github.com/DATA-DOG/go-sqlmock"
+ "github.com/stretchr/testify/require"
+)
+
+var migrationTrackingDriverSeq atomic.Uint64
+
+func TestApplyMigrationsFS_PinsSingleDatabaseSession(t *testing.T) {
+ tracker := &migrationTrackingDriver{}
+ driverName := fmt.Sprintf("migration_session_tracker_%d", migrationTrackingDriverSeq.Add(1))
+ sql.Register(driverName, tracker)
+
+ db, err := sql.Open(driverName, "")
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+ // A DB-level implementation would close/reopen a physical session between
+ // calls under this setting, exposing the original advisory-lock bug.
+ db.SetMaxIdleConns(0)
+
+ require.NoError(t, applyMigrationsFS(context.Background(), db, fstest.MapFS{}))
+
+ connectionIDs := tracker.connectionIDs()
+ require.GreaterOrEqual(t, len(connectionIDs), 6)
+ for _, connectionID := range connectionIDs[1:] {
+ require.Equal(t, connectionIDs[0], connectionID,
+ "lock, migration bootstrap, and unlock must use one physical database session")
+ }
+}
+
+func TestApplyMigrationsFS_ReturnsUnlockFailure(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+
+ prepareMigrationsBootstrapExpectations(mock)
+ mock.ExpectQuery("SELECT pg_advisory_unlock\\(\\$1\\)").
+ WithArgs(migrationsAdvisoryLockID).
+ WillReturnError(errors.New("unlock failed"))
+
+ err = applyMigrationsFS(context.Background(), db, fstest.MapFS{})
+ require.ErrorContains(t, err, "release migrations lock")
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestPrepareWalletIntegrityIndexesMigration_RejectsMismatchedValidIndex(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectQuery("SELECT i.indisvalid").
+ WithArgs("idx_wallet_ledger_one_activation").
+ WillReturnRows(sqlmock.NewRows([]string{"indisvalid", "indisunique", "table", "key_count", "key", "predicate"}).
+ AddRow(true, false, "subscription_wallet_ledger", 1, "subscription_id", "(reason = 'activation'::text)"))
+
+ err = prepareWalletIntegrityIndexesMigration(context.Background(), db)
+ require.ErrorContains(t, err, "does not match required wallet integrity definition")
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestPrepareAPIKeyHashIndexMigration_RejectsMismatchedValidIndex(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectQuery("SELECT i.indisvalid").
+ WithArgs("apikey_key_hash").
+ WillReturnRows(sqlmock.NewRows([]string{"indisvalid", "indisunique", "table", "key_count", "key", "predicate"}).
+ AddRow(true, false, "api_keys", 1, "key_hash", "((deleted_at IS NULL) AND (key_hash IS NOT NULL))"))
+
+ err = prepareNonTransactionalMigration(context.Background(), db, apiKeyHashIndexMigration)
+ require.ErrorContains(t, err, "does not match required definition")
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestPreparePaymentOrderUniqueIndex_RejectsMismatchedValidIndex(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectQuery("SELECT out_trade_no, COUNT\\(\\*\\) AS duplicate_count FROM payment_orders").
+ WillReturnRows(sqlmock.NewRows([]string{"out_trade_no", "duplicate_count"}))
+ mock.ExpectQuery("SELECT i.indisvalid").
+ WithArgs("paymentorder_out_trade_no_unique").
+ WillReturnRows(sqlmock.NewRows([]string{"indisvalid", "indisunique", "table", "key_count", "key", "predicate"}).
+ AddRow(true, false, "payment_orders", 1, "out_trade_no", "(out_trade_no <> ''::text)"))
+
+ err = preparePaymentOrdersOutTradeNoUniqueMigration(context.Background(), db)
+ require.ErrorContains(t, err, "does not match required definition")
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestPgAdvisoryUnlock_RejectsSessionThatDoesNotOwnLock(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectQuery("SELECT pg_advisory_unlock\\(\\$1\\)").
+ WithArgs(migrationsAdvisoryLockID).
+ WillReturnRows(sqlmock.NewRows([]string{"pg_advisory_unlock"}).AddRow(false))
+
+ err = pgAdvisoryUnlock(context.Background(), db)
+ require.ErrorContains(t, err, "does not own the lock")
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestNormalizeIndexPredicate_IgnoresPostgresFormattingAndCasts(t *testing.T) {
+ expected := "wallet_balance_usd IS NOT NULL AND status = 'active' AND deleted_at IS NULL AND expires_at >= '2099-12-30 23:59:59+00'"
+
+ for _, actual := range []string{
+ "((wallet_balance_usd IS NOT NULL) AND ((status)::text = 'active'::text) AND (deleted_at IS NULL) AND (expires_at >= '2099-12-30 23:59:59+00:00'::timestamp with time zone))",
+ "((wallet_balance_usd IS NOT NULL) AND ((status)::text = 'active'::text) AND (deleted_at IS NULL) AND (expires_at >= '2099-12-31 07:59:59+08'::timestamp with time zone))",
+ } {
+ require.Equal(t, normalizeIndexPredicate(expected), normalizeIndexPredicate(actual))
+ }
+}
+
+func TestNormalizeIndexPredicate_PreservesLiteralSemantics(t *testing.T) {
+ expectedReason := normalizeIndexPredicate("reason = 'activation'")
+ require.NotEqual(t, expectedReason, normalizeIndexPredicate("reason = 'ACTIVATION'"))
+ require.NotEqual(t, expectedReason, normalizeIndexPredicate("reason = 'acti vation'"))
+
+ expectedCutoff := normalizeIndexPredicate("expires_at >= '2099-12-30 23:59:59+00'::timestamp with time zone")
+ require.NotEqual(t, expectedCutoff, normalizeIndexPredicate("expires_at >= '2099-12-30 23:59:59.5+00'::timestamp with time zone"))
+}
+
+func TestPrepareConcurrentIndexArtifacts_RejectsUnsupportedIndexNameSyntax(t *testing.T) {
+ db, _, err := sqlmock.New()
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+
+ err = prepareConcurrentIndexArtifacts(context.Background(), db,
+ `CREATE INDEX CONCURRENTLY IF NOT EXISTS "quoted_idx" ON t(id);`)
+ require.ErrorContains(t, err, "unsupported CREATE INDEX CONCURRENTLY syntax")
+}
+
+type migrationTrackingDriver struct {
+ mu sync.Mutex
+ nextID int
+ usedIDs []int
+}
+
+func (d *migrationTrackingDriver) Open(string) (driver.Conn, error) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ d.nextID++
+ return &migrationTrackingConn{driver: d, id: d.nextID}, nil
+}
+
+func (d *migrationTrackingDriver) record(connectionID int) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ d.usedIDs = append(d.usedIDs, connectionID)
+}
+
+func (d *migrationTrackingDriver) connectionIDs() []int {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ return append([]int(nil), d.usedIDs...)
+}
+
+type migrationTrackingConn struct {
+ driver *migrationTrackingDriver
+ id int
+}
+
+func (c *migrationTrackingConn) Prepare(string) (driver.Stmt, error) {
+ return nil, errors.New("unexpected prepared statement")
+}
+
+func (c *migrationTrackingConn) Close() error { return nil }
+
+func (c *migrationTrackingConn) Begin() (driver.Tx, error) {
+ return nil, errors.New("unexpected transaction")
+}
+
+func (c *migrationTrackingConn) ExecContext(_ context.Context, _ string, _ []driver.NamedValue) (driver.Result, error) {
+ c.driver.record(c.id)
+ return driver.RowsAffected(1), nil
+}
+
+func (c *migrationTrackingConn) QueryContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Rows, error) {
+ c.driver.record(c.id)
+ normalized := strings.ToLower(query)
+ switch {
+ case strings.Contains(normalized, "pg_try_advisory_lock"):
+ return &migrationTrackingRows{column: "pg_try_advisory_lock", value: true}, nil
+ case strings.Contains(normalized, "pg_advisory_unlock"):
+ return &migrationTrackingRows{column: "pg_advisory_unlock", value: true}, nil
+ case strings.Contains(normalized, "information_schema.tables"):
+ return &migrationTrackingRows{column: "exists", value: true}, nil
+ case strings.Contains(normalized, "count(*) from atlas_schema_revisions"):
+ return &migrationTrackingRows{column: "count", value: int64(1)}, nil
+ default:
+ return nil, fmt.Errorf("unexpected query: %s", query)
+ }
+}
+
+type migrationTrackingRows struct {
+ column string
+ value driver.Value
+ done bool
+}
+
+func (r *migrationTrackingRows) Columns() []string { return []string{r.column} }
+func (r *migrationTrackingRows) Close() error { return nil }
+
+func (r *migrationTrackingRows) Next(dest []driver.Value) error {
+ if r.done {
+ return io.EOF
+ }
+ dest[0] = r.value
+ r.done = true
+ return nil
+}
diff --git a/backend/internal/repository/migrations_runner_notx_test.go b/backend/internal/repository/migrations_runner_notx_test.go
index b7cb396c470..2e451ba7d68 100644
--- a/backend/internal/repository/migrations_runner_notx_test.go
+++ b/backend/internal/repository/migrations_runner_notx_test.go
@@ -3,10 +3,12 @@ package repository
import (
"context"
"database/sql"
+ "errors"
"testing"
"testing/fstest"
sqlmock "github.com/DATA-DOG/go-sqlmock"
+ dbmigrations "github.com/Wei-Shaw/sub2api/migrations"
"github.com/stretchr/testify/require"
)
@@ -51,6 +53,93 @@ DROP INDEX CONCURRENTLY IF EXISTS idx_b;
})
}
+func TestProviderCapacityIndexMigrationUsesValidOnlineExecutionMode(t *testing.T) {
+ const filename = "194_payment_provider_capacity_index_notx.sql"
+ content, err := dbmigrations.FS.ReadFile(filename)
+ require.NoError(t, err)
+
+ nonTx, err := validateMigrationExecutionMode(filename, string(content))
+ require.NoError(t, err)
+ require.True(t, nonTx)
+}
+
+func TestWalletIntegrityIndexMigrationKeepsValidIndexesWhenRecordMissing(t *testing.T) {
+ content, err := dbmigrations.FS.ReadFile("178_wallet_integrity_indexes_notx.sql")
+ require.NoError(t, err)
+ nonTx, err := validateMigrationExecutionMode("178_wallet_integrity_indexes_notx.sql", string(content))
+ require.NoError(t, err)
+ require.True(t, nonTx)
+
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+
+ fsys := fstest.MapFS{
+ "178_wallet_integrity_indexes_notx.sql": &fstest.MapFile{Data: content},
+ }
+
+ prepareMigrationsBootstrapExpectations(mock)
+ mock.ExpectQuery("SELECT checksum FROM schema_migrations WHERE filename = \\$1").
+ WithArgs("178_wallet_integrity_indexes_notx.sql").
+ WillReturnError(sql.ErrNoRows)
+ mock.ExpectQuery("SELECT i.indisvalid").
+ WithArgs("idx_wallet_ledger_one_activation").
+ WillReturnRows(sqlmock.NewRows([]string{"indisvalid", "indisunique", "table", "key_count", "key", "predicate"}).
+ AddRow(true, true, "subscription_wallet_ledger", 1, "subscription_id", "(reason = 'activation'::text)"))
+ mock.ExpectQuery("SELECT i.indisvalid").
+ WithArgs("idx_user_subscriptions_one_active_credits_wallet").
+ WillReturnRows(sqlmock.NewRows([]string{"indisvalid", "indisunique", "table", "key_count", "key", "predicate"}).
+ AddRow(true, true, "user_subscriptions", 1, "user_id", "((wallet_balance_usd IS NOT NULL) AND ((status)::text = 'active'::text) AND (deleted_at IS NULL) AND (expires_at >= '2099-12-30 23:59:59+00'::timestamp with time zone))"))
+ mock.ExpectExec("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_wallet_ledger_one_activation").
+ WillReturnResult(sqlmock.NewResult(0, 0))
+ mock.ExpectExec("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_user_subscriptions_one_active_credits_wallet").
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("INSERT INTO schema_migrations \\(filename, checksum\\) VALUES \\(\\$1, \\$2\\)").
+ WithArgs("178_wallet_integrity_indexes_notx.sql", sqlmock.AnyArg()).
+ WillReturnResult(sqlmock.NewResult(1, 1))
+ expectMigrationsUnlock(mock)
+
+ require.NoError(t, applyMigrationsFS(context.Background(), db, fsys),
+ "valid indexes must remain in place while the missing migration record is repaired")
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestWalletIntegrityIndexMigrationDropsOnlyInvalidArtifactBeforeRetry(t *testing.T) {
+ content, err := dbmigrations.FS.ReadFile("178_wallet_integrity_indexes_notx.sql")
+ require.NoError(t, err)
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+
+ prepareMigrationsBootstrapExpectations(mock)
+ mock.ExpectQuery("SELECT checksum FROM schema_migrations WHERE filename = \\$1").
+ WithArgs("178_wallet_integrity_indexes_notx.sql").
+ WillReturnError(sql.ErrNoRows)
+ mock.ExpectQuery("SELECT i.indisvalid").
+ WithArgs("idx_wallet_ledger_one_activation").
+ WillReturnRows(sqlmock.NewRows([]string{"indisvalid", "indisunique", "table", "key_count", "key", "predicate"}).
+ AddRow(false, true, "subscription_wallet_ledger", 1, "subscription_id", "(reason = 'activation'::text)"))
+ mock.ExpectExec("DROP INDEX CONCURRENTLY IF EXISTS idx_wallet_ledger_one_activation").
+ WillReturnResult(sqlmock.NewResult(0, 0))
+ mock.ExpectQuery("SELECT i.indisvalid").
+ WithArgs("idx_user_subscriptions_one_active_credits_wallet").
+ WillReturnError(sql.ErrNoRows)
+ mock.ExpectExec("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_wallet_ledger_one_activation").
+ WillReturnResult(sqlmock.NewResult(0, 0))
+ mock.ExpectExec("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_user_subscriptions_one_active_credits_wallet").
+ WillReturnResult(sqlmock.NewResult(0, 0))
+ mock.ExpectExec("INSERT INTO schema_migrations \\(filename, checksum\\) VALUES \\(\\$1, \\$2\\)").
+ WithArgs("178_wallet_integrity_indexes_notx.sql", sqlmock.AnyArg()).
+ WillReturnResult(sqlmock.NewResult(1, 1))
+ expectMigrationsUnlock(mock)
+
+ fsys := fstest.MapFS{
+ "178_wallet_integrity_indexes_notx.sql": &fstest.MapFile{Data: content},
+ }
+ require.NoError(t, applyMigrationsFS(context.Background(), db, fsys))
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
func TestApplyMigrationsFS_NonTransactionalMigration(t *testing.T) {
db, mock, err := sqlmock.New()
require.NoError(t, err)
@@ -60,14 +149,17 @@ func TestApplyMigrationsFS_NonTransactionalMigration(t *testing.T) {
mock.ExpectQuery("SELECT checksum FROM schema_migrations WHERE filename = \\$1").
WithArgs("001_add_idx_notx.sql").
WillReturnError(sql.ErrNoRows)
+ mock.ExpectQuery("SELECT EXISTS \\(").
+ WithArgs("idx_t_a").
+ WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
+ mock.ExpectExec("DROP INDEX CONCURRENTLY IF EXISTS idx_t_a").
+ WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_t_a ON t\\(a\\)").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("INSERT INTO schema_migrations \\(filename, checksum\\) VALUES \\(\\$1, \\$2\\)").
WithArgs("001_add_idx_notx.sql", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
- mock.ExpectExec("SELECT pg_advisory_unlock\\(\\$1\\)").
- WithArgs(migrationsAdvisoryLockID).
- WillReturnResult(sqlmock.NewResult(0, 1))
+ expectMigrationsUnlock(mock)
fsys := fstest.MapFS{
"001_add_idx_notx.sql": &fstest.MapFile{
@@ -89,6 +181,12 @@ func TestApplyMigrationsFS_NonTransactionalMigration_MultiStatements(t *testing.
mock.ExpectQuery("SELECT checksum FROM schema_migrations WHERE filename = \\$1").
WithArgs("001_add_multi_idx_notx.sql").
WillReturnError(sql.ErrNoRows)
+ mock.ExpectQuery("SELECT EXISTS \\(").
+ WithArgs("idx_t_a").
+ WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
+ mock.ExpectQuery("SELECT EXISTS \\(").
+ WithArgs("idx_t_b").
+ WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
mock.ExpectExec("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_t_a ON t\\(a\\)").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_t_b ON t\\(b\\)").
@@ -96,9 +194,7 @@ func TestApplyMigrationsFS_NonTransactionalMigration_MultiStatements(t *testing.
mock.ExpectExec("INSERT INTO schema_migrations \\(filename, checksum\\) VALUES \\(\\$1, \\$2\\)").
WithArgs("001_add_multi_idx_notx.sql", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
- mock.ExpectExec("SELECT pg_advisory_unlock\\(\\$1\\)").
- WithArgs(migrationsAdvisoryLockID).
- WillReturnResult(sqlmock.NewResult(0, 1))
+ expectMigrationsUnlock(mock)
fsys := fstest.MapFS{
"001_add_multi_idx_notx.sql": &fstest.MapFile{
@@ -127,9 +223,7 @@ func TestApplyMigrationsFS_PaymentOrdersOutTradeNoUniqueMigration_FailsFastOnDup
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery("SELECT out_trade_no, COUNT\\(\\*\\) AS duplicate_count FROM payment_orders").
WillReturnRows(sqlmock.NewRows([]string{"out_trade_no", "duplicate_count"}).AddRow("dup-out-trade-no", 2))
- mock.ExpectExec("SELECT pg_advisory_unlock\\(\\$1\\)").
- WithArgs(migrationsAdvisoryLockID).
- WillReturnResult(sqlmock.NewResult(0, 1))
+ expectMigrationsUnlock(mock)
fsys := fstest.MapFS{
"120_enforce_payment_orders_out_trade_no_unique_notx.sql": &fstest.MapFile{
@@ -161,9 +255,10 @@ func TestApplyMigrationsFS_PaymentOrdersOutTradeNoUniqueMigration_DropsInvalidIn
WillReturnError(sql.ErrNoRows)
mock.ExpectQuery("SELECT out_trade_no, COUNT\\(\\*\\) AS duplicate_count FROM payment_orders").
WillReturnRows(sqlmock.NewRows([]string{"out_trade_no", "duplicate_count"}))
- mock.ExpectQuery("SELECT EXISTS \\(").
+ mock.ExpectQuery("SELECT i.indisvalid").
WithArgs("paymentorder_out_trade_no_unique").
- WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
+ WillReturnRows(sqlmock.NewRows([]string{"indisvalid", "indisunique", "table", "key_count", "key", "predicate"}).
+ AddRow(false, true, "payment_orders", 1, "out_trade_no", "(out_trade_no <> ''::text)"))
mock.ExpectExec("DROP INDEX CONCURRENTLY IF EXISTS paymentorder_out_trade_no_unique").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS paymentorder_out_trade_no_unique").
@@ -173,9 +268,7 @@ func TestApplyMigrationsFS_PaymentOrdersOutTradeNoUniqueMigration_DropsInvalidIn
mock.ExpectExec("INSERT INTO schema_migrations \\(filename, checksum\\) VALUES \\(\\$1, \\$2\\)").
WithArgs("120_enforce_payment_orders_out_trade_no_unique_notx.sql", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
- mock.ExpectExec("SELECT pg_advisory_unlock\\(\\$1\\)").
- WithArgs(migrationsAdvisoryLockID).
- WillReturnResult(sqlmock.NewResult(0, 1))
+ expectMigrationsUnlock(mock)
fsys := fstest.MapFS{
"120_enforce_payment_orders_out_trade_no_unique_notx.sql": &fstest.MapFile{
@@ -204,15 +297,17 @@ func TestApplyMigrationsFS_TransactionalMigration(t *testing.T) {
WithArgs("001_add_col.sql").
WillReturnError(sql.ErrNoRows)
mock.ExpectBegin()
+ mock.ExpectExec("SET LOCAL lock_timeout = '5s'").
+ WillReturnResult(sqlmock.NewResult(0, 0))
+ mock.ExpectExec("SET LOCAL statement_timeout = '10min'").
+ WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("ALTER TABLE t ADD COLUMN name TEXT").
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec("INSERT INTO schema_migrations \\(filename, checksum\\) VALUES \\(\\$1, \\$2\\)").
WithArgs("001_add_col.sql", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
- mock.ExpectExec("SELECT pg_advisory_unlock\\(\\$1\\)").
- WithArgs(migrationsAdvisoryLockID).
- WillReturnResult(sqlmock.NewResult(0, 1))
+ expectMigrationsUnlock(mock)
fsys := fstest.MapFS{
"001_add_col.sql": &fstest.MapFile{
@@ -225,6 +320,43 @@ func TestApplyMigrationsFS_TransactionalMigration(t *testing.T) {
require.NoError(t, mock.ExpectationsWereMet())
}
+func TestApplyMigrationsFS_TransactionalTimeoutSetupFailureRollsBack(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ failStatement string
+ expectLockSetup bool
+ }{
+ {name: "lock timeout", failStatement: "SET LOCAL lock_timeout = '5s'"},
+ {name: "statement timeout", failStatement: "SET LOCAL statement_timeout = '10min'", expectLockSetup: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+
+ prepareMigrationsBootstrapExpectations(mock)
+ mock.ExpectQuery("SELECT checksum FROM schema_migrations WHERE filename = \\$1").
+ WithArgs("001_add_col.sql").
+ WillReturnError(sql.ErrNoRows)
+ mock.ExpectBegin()
+ if tc.expectLockSetup {
+ mock.ExpectExec("SET LOCAL lock_timeout = '5s'").
+ WillReturnResult(sqlmock.NewResult(0, 0))
+ }
+ mock.ExpectExec(tc.failStatement).WillReturnError(errors.New("injected timeout setup failure"))
+ mock.ExpectRollback()
+ expectMigrationsUnlock(mock)
+
+ fsys := fstest.MapFS{
+ "001_add_col.sql": &fstest.MapFile{Data: []byte("ALTER TABLE t ADD COLUMN name TEXT;")},
+ }
+ err = applyMigrationsFS(context.Background(), db, fsys)
+ require.ErrorContains(t, err, "timeout for migration")
+ require.NoError(t, mock.ExpectationsWereMet())
+ })
+ }
+}
+
func prepareMigrationsBootstrapExpectations(mock sqlmock.Sqlmock) {
mock.ExpectQuery("SELECT pg_try_advisory_lock\\(\\$1\\)").
WithArgs(migrationsAdvisoryLockID).
@@ -240,3 +372,9 @@ func prepareMigrationsBootstrapExpectations(mock sqlmock.Sqlmock) {
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM atlas_schema_revisions").
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
}
+
+func expectMigrationsUnlock(mock sqlmock.Sqlmock) {
+ mock.ExpectQuery("SELECT pg_advisory_unlock\\(\\$1\\)").
+ WithArgs(migrationsAdvisoryLockID).
+ WillReturnRows(sqlmock.NewRows([]string{"pg_advisory_unlock"}).AddRow(true))
+}
diff --git a/backend/internal/repository/migrations_schema_integration_test.go b/backend/internal/repository/migrations_schema_integration_test.go
index eeee5c23f40..37999252c55 100644
--- a/backend/internal/repository/migrations_schema_integration_test.go
+++ b/backend/internal/repository/migrations_schema_integration_test.go
@@ -5,8 +5,10 @@ package repository
import (
"context"
"database/sql"
+ "strings"
"testing"
+ dbmigrations "github.com/Wei-Shaw/sub2api/migrations"
"github.com/stretchr/testify/require"
)
@@ -24,6 +26,15 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
// users: columns required by repository queries
requireColumn(t, tx, "users", "username", "character varying", 100, false)
requireColumn(t, tx, "users", "notes", "text", 0, false)
+ requireColumn(t, tx, "users", "signup_ip", "character varying", 64, false)
+ requireColumn(t, tx, "users", "signup_ip_prefix", "character varying", 64, false)
+ requireColumn(t, tx, "users", "signup_user_agent_hash", "character varying", 64, false)
+ requireColumn(t, tx, "users", "signup_device_fingerprint_hash", "character varying", 64, false)
+ requireColumn(t, tx, "users", "trial_bonus_eligible", "boolean", 0, false)
+ requireColumn(t, tx, "users", "trial_bonus_hold_reason", "character varying", 80, false)
+ requireColumn(t, tx, "users", "trial_bonus_risk_score", "integer", 0, false)
+ requireIndex(t, tx, "users", "idx_users_signup_ip_prefix_created_at")
+ requireIndex(t, tx, "users", "idx_users_signup_device_fingerprint_created_at")
// accounts: schedulable and rate-limit fields
requireColumn(t, tx, "accounts", "notes", "text", 0, true)
@@ -33,8 +44,13 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
requireColumn(t, tx, "accounts", "overload_until", "timestamp with time zone", 0, true)
requireColumn(t, tx, "accounts", "session_window_status", "character varying", 20, true)
- // api_keys: key length should be 128
- requireColumn(t, tx, "api_keys", "key", "character varying", 128, false)
+ // api_keys: AES-GCM envelope requires more room than the raw 128-byte key.
+ requireColumn(t, tx, "api_keys", "key", "character varying", 512, false)
+ requireColumn(t, tx, "api_keys", "key_hash", "character varying", 64, true)
+ requireColumn(t, tx, "api_keys", "key_prefix", "character varying", 16, false)
+ requireIndex(t, tx, "api_keys", "apikey_key_hash")
+ requirePartialUniqueIndexDefinition(t, tx, "api_keys", "apikey_key_hash", "key_hash", "deleted_at IS NULL", "key_hash IS NOT NULL")
+ requireConstraintDefinitionContains(t, tx, "api_keys", "chk_api_keys_encrypted_storage", "enc:v1:", "__deleted__")
// redeem_codes: subscription fields
requireColumn(t, tx, "redeem_codes", "group_id", "bigint", 0, true)
@@ -44,6 +60,10 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
requireColumn(t, tx, "usage_logs", "billing_type", "smallint", 0, false)
requireColumn(t, tx, "usage_logs", "request_type", "smallint", 0, false)
requireColumn(t, tx, "usage_logs", "openai_ws_mode", "boolean", 0, false)
+ requireColumn(t, tx, "usage_logs", "billing_model", "character varying", 100, true)
+ requireColumn(t, tx, "usage_logs", "pricing_source", "character varying", 50, true)
+ requireColumn(t, tx, "usage_logs", "pricing_revision", "character varying", 100, true)
+ requireColumn(t, tx, "usage_logs", "pricing_hash", "character varying", 64, true)
// usage_billing_dedup: billing idempotency narrow table
var usageBillingDedupRegclass sql.NullString
@@ -76,6 +96,165 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
// user_subscriptions: deleted_at for soft delete support (migration 012)
requireColumn(t, tx, "user_subscriptions", "deleted_at", "timestamp with time zone", 0, true)
+ requireColumn(t, tx, "user_subscriptions", "locked_rates", "jsonb", 0, true)
+ requireConstraintDefinitionContains(t, tx, "user_subscriptions", "chk_user_subscriptions_locked_rates_object", "jsonb_typeof", "locked_rates")
+ requirePartialUniqueIndexDefinition(
+ t,
+ tx,
+ "user_subscriptions",
+ "idx_user_subscriptions_one_active_credits_wallet",
+ "user_id",
+ "wallet_balance_usd IS NOT NULL",
+ "status",
+ "'active'",
+ "deleted_at IS NULL",
+ "2099-12-30 23:59:59+00",
+ )
+ requireTriggerDefinitionContains(
+ t,
+ tx,
+ "user_subscriptions",
+ "trg_hfc_guard_user_subscription_no_finite_wallet",
+ "BEFORE INSERT OR UPDATE",
+ "hfc_guard_user_subscription_no_finite_wallet",
+ )
+ requireConstraintAbsent(t, tx, "user_subscriptions", "chk_user_subscriptions_wallet_balance_nonneg")
+ requireConstraintDefinitionContains(
+ t,
+ tx,
+ "user_subscriptions",
+ "chk_user_subscriptions_wallet_balance_finite",
+ "wallet_balance_usd",
+ "NaN",
+ "Infinity",
+ )
+ requireForeignKeyOnDelete(t, tx, "user_subscriptions", "group_id", "groups", "RESTRICT")
+ requireForeignKeyOnDelete(t, tx, "subscription_plans", "group_id", "groups", "RESTRICT")
+ requireForeignKeyOnDelete(t, tx, "subscription_plan_groups", "group_id", "groups", "RESTRICT")
+ requireConstraintDefinitionContains(
+ t,
+ tx,
+ "user_subscriptions",
+ "chk_user_subscriptions_wallet_initial_valid",
+ "wallet_initial_usd",
+ "NaN",
+ "Infinity",
+ )
+
+ // Wallet integrity and monthly-vs-credits separation (migrations 175-176).
+ requirePartialUniqueIndexDefinition(
+ t,
+ tx,
+ "subscription_wallet_ledger",
+ "idx_wallet_ledger_one_activation",
+ "subscription_id",
+ "reason",
+ "'activation'",
+ )
+ requireConstraintDefinitionContains(
+ t,
+ tx,
+ "subscription_wallet_ledger",
+ "chk_wallet_ledger_amounts_finite",
+ "delta_usd",
+ "balance_after",
+ "NaN",
+ "Infinity",
+ )
+ requireTriggerDefinitionContains(
+ t,
+ tx,
+ "subscription_wallet_ledger",
+ "trg_hfc_lock_wallet_ledger_parent",
+ "BEFORE INSERT",
+ "hfc_lock_wallet_ledger_parent",
+ )
+ requireTriggerDefinitionContains(
+ t,
+ tx,
+ "subscription_wallet_ledger",
+ "trg_hfc_reject_wallet_ledger_mutation",
+ "BEFORE DELETE OR UPDATE",
+ "hfc_reject_wallet_ledger_mutation",
+ )
+ requireTriggerDefinitionContains(
+ t,
+ tx,
+ "subscription_wallet_ledger",
+ "trg_hfc_check_wallet_ledger_integrity",
+ "AFTER INSERT",
+ "DEFERRABLE INITIALLY DEFERRED",
+ "hfc_check_wallet_ledger_integrity",
+ )
+ requireForeignKeyOnDelete(t, tx, "subscription_wallet_ledger", "subscription_id", "user_subscriptions", "RESTRICT")
+ requireTriggerDefinitionContains(
+ t,
+ tx,
+ "user_subscriptions",
+ "trg_hfc_check_wallet_subscription_integrity",
+ "AFTER INSERT OR UPDATE",
+ "DEFERRABLE INITIALLY DEFERRED",
+ "hfc_check_wallet_subscription_integrity",
+ )
+ requireConstraintDefinitionContains(
+ t,
+ tx,
+ "subscription_plans",
+ "chk_hfc_subscription_plans_monthly_group_only",
+ "plan_type",
+ "'subscription'",
+ "group_id IS NOT NULL",
+ "wallet_quota_usd IS NULL",
+ "'credits'",
+ )
+ requireTriggerDefinitionContains(
+ t,
+ tx,
+ "payment_orders",
+ "trg_hfc_guard_monthly_payment_order_group_path",
+ "BEFORE INSERT OR UPDATE",
+ "status",
+ "paid_at",
+ "payment_trade_no",
+ "hfc_guard_monthly_payment_order_group_path",
+ )
+ requireIndex(t, tx, "payment_orders", "paymentorder_provider_instance_id_created_at")
+ requireTriggerDefinitionContains(
+ t,
+ tx,
+ "subscription_plans",
+ "trg_hfc_guard_subscription_plan_group",
+ "BEFORE INSERT OR UPDATE",
+ "hfc_guard_subscription_plan_group",
+ )
+ requireTriggerDefinitionContains(
+ t,
+ tx,
+ "subscription_plans",
+ "trg_hfc_guard_plan_shape_change_with_open_orders",
+ "BEFORE UPDATE",
+ "plan_type",
+ "group_id",
+ "wallet_quota_usd",
+ "hfc_guard_plan_shape_change_with_open_orders",
+ )
+ requireTriggerDefinitionContains(
+ t,
+ tx,
+ "subscription_plans",
+ "trg_hfc_guard_plan_delete_with_open_orders",
+ "BEFORE DELETE",
+ "hfc_guard_plan_delete_with_open_orders",
+ )
+ requireTriggerDefinitionContains(
+ t,
+ tx,
+ "groups",
+ "trg_hfc_guard_subscription_group_lifecycle",
+ "AFTER DELETE OR UPDATE",
+ "DEFERRABLE INITIALLY DEFERRED",
+ "hfc_guard_subscription_group_lifecycle",
+ )
// orphan_allowed_groups_audit table should exist (migration 013)
var orphanAuditRegclass sql.NullString
@@ -89,6 +268,92 @@ func TestMigrationsRunner_IsIdempotent_AndSchemaIsUpToDate(t *testing.T) {
requireColumn(t, tx, "user_allowed_groups", "created_at", "timestamp with time zone", 0, false)
}
+func TestUsageLogBillingModelMigrationUpgradesHistoricalRowsWithoutBackfill(t *testing.T) {
+ tx := testTx(t)
+ ctx := context.Background()
+
+ content, err := dbmigrations.FS.ReadFile("172_add_usage_log_billing_model.sql")
+ require.NoError(t, err)
+ require.Equal(
+ t,
+ "ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS billing_model VARCHAR(100);",
+ strings.TrimSpace(string(content)),
+ )
+
+ _, err = tx.ExecContext(ctx, `
+ CREATE TEMP TABLE usage_logs (
+ id BIGINT PRIMARY KEY,
+ actual_cost NUMERIC(20,10) NOT NULL
+ )
+ `)
+ require.NoError(t, err)
+ _, err = tx.ExecContext(ctx, "INSERT INTO usage_logs (id, actual_cost) VALUES (1, 12.34)")
+ require.NoError(t, err)
+ _, err = tx.ExecContext(ctx, string(content))
+ require.NoError(t, err)
+
+ var (
+ billingModel sql.NullString
+ actualCost float64
+ )
+ require.NoError(t, tx.QueryRowContext(ctx, "SELECT billing_model, actual_cost FROM usage_logs WHERE id = 1").Scan(&billingModel, &actualCost))
+ require.False(t, billingModel.Valid)
+ require.InDelta(t, 12.34, actualCost, 1e-12)
+
+ var (
+ dataType string
+ maxLen sql.NullInt64
+ nullable string
+ columnDefault sql.NullString
+ )
+ require.NoError(t, tx.QueryRowContext(ctx, `
+ SELECT data_type, character_maximum_length, is_nullable, column_default
+ FROM information_schema.columns
+ WHERE table_schema = (SELECT nspname FROM pg_namespace WHERE oid = pg_my_temp_schema())
+ AND table_name = 'usage_logs'
+ AND column_name = 'billing_model'
+ `).Scan(&dataType, &maxLen, &nullable, &columnDefault))
+ require.Equal(t, "character varying", dataType)
+ require.Equal(t, int64(100), maxLen.Int64)
+ require.Equal(t, "YES", nullable)
+ require.False(t, columnDefault.Valid)
+}
+
+func TestUsageLogPricingEvidenceMigration173IsIdempotentAndLeavesHistoricalRowsNull(t *testing.T) {
+ tx := testTx(t)
+ ctx := context.Background()
+ content, err := dbmigrations.FS.ReadFile("173_add_usage_log_pricing_evidence.sql")
+ require.NoError(t, err)
+
+ _, err = tx.ExecContext(ctx, `CREATE TEMP TABLE usage_logs (id BIGINT PRIMARY KEY, billing_model VARCHAR(100))`)
+ require.NoError(t, err)
+ _, err = tx.ExecContext(ctx, "INSERT INTO usage_logs (id, billing_model) VALUES (1, 'gpt-5.6-terra')")
+ require.NoError(t, err)
+ _, err = tx.ExecContext(ctx, string(content))
+ require.NoError(t, err)
+ _, err = tx.ExecContext(ctx, string(content))
+ require.NoError(t, err)
+
+ var source, revision, hash sql.NullString
+ require.NoError(t, tx.QueryRowContext(ctx, "SELECT pricing_source, pricing_revision, pricing_hash FROM usage_logs WHERE id = 1").Scan(&source, &revision, &hash))
+ require.False(t, source.Valid)
+ require.False(t, revision.Valid)
+ require.False(t, hash.Valid)
+
+ for _, column := range []string{"pricing_source", "pricing_revision", "pricing_hash"} {
+ var nullable string
+ var defaultValue sql.NullString
+ require.NoError(t, tx.QueryRowContext(ctx, `
+ SELECT is_nullable, column_default
+ FROM information_schema.columns
+ WHERE table_schema = (SELECT nspname FROM pg_namespace WHERE oid = pg_my_temp_schema())
+ AND table_name = 'usage_logs' AND column_name = $1
+ `, column).Scan(&nullable, &defaultValue))
+ require.Equal(t, "YES", nullable)
+ require.False(t, defaultValue.Valid)
+ }
+}
+
func TestMigrationsRunner_AuthIdentityAndPaymentSchemaStayAligned(t *testing.T) {
tx := testTx(t)
@@ -228,6 +493,46 @@ WHERE ns.nspname = 'public'
}
}
+func requireConstraintAbsent(t *testing.T, tx *sql.Tx, table, constraint string) {
+ t.Helper()
+
+ var exists bool
+ err := tx.QueryRowContext(context.Background(), `
+SELECT EXISTS (
+ SELECT 1
+ FROM pg_constraint c
+ JOIN pg_class tbl ON tbl.oid = c.conrelid
+ JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
+ WHERE ns.nspname = 'public'
+ AND tbl.relname = $1
+ AND c.conname = $2
+)
+`, table, constraint).Scan(&exists)
+ require.NoError(t, err, "query constraint existence for %s.%s", table, constraint)
+ require.False(t, exists, "expected constraint %s on %s to be absent", constraint, table)
+}
+
+func requireTriggerDefinitionContains(t *testing.T, tx *sql.Tx, table, trigger string, fragments ...string) {
+ t.Helper()
+
+ var def string
+ err := tx.QueryRowContext(context.Background(), `
+SELECT pg_get_triggerdef(t.oid)
+FROM pg_trigger t
+JOIN pg_class tbl ON tbl.oid = t.tgrelid
+JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
+WHERE ns.nspname = 'public'
+ AND tbl.relname = $1
+ AND t.tgname = $2
+ AND NOT t.tgisinternal
+`, table, trigger).Scan(&def)
+ require.NoError(t, err, "query trigger definition for %s.%s", table, trigger)
+
+ for _, fragment := range fragments {
+ require.Contains(t, def, fragment, "expected trigger definition for %s.%s to contain %q", table, trigger, fragment)
+ }
+}
+
func requireColumnDefaultContains(t *testing.T, tx *sql.Tx, table, column string, fragments ...string) {
t.Helper()
diff --git a/backend/internal/repository/payment_paid_evidence_integration_test.go b/backend/internal/repository/payment_paid_evidence_integration_test.go
new file mode 100644
index 00000000000..d8cddf4e293
--- /dev/null
+++ b/backend/internal/repository/payment_paid_evidence_integration_test.go
@@ -0,0 +1,107 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+func TestUnpaidFailedWalletOrderCannotFulfillOrRetry(t *testing.T) {
+ ctx := context.Background()
+ fixture, paymentSvc := newWalletPaidEvidenceFixture(t, ctx, "unpaid-failed")
+ setPaymentOrderFailedEvidence(t, ctx, fixture.orderID, false)
+
+ err := paymentSvc.ExecuteSubscriptionFulfillment(ctx, fixture.orderID)
+ require.ErrorContains(t, err, "payment is not confirmed")
+ err = paymentSvc.RetryFulfillment(ctx, fixture.orderID)
+ require.ErrorContains(t, err, "payment is not confirmed")
+ requirePaymentOrderStatus(t, ctx, fixture.orderID, service.OrderStatusFailed)
+ assertWalletPaymentNotDelivered(t, ctx, fixture)
+}
+
+func TestLateSuccessfulWebhookPaysUnpaidFailedWalletOrderBeforeSingleFulfillment(t *testing.T) {
+ ctx := context.Background()
+ fixture, paymentSvc := newWalletPaidEvidenceFixture(t, ctx, "late-webhook")
+ setPaymentOrderFailedEvidence(t, ctx, fixture.orderID, false)
+ order, err := fixture.client.PaymentOrder.Get(ctx, fixture.orderID)
+ require.NoError(t, err)
+ notification := &payment.PaymentNotification{
+ OrderID: order.OutTradeNo,
+ TradeNo: "late-provider-trade-1",
+ Amount: order.PayAmount,
+ Status: payment.NotificationStatusSuccess,
+ }
+
+ require.NoError(t, paymentSvc.HandlePaymentNotification(ctx, notification, payment.TypeAlipay))
+ assertWalletPaymentDeliveredExactlyOnce(t, ctx, fixture)
+ reloaded, err := fixture.client.PaymentOrder.Get(ctx, fixture.orderID)
+ require.NoError(t, err)
+ require.NotNil(t, reloaded.PaidAt)
+ require.Equal(t, notification.TradeNo, reloaded.PaymentTradeNo)
+
+ require.NoError(t, paymentSvc.HandlePaymentNotification(ctx, notification, payment.TypeAlipay))
+ assertWalletPaymentDeliveredExactlyOnce(t, ctx, fixture)
+}
+
+func TestPaidFailedWalletOrderCanRetryFulfillmentExactlyOnce(t *testing.T) {
+ ctx := context.Background()
+ fixture, paymentSvc := newWalletPaidEvidenceFixture(t, ctx, "paid-failed")
+ setPaymentOrderFailedEvidence(t, ctx, fixture.orderID, true)
+
+ require.NoError(t, paymentSvc.RetryFulfillment(ctx, fixture.orderID))
+ assertWalletPaymentDeliveredExactlyOnce(t, ctx, fixture)
+ require.ErrorContains(t, paymentSvc.RetryFulfillment(ctx, fixture.orderID), "already completed")
+ assertWalletPaymentDeliveredExactlyOnce(t, ctx, fixture)
+}
+
+func newWalletPaidEvidenceFixture(t *testing.T, ctx context.Context, label string) (*walletPaymentAtomicityFixture, *service.PaymentService) {
+ t.Helper()
+ fixture := newWalletPaymentAtomicityFixture(t, ctx, label)
+ apiKeySvc := service.NewAPIKeyService(
+ fixture.apiKeyRepo,
+ fixture.userRepo,
+ fixture.groupRepo,
+ fixture.subRepo,
+ nil,
+ nil,
+ &config.Config{},
+ )
+ return fixture, fixture.paymentService(apiKeySvc)
+}
+
+func setPaymentOrderFailedEvidence(t *testing.T, ctx context.Context, orderID int64, paid bool) {
+ t.Helper()
+ if paid {
+ _, err := integrationDB.ExecContext(ctx, `
+ UPDATE payment_orders
+ SET status = $1,
+ paid_at = $2,
+ payment_trade_no = 'confirmed-provider-trade',
+ failed_at = $2,
+ failed_reason = 'fulfillment_failed',
+ updated_at = $2
+ WHERE id = $3
+ `, service.OrderStatusFailed, time.Now().UTC(), orderID)
+ require.NoError(t, err)
+ return
+ }
+ _, err := integrationDB.ExecContext(ctx, `
+ UPDATE payment_orders
+ SET status = $1,
+ paid_at = NULL,
+ payment_trade_no = '',
+ failed_at = NOW(),
+ failed_reason = 'payment_create_failed',
+ updated_at = NOW()
+ WHERE id = $2
+ `, service.OrderStatusFailed, orderID)
+ require.NoError(t, err)
+}
diff --git a/backend/internal/repository/payment_snapshot_test_helpers_integration_test.go b/backend/internal/repository/payment_snapshot_test_helpers_integration_test.go
new file mode 100644
index 00000000000..3753fe6e607
--- /dev/null
+++ b/backend/internal/repository/payment_snapshot_test_helpers_integration_test.go
@@ -0,0 +1,94 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "encoding/json"
+ "strconv"
+ "testing"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/stretchr/testify/require"
+)
+
+type integrationPlanFulfillmentSnapshot struct {
+ SchemaVersion int `json:"schema_version"`
+ PlanID int64 `json:"plan_id"`
+ PlanType string `json:"plan_type"`
+ PlanName string `json:"plan_name"`
+ ProductName string `json:"product_name"`
+ PlanPrice float64 `json:"plan_price"`
+ GroupID *int64 `json:"group_id"`
+ SubscriptionDays int `json:"subscription_days"`
+ WalletQuotaUSD *float64 `json:"wallet_quota_usd"`
+ CoveredGroupIDs []int64 `json:"covered_group_ids"`
+ LockedRates map[string]float64 `json:"locked_rates"`
+ CaptureSource string `json:"capture_source"`
+}
+
+func insertCreditsPlanFulfillmentSnapshot(
+ t *testing.T,
+ ctx context.Context,
+ client *dbent.Client,
+ orderID, userID, planID int64,
+ price float64,
+ days int,
+ quotaUSD float64,
+) {
+ t.Helper()
+ insertIntegrationPlanFulfillmentSnapshot(t, ctx, client, orderID, userID, integrationPlanFulfillmentSnapshot{
+ SchemaVersion: 1,
+ PlanID: planID,
+ PlanType: "credits",
+ PlanName: "integration credits plan",
+ PlanPrice: price,
+ SubscriptionDays: days,
+ WalletQuotaUSD: "aUSD,
+ CoveredGroupIDs: []int64{},
+ LockedRates: map[string]float64{},
+ CaptureSource: "integration-test",
+ })
+}
+
+func insertMonthlyPlanFulfillmentSnapshot(
+ t *testing.T,
+ ctx context.Context,
+ client *dbent.Client,
+ orderID, userID, planID, groupID int64,
+ price float64,
+ days int,
+ rate float64,
+) {
+ t.Helper()
+ insertIntegrationPlanFulfillmentSnapshot(t, ctx, client, orderID, userID, integrationPlanFulfillmentSnapshot{
+ SchemaVersion: 1,
+ PlanID: planID,
+ PlanType: "subscription",
+ PlanName: "integration monthly plan",
+ PlanPrice: price,
+ GroupID: &groupID,
+ SubscriptionDays: days,
+ CoveredGroupIDs: []int64{groupID},
+ LockedRates: map[string]float64{strconv.FormatInt(groupID, 10): rate},
+ CaptureSource: "integration-test",
+ })
+}
+
+func insertIntegrationPlanFulfillmentSnapshot(
+ t *testing.T,
+ ctx context.Context,
+ client *dbent.Client,
+ orderID, userID int64,
+ snapshot integrationPlanFulfillmentSnapshot,
+) {
+ t.Helper()
+ payload, err := json.Marshal(snapshot)
+ require.NoError(t, err)
+ _, err = client.ExecContext(ctx, `
+ INSERT INTO subscription_plan_fulfillment_snapshots (
+ payment_order_id, user_id, source_plan_id, snapshot
+ ) VALUES ($1, $2, $3, $4::jsonb)
+ `, orderID, userID, snapshot.PlanID, string(payload))
+ require.NoError(t, err)
+}
diff --git a/backend/internal/repository/payment_stale_recovery_integration_test.go b/backend/internal/repository/payment_stale_recovery_integration_test.go
new file mode 100644
index 00000000000..26713784309
--- /dev/null
+++ b/backend/internal/repository/payment_stale_recovery_integration_test.go
@@ -0,0 +1,328 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+const stalePaymentOrderAge = 15 * time.Minute
+
+func TestRecoverStalePaidWalletOrderDeliversExactlyOnce(t *testing.T) {
+ ctx := context.Background()
+ fixture := newWalletPaymentAtomicityFixture(t, ctx, "stale-paid-background")
+ apiKeySvc := service.NewAPIKeyService(
+ fixture.apiKeyRepo,
+ fixture.userRepo,
+ fixture.groupRepo,
+ fixture.subRepo,
+ nil,
+ nil,
+ &config.Config{},
+ )
+ paymentSvc := fixture.paymentService(apiKeySvc)
+ setPaymentOrderStateAge(t, ctx, fixture.orderID, service.OrderStatusPaid, stalePaymentOrderAge)
+
+ recovered, err := paymentSvc.RecoverStaleFulfillments(ctx, 10)
+ require.NoError(t, err)
+ require.Equal(t, 1, recovered)
+ assertWalletPaymentDeliveredExactlyOnce(t, ctx, fixture)
+
+ recovered, err = paymentSvc.RecoverStaleFulfillments(ctx, 10)
+ require.NoError(t, err)
+ require.Zero(t, recovered, "completed wallet order must not be selected again")
+ assertWalletPaymentDeliveredExactlyOnce(t, ctx, fixture)
+}
+
+func TestDuplicateWebhookReclaimsStaleRechargingWalletExactlyOnce(t *testing.T) {
+ ctx := context.Background()
+ fixture := newWalletPaymentAtomicityFixture(t, ctx, "stale-webhook")
+ apiKeySvc := service.NewAPIKeyService(
+ fixture.apiKeyRepo,
+ fixture.userRepo,
+ fixture.groupRepo,
+ fixture.subRepo,
+ nil,
+ nil,
+ &config.Config{},
+ )
+ paymentSvc := fixture.paymentService(apiKeySvc)
+ setPaymentOrderStateAge(t, ctx, fixture.orderID, service.OrderStatusRecharging, stalePaymentOrderAge)
+
+ order, err := fixture.client.PaymentOrder.Get(ctx, fixture.orderID)
+ require.NoError(t, err)
+ notification := &payment.PaymentNotification{
+ OrderID: order.OutTradeNo,
+ TradeNo: order.PaymentTradeNo,
+ Amount: order.PayAmount,
+ Status: payment.NotificationStatusSuccess,
+ }
+
+ errs := runConcurrentPaymentAttempts(2, func() error {
+ return paymentSvc.HandlePaymentNotification(ctx, notification, payment.TypeAlipay)
+ })
+ for _, err := range errs {
+ require.NoError(t, err, "duplicate successful webhooks must be acknowledged")
+ }
+ assertWalletPaymentDeliveredExactlyOnce(t, ctx, fixture)
+}
+
+func TestFreshRechargingWalletCannotBeTakenOver(t *testing.T) {
+ ctx := context.Background()
+ fixture := newWalletPaymentAtomicityFixture(t, ctx, "fresh-lease")
+ apiKeySvc := service.NewAPIKeyService(
+ fixture.apiKeyRepo,
+ fixture.userRepo,
+ fixture.groupRepo,
+ fixture.subRepo,
+ nil,
+ nil,
+ &config.Config{},
+ )
+ paymentSvc := fixture.paymentService(apiKeySvc)
+ setPaymentOrderStateAge(t, ctx, fixture.orderID, service.OrderStatusRecharging, 0)
+
+ err := paymentSvc.RetryFulfillment(ctx, fixture.orderID)
+ require.ErrorContains(t, err, "being processed")
+ order, err := fixture.client.PaymentOrder.Get(ctx, fixture.orderID)
+ require.NoError(t, err)
+ require.NoError(t, paymentSvc.HandlePaymentNotification(ctx, &payment.PaymentNotification{
+ OrderID: order.OutTradeNo,
+ TradeNo: order.PaymentTradeNo,
+ Amount: order.PayAmount,
+ Status: payment.NotificationStatusSuccess,
+ }, payment.TypeAlipay), "duplicate webhook must acknowledge a fresh active lease without taking it over")
+ recovered, err := paymentSvc.RecoverStaleFulfillments(ctx, 10)
+ require.NoError(t, err)
+ require.Zero(t, recovered)
+ requirePaymentOrderStatus(t, ctx, fixture.orderID, service.OrderStatusRecharging)
+ assertWalletPaymentNotDelivered(t, ctx, fixture)
+}
+
+func TestInFlightWalletFulfillmentPreventsStaleLeaseTakeover(t *testing.T) {
+ ctx := context.Background()
+ fixture := newWalletPaymentAtomicityFixture(t, ctx, "lease-fencing")
+ apiKeySvc := service.NewAPIKeyService(
+ fixture.apiKeyRepo,
+ fixture.userRepo,
+ fixture.groupRepo,
+ fixture.subRepo,
+ nil,
+ nil,
+ &config.Config{},
+ )
+ blockingKeySvc := newBlockingWalletKeyService(apiKeySvc)
+ t.Cleanup(blockingKeySvc.release)
+ oldWorker := fixture.paymentService(blockingKeySvc)
+ setPaymentOrderStateAge(t, ctx, fixture.orderID, service.OrderStatusPaid, 0)
+
+ oldResult := make(chan error, 1)
+ go func() {
+ oldResult <- oldWorker.ExecuteSubscriptionFulfillment(ctx, fixture.orderID)
+ }()
+ select {
+ case <-blockingKeySvc.reached:
+ case <-time.After(5 * time.Second):
+ t.Fatal("old fulfillment worker did not reach the controlled crash window")
+ }
+
+ // The immutable payment-source FK and deferred evidence guard deliberately
+ // keep the order row locked while wallet effects are uncommitted. A second
+ // worker therefore cannot age/reclaim the lease underneath a live atomic
+ // fulfillment transaction. A real crashed worker releases this lock when
+ // PostgreSQL rolls the transaction back; stale recovery is covered above.
+ updateCtx, cancel := context.WithTimeout(ctx, 250*time.Millisecond)
+ defer cancel()
+ _, err := integrationDB.ExecContext(updateCtx, `
+ UPDATE payment_orders
+ SET status = $1, updated_at = $2
+ WHERE id = $3
+ `, service.OrderStatusRecharging, time.Now().Add(-stalePaymentOrderAge), fixture.orderID)
+ require.Error(t, err, "a live wallet transaction must fence a concurrent stale-lease takeover")
+ require.ErrorIs(t, updateCtx.Err(), context.DeadlineExceeded)
+ blockingKeySvc.release()
+ require.NoError(t, <-oldResult)
+ assertWalletPaymentDeliveredExactlyOnce(t, ctx, fixture)
+ require.Equal(t, 0, paymentAuditCount(t, ctx, fixture.orderID, "FULFILLMENT_LEASE_RECLAIMED"))
+}
+
+func TestConcurrentStaleGroupTakeoverCreatesMonthlyEntitlementOnce(t *testing.T) {
+ ctx := context.Background()
+ fixture := newGroupPaymentAtomicityFixture(t, ctx, "stale-new-monthly")
+ paymentSvc := fixture.paymentService()
+ setPaymentOrderStateAge(t, ctx, fixture.orderID, service.OrderStatusRecharging, stalePaymentOrderAge)
+
+ errs := runConcurrentPaymentAttempts(8, func() error {
+ return paymentSvc.RetryFulfillment(ctx, fixture.orderID)
+ })
+ requireAtLeastOneSuccessfulAttempt(t, errs)
+ require.Equal(t, 1, groupSubscriptionCount(t, ctx, fixture.user.ID, fixture.group.ID))
+ require.Equal(t, 1, paymentAuditCount(t, ctx, fixture.orderID, "SUBSCRIPTION_SUCCESS"))
+ require.Equal(t, 1, paymentAuditCount(t, ctx, fixture.orderID, "FULFILLMENT_LEASE_RECLAIMED"))
+ requirePaymentOrderStatus(t, ctx, fixture.orderID, service.OrderStatusCompleted)
+}
+
+func TestConcurrentStaleGroupTakeoverExtendsMonthlyEntitlementOnce(t *testing.T) {
+ ctx := context.Background()
+ fixture := newGroupPaymentAtomicityFixture(t, ctx, "stale-extend-monthly")
+ initialExpiry := time.Now().UTC().Add(10 * 24 * time.Hour).Truncate(time.Microsecond)
+ existing := mustCreateSubscription(t, fixture.client, &service.UserSubscription{
+ UserID: fixture.user.ID,
+ GroupID: &fixture.group.ID,
+ StartsAt: time.Now().UTC().Add(-time.Hour),
+ ExpiresAt: initialExpiry,
+ Status: service.SubscriptionStatusActive,
+ })
+ paymentSvc := fixture.paymentService()
+ setPaymentOrderStateAge(t, ctx, fixture.orderID, service.OrderStatusRecharging, stalePaymentOrderAge)
+
+ errs := runConcurrentPaymentAttempts(8, func() error {
+ return paymentSvc.RetryFulfillment(ctx, fixture.orderID)
+ })
+ requireAtLeastOneSuccessfulAttempt(t, errs)
+ require.WithinDuration(t, initialExpiry.AddDate(0, 0, fixture.days), groupSubscriptionExpiry(t, ctx, existing.ID), time.Millisecond)
+ require.Equal(t, 1, groupSubscriptionCount(t, ctx, fixture.user.ID, fixture.group.ID))
+ require.Equal(t, 1, paymentAuditCount(t, ctx, fixture.orderID, "SUBSCRIPTION_SUCCESS"))
+ require.Equal(t, 1, paymentAuditCount(t, ctx, fixture.orderID, "FULFILLMENT_LEASE_RECLAIMED"))
+ requirePaymentOrderStatus(t, ctx, fixture.orderID, service.OrderStatusCompleted)
+}
+
+func TestConcurrentStaleBalanceTakeoverCreditsOnce(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("stale-balance-%s@example.com", uuid.NewString()),
+ Username: "stale-balance",
+ PasswordHash: "hash",
+ })
+ orderUUID := uuid.NewString()
+ order, err := client.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(88).
+ SetPayAmount(88).
+ SetFeeRate(0).
+ SetRechargeCode("STALEBAL-" + orderUUID[:20]).
+ SetOutTradeNo("stale-bal-" + orderUUID).
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("tr-stale-bal-" + orderUUID).
+ SetOrderType(payment.OrderTypeBalance).
+ SetStatus(service.OrderStatusRecharging).
+ SetPaidAt(time.Now().Add(-stalePaymentOrderAge)).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+ setPaymentOrderStateAge(t, ctx, order.ID, service.OrderStatusRecharging, stalePaymentOrderAge)
+
+ userRepo := NewUserRepository(client, integrationDB)
+ redeemRepo := NewRedeemCodeRepository(client)
+ redeemSvc := service.NewRedeemService(redeemRepo, userRepo, nil, nil, nil, client, nil, nil)
+ paymentSvc := service.NewPaymentService(client, nil, nil, redeemSvc, nil, nil, userRepo, nil, nil)
+
+ errs := runConcurrentPaymentAttempts(8, func() error {
+ return paymentSvc.RetryFulfillment(ctx, order.ID)
+ })
+ requireAtLeastOneSuccessfulAttempt(t, errs)
+
+ reloadedUser, err := userRepo.GetByID(ctx, user.ID)
+ require.NoError(t, err)
+ require.InDelta(t, 88, reloadedUser.Balance, 0.000001, "balance order must credit exactly once")
+ var usedCodeCount int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM redeem_codes
+ WHERE code = $1 AND status = $2 AND used_by = $3
+ `, order.RechargeCode, service.StatusUsed, user.ID).Scan(&usedCodeCount))
+ require.Equal(t, 1, usedCodeCount)
+ require.Equal(t, 1, paymentAuditCount(t, ctx, order.ID, "RECHARGE_SUCCESS"))
+ require.Equal(t, 1, paymentAuditCount(t, ctx, order.ID, "FULFILLMENT_LEASE_RECLAIMED"))
+ requirePaymentOrderStatus(t, ctx, order.ID, service.OrderStatusCompleted)
+}
+
+func setPaymentOrderStateAge(t *testing.T, ctx context.Context, orderID int64, status string, age time.Duration) {
+ t.Helper()
+ updatedAt := time.Now().Add(-age)
+ _, err := integrationDB.ExecContext(ctx, `
+ UPDATE payment_orders
+ SET status = $1,
+ paid_at = COALESCE(paid_at, $2),
+ updated_at = $2
+ WHERE id = $3
+ `, status, updatedAt, orderID)
+ require.NoError(t, err)
+}
+
+func runConcurrentPaymentAttempts(count int, attempt func() error) []error {
+ start := make(chan struct{})
+ errs := make([]error, count)
+ var wg sync.WaitGroup
+ wg.Add(count)
+ for i := range count {
+ go func(index int) {
+ defer wg.Done()
+ <-start
+ errs[index] = attempt()
+ }(i)
+ }
+ close(start)
+ wg.Wait()
+ return errs
+}
+
+func requireAtLeastOneSuccessfulAttempt(t *testing.T, errs []error) {
+ t.Helper()
+ successes := 0
+ for _, err := range errs {
+ if err == nil {
+ successes++
+ }
+ }
+ require.GreaterOrEqual(t, successes, 1, "one worker must recover the stale fulfillment; errors=%v", errs)
+}
+
+type blockingWalletKeyService struct {
+ service.WalletGroupKeyService
+ reached chan struct{}
+ releaseCh chan struct{}
+ reachedOnce sync.Once
+ releaseOnce sync.Once
+}
+
+func newBlockingWalletKeyService(inner service.WalletGroupKeyService) *blockingWalletKeyService {
+ return &blockingWalletKeyService{
+ WalletGroupKeyService: inner,
+ reached: make(chan struct{}),
+ releaseCh: make(chan struct{}),
+ }
+}
+
+func (s *blockingWalletKeyService) EnsureWalletUniversalKey(ctx context.Context, userID int64) (*service.APIKey, bool, error) {
+ key, created, err := s.WalletGroupKeyService.EnsureWalletUniversalKey(ctx, userID)
+ if err != nil {
+ return nil, false, err
+ }
+ s.reachedOnce.Do(func() { close(s.reached) })
+ select {
+ case <-s.releaseCh:
+ return key, created, nil
+ case <-ctx.Done():
+ return nil, false, ctx.Err()
+ }
+}
+
+func (s *blockingWalletKeyService) release() {
+ s.releaseOnce.Do(func() { close(s.releaseCh) })
+}
diff --git a/backend/internal/repository/plan_snapshot_integrity_integration_test.go b/backend/internal/repository/plan_snapshot_integrity_integration_test.go
new file mode 100644
index 00000000000..fe2f83783df
--- /dev/null
+++ b/backend/internal/repository/plan_snapshot_integrity_integration_test.go
@@ -0,0 +1,177 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestPlanSnapshotSurvivesPlanMutationAndDeletion(t *testing.T) {
+ tx := testTx(t)
+ ctx := context.Background()
+ userID, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ planID := insertMigrationPlan(t, tx, "subscription", &groupIDs[0], nil)
+ orderID := insertPlanSnapshotTestOrder(t, tx, userID, planID, groupIDs[0])
+
+ insertPlanSnapshotTestRow(t, tx, orderID, userID, planID, groupIDs[0])
+ require.NoError(t, execMigrationTestSQL(tx, "SET CONSTRAINTS ALL IMMEDIATE"))
+ require.NoError(t, execMigrationTestSQL(tx, "SET CONSTRAINTS ALL DEFERRED"))
+
+ _, err := tx.ExecContext(ctx, `
+ UPDATE subscription_plans
+ SET plan_type = 'credits', group_id = NULL, wallet_quota_usd = 999,
+ validity_days = 36500, validity_unit = 'day'
+ WHERE id = $1
+ `, planID)
+ require.NoError(t, err, "a durable order snapshot must decouple fulfillment from later plan edits")
+ _, err = tx.ExecContext(ctx, "DELETE FROM subscription_plans WHERE id = $1", planID)
+ require.NoError(t, err, "a durable order snapshot must survive source plan deletion")
+
+ var planType string
+ var snapshotGroupID int64
+ var snapshotDays int
+ var snapshotQuota sql.NullFloat64
+ require.NoError(t, tx.QueryRowContext(ctx, `
+ SELECT
+ snapshot->>'plan_type',
+ (snapshot->>'group_id')::bigint,
+ (snapshot->>'subscription_days')::integer,
+ (snapshot->>'wallet_quota_usd')::numeric
+ FROM subscription_plan_fulfillment_snapshots
+ WHERE payment_order_id = $1
+ `, orderID).Scan(&planType, &snapshotGroupID, &snapshotDays, &snapshotQuota))
+ require.Equal(t, "subscription", planType)
+ require.Equal(t, groupIDs[0], snapshotGroupID)
+ require.Equal(t, 30, snapshotDays)
+ require.False(t, snapshotQuota.Valid)
+
+ _, err = tx.ExecContext(ctx, `
+ UPDATE payment_orders
+ SET status = 'PAID', paid_at = NOW(), payment_trade_no = 'snapshot-trade'
+ WHERE id = $1
+ `, orderID)
+ require.NoError(t, err)
+ require.NoError(t, execMigrationTestSQL(tx, "SET CONSTRAINTS ALL IMMEDIATE"),
+ "paid transition must validate against the immutable snapshot, not the deleted plan")
+}
+
+func TestPlanSnapshotIsRequiredAndImmutable(t *testing.T) {
+ tx := testTx(t)
+ ctx := context.Background()
+ userID, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ planID := insertMigrationPlan(t, tx, "subscription", &groupIDs[0], nil)
+
+ require.NoError(t, execMigrationTestSQL(tx, "SAVEPOINT before_missing_snapshot"))
+ _ = insertPlanSnapshotTestOrder(t, tx, userID, planID, groupIDs[0])
+ _, err := tx.ExecContext(ctx, "SET CONSTRAINTS ALL IMMEDIATE")
+ requirePostgresCode(t, err, "23514")
+ require.NoError(t, execMigrationTestSQL(tx, "ROLLBACK TO SAVEPOINT before_missing_snapshot"))
+ require.NoError(t, execMigrationTestSQL(tx, "SET CONSTRAINTS ALL DEFERRED"))
+
+ require.NoError(t, execMigrationTestSQL(tx, "SAVEPOINT before_malformed_snapshot"))
+ malformedOrderID := insertPlanSnapshotTestOrder(t, tx, userID, planID, groupIDs[0])
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO subscription_plan_fulfillment_snapshots (
+ payment_order_id, user_id, source_plan_id, snapshot
+ ) VALUES ($1::bigint, $2::bigint, $3::bigint, jsonb_build_object(
+ 'schema_version', 1,
+ 'plan_id', $3::bigint,
+ 'plan_type', 'subscription',
+ 'plan_price', 20,
+ 'group_id', $4::bigint,
+ 'subscription_days', 30,
+ 'wallet_quota_usd', NULL::numeric,
+ 'covered_group_ids', jsonb_build_array($4::bigint)
+ ))
+ `, malformedOrderID, userID, planID, groupIDs[0])
+ requirePostgresCode(t, err, "23514")
+ require.NoError(t, execMigrationTestSQL(tx, "ROLLBACK TO SAVEPOINT before_malformed_snapshot"))
+
+ orderID := insertPlanSnapshotTestOrder(t, tx, userID, planID, groupIDs[0])
+ insertPlanSnapshotTestRow(t, tx, orderID, userID, planID, groupIDs[0])
+
+ require.NoError(t, execMigrationTestSQL(tx, "SAVEPOINT before_snapshot_mutation"))
+ _, err = tx.ExecContext(ctx, `
+ UPDATE subscription_plan_fulfillment_snapshots
+ SET snapshot = jsonb_set(snapshot, '{subscription_days}', '7'::jsonb)
+ WHERE payment_order_id = $1
+ `, orderID)
+ requirePostgresCode(t, err, "23514")
+ require.NoError(t, execMigrationTestSQL(tx, "ROLLBACK TO SAVEPOINT before_snapshot_mutation"))
+
+ var subscriptionID int64
+ require.NoError(t, tx.QueryRowContext(ctx, `
+ INSERT INTO user_subscriptions (
+ user_id, group_id, starts_at, expires_at, status
+ ) VALUES ($1, $2, NOW(), NOW() + INTERVAL '30 days', 'active')
+ RETURNING id
+ `, userID, groupIDs[0]).Scan(&subscriptionID))
+ _, err = tx.ExecContext(ctx, `
+ UPDATE subscription_plan_fulfillment_snapshots
+ SET user_subscription_id = $2,
+ grant_starts_at = NOW(),
+ grant_expires_at = NOW() + INTERVAL '30 days',
+ attached_at = NOW()
+ WHERE payment_order_id = $1
+ `, orderID, subscriptionID)
+ require.NoError(t, err)
+ require.NoError(t, execMigrationTestSQL(tx, "SET CONSTRAINTS ALL IMMEDIATE"))
+ require.NoError(t, execMigrationTestSQL(tx, "SET CONSTRAINTS ALL DEFERRED"))
+
+ require.NoError(t, execMigrationTestSQL(tx, "SAVEPOINT before_grant_mutation"))
+ _, err = tx.ExecContext(ctx, `
+ UPDATE subscription_plan_fulfillment_snapshots
+ SET grant_expires_at = grant_expires_at + INTERVAL '1 day'
+ WHERE payment_order_id = $1
+ `, orderID)
+ requirePostgresCode(t, err, "23514")
+ require.NoError(t, execMigrationTestSQL(tx, "ROLLBACK TO SAVEPOINT before_grant_mutation"))
+
+ require.NoError(t, execMigrationTestSQL(tx, "SAVEPOINT before_snapshot_delete"))
+ _, err = tx.ExecContext(ctx, `
+ DELETE FROM subscription_plan_fulfillment_snapshots
+ WHERE payment_order_id = $1
+ `, orderID)
+ requirePostgresCode(t, err, "23514")
+ require.NoError(t, execMigrationTestSQL(tx, "ROLLBACK TO SAVEPOINT before_snapshot_delete"))
+}
+
+func insertPlanSnapshotTestOrder(t *testing.T, tx *sql.Tx, userID, planID, groupID int64) int64 {
+ t.Helper()
+ var orderID int64
+ err := tx.QueryRowContext(context.Background(), `
+ INSERT INTO payment_orders (
+ user_id, user_email, user_name, amount, pay_amount,
+ order_type, plan_id, subscription_group_id, subscription_days,
+ status, expires_at
+ ) VALUES ($1, 'snapshot@example.test', 'snapshot test', 20, 20,
+ 'subscription', $2, $3, 30, 'PENDING', NOW() + INTERVAL '15 minutes')
+ RETURNING id
+ `, userID, planID, groupID).Scan(&orderID)
+ require.NoError(t, err)
+ return orderID
+}
+
+func insertPlanSnapshotTestRow(t *testing.T, tx *sql.Tx, orderID, userID, planID, groupID int64) {
+ t.Helper()
+ _, err := tx.ExecContext(context.Background(), `
+ INSERT INTO subscription_plan_fulfillment_snapshots (
+ payment_order_id, user_id, source_plan_id, snapshot
+ ) VALUES ($1::bigint, $2::bigint, $3::bigint, jsonb_build_object(
+ 'schema_version', 1,
+ 'plan_id', $3::bigint,
+ 'plan_type', 'subscription',
+ 'plan_price', 20,
+ 'group_id', $4::bigint,
+ 'subscription_days', 30,
+ 'wallet_quota_usd', NULL::numeric,
+ 'covered_group_ids', jsonb_build_array($4::bigint),
+ 'locked_rates', jsonb_build_object($4::text, 8.5)
+ ))
+ `, orderID, userID, planID, groupID)
+ require.NoError(t, err)
+}
diff --git a/backend/internal/repository/proxy_credentials_storage_integration_test.go b/backend/internal/repository/proxy_credentials_storage_integration_test.go
new file mode 100644
index 00000000000..fa73f280da8
--- /dev/null
+++ b/backend/internal/repository/proxy_credentials_storage_integration_test.go
@@ -0,0 +1,50 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestProxyCredentialStorageConstraintRejectsPlaintext(t *testing.T) {
+ tx := testTx(t)
+
+ var (
+ dataType string
+ characterMaxLen sql.NullInt64
+ )
+ err := tx.QueryRowContext(context.Background(), `
+SELECT data_type, character_maximum_length
+FROM information_schema.columns
+WHERE table_schema = 'public'
+ AND table_name = 'proxies'
+ AND column_name = 'password'
+`).Scan(&dataType, &characterMaxLen)
+ require.NoError(t, err)
+ require.Equal(t, "text", dataType)
+ require.False(t, characterMaxLen.Valid)
+
+ var definition string
+ err = tx.QueryRowContext(context.Background(), `
+SELECT pg_get_constraintdef(c.oid)
+FROM pg_constraint c
+JOIN pg_class tbl ON tbl.oid = c.conrelid
+JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
+WHERE ns.nspname = 'public'
+ AND tbl.relname = 'proxies'
+ AND c.conname = 'chk_proxies_password_encrypted_storage'
+`).Scan(&definition)
+ require.NoError(t, err)
+ require.Contains(t, definition, "password IS NULL")
+ require.Contains(t, definition, "sd:v3:%")
+
+ _, err = tx.ExecContext(context.Background(), `
+INSERT INTO proxies (name, protocol, host, port, password, status)
+VALUES ('plaintext-constraint-probe', 'http', '127.0.0.1', 8080, 'plaintext-secret', 'active')
+`)
+ require.Error(t, err)
+}
diff --git a/backend/internal/repository/proxy_repo.go b/backend/internal/repository/proxy_repo.go
index 60b2f069ecb..32f51e75f1a 100644
--- a/backend/internal/repository/proxy_repo.go
+++ b/backend/internal/repository/proxy_repo.go
@@ -2,9 +2,12 @@ package repository
import (
"context"
+ "crypto/subtle"
"database/sql"
+ "fmt"
"sort"
"strings"
+ "unicode/utf8"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/proxy"
@@ -20,16 +23,19 @@ type sqlQuerier interface {
}
type proxyRepository struct {
- client *dbent.Client
- sql sqlQuerier
+ client *dbent.Client
+ sql sqlQuerier
+ encryptor service.SecretEncryptor
}
-func NewProxyRepository(client *dbent.Client, sqlDB *sql.DB) service.ProxyRepository {
- return newProxyRepositoryWithSQL(client, sqlDB)
+const maxProxyPasswordRunes = 100
+
+func NewProxyRepository(client *dbent.Client, sqlDB *sql.DB, encryptor service.SecretEncryptor) service.ProxyRepository {
+ return newProxyRepositoryWithSQL(client, sqlDB, encryptor)
}
-func newProxyRepositoryWithSQL(client *dbent.Client, sqlq sqlQuerier) *proxyRepository {
- return &proxyRepository{client: client, sql: sqlq}
+func newProxyRepositoryWithSQL(client *dbent.Client, sqlq sqlQuerier, encryptor service.SecretEncryptor) *proxyRepository {
+ return &proxyRepository{client: client, sql: sqlq, encryptor: encryptor}
}
func (r *proxyRepository) Create(ctx context.Context, proxyIn *service.Proxy) error {
@@ -43,7 +49,11 @@ func (r *proxyRepository) Create(ctx context.Context, proxyIn *service.Proxy) er
builder.SetUsername(proxyIn.Username)
}
if proxyIn.Password != "" {
- builder.SetPassword(proxyIn.Password)
+ encrypted, err := r.encryptPassword(proxyIn.Password)
+ if err != nil {
+ return err
+ }
+ builder.SetPassword(encrypted)
}
created, err := builder.Save(ctx)
@@ -61,7 +71,7 @@ func (r *proxyRepository) GetByID(ctx context.Context, id int64) (*service.Proxy
}
return nil, err
}
- return proxyEntityToService(m), nil
+ return r.proxyEntityToService(m)
}
func (r *proxyRepository) ListByIDs(ctx context.Context, ids []int64) ([]service.Proxy, error) {
@@ -78,7 +88,11 @@ func (r *proxyRepository) ListByIDs(ctx context.Context, ids []int64) ([]service
out := make([]service.Proxy, 0, len(proxies))
for i := range proxies {
- out = append(out, *proxyEntityToService(proxies[i]))
+ mapped, err := r.proxyEntityToService(proxies[i])
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, *mapped)
}
return out, nil
}
@@ -96,7 +110,11 @@ func (r *proxyRepository) Update(ctx context.Context, proxyIn *service.Proxy) er
builder.ClearUsername()
}
if proxyIn.Password != "" {
- builder.SetPassword(proxyIn.Password)
+ encrypted, err := r.encryptPassword(proxyIn.Password)
+ if err != nil {
+ return err
+ }
+ builder.SetPassword(encrypted)
} else {
builder.ClearPassword()
}
@@ -153,7 +171,11 @@ func (r *proxyRepository) ListWithFilters(ctx context.Context, params pagination
outProxies := make([]service.Proxy, 0, len(proxies))
for i := range proxies {
- outProxies = append(outProxies, *proxyEntityToService(proxies[i]))
+ mapped, err := r.proxyEntityToService(proxies[i])
+ if err != nil {
+ return nil, nil, err
+ }
+ outProxies = append(outProxies, *mapped)
}
return outProxies, paginationResultFromTotal(int64(total), params), nil
@@ -231,7 +253,10 @@ func (r *proxyRepository) buildProxyWithAccountCountResult(ctx context.Context,
result := make([]service.ProxyWithAccountCount, 0, len(proxies))
for i := range proxies {
- proxyOut := proxyEntityToService(proxies[i])
+ proxyOut, err := r.proxyEntityToService(proxies[i])
+ if err != nil {
+ return nil, nil, err
+ }
if proxyOut == nil {
continue
}
@@ -277,7 +302,11 @@ func (r *proxyRepository) ListActive(ctx context.Context) ([]service.Proxy, erro
}
outProxies := make([]service.Proxy, 0, len(proxies))
for i := range proxies {
- outProxies = append(outProxies, *proxyEntityToService(proxies[i]))
+ mapped, err := r.proxyEntityToService(proxies[i])
+ if err != nil {
+ return nil, err
+ }
+ outProxies = append(outProxies, *mapped)
}
return outProxies, nil
}
@@ -292,14 +321,26 @@ func (r *proxyRepository) ExistsByHostPortAuth(ctx context.Context, host string,
} else {
q = q.Where(proxy.UsernameEQ(username))
}
- if password == "" {
- q = q.Where(proxy.Or(proxy.PasswordIsNil(), proxy.PasswordEQ("")))
- } else {
- q = q.Where(proxy.PasswordEQ(password))
+ rows, err := q.All(ctx)
+ if err != nil {
+ return false, err
}
-
- count, err := q.Count(ctx)
- return count > 0, err
+ for _, row := range rows {
+ if row.Password == nil || *row.Password == "" {
+ if password == "" {
+ return true, nil
+ }
+ continue
+ }
+ plaintext, err := r.decryptPassword(*row.Password)
+ if err != nil {
+ return false, err
+ }
+ if subtle.ConstantTimeCompare([]byte(plaintext), []byte(password)) == 1 {
+ return true, nil
+ }
+ }
+ return false, nil
}
// CountAccountsByProxyID returns the number of accounts using a specific proxy
@@ -399,7 +440,10 @@ func (r *proxyRepository) ListActiveWithAccountCount(ctx context.Context) ([]ser
// Build result with account counts
result := make([]service.ProxyWithAccountCount, 0, len(proxies))
for i := range proxies {
- proxyOut := proxyEntityToService(proxies[i])
+ proxyOut, err := r.proxyEntityToService(proxies[i])
+ if err != nil {
+ return nil, err
+ }
if proxyOut == nil {
continue
}
@@ -412,9 +456,13 @@ func (r *proxyRepository) ListActiveWithAccountCount(ctx context.Context) ([]ser
return result, nil
}
-func proxyEntityToService(m *dbent.Proxy) *service.Proxy {
+func (r *proxyRepository) proxyEntityToService(m *dbent.Proxy) (*service.Proxy, error) {
+ return proxyEntityToService(m, r.encryptor)
+}
+
+func proxyEntityToService(m *dbent.Proxy, encryptor service.SecretEncryptor) (*service.Proxy, error) {
if m == nil {
- return nil
+ return nil, nil
}
out := &service.Proxy{
ID: m.ID,
@@ -430,9 +478,34 @@ func proxyEntityToService(m *dbent.Proxy) *service.Proxy {
out.Username = *m.Username
}
if m.Password != nil {
- out.Password = *m.Password
+ plaintext, err := service.DecryptForSecretDomain(encryptor, service.SecretDomainProxyCredential, *m.Password)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt proxy credential %d: %w", m.ID, err)
+ }
+ out.Password = plaintext
+ }
+ return out, nil
+}
+
+func (r *proxyRepository) encryptPassword(plaintext string) (string, error) {
+ if plaintext == "" {
+ return "", nil
+ }
+ if utf8.RuneCountInString(plaintext) > maxProxyPasswordRunes {
+ return "", fmt.Errorf("proxy password must be at most %d characters", maxProxyPasswordRunes)
+ }
+ ciphertext, err := service.EncryptForSecretDomain(r.encryptor, service.SecretDomainProxyCredential, plaintext)
+ if err != nil {
+ return "", fmt.Errorf("encrypt proxy credential: %w", err)
+ }
+ return ciphertext, nil
+}
+
+func (r *proxyRepository) decryptPassword(ciphertext string) (string, error) {
+ if ciphertext == "" {
+ return "", nil
}
- return out
+ return service.DecryptForSecretDomain(r.encryptor, service.SecretDomainProxyCredential, ciphertext)
}
func applyProxyEntityToService(dst *service.Proxy, src *dbent.Proxy) {
diff --git a/backend/internal/repository/proxy_repo_encryption_test.go b/backend/internal/repository/proxy_repo_encryption_test.go
new file mode 100644
index 00000000000..38b24a6d0e1
--- /dev/null
+++ b/backend/internal/repository/proxy_repo_encryption_test.go
@@ -0,0 +1,110 @@
+package repository
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestProxyRepositoryEncryptsPasswordAtRestAndDecryptsOnRead(t *testing.T) {
+ ctx := context.Background()
+ client := newCredentialEncryptionTestClient(t)
+ encryptor := newDomainMigrationTestEncryptor(t)
+ repo := newProxyRepositoryWithSQL(client, nil, encryptor)
+ input := &service.Proxy{
+ Name: "encrypted-proxy", Protocol: "http", Host: "proxy.example.test",
+ Port: 8080, Username: "proxy-user", Password: "proxy-secret", Status: service.StatusActive,
+ }
+
+ require.NoError(t, repo.Create(ctx, input))
+ raw, err := client.Proxy.Get(ctx, input.ID)
+ require.NoError(t, err)
+ require.NotNil(t, raw.Password)
+ require.NotEqual(t, "proxy-secret", *raw.Password)
+ plaintext, err := service.DecryptForSecretDomain(encryptor, service.SecretDomainProxyCredential, *raw.Password)
+ require.NoError(t, err)
+ require.Equal(t, "proxy-secret", plaintext)
+ _, err = service.DecryptForSecretDomain(encryptor, service.SecretDomainAccountCredential, *raw.Password)
+ require.Error(t, err)
+
+ got, err := repo.GetByID(ctx, input.ID)
+ require.NoError(t, err)
+ require.Equal(t, "proxy-secret", got.Password)
+ byIDs, err := repo.ListByIDs(ctx, []int64{input.ID})
+ require.NoError(t, err)
+ require.Len(t, byIDs, 1)
+ require.Equal(t, "proxy-secret", byIDs[0].Password)
+ listed, _, err := repo.List(ctx, pagination.PaginationParams{Page: 1, PageSize: 10})
+ require.NoError(t, err)
+ require.Len(t, listed, 1)
+ require.Equal(t, "proxy-secret", listed[0].Password)
+ active, err := repo.ListActive(ctx)
+ require.NoError(t, err)
+ require.Len(t, active, 1)
+ require.Equal(t, "proxy-secret", active[0].Password)
+
+ exists, err := repo.ExistsByHostPortAuth(ctx, input.Host, input.Port, input.Username, input.Password)
+ require.NoError(t, err)
+ require.True(t, exists)
+ exists, err = repo.ExistsByHostPortAuth(ctx, input.Host, input.Port, input.Username, "wrong-secret")
+ require.NoError(t, err)
+ require.False(t, exists)
+
+ input.Password = "rotated-secret"
+ require.NoError(t, repo.Update(ctx, input))
+ updatedRaw, err := client.Proxy.Get(ctx, input.ID)
+ require.NoError(t, err)
+ require.NotNil(t, updatedRaw.Password)
+ require.NotEqual(t, "rotated-secret", *updatedRaw.Password)
+ plaintext, err = service.DecryptForSecretDomain(encryptor, service.SecretDomainProxyCredential, *updatedRaw.Password)
+ require.NoError(t, err)
+ require.Equal(t, "rotated-secret", plaintext)
+}
+
+func TestProxyRepositoryFailsClosedOnUnreadablePasswordCiphertext(t *testing.T) {
+ ctx := context.Background()
+ client := newCredentialEncryptionTestClient(t)
+ repo := newProxyRepositoryWithSQL(client, nil, newDomainMigrationTestEncryptor(t))
+ raw, err := client.Proxy.Create().
+ SetName("corrupt-proxy").
+ SetProtocol("http").
+ SetHost("proxy.example.test").
+ SetPort(8080).
+ SetPassword("sd:v3:not-valid").
+ SetStatus(service.StatusActive).
+ Save(ctx)
+ require.NoError(t, err)
+
+ got, err := repo.GetByID(ctx, raw.ID)
+
+ require.Nil(t, got)
+ require.ErrorContains(t, err, "decrypt proxy credential")
+}
+
+func TestProxyRepositoryPreservesPlaintextPasswordLimitBeforeEncryption(t *testing.T) {
+ ctx := context.Background()
+ client := newCredentialEncryptionTestClient(t)
+ repo := newProxyRepositoryWithSQL(client, nil, newDomainMigrationTestEncryptor(t))
+ input := &service.Proxy{
+ Name: "unicode-proxy", Protocol: "http", Host: "unicode-proxy.example.test",
+ Port: 8080, Password: strings.Repeat("😀", maxProxyPasswordRunes), Status: service.StatusActive,
+ }
+
+ require.NoError(t, repo.Create(ctx, input))
+ raw, err := client.Proxy.Get(ctx, input.ID)
+ require.NoError(t, err)
+ require.NotNil(t, raw.Password)
+ require.Greater(t, len(*raw.Password), 512)
+
+ tooLong := &service.Proxy{
+ Name: "too-long-proxy", Protocol: "http", Host: "too-long-proxy.example.test",
+ Port: 8081, Password: strings.Repeat("😀", maxProxyPasswordRunes+1), Status: service.StatusActive,
+ }
+ err = repo.Create(ctx, tooLong)
+ require.ErrorContains(t, err, "proxy password must be at most 100 characters")
+ require.Zero(t, tooLong.ID)
+}
diff --git a/backend/internal/repository/proxy_repo_integration_test.go b/backend/internal/repository/proxy_repo_integration_test.go
index 8f5ef01ef4d..ac7a944f305 100644
--- a/backend/internal/repository/proxy_repo_integration_test.go
+++ b/backend/internal/repository/proxy_repo_integration_test.go
@@ -24,7 +24,7 @@ func (s *ProxyRepoSuite) SetupTest() {
s.ctx = context.Background()
tx := testEntTx(s.T())
s.tx = tx
- s.repo = newProxyRepositoryWithSQL(tx.Client(), tx)
+ s.repo = newProxyRepositoryWithSQL(tx.Client(), tx, newDomainMigrationTestEncryptor(s.T()))
}
func TestProxyRepoSuite(t *testing.T) {
diff --git a/backend/internal/repository/redeem_code_repo.go b/backend/internal/repository/redeem_code_repo.go
index 07975970ef8..2bf1aee4d31 100644
--- a/backend/internal/repository/redeem_code_repo.go
+++ b/backend/internal/repository/redeem_code_repo.go
@@ -23,7 +23,8 @@ func NewRedeemCodeRepository(client *dbent.Client) service.RedeemCodeRepository
}
func (r *redeemCodeRepository) Create(ctx context.Context, code *service.RedeemCode) error {
- created, err := r.client.RedeemCode.Create().
+ client := clientFromContext(ctx, r.client)
+ created, err := client.RedeemCode.Create().
SetCode(code.Code).
SetType(code.Type).
SetValue(code.Value).
@@ -33,6 +34,7 @@ func (r *redeemCodeRepository) Create(ctx context.Context, code *service.RedeemC
SetNillableUsedBy(code.UsedBy).
SetNillableUsedAt(code.UsedAt).
SetNillableGroupID(code.GroupID).
+ SetNillablePlanID(code.PlanID).
Save(ctx)
if err == nil {
code.ID = created.ID
@@ -46,10 +48,11 @@ func (r *redeemCodeRepository) CreateBatch(ctx context.Context, codes []service.
return nil
}
+ client := clientFromContext(ctx, r.client)
builders := make([]*dbent.RedeemCodeCreate, 0, len(codes))
for i := range codes {
c := &codes[i]
- b := r.client.RedeemCode.Create().
+ b := client.RedeemCode.Create().
SetCode(c.Code).
SetType(c.Type).
SetValue(c.Value).
@@ -58,15 +61,17 @@ func (r *redeemCodeRepository) CreateBatch(ctx context.Context, codes []service.
SetValidityDays(c.ValidityDays).
SetNillableUsedBy(c.UsedBy).
SetNillableUsedAt(c.UsedAt).
- SetNillableGroupID(c.GroupID)
+ SetNillableGroupID(c.GroupID).
+ SetNillablePlanID(c.PlanID)
builders = append(builders, b)
}
- return r.client.RedeemCode.CreateBulk(builders...).Exec(ctx)
+ return client.RedeemCode.CreateBulk(builders...).Exec(ctx)
}
func (r *redeemCodeRepository) GetByID(ctx context.Context, id int64) (*service.RedeemCode, error) {
- m, err := r.client.RedeemCode.Query().
+ client := clientFromContext(ctx, r.client)
+ m, err := client.RedeemCode.Query().
Where(redeemcode.IDEQ(id)).
Only(ctx)
if err != nil {
@@ -79,7 +84,8 @@ func (r *redeemCodeRepository) GetByID(ctx context.Context, id int64) (*service.
}
func (r *redeemCodeRepository) GetByCode(ctx context.Context, code string) (*service.RedeemCode, error) {
- m, err := r.client.RedeemCode.Query().
+ client := clientFromContext(ctx, r.client)
+ m, err := client.RedeemCode.Query().
Where(redeemcode.CodeEQ(code)).
Only(ctx)
if err != nil {
@@ -91,9 +97,22 @@ func (r *redeemCodeRepository) GetByCode(ctx context.Context, code string) (*ser
return redeemCodeEntityToService(m), nil
}
-func (r *redeemCodeRepository) Delete(ctx context.Context, id int64) error {
- _, err := r.client.RedeemCode.Delete().Where(redeemcode.IDEQ(id)).Exec(ctx)
- return err
+func (r *redeemCodeRepository) DeleteIfUnused(ctx context.Context, id int64) (bool, error) {
+ client := clientFromContext(ctx, r.client)
+ affected, err := client.RedeemCode.Delete().Where(
+ redeemcode.IDEQ(id),
+ redeemcode.StatusEQ(service.StatusUnused),
+ ).Exec(ctx)
+ return affected == 1, err
+}
+
+func (r *redeemCodeRepository) ExpireIfUnused(ctx context.Context, id int64) (bool, error) {
+ client := clientFromContext(ctx, r.client)
+ affected, err := client.RedeemCode.Update().
+ Where(redeemcode.IDEQ(id), redeemcode.StatusEQ(service.StatusUnused)).
+ SetStatus(service.StatusExpired).
+ Save(ctx)
+ return affected == 1, err
}
func (r *redeemCodeRepository) List(ctx context.Context, params pagination.PaginationParams) ([]service.RedeemCode, *pagination.PaginationResult, error) {
@@ -101,7 +120,8 @@ func (r *redeemCodeRepository) List(ctx context.Context, params pagination.Pagin
}
func (r *redeemCodeRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, codeType, status, search string) ([]service.RedeemCode, *pagination.PaginationResult, error) {
- q := r.client.RedeemCode.Query()
+ client := clientFromContext(ctx, r.client)
+ q := client.RedeemCode.Query()
if codeType != "" {
q = q.Where(redeemcode.TypeEQ(codeType))
@@ -171,7 +191,8 @@ func redeemCodeListOrder(params pagination.PaginationParams) []func(*entsql.Sele
}
func (r *redeemCodeRepository) Update(ctx context.Context, code *service.RedeemCode) error {
- up := r.client.RedeemCode.UpdateOneID(code.ID).
+ client := clientFromContext(ctx, r.client)
+ up := client.RedeemCode.UpdateOneID(code.ID).
SetCode(code.Code).
SetType(code.Type).
SetValue(code.Value).
@@ -194,6 +215,11 @@ func (r *redeemCodeRepository) Update(ctx context.Context, code *service.RedeemC
} else {
up.ClearGroupID()
}
+ if code.PlanID != nil {
+ up.SetPlanID(*code.PlanID)
+ } else {
+ up.ClearPlanID()
+ }
updated, err := up.Save(ctx)
if err != nil {
@@ -229,7 +255,8 @@ func (r *redeemCodeRepository) ListByUser(ctx context.Context, userID int64, lim
limit = 10
}
- codes, err := r.client.RedeemCode.Query().
+ client := clientFromContext(ctx, r.client)
+ codes, err := client.RedeemCode.Query().
Where(redeemcode.UsedByEQ(userID)).
WithGroup().
Order(dbent.Desc(redeemcode.FieldUsedAt)).
@@ -245,7 +272,8 @@ func (r *redeemCodeRepository) ListByUser(ctx context.Context, userID int64, lim
// ListByUserPaginated returns paginated balance/concurrency history for a user.
// Supports optional type filter (e.g. "balance", "admin_balance", "concurrency", "admin_concurrency", "subscription").
func (r *redeemCodeRepository) ListByUserPaginated(ctx context.Context, userID int64, params pagination.PaginationParams, codeType string) ([]service.RedeemCode, *pagination.PaginationResult, error) {
- q := r.client.RedeemCode.Query().
+ client := clientFromContext(ctx, r.client)
+ q := client.RedeemCode.Query().
Where(redeemcode.UsedByEQ(userID))
// Optional type filter
@@ -276,7 +304,8 @@ func (r *redeemCodeRepository) SumPositiveBalanceByUser(ctx context.Context, use
var result []struct {
Sum float64 `json:"sum"`
}
- err := r.client.RedeemCode.Query().
+ client := clientFromContext(ctx, r.client)
+ err := client.RedeemCode.Query().
Where(
redeemcode.UsedByEQ(userID),
redeemcode.ValueGT(0),
@@ -309,6 +338,7 @@ func redeemCodeEntityToService(m *dbent.RedeemCode) *service.RedeemCode {
CreatedAt: m.CreatedAt,
GroupID: m.GroupID,
ValidityDays: m.ValidityDays,
+ PlanID: m.PlanID,
}
if m.Edges.User != nil {
out.User = userEntityToService(m.Edges.User)
diff --git a/backend/internal/repository/redeem_code_repo_integration_test.go b/backend/internal/repository/redeem_code_repo_integration_test.go
index 39674b52c0a..f657e18d184 100644
--- a/backend/internal/repository/redeem_code_repo_integration_test.go
+++ b/backend/internal/repository/redeem_code_repo_integration_test.go
@@ -4,12 +4,15 @@ package repository
import (
"context"
+ "fmt"
+ "sync"
"testing"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
@@ -25,6 +28,12 @@ func (s *RedeemCodeRepoSuite) SetupTest() {
tx := testEntTx(s.T())
s.client = tx.Client()
s.repo = NewRedeemCodeRepository(s.client).(*redeemCodeRepository)
+ _, err := tx.ExecContext(s.ctx, `
+ SET LOCAL session_replication_role = 'replica';
+ DELETE FROM redeem_codes;
+ SET LOCAL session_replication_role = 'origin';
+ `)
+ s.Require().NoError(err, "isolate redeem-code repository test transaction")
}
func TestRedeemCodeRepoSuite(t *testing.T) {
@@ -115,7 +124,7 @@ func (s *RedeemCodeRepoSuite) TestGetByCode_NotFound() {
// --- Delete ---
-func (s *RedeemCodeRepoSuite) TestDelete() {
+func (s *RedeemCodeRepoSuite) TestDeleteIfUnused() {
created, err := s.client.RedeemCode.Create().
SetCode("TO-DELETE").
SetType(service.RedeemTypeBalance).
@@ -126,44 +135,262 @@ func (s *RedeemCodeRepoSuite) TestDelete() {
Save(s.ctx)
s.Require().NoError(err)
- err = s.repo.Delete(s.ctx, created.ID)
- s.Require().NoError(err, "Delete")
+ deleted, err := s.repo.DeleteIfUnused(s.ctx, created.ID)
+ s.Require().NoError(err, "DeleteIfUnused")
+ s.Require().True(deleted)
_, err = s.repo.GetByID(s.ctx, created.ID)
s.Require().Error(err, "expected error after delete")
s.Require().ErrorIs(err, service.ErrRedeemCodeNotFound)
}
+func (s *RedeemCodeRepoSuite) TestDeleteIfUnusedProtectsUsedCode() {
+ user := s.createUser(uniqueTestValue(s.T(), "delete-used") + "@example.com")
+ usedAt := time.Now().UTC().Truncate(time.Second)
+ created, err := s.client.RedeemCode.Create().
+ SetCode("DO-NOT-DELETE-USED").
+ SetType(service.RedeemTypeBalance).
+ SetStatus(service.StatusUsed).
+ SetValue(10).
+ SetUsedBy(user.ID).
+ SetUsedAt(usedAt).
+ Save(s.ctx)
+ s.Require().NoError(err)
+ ledgerKey := fmt.Sprintf("redeem-delete-guard-%d", created.ID)
+ _, err = s.client.ExecContext(s.ctx, `
+ INSERT INTO ledger_transactions (
+ transaction_type, idempotency_key, source_type, source_id,
+ user_id, redeem_code_id, amount_usd, description
+ ) VALUES ('redeem', $1, 'redeem_code', $2, $3, $2, 10, 'delete guard')
+ `, ledgerKey, created.ID, user.ID)
+ s.Require().NoError(err)
+ _, err = s.client.ExecContext(s.ctx, `
+ INSERT INTO hfc_abuse_risk_events (source, user_id, redeem_code_id, summary)
+ VALUES ('redeem-delete-guard', $1, $2, 'delete guard')
+ `, user.ID, created.ID)
+ s.Require().NoError(err)
+
+ deleted, err := s.repo.DeleteIfUnused(s.ctx, created.ID)
+ s.Require().NoError(err)
+ s.Require().False(deleted)
+
+ got, err := s.repo.GetByID(s.ctx, created.ID)
+ s.Require().NoError(err)
+ s.Require().Equal(service.StatusUsed, got.Status)
+ s.Require().NotNil(got.UsedBy)
+ s.Require().Equal(user.ID, *got.UsedBy)
+ s.Require().NotNil(got.UsedAt)
+ s.Require().WithinDuration(usedAt, *got.UsedAt, time.Second)
+ ledgerRefs, abuseRefs := queryRedeemAuditReferenceCounts(s.T(), s.ctx, s.client, ledgerKey, created.ID)
+ s.Require().Equal(1, ledgerRefs)
+ s.Require().Equal(1, abuseRefs)
+}
+
+func (s *RedeemCodeRepoSuite) TestDeleteIfUnusedPreservesExpiredCode() {
+ created, err := s.client.RedeemCode.Create().
+ SetCode("PRESERVE-EXPIRED").
+ SetType(service.RedeemTypeBalance).
+ SetStatus(service.StatusExpired).
+ SetValue(10).
+ Save(s.ctx)
+ s.Require().NoError(err)
+
+ deleted, err := s.repo.DeleteIfUnused(s.ctx, created.ID)
+ s.Require().NoError(err)
+ s.Require().False(deleted)
+
+ got, err := s.repo.GetByID(s.ctx, created.ID)
+ s.Require().NoError(err)
+ s.Require().Equal(service.StatusExpired, got.Status)
+}
+
+func (s *RedeemCodeRepoSuite) TestExpireIfUnusedProtectsUsedCode() {
+ user := s.createUser(uniqueTestValue(s.T(), "expire-used") + "@example.com")
+ usedAt := time.Now().UTC().Truncate(time.Second)
+ created, err := s.client.RedeemCode.Create().
+ SetCode("DO-NOT-EXPIRE-USED").
+ SetType(service.RedeemTypeBalance).
+ SetStatus(service.StatusUsed).
+ SetValue(10).
+ SetUsedBy(user.ID).
+ SetUsedAt(usedAt).
+ Save(s.ctx)
+ s.Require().NoError(err)
+
+ expired, err := s.repo.ExpireIfUnused(s.ctx, created.ID)
+ s.Require().NoError(err)
+ s.Require().False(expired)
+
+ got, err := s.repo.GetByID(s.ctx, created.ID)
+ s.Require().NoError(err)
+ s.Require().Equal(service.StatusUsed, got.Status)
+ s.Require().NotNil(got.UsedBy)
+ s.Require().Equal(user.ID, *got.UsedBy)
+ s.Require().NotNil(got.UsedAt)
+ s.Require().WithinDuration(usedAt, *got.UsedAt, time.Second)
+}
+
+func TestRedeemCodeExpireAndUseRaceHasSingleWinner(t *testing.T) {
+ ctx, repo, userID, codeID := newConcurrentRedeemFixture(t, "expire-use")
+ start := make(chan struct{})
+ var useErr, expireErr error
+ var expired bool
+ var wg sync.WaitGroup
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ <-start
+ useErr = repo.Use(ctx, codeID, userID)
+ }()
+ go func() {
+ defer wg.Done()
+ <-start
+ expired, expireErr = repo.ExpireIfUnused(ctx, codeID)
+ }()
+ close(start)
+ wg.Wait()
+
+ require.NoError(t, expireErr)
+ require.NotEqual(t, expired, useErr == nil, "exactly one unused-state CAS must win")
+ got, err := repo.GetByID(ctx, codeID)
+ require.NoError(t, err)
+ if expired {
+ require.Equal(t, service.StatusExpired, got.Status)
+ require.Nil(t, got.UsedBy)
+ require.Nil(t, got.UsedAt)
+ return
+ }
+ require.Equal(t, service.StatusUsed, got.Status)
+ require.NotNil(t, got.UsedBy)
+ require.Equal(t, userID, *got.UsedBy)
+ require.NotNil(t, got.UsedAt)
+}
+
+func TestRedeemCodeDeleteAndUseRaceCannotEraseUsedWinner(t *testing.T) {
+ ctx, repo, userID, codeID := newConcurrentRedeemFixture(t, "delete-use")
+ start := make(chan struct{})
+ var useErr, deleteErr error
+ var deleted bool
+ var wg sync.WaitGroup
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ <-start
+ useErr = repo.Use(ctx, codeID, userID)
+ }()
+ go func() {
+ defer wg.Done()
+ <-start
+ deleted, deleteErr = repo.DeleteIfUnused(ctx, codeID)
+ }()
+ close(start)
+ wg.Wait()
+
+ require.NoError(t, deleteErr)
+ require.NotEqual(t, deleted, useErr == nil, "exactly one unused-state CAS must win")
+ got, err := repo.GetByID(ctx, codeID)
+ if deleted {
+ require.ErrorIs(t, err, service.ErrRedeemCodeNotFound)
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, service.StatusUsed, got.Status)
+ require.NotNil(t, got.UsedBy)
+ require.Equal(t, userID, *got.UsedBy)
+ require.NotNil(t, got.UsedAt)
+}
+
+func newConcurrentRedeemFixture(t *testing.T, prefix string) (context.Context, *redeemCodeRepository, int64, int64) {
+ t.Helper()
+ ctx := context.Background()
+ client := testEntClient(t)
+ repo := NewRedeemCodeRepository(client).(*redeemCodeRepository)
+ suffix := time.Now().UnixNano()
+ user, err := client.User.Create().
+ SetEmail(fmt.Sprintf("redeem-%s-%d@example.test", prefix, suffix)).
+ SetPasswordHash("test-password-hash").
+ Save(ctx)
+ require.NoError(t, err)
+ code, err := client.RedeemCode.Create().
+ SetCode(fmt.Sprintf("REDEEM-%s-%d", prefix, suffix)).
+ SetType(service.RedeemTypeBalance).
+ SetStatus(service.StatusUnused).
+ SetValue(1).
+ Save(ctx)
+ require.NoError(t, err)
+ t.Cleanup(func() {
+ _ = client.RedeemCode.DeleteOneID(code.ID).Exec(ctx)
+ _ = client.User.DeleteOneID(user.ID).Exec(ctx)
+ })
+ return ctx, repo, user.ID, code.ID
+}
+
+func queryRedeemAuditReferenceCounts(t *testing.T, ctx context.Context, client *dbent.Client, ledgerKey string, codeID int64) (int, int) {
+ t.Helper()
+ rows, err := client.QueryContext(ctx, `
+ SELECT
+ (SELECT COUNT(*) FROM ledger_transactions WHERE idempotency_key = $1 AND redeem_code_id = $2),
+ (SELECT COUNT(*) FROM hfc_abuse_risk_events WHERE source = 'redeem-delete-guard' AND redeem_code_id = $2)
+ `, ledgerKey, codeID)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, rows.Close()) }()
+ require.True(t, rows.Next())
+ var ledgerRefs, abuseRefs int
+ require.NoError(t, rows.Scan(&ledgerRefs, &abuseRefs))
+ require.NoError(t, rows.Err())
+ return ledgerRefs, abuseRefs
+}
+
// --- List / ListWithFilters ---
func (s *RedeemCodeRepoSuite) TestList() {
+ _, basePage, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 100})
+ s.Require().NoError(err, "List base")
s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "LIST-1", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused}))
s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "LIST-2", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused}))
- codes, page, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10})
+ codes, page, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 100})
s.Require().NoError(err, "List")
- s.Require().Len(codes, 2)
- s.Require().Equal(int64(2), page.Total)
+ s.Require().Len(codes, int(basePage.Total)+2)
+ s.Require().Equal(basePage.Total+2, page.Total)
}
func (s *RedeemCodeRepoSuite) TestListWithFilters_Type() {
+ _, basePage, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 100}, service.RedeemTypeSubscription, "", "")
+ s.Require().NoError(err)
s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "TYPE-BAL", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused}))
s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "TYPE-SUB", Type: service.RedeemTypeSubscription, Value: 0, Status: service.StatusUnused}))
- codes, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.RedeemTypeSubscription, "", "")
+ codes, page, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 100}, service.RedeemTypeSubscription, "", "")
s.Require().NoError(err)
- s.Require().Len(codes, 1)
- s.Require().Equal(service.RedeemTypeSubscription, codes[0].Type)
+ s.Require().Equal(basePage.Total+1, page.Total)
+ var found bool
+ for _, code := range codes {
+ if code.Code == "TYPE-SUB" {
+ found = true
+ s.Require().Equal(service.RedeemTypeSubscription, code.Type)
+ }
+ }
+ s.Require().True(found)
}
func (s *RedeemCodeRepoSuite) TestListWithFilters_Status() {
+ _, basePage, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 100}, "", service.StatusUsed, "")
+ s.Require().NoError(err)
s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "STAT-UNUSED", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUnused}))
s.Require().NoError(s.repo.Create(s.ctx, &service.RedeemCode{Code: "STAT-USED", Type: service.RedeemTypeBalance, Value: 0, Status: service.StatusUsed}))
- codes, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", service.StatusUsed, "")
+ codes, page, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 100}, "", service.StatusUsed, "")
s.Require().NoError(err)
- s.Require().Len(codes, 1)
- s.Require().Equal(service.StatusUsed, codes[0].Status)
+ s.Require().Equal(basePage.Total+1, page.Total)
+ var found bool
+ for _, code := range codes {
+ if code.Code == "STAT-USED" {
+ found = true
+ s.Require().Equal(service.StatusUsed, code.Status)
+ }
+ }
+ s.Require().True(found)
}
func (s *RedeemCodeRepoSuite) TestListWithFilters_Search() {
@@ -189,7 +416,7 @@ func (s *RedeemCodeRepoSuite) TestListWithFilters_GroupPreload() {
Save(s.ctx)
s.Require().NoError(err)
- codes, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", "", "")
+ codes, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, "", "", "WITH-GROUP")
s.Require().NoError(err)
s.Require().Len(codes, 1)
s.Require().NotNil(codes[0].Group, "expected Group preload")
diff --git a/backend/internal/repository/redeem_code_repo_security_test.go b/backend/internal/repository/redeem_code_repo_security_test.go
new file mode 100644
index 00000000000..fc72079563d
--- /dev/null
+++ b/backend/internal/repository/redeem_code_repo_security_test.go
@@ -0,0 +1,117 @@
+package repository
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/ent/redeemcode"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRedeemCodeRepositoryDeleteIfUnusedProtectsUsedAuditRow(t *testing.T) {
+ ctx := context.Background()
+ client := newSecuritySecretTestClient(t)
+ repo := &redeemCodeRepository{client: client}
+ user, err := client.User.Create().
+ SetEmail("redeem-delete-guard@example.com").
+ SetPasswordHash("not-a-real-password-hash").
+ Save(ctx)
+ require.NoError(t, err)
+ usedBy := user.ID
+ usedAt := time.Now().UTC().Truncate(time.Second)
+ used, err := client.RedeemCode.Create().
+ SetCode("used-delete-guard").
+ SetType(service.RedeemTypeBalance).
+ SetValue(10).
+ SetStatus(service.StatusUsed).
+ SetUsedBy(usedBy).
+ SetUsedAt(usedAt).
+ Save(ctx)
+ require.NoError(t, err)
+ unused, err := client.RedeemCode.Create().
+ SetCode("unused-delete-guard").
+ SetType(service.RedeemTypeBalance).
+ SetValue(10).
+ SetStatus(service.StatusUnused).
+ Save(ctx)
+ require.NoError(t, err)
+ expired, err := client.RedeemCode.Create().
+ SetCode("expired-delete-guard").
+ SetType(service.RedeemTypeBalance).
+ SetValue(10).
+ SetStatus(service.StatusExpired).
+ Save(ctx)
+ require.NoError(t, err)
+
+ deleted, err := repo.DeleteIfUnused(ctx, used.ID)
+ require.NoError(t, err)
+ require.False(t, deleted)
+ stillUsed, err := client.RedeemCode.Get(ctx, used.ID)
+ require.NoError(t, err)
+ require.Equal(t, service.StatusUsed, stillUsed.Status)
+ require.Equal(t, usedBy, *stillUsed.UsedBy)
+ require.WithinDuration(t, usedAt, *stillUsed.UsedAt, time.Second)
+
+ deleted, err = repo.DeleteIfUnused(ctx, unused.ID)
+ require.NoError(t, err)
+ require.True(t, deleted)
+ exists, err := client.RedeemCode.Query().Where(redeemcode.IDEQ(unused.ID)).Exist(ctx)
+ require.NoError(t, err)
+ require.False(t, exists)
+
+ deleted, err = repo.DeleteIfUnused(ctx, expired.ID)
+ require.NoError(t, err)
+ require.False(t, deleted)
+ stillExpired, err := client.RedeemCode.Get(ctx, expired.ID)
+ require.NoError(t, err)
+ require.Equal(t, service.StatusExpired, stillExpired.Status)
+}
+
+func TestRedeemCodeRepositoryExpireIfUnusedCannotOverwriteUsedState(t *testing.T) {
+ ctx := context.Background()
+ client := newSecuritySecretTestClient(t)
+ repo := &redeemCodeRepository{client: client}
+ user, err := client.User.Create().
+ SetEmail("redeem-expire-guard@example.com").
+ SetPasswordHash("not-a-real-password-hash").
+ Save(ctx)
+ require.NoError(t, err)
+ usedBy := user.ID
+ usedAt := time.Now().UTC().Truncate(time.Second)
+ used, err := client.RedeemCode.Create().
+ SetCode("used-expire-guard").
+ SetType(service.RedeemTypeBalance).
+ SetValue(5).
+ SetStatus(service.StatusUsed).
+ SetUsedBy(usedBy).
+ SetUsedAt(usedAt).
+ Save(ctx)
+ require.NoError(t, err)
+ unused, err := client.RedeemCode.Create().
+ SetCode("unused-expire-guard").
+ SetType(service.RedeemTypeBalance).
+ SetValue(5).
+ SetStatus(service.StatusUnused).
+ Save(ctx)
+ require.NoError(t, err)
+
+ expired, err := repo.ExpireIfUnused(ctx, used.ID)
+ require.NoError(t, err)
+ require.False(t, expired)
+ stillUsed, err := client.RedeemCode.Get(ctx, used.ID)
+ require.NoError(t, err)
+ require.Equal(t, service.StatusUsed, stillUsed.Status)
+ require.Equal(t, usedBy, *stillUsed.UsedBy)
+ require.WithinDuration(t, usedAt, *stillUsed.UsedAt, time.Second)
+
+ expired, err = repo.ExpireIfUnused(ctx, unused.ID)
+ require.NoError(t, err)
+ require.True(t, expired)
+ nowExpired, err := client.RedeemCode.Get(ctx, unused.ID)
+ require.NoError(t, err)
+ require.Equal(t, service.StatusExpired, nowExpired.Status)
+ require.Nil(t, nowExpired.UsedBy)
+ require.Nil(t, nowExpired.UsedAt)
+}
diff --git a/backend/internal/repository/redeem_code_repo_sort_integration_test.go b/backend/internal/repository/redeem_code_repo_sort_integration_test.go
index 30d32f4cf97..b03cc1a21c7 100644
--- a/backend/internal/repository/redeem_code_repo_sort_integration_test.go
+++ b/backend/internal/repository/redeem_code_repo_sort_integration_test.go
@@ -16,7 +16,7 @@ func (s *RedeemCodeRepoSuite) TestListWithFilters_SortByValueAsc() {
PageSize: 10,
SortBy: "value",
SortOrder: "asc",
- }, "", "", "")
+ }, "", "", "VALUE-")
s.Require().NoError(err)
s.Require().Len(codes, 2)
s.Require().Equal("VALUE-10", codes[0].Code)
diff --git a/backend/internal/repository/redeem_code_transaction_integration_test.go b/backend/internal/repository/redeem_code_transaction_integration_test.go
new file mode 100644
index 00000000000..aff6e10b694
--- /dev/null
+++ b/backend/internal/repository/redeem_code_transaction_integration_test.go
@@ -0,0 +1,39 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRedeemCodeRepositoryCreateUsesCallerTransaction(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ repo := NewRedeemCodeRepository(client)
+ codeValue := "tx-rollback-" + uuid.NewString()[:20]
+
+ tx, err := client.Tx(ctx)
+ require.NoError(t, err)
+ txCtx := dbent.NewTxContext(ctx, tx)
+
+ created := &service.RedeemCode{
+ Code: codeValue,
+ Type: service.RedeemTypeBalance,
+ Value: 10,
+ Status: service.StatusUnused,
+ }
+ require.NoError(t, repo.Create(txCtx, created))
+ _, err = repo.GetByCode(txCtx, codeValue)
+ require.NoError(t, err, "transaction must read its own uncommitted redeem code")
+ require.NoError(t, tx.Rollback())
+
+ _, err = repo.GetByCode(ctx, codeValue)
+ require.True(t, errors.Is(err, service.ErrRedeemCodeNotFound), "rollback must remove the redeem code")
+}
diff --git a/backend/internal/repository/redis_test.go b/backend/internal/repository/redis_test.go
index 7cb31002b37..0c98c819960 100644
--- a/backend/internal/repository/redis_test.go
+++ b/backend/internal/repository/redis_test.go
@@ -1,13 +1,23 @@
package repository
import (
+ "bytes"
+ "encoding/json"
+ "strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
+func newTotpCacheTestEncryptor() *AESEncryptor {
+ return &AESEncryptor{domainKeys: map[string][]byte{
+ service.SecretDomainTOTPCache: bytes.Repeat([]byte{0x42}, 32),
+ }}
+}
+
func TestBuildRedisOptions(t *testing.T) {
cfg := &config.Config{
Redis: config.RedisConfig{
@@ -45,3 +55,48 @@ func TestBuildRedisOptions(t *testing.T) {
require.NotNil(t, optsTLS.TLSConfig)
require.Equal(t, "localhost", optsTLS.TLSConfig.ServerName)
}
+
+func TestEncodeTotpSetupSessionEncryptsSecretAndSetupToken(t *testing.T) {
+ session := &service.TotpSetupSession{
+ Secret: "JBSWY3DPEHPK3PXP",
+ SetupToken: "setup-token-that-must-not-be-plaintext",
+ CreatedAt: time.Date(2026, 7, 13, 1, 2, 3, 0, time.UTC),
+ }
+
+ encryptor := newTotpCacheTestEncryptor()
+ encoded, err := encodeTotpSetupSession(session, encryptor)
+ require.NoError(t, err)
+ require.True(t, strings.HasPrefix(string(encoded), totpSetupEncryptedV2Prefix))
+ require.NotContains(t, string(encoded), session.Secret)
+ require.NotContains(t, string(encoded), session.SetupToken)
+
+ decoded, err := decodeTotpSetupSession(encoded, encryptor)
+ require.NoError(t, err)
+ require.Equal(t, session, decoded)
+}
+
+func TestDecodeTotpSetupSessionRejectsLegacyPlaintextJSON(t *testing.T) {
+ legacy := &service.TotpSetupSession{
+ Secret: "LEGACYSECRET",
+ SetupToken: "legacy-setup-token",
+ CreatedAt: time.Date(2026, 7, 13, 1, 2, 3, 0, time.UTC),
+ }
+ data, err := json.Marshal(legacy)
+ require.NoError(t, err)
+
+ _, err = decodeTotpSetupSession(data, newTotpCacheTestEncryptor())
+ require.Error(t, err)
+}
+
+func TestDecodeTotpSetupSessionRejectsInvalidCiphertext(t *testing.T) {
+ _, err := decodeTotpSetupSession([]byte(totpSetupEncryptedV2Prefix+"not-base64"), newTotpCacheTestEncryptor())
+ require.Error(t, err)
+}
+
+func TestEncodeTotpSetupSessionFailsClosedWithoutEncryptor(t *testing.T) {
+ _, err := encodeTotpSetupSession(&service.TotpSetupSession{
+ Secret: "must-not-fall-back-to-plaintext",
+ SetupToken: "must-not-fall-back-to-plaintext",
+ }, nil)
+ require.Error(t, err)
+}
diff --git a/backend/internal/repository/refresh_token_cache.go b/backend/internal/repository/refresh_token_cache.go
index b01bd476971..75732db023a 100644
--- a/backend/internal/repository/refresh_token_cache.go
+++ b/backend/internal/repository/refresh_token_cache.go
@@ -65,6 +65,22 @@ func (c *refreshTokenCache) GetRefreshToken(ctx context.Context, tokenHash strin
return &data, nil
}
+func (c *refreshTokenCache) ConsumeRefreshToken(ctx context.Context, tokenHash string) (*service.RefreshTokenData, error) {
+ key := refreshTokenKey(tokenHash)
+ val, err := c.rdb.GetDel(ctx, key).Result()
+ if err != nil {
+ if err == redis.Nil {
+ return nil, service.ErrRefreshTokenNotFound
+ }
+ return nil, err
+ }
+ var data service.RefreshTokenData
+ if err := json.Unmarshal([]byte(val), &data); err != nil {
+ return nil, fmt.Errorf("unmarshal consumed refresh token data: %w", err)
+ }
+ return &data, nil
+}
+
func (c *refreshTokenCache) DeleteRefreshToken(ctx context.Context, tokenHash string) error {
key := refreshTokenKey(tokenHash)
return c.rdb.Del(ctx, key).Err()
diff --git a/backend/internal/repository/refresh_token_cache_integration_test.go b/backend/internal/repository/refresh_token_cache_integration_test.go
new file mode 100644
index 00000000000..cb4d1917e94
--- /dev/null
+++ b/backend/internal/repository/refresh_token_cache_integration_test.go
@@ -0,0 +1,65 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRefreshTokenCacheConsumeExactlyOnce(t *testing.T) {
+ ctx := context.Background()
+ cache := NewRefreshTokenCache(testRedis(t))
+ data := &service.RefreshTokenData{
+ UserID: 91,
+ TokenVersion: 3,
+ FamilyID: "family-exactly-once",
+ CreatedAt: time.Now().UTC().Truncate(time.Millisecond),
+ ExpiresAt: time.Now().UTC().Add(time.Hour).Truncate(time.Millisecond),
+ }
+ require.NoError(t, cache.StoreRefreshToken(ctx, "parent-hash", data, time.Hour))
+
+ const callers = 16
+ start := make(chan struct{})
+ var wg sync.WaitGroup
+ type consumeResult struct {
+ data *service.RefreshTokenData
+ err error
+ }
+ results := make(chan consumeResult, callers)
+ for range callers {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-start
+ consumed, err := cache.ConsumeRefreshToken(ctx, "parent-hash")
+ results <- consumeResult{data: consumed, err: err}
+ }()
+ }
+ close(start)
+ wg.Wait()
+ close(results)
+
+ var successes atomic.Int32
+ var misses atomic.Int32
+ for result := range results {
+ switch {
+ case result.err == nil:
+ require.Equal(t, data, result.data)
+ successes.Add(1)
+ case errors.Is(result.err, service.ErrRefreshTokenNotFound):
+ misses.Add(1)
+ default:
+ require.NoError(t, result.err)
+ }
+ }
+ require.Equal(t, int32(1), successes.Load())
+ require.Equal(t, int32(callers-1), misses.Load())
+}
diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go
index 590ddaa36e2..d1ee2fb1514 100644
--- a/backend/internal/repository/scheduler_cache.go
+++ b/backend/internal/repository/scheduler_cache.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"strconv"
+ "strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -13,14 +14,21 @@ import (
const (
schedulerBucketSetKey = "sched:buckets"
- schedulerOutboxWatermarkKey = "sched:outbox:watermark"
- schedulerAccountPrefix = "sched:acc:"
- schedulerAccountMetaPrefix = "sched:meta:"
- schedulerActivePrefix = "sched:active:"
- schedulerReadyPrefix = "sched:ready:"
- schedulerVersionPrefix = "sched:ver:"
- schedulerSnapshotPrefix = "sched:"
- schedulerLockPrefix = "sched:lock:"
+ schedulerOutboxWatermarkKey = "sched:v2:outbox:watermark"
+ schedulerAccountPrefix = "sched:v2:acc:"
+ schedulerAccountMetaPrefix = "sched:v2:meta:"
+ schedulerActivePrefix = "sched:v2:active:"
+ schedulerReadyPrefix = "sched:v2:ready:"
+ schedulerVersionPrefix = "sched:v2:ver:"
+ schedulerSnapshotPrefix = "sched:v2:"
+ schedulerLockPrefix = "sched:v2:lock:"
+
+ schedulerCachePayloadVersion = 1
+ schedulerCachePayloadKindAccount = "account"
+ schedulerCachePayloadKindMetadata = "metadata"
+ schedulerLegacyAccountPattern = "sched:acc:*"
+ schedulerLegacyMetadataPattern = "sched:meta:*"
+ schedulerLegacyWatermarkKey = "sched:outbox:watermark"
defaultSchedulerSnapshotMGetChunkSize = 128
defaultSchedulerSnapshotWriteChunkSize = 256
@@ -71,15 +79,28 @@ return 1
type schedulerCache struct {
rdb *redis.Client
+ encryptor service.SecretEncryptor
mgetChunkSize int
writeChunkSize int
}
-func NewSchedulerCache(rdb *redis.Client) service.SchedulerCache {
- return newSchedulerCacheWithChunkSizes(rdb, defaultSchedulerSnapshotMGetChunkSize, defaultSchedulerSnapshotWriteChunkSize)
+type schedulerCachePayload struct {
+ Version int `json:"version"`
+ Kind string `json:"kind"`
+ Account service.Account `json:"account"`
}
-func newSchedulerCacheWithChunkSizes(rdb *redis.Client, mgetChunkSize, writeChunkSize int) service.SchedulerCache {
+func NewSchedulerCache(rdb *redis.Client, encryptor service.SecretEncryptor) (service.SchedulerCache, error) {
+ return newSchedulerCacheWithChunkSizes(rdb, encryptor, defaultSchedulerSnapshotMGetChunkSize, defaultSchedulerSnapshotWriteChunkSize)
+}
+
+func newSchedulerCacheWithChunkSizes(rdb *redis.Client, encryptor service.SecretEncryptor, mgetChunkSize, writeChunkSize int) (service.SchedulerCache, error) {
+ if rdb == nil {
+ return nil, fmt.Errorf("scheduler cache Redis client is required")
+ }
+ if _, ok := encryptor.(service.DomainSecretEncryptor); !ok || encryptor == nil {
+ return nil, fmt.Errorf("scheduler cache domain secret encryptor is required")
+ }
if mgetChunkSize <= 0 {
mgetChunkSize = defaultSchedulerSnapshotMGetChunkSize
}
@@ -88,9 +109,10 @@ func newSchedulerCacheWithChunkSizes(rdb *redis.Client, mgetChunkSize, writeChun
}
return &schedulerCache{
rdb: rdb,
+ encryptor: encryptor,
mgetChunkSize: mgetChunkSize,
writeChunkSize: writeChunkSize,
- }
+ }, nil
}
func (c *schedulerCache) GetSnapshot(ctx context.Context, bucket service.SchedulerBucket) ([]*service.Account, bool, error) {
@@ -140,7 +162,7 @@ func (c *schedulerCache) GetSnapshot(ctx context.Context, bucket service.Schedul
if val == nil {
return nil, false, nil
}
- account, err := decodeCachedAccount(val)
+ account, err := decodeSchedulerCachedAccount(val, schedulerCachePayloadKindMetadata, c.encryptor)
if err != nil {
return nil, false, err
}
@@ -217,7 +239,7 @@ func (c *schedulerCache) GetAccount(ctx context.Context, accountID int64) (*serv
if err != nil {
return nil, err
}
- return decodeCachedAccount(val)
+ return decodeSchedulerCachedAccount(val, schedulerCachePayloadKindAccount, c.encryptor)
}
func (c *schedulerCache) SetAccount(ctx context.Context, account *service.Account) error {
@@ -257,16 +279,16 @@ func (c *schedulerCache) UpdateLastUsed(ctx context.Context, updates map[int64]t
if val == nil {
continue
}
- account, err := decodeCachedAccount(val)
+ account, err := decodeSchedulerCachedAccount(val, schedulerCachePayloadKindAccount, c.encryptor)
if err != nil {
return err
}
account.LastUsedAt = ptrTime(updates[ids[i]])
- updated, err := json.Marshal(account)
+ updated, err := encodeSchedulerCachedAccount(*account, schedulerCachePayloadKindAccount, c.encryptor)
if err != nil {
return err
}
- metaPayload, err := json.Marshal(buildSchedulerMetadataAccount(*account))
+ metaPayload, err := encodeSchedulerCachedAccount(buildSchedulerMetadataAccount(*account), schedulerCachePayloadKindMetadata, c.encryptor)
if err != nil {
return err
}
@@ -342,21 +364,50 @@ func ptrTime(t time.Time) *time.Time {
return &t
}
-func decodeCachedAccount(val any) (*service.Account, error) {
- var payload []byte
+func encodeSchedulerCachedAccount(account service.Account, kind string, encryptor service.SecretEncryptor) (string, error) {
+ if kind != schedulerCachePayloadKindAccount && kind != schedulerCachePayloadKindMetadata {
+ return "", fmt.Errorf("unsupported scheduler cache payload kind %q", kind)
+ }
+ payload, err := json.Marshal(schedulerCachePayload{
+ Version: schedulerCachePayloadVersion,
+ Kind: kind,
+ Account: account,
+ })
+ if err != nil {
+ return "", fmt.Errorf("marshal scheduler cache payload: %w", err)
+ }
+ ciphertext, err := service.EncryptForSecretDomain(encryptor, service.SecretDomainSchedulerCache, string(payload))
+ if err != nil {
+ return "", fmt.Errorf("encrypt scheduler cache payload: %w", err)
+ }
+ return ciphertext, nil
+}
+
+func decodeSchedulerCachedAccount(val any, expectedKind string, encryptor service.SecretEncryptor) (*service.Account, error) {
+ var ciphertext string
switch raw := val.(type) {
case string:
- payload = []byte(raw)
+ ciphertext = raw
case []byte:
- payload = raw
+ ciphertext = string(raw)
default:
return nil, fmt.Errorf("unexpected account cache type: %T", val)
}
- var account service.Account
- if err := json.Unmarshal(payload, &account); err != nil {
- return nil, err
+ plaintext, err := service.DecryptForSecretDomain(encryptor, service.SecretDomainSchedulerCache, ciphertext)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt scheduler cache payload: %w", err)
+ }
+ var payload schedulerCachePayload
+ if err := json.Unmarshal([]byte(plaintext), &payload); err != nil {
+ return nil, fmt.Errorf("decode scheduler cache payload: %w", err)
}
- return &account, nil
+ if payload.Version != schedulerCachePayloadVersion {
+ return nil, fmt.Errorf("unsupported scheduler cache payload version %d", payload.Version)
+ }
+ if payload.Kind != expectedKind {
+ return nil, fmt.Errorf("scheduler cache payload kind %q does not match expected %q", payload.Kind, expectedKind)
+ }
+ return &payload.Account, nil
}
func (c *schedulerCache) writeAccounts(ctx context.Context, accounts []service.Account) error {
@@ -379,11 +430,11 @@ func (c *schedulerCache) writeAccounts(ctx context.Context, accounts []service.A
}
for _, account := range accounts {
- fullPayload, err := json.Marshal(account)
+ fullPayload, err := encodeSchedulerCachedAccount(account, schedulerCachePayloadKindAccount, c.encryptor)
if err != nil {
return err
}
- metaPayload, err := json.Marshal(buildSchedulerMetadataAccount(account))
+ metaPayload, err := encodeSchedulerCachedAccount(buildSchedulerMetadataAccount(account), schedulerCachePayloadKindMetadata, c.encryptor)
if err != nil {
return err
}
@@ -516,19 +567,108 @@ func filterSchedulerCredentials(credentials map[string]any) map[string]any {
if len(credentials) == 0 {
return nil
}
- keys := []string{"model_mapping", "api_key", "project_id", "oauth_type"}
+ keys := []string{"model_mapping", "project_id", "oauth_type"}
filtered := make(map[string]any)
for _, key := range keys {
if value, ok := credentials[key]; ok && value != nil {
filtered[key] = value
}
}
+ if apiKey, ok := credentials["api_key"].(string); ok && strings.TrimSpace(apiKey) != "" {
+ filtered[service.SchedulerMetadataAPIKeyConfigured] = true
+ }
if len(filtered) == 0 {
return nil
}
return filtered
}
+// PurgeLegacySchedulerCacheSecrets removes the pre-v2 plaintext account and
+// metadata namespaces. New encrypted entries use sched:v2:* and are never
+// touched, so the purge is idempotent across restarts.
+func PurgeLegacySchedulerCacheSecrets(ctx context.Context, rdb *redis.Client) (int64, error) {
+ if rdb == nil {
+ return 0, fmt.Errorf("scheduler cache Redis client is required")
+ }
+ var removed int64
+ for _, pattern := range []string{schedulerLegacyAccountPattern, schedulerLegacyMetadataPattern} {
+ count, err := purgeLegacySchedulerCachePattern(ctx, rdb, pattern)
+ removed += count
+ if err != nil {
+ return removed, err
+ }
+ }
+ return removed, nil
+}
+
+// SeedSchedulerCacheV2Watermark copies the non-secret legacy outbox position
+// exactly once. This avoids a full replay without allowing an old process to
+// advance the new namespace during a rolling cutover.
+func SeedSchedulerCacheV2Watermark(ctx context.Context, rdb *redis.Client) (bool, error) {
+ if rdb == nil {
+ return false, fmt.Errorf("scheduler cache Redis client is required")
+ }
+ current, err := rdb.Get(ctx, schedulerOutboxWatermarkKey).Result()
+ if err == nil {
+ watermark, parseErr := strconv.ParseInt(current, 10, 64)
+ if parseErr != nil || watermark < 0 {
+ return false, fmt.Errorf("invalid scheduler v2 outbox watermark")
+ }
+ return false, nil
+ }
+ if err != redis.Nil {
+ return false, fmt.Errorf("read scheduler v2 outbox watermark: %w", err)
+ }
+ legacy, err := rdb.Get(ctx, schedulerLegacyWatermarkKey).Result()
+ if err == redis.Nil {
+ return false, nil
+ }
+ if err != nil {
+ return false, fmt.Errorf("read legacy scheduler outbox watermark: %w", err)
+ }
+ watermark, err := strconv.ParseInt(legacy, 10, 64)
+ if err != nil || watermark < 0 {
+ return false, fmt.Errorf("invalid legacy scheduler outbox watermark")
+ }
+ seeded, err := rdb.SetNX(ctx, schedulerOutboxWatermarkKey, strconv.FormatInt(watermark, 10), 0).Result()
+ if err != nil {
+ return false, fmt.Errorf("seed scheduler v2 outbox watermark: %w", err)
+ }
+ return seeded, nil
+}
+
+func purgeLegacySchedulerCachePattern(ctx context.Context, rdb *redis.Client, pattern string) (int64, error) {
+ var removed int64
+ for pass := 0; pass < 3; pass++ {
+ var cursor uint64
+ for {
+ keys, next, err := rdb.Scan(ctx, cursor, pattern, 256).Result()
+ if err != nil {
+ return removed, fmt.Errorf("scan legacy scheduler cache %q: %w", pattern, err)
+ }
+ if len(keys) > 0 {
+ count, err := rdb.Unlink(ctx, keys...).Result()
+ if err != nil {
+ return removed, fmt.Errorf("remove legacy scheduler cache %q: %w", pattern, err)
+ }
+ removed += count
+ }
+ cursor = next
+ if cursor == 0 {
+ break
+ }
+ }
+ remaining, _, err := rdb.Scan(ctx, 0, pattern, 1).Result()
+ if err != nil {
+ return removed, fmt.Errorf("verify legacy scheduler cache %q: %w", pattern, err)
+ }
+ if len(remaining) == 0 {
+ return removed, nil
+ }
+ }
+ return removed, fmt.Errorf("legacy scheduler cache keys remain for %q", pattern)
+}
+
func filterSchedulerExtra(extra map[string]any) map[string]any {
if len(extra) == 0 {
return nil
diff --git a/backend/internal/repository/scheduler_cache_integration_test.go b/backend/internal/repository/scheduler_cache_integration_test.go
index 948c2c73e0b..d01d33443e7 100644
--- a/backend/internal/repository/scheduler_cache_integration_test.go
+++ b/backend/internal/repository/scheduler_cache_integration_test.go
@@ -15,7 +15,8 @@ import (
func TestSchedulerCacheSnapshotUsesSlimMetadataButKeepsFullAccount(t *testing.T) {
ctx := context.Background()
rdb := testRedis(t)
- cache := NewSchedulerCache(rdb)
+ cache, err := NewSchedulerCache(rdb, newDomainMigrationTestEncryptor(t))
+ require.NoError(t, err)
bucket := service.SchedulerBucket{GroupID: 2, Platform: service.PlatformGemini, Mode: service.SchedulerModeSingle}
now := time.Now().UTC().Truncate(time.Second)
@@ -69,6 +70,16 @@ func TestSchedulerCacheSnapshotUsesSlimMetadataButKeepsFullAccount(t *testing.T)
require.NoError(t, cache.SetSnapshot(ctx, bucket, []service.Account{account}))
+ storedFull, err := rdb.Get(ctx, schedulerAccountKey("101")).Result()
+ require.NoError(t, err)
+ require.True(t, strings.HasPrefix(storedFull, secretDomainCiphertextPrefix))
+ require.NotContains(t, storedFull, "secret-access-token")
+ require.NotContains(t, storedFull, "gemini-api-key")
+ storedMetadata, err := rdb.Get(ctx, schedulerAccountMetaKey("101")).Result()
+ require.NoError(t, err)
+ require.True(t, strings.HasPrefix(storedMetadata, secretDomainCiphertextPrefix))
+ require.NotContains(t, storedMetadata, "gemini-api-key")
+
snapshot, hit, err := cache.GetSnapshot(ctx, bucket)
require.NoError(t, err)
require.True(t, hit)
@@ -76,7 +87,8 @@ func TestSchedulerCacheSnapshotUsesSlimMetadataButKeepsFullAccount(t *testing.T)
got := snapshot[0]
require.NotNil(t, got)
- require.Equal(t, "gemini-api-key", got.GetCredential("api_key"))
+ require.Empty(t, got.GetCredential("api_key"))
+ require.Equal(t, true, got.Credentials[service.SchedulerMetadataAPIKeyConfigured])
require.Equal(t, "proj-1", got.GetCredential("project_id"))
require.Equal(t, "ai_studio", got.GetCredential("oauth_type"))
require.NotEmpty(t, got.GetModelMapping())
@@ -102,3 +114,36 @@ func TestSchedulerCacheSnapshotUsesSlimMetadataButKeepsFullAccount(t *testing.T)
require.Len(t, full.AccountGroups, 1)
require.NotNil(t, full.AccountGroups[0].Group)
}
+
+func TestPurgeLegacySchedulerCacheSecretsRemovesOnlyPlaintextNamespace(t *testing.T) {
+ ctx := context.Background()
+ rdb := testRedis(t)
+
+ require.NoError(t, rdb.Set(ctx, "sched:acc:7", `{"Credentials":{"access_token":"legacy-secret"}}`, 0).Err())
+ require.NoError(t, rdb.Set(ctx, "sched:meta:7", `{"Credentials":{"api_key":"legacy-key"}}`, 0).Err())
+ require.NoError(t, rdb.Set(ctx, schedulerAccountKey("7"), "new-encrypted-namespace", 0).Err())
+
+ removed, err := PurgeLegacySchedulerCacheSecrets(ctx, rdb)
+ require.NoError(t, err)
+ require.EqualValues(t, 2, removed)
+ require.EqualValues(t, 0, rdb.Exists(ctx, "sched:acc:7", "sched:meta:7").Val())
+ require.EqualValues(t, 1, rdb.Exists(ctx, schedulerAccountKey("7")).Val())
+}
+
+func TestSeedSchedulerCacheV2WatermarkCopiesLegacyOnlyOnce(t *testing.T) {
+ ctx := context.Background()
+ rdb := testRedis(t)
+ require.NoError(t, rdb.Del(ctx, schedulerOutboxWatermarkKey, schedulerLegacyWatermarkKey).Err())
+ require.NoError(t, rdb.Set(ctx, schedulerLegacyWatermarkKey, "123", 0).Err())
+
+ seeded, err := SeedSchedulerCacheV2Watermark(ctx, rdb)
+ require.NoError(t, err)
+ require.True(t, seeded)
+ require.Equal(t, "123", rdb.Get(ctx, schedulerOutboxWatermarkKey).Val())
+
+ require.NoError(t, rdb.Set(ctx, schedulerLegacyWatermarkKey, "456", 0).Err())
+ seeded, err = SeedSchedulerCacheV2Watermark(ctx, rdb)
+ require.NoError(t, err)
+ require.False(t, seeded)
+ require.Equal(t, "123", rdb.Get(ctx, schedulerOutboxWatermarkKey).Val())
+}
diff --git a/backend/internal/repository/scheduler_cache_unit_test.go b/backend/internal/repository/scheduler_cache_unit_test.go
index 33f3b581b51..fcae954c66b 100644
--- a/backend/internal/repository/scheduler_cache_unit_test.go
+++ b/backend/internal/repository/scheduler_cache_unit_test.go
@@ -3,12 +3,58 @@
package repository
import (
+ "encoding/json"
+ "strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
+func TestEncodeSchedulerCachedAccountEncryptsFullSecretPayload(t *testing.T) {
+ account := service.Account{
+ ID: 42,
+ Credentials: map[string]any{"access_token": "account-secret-that-must-not-be-plaintext"},
+ Proxy: &service.Proxy{Password: "proxy-secret-that-must-not-be-plaintext"},
+ }
+ encryptor := newDomainMigrationTestEncryptor(t)
+
+ encoded, err := encodeSchedulerCachedAccount(account, schedulerCachePayloadKindAccount, encryptor)
+ require.NoError(t, err)
+ require.True(t, strings.HasPrefix(encoded, secretDomainCiphertextPrefix))
+ require.NotContains(t, encoded, "account-secret-that-must-not-be-plaintext")
+ require.NotContains(t, encoded, "proxy-secret-that-must-not-be-plaintext")
+
+ decoded, err := decodeSchedulerCachedAccount(encoded, schedulerCachePayloadKindAccount, encryptor)
+ require.NoError(t, err)
+ require.Equal(t, "account-secret-that-must-not-be-plaintext", decoded.GetCredential("access_token"))
+ require.NotNil(t, decoded.Proxy)
+ require.Equal(t, "proxy-secret-that-must-not-be-plaintext", decoded.Proxy.Password)
+}
+
+func TestDecodeSchedulerCachedAccountRejectsPlaintextAndWrongPayloadKind(t *testing.T) {
+ encryptor := newDomainMigrationTestEncryptor(t)
+ legacy, err := json.Marshal(service.Account{ID: 42, Credentials: map[string]any{"api_key": "legacy-plaintext"}})
+ require.NoError(t, err)
+
+ _, err = decodeSchedulerCachedAccount(legacy, schedulerCachePayloadKindAccount, encryptor)
+ require.Error(t, err)
+
+ encoded, err := encodeSchedulerCachedAccount(service.Account{ID: 42}, schedulerCachePayloadKindAccount, encryptor)
+ require.NoError(t, err)
+ _, err = decodeSchedulerCachedAccount(encoded, schedulerCachePayloadKindMetadata, encryptor)
+ require.ErrorContains(t, err, "payload kind")
+}
+
+func TestProvideSchedulerCacheFailsClosedWithoutDomainEncryptor(t *testing.T) {
+ rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"})
+ t.Cleanup(func() { _ = rdb.Close() })
+
+ _, err := NewSchedulerCache(rdb, nil)
+ require.ErrorContains(t, err, "domain secret encryptor")
+}
+
func TestBuildSchedulerMetadataAccount_KeepsOpenAIWSFlags(t *testing.T) {
account := service.Account{
ID: 42,
@@ -71,3 +117,24 @@ func TestBuildSchedulerMetadataAccount_KeepsSlimGroupMembership(t *testing.T) {
require.Equal(t, int64(11), got.AccountGroups[1].GroupID)
require.Nil(t, got.Groups)
}
+
+func TestBuildSchedulerMetadataAccount_DropsAPIKeyButKeepsPresenceMarker(t *testing.T) {
+ account := service.Account{
+ ID: 42,
+ Platform: service.PlatformGemini,
+ Type: service.AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "must-not-be-copied",
+ "project_id": "project-1",
+ "oauth_type": "ai_studio",
+ "model_mapping": map[string]any{"gemini-2.5-pro": "gemini-2.5-pro"},
+ },
+ }
+
+ got := buildSchedulerMetadataAccount(account)
+
+ require.Empty(t, got.GetCredential("api_key"))
+ require.Equal(t, true, got.Credentials[service.SchedulerMetadataAPIKeyConfigured])
+ require.Equal(t, "project-1", got.GetCredential("project_id"))
+ require.NotEmpty(t, got.GetModelMapping())
+}
diff --git a/backend/internal/repository/scheduler_snapshot_outbox_integration_test.go b/backend/internal/repository/scheduler_snapshot_outbox_integration_test.go
index a88b74ef86b..b94f819ee1d 100644
--- a/backend/internal/repository/scheduler_snapshot_outbox_integration_test.go
+++ b/backend/internal/repository/scheduler_snapshot_outbox_integration_test.go
@@ -21,7 +21,8 @@ func TestSchedulerSnapshotOutboxReplay(t *testing.T) {
accountRepo := newAccountRepositoryWithSQL(client, integrationDB, nil)
outboxRepo := NewSchedulerOutboxRepository(integrationDB)
- cache := NewSchedulerCache(rdb)
+ cache, err := NewSchedulerCache(rdb, newDomainMigrationTestEncryptor(t))
+ require.NoError(t, err)
cfg := &config.Config{
RunMode: config.RunModeStandard,
diff --git a/backend/internal/repository/security_secret_bootstrap.go b/backend/internal/repository/security_secret_bootstrap.go
index e773c238ffa..7a25e355d5b 100644
--- a/backend/internal/repository/security_secret_bootstrap.go
+++ b/backend/internal/repository/security_secret_bootstrap.go
@@ -2,18 +2,17 @@ package repository
import (
"context"
- "crypto/rand"
+ "crypto/subtle"
"database/sql"
- "encoding/hex"
"errors"
"fmt"
- "log"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
"github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/service"
)
const (
@@ -22,107 +21,114 @@ const (
securitySecretReadRetryWait = 10 * time.Millisecond
)
-var readRandomBytes = rand.Read
-
-func ensureBootstrapSecrets(ctx context.Context, client *ent.Client, cfg *config.Config) error {
+func ensureBootstrapSecrets(ctx context.Context, client *ent.Client, cfg *config.Config, encryptor service.SecretEncryptor) error {
if client == nil {
return fmt.Errorf("nil ent client")
}
if cfg == nil {
return fmt.Errorf("nil config")
}
+ if encryptor == nil {
+ return fmt.Errorf("jwt secret encryptor is required")
+ }
cfg.JWT.Secret = strings.TrimSpace(cfg.JWT.Secret)
- if cfg.JWT.Secret != "" {
- storedSecret, err := createSecuritySecretIfAbsent(ctx, client, securitySecretKeyJWT, cfg.JWT.Secret)
- if err != nil {
- return fmt.Errorf("persist jwt secret: %w", err)
+ configuredSecret := cfg.JWT.Secret
+ if configuredSecret != "" {
+ if err := validateBootstrapSecret(securitySecretKeyJWT, configuredSecret); err != nil {
+ return err
+ }
+ }
+
+ stored, err := client.SecuritySecret.Query().Where(securitysecret.KeyEQ(securitySecretKeyJWT)).Only(ctx)
+ if ent.IsNotFound(err) {
+ if configuredSecret == "" {
+ return fmt.Errorf("JWT_SECRET is required when no persisted jwt secret exists")
}
- if storedSecret != cfg.JWT.Secret {
- log.Println("Warning: configured JWT secret mismatches persisted value; using persisted secret for cross-instance consistency.")
+ ciphertext, encryptErr := service.EncryptForSecretDomain(encryptor, service.SecretDomainJWTHMAC, configuredSecret)
+ if encryptErr != nil {
+ return fmt.Errorf("encrypt jwt secret: %w", encryptErr)
}
- cfg.JWT.Secret = storedSecret
- return nil
+ if createErr := client.SecuritySecret.Create().
+ SetKey(securitySecretKeyJWT).
+ SetValue(ciphertext).
+ OnConflictColumns(securitysecret.FieldKey).
+ DoNothing().
+ Exec(ctx); createErr != nil && !isSQLNoRowsError(createErr) {
+ return fmt.Errorf("persist encrypted jwt secret: %w", createErr)
+ }
+ stored, err = querySecuritySecretWithRetry(ctx, client, securitySecretKeyJWT)
+ if err != nil {
+ return fmt.Errorf("read persisted jwt secret: %w", err)
+ }
+ } else if err != nil {
+ return fmt.Errorf("read jwt secret: %w", err)
}
- secret, created, err := getOrCreateGeneratedSecuritySecret(ctx, client, securitySecretKeyJWT, 32)
+ plaintext, err := openOrMigrateBootstrapSecret(ctx, client, encryptor, stored)
if err != nil {
- return fmt.Errorf("ensure jwt secret: %w", err)
+ return fmt.Errorf("load encrypted jwt secret: %w", err)
}
- cfg.JWT.Secret = secret
-
- if created {
- log.Println("Warning: JWT secret auto-generated and persisted to database. Consider rotating to a managed secret for production.")
+ if subtle.ConstantTimeCompare(
+ []byte(plaintext),
+ []byte(strings.TrimSpace(cfg.SecretEncryption.JWTHMACKey)),
+ ) == 1 {
+ return fmt.Errorf("persisted JWT secret must be distinct from SECRET_ENCRYPTION_JWT_HMAC_KEY")
+ }
+ if configuredSecret != "" && configuredSecret != plaintext {
+ return fmt.Errorf("configured JWT secret mismatches persisted secret")
}
+ cfg.JWT.Secret = plaintext
return nil
}
-func getOrCreateGeneratedSecuritySecret(ctx context.Context, client *ent.Client, key string, byteLength int) (string, bool, error) {
- existing, err := client.SecuritySecret.Query().Where(securitysecret.KeyEQ(key)).Only(ctx)
- if err == nil {
- value := strings.TrimSpace(existing.Value)
- if len([]byte(value)) < 32 {
- return "", false, fmt.Errorf("stored secret %q must be at least 32 bytes", key)
- }
- return value, false, nil
+func openOrMigrateBootstrapSecret(ctx context.Context, client *ent.Client, encryptor service.SecretEncryptor, stored *ent.SecuritySecret) (string, error) {
+ if stored == nil {
+ return "", fmt.Errorf("nil persisted secret")
}
- if !ent.IsNotFound(err) {
- return "", false, err
+ rawValue := strings.TrimSpace(stored.Value)
+ if strings.HasPrefix(rawValue, secretDomainCiphertextPrefix) ||
+ strings.HasPrefix(rawValue, legacySecretDomainCiphertextPrefixV2) {
+ plaintext, err := service.DecryptForSecretDomain(encryptor, service.SecretDomainJWTHMAC, rawValue)
+ if err != nil {
+ return "", fmt.Errorf("decrypt %q: %w", stored.Key, err)
+ }
+ if err := validateBootstrapSecret(stored.Key, plaintext); err != nil {
+ return "", err
+ }
+ return plaintext, nil
}
- generated, err := generateHexSecret(byteLength)
- if err != nil {
- return "", false, err
+ if err := validateBootstrapSecret(stored.Key, rawValue); err != nil {
+ return "", err
}
-
- if err := client.SecuritySecret.Create().
- SetKey(key).
- SetValue(generated).
- OnConflictColumns(securitysecret.FieldKey).
- DoNothing().
- Exec(ctx); err != nil {
- if !isSQLNoRowsError(err) {
- return "", false, err
- }
+ ciphertext, err := service.EncryptForSecretDomain(encryptor, service.SecretDomainJWTHMAC, rawValue)
+ if err != nil {
+ return "", fmt.Errorf("encrypt legacy plaintext %q: %w", stored.Key, err)
}
-
- stored, err := querySecuritySecretWithRetry(ctx, client, key)
+ affected, err := client.SecuritySecret.Update().
+ Where(securitysecret.IDEQ(stored.ID), securitysecret.ValueEQ(stored.Value)).
+ SetValue(ciphertext).
+ Save(ctx)
if err != nil {
- return "", false, err
+ return "", fmt.Errorf("migrate plaintext %q: %w", stored.Key, err)
}
- value := strings.TrimSpace(stored.Value)
- if len([]byte(value)) < 32 {
- return "", false, fmt.Errorf("stored secret %q must be at least 32 bytes", key)
+ if affected == 0 {
+ latest, err := querySecuritySecretWithRetry(ctx, client, stored.Key)
+ if err != nil {
+ return "", err
+ }
+ return openOrMigrateBootstrapSecret(ctx, client, encryptor, latest)
}
- return value, value == generated, nil
+ return rawValue, nil
}
-func createSecuritySecretIfAbsent(ctx context.Context, client *ent.Client, key, value string) (string, error) {
+func validateBootstrapSecret(key, value string) error {
value = strings.TrimSpace(value)
if len([]byte(value)) < 32 {
- return "", fmt.Errorf("secret %q must be at least 32 bytes", key)
- }
-
- if err := client.SecuritySecret.Create().
- SetKey(key).
- SetValue(value).
- OnConflictColumns(securitysecret.FieldKey).
- DoNothing().
- Exec(ctx); err != nil {
- if !isSQLNoRowsError(err) {
- return "", err
- }
+ return fmt.Errorf("secret %q must be at least 32 bytes", key)
}
-
- stored, err := querySecuritySecretWithRetry(ctx, client, key)
- if err != nil {
- return "", err
- }
- storedValue := strings.TrimSpace(stored.Value)
- if len([]byte(storedValue)) < 32 {
- return "", fmt.Errorf("stored secret %q must be at least 32 bytes", key)
- }
- return storedValue, nil
+ return nil
}
func querySecuritySecretWithRetry(ctx context.Context, client *ent.Client, key string) (*ent.SecuritySecret, error) {
@@ -164,14 +170,3 @@ func isSQLNoRowsError(err error) bool {
}
return errors.Is(err, sql.ErrNoRows) || strings.Contains(err.Error(), "no rows in result set")
}
-
-func generateHexSecret(byteLength int) (string, error) {
- if byteLength <= 0 {
- byteLength = 32
- }
- buf := make([]byte, byteLength)
- if _, err := readRandomBytes(buf); err != nil {
- return "", fmt.Errorf("generate random secret: %w", err)
- }
- return hex.EncodeToString(buf), nil
-}
diff --git a/backend/internal/repository/security_secret_bootstrap_test.go b/backend/internal/repository/security_secret_bootstrap_test.go
index 288edf334fe..35ad579bf0f 100644
--- a/backend/internal/repository/security_secret_bootstrap_test.go
+++ b/backend/internal/repository/security_secret_bootstrap_test.go
@@ -3,17 +3,16 @@ package repository
import (
"context"
"database/sql"
- "encoding/hex"
"errors"
"fmt"
"strings"
- "sync"
"testing"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/enttest"
"github.com/Wei-Shaw/sub2api/ent/securitysecret"
"github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
"entgo.io/ent/dialect"
@@ -40,28 +39,33 @@ func newSecuritySecretTestClient(t *testing.T) *dbent.Client {
}
func TestEnsureBootstrapSecretsNilInputs(t *testing.T) {
- err := ensureBootstrapSecrets(context.Background(), nil, &config.Config{})
+ encryptor := newSecuritySecretTestEncryptor(t)
+ err := ensureBootstrapSecrets(context.Background(), nil, &config.Config{}, encryptor)
require.Error(t, err)
require.Contains(t, err.Error(), "nil ent client")
client := newSecuritySecretTestClient(t)
- err = ensureBootstrapSecrets(context.Background(), client, nil)
+ err = ensureBootstrapSecrets(context.Background(), client, nil, encryptor)
require.Error(t, err)
require.Contains(t, err.Error(), "nil config")
+
+ err = ensureBootstrapSecrets(context.Background(), client, &config.Config{}, nil)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "encryptor")
}
-func TestEnsureBootstrapSecretsGenerateAndPersistJWTSecret(t *testing.T) {
+func TestEnsureBootstrapSecretsRejectsMissingJWTSecret(t *testing.T) {
client := newSecuritySecretTestClient(t)
cfg := &config.Config{}
+ encryptor := newSecuritySecretTestEncryptor(t)
- err := ensureBootstrapSecrets(context.Background(), client, cfg)
- require.NoError(t, err)
- require.NotEmpty(t, cfg.JWT.Secret)
- require.GreaterOrEqual(t, len([]byte(cfg.JWT.Secret)), 32)
-
- stored, err := client.SecuritySecret.Query().Where(securitysecret.KeyEQ(securitySecretKeyJWT)).Only(context.Background())
- require.NoError(t, err)
- require.Equal(t, cfg.JWT.Secret, stored.Value)
+ err := ensureBootstrapSecrets(context.Background(), client, cfg, encryptor)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "JWT_SECRET is required")
+ require.Empty(t, cfg.JWT.Secret)
+ count, countErr := client.SecuritySecret.Query().Where(securitysecret.KeyEQ(securitySecretKeyJWT)).Count(context.Background())
+ require.NoError(t, countErr)
+ require.Zero(t, count)
}
func TestEnsureBootstrapSecretsLoadExistingJWTSecret(t *testing.T) {
@@ -70,9 +74,13 @@ func TestEnsureBootstrapSecretsLoadExistingJWTSecret(t *testing.T) {
require.NoError(t, err)
cfg := &config.Config{}
- err = ensureBootstrapSecrets(context.Background(), client, cfg)
+ encryptor := newSecuritySecretTestEncryptor(t)
+ err = ensureBootstrapSecrets(context.Background(), client, cfg, encryptor)
require.NoError(t, err)
require.Equal(t, "existing-jwt-secret-32bytes-long!!!!", cfg.JWT.Secret)
+ stored, err := client.SecuritySecret.Query().Where(securitysecret.KeyEQ(securitySecretKeyJWT)).Only(context.Background())
+ require.NoError(t, err)
+ require.True(t, strings.HasPrefix(stored.Value, secretDomainCiphertextPrefix))
}
func TestEnsureBootstrapSecretsRejectInvalidStoredSecret(t *testing.T) {
@@ -81,7 +89,7 @@ func TestEnsureBootstrapSecretsRejectInvalidStoredSecret(t *testing.T) {
require.NoError(t, err)
cfg := &config.Config{}
- err = ensureBootstrapSecrets(context.Background(), client, cfg)
+ err = ensureBootstrapSecrets(context.Background(), client, cfg, newSecuritySecretTestEncryptor(t))
require.Error(t, err)
require.Contains(t, err.Error(), "at least 32 bytes")
}
@@ -92,24 +100,79 @@ func TestEnsureBootstrapSecretsPersistConfiguredJWTSecret(t *testing.T) {
JWT: config.JWTConfig{Secret: "configured-jwt-secret-32bytes-long!!"},
}
- err := ensureBootstrapSecrets(context.Background(), client, cfg)
+ encryptor := newSecuritySecretTestEncryptor(t)
+ err := ensureBootstrapSecrets(context.Background(), client, cfg, encryptor)
require.NoError(t, err)
stored, err := client.SecuritySecret.Query().Where(securitysecret.KeyEQ(securitySecretKeyJWT)).Only(context.Background())
require.NoError(t, err)
- require.Equal(t, "configured-jwt-secret-32bytes-long!!", stored.Value)
+ require.True(t, strings.HasPrefix(stored.Value, secretDomainCiphertextPrefix))
+ plaintext, err := service.DecryptForSecretDomain(encryptor, service.SecretDomainJWTHMAC, stored.Value)
+ require.NoError(t, err)
+ require.Equal(t, "configured-jwt-secret-32bytes-long!!", plaintext)
+}
+
+func TestEnsureBootstrapSecretsEncryptedSecondBootstrapIsIdempotent(t *testing.T) {
+ client := newSecuritySecretTestClient(t)
+ encryptor := newSecuritySecretTestEncryptor(t)
+ first := &config.Config{JWT: config.JWTConfig{Secret: "configured-jwt-secret-32bytes-long!!"}}
+ require.NoError(t, ensureBootstrapSecrets(context.Background(), client, first, encryptor))
+ storedBefore, err := client.SecuritySecret.Query().Where(securitysecret.KeyEQ(securitySecretKeyJWT)).Only(context.Background())
+ require.NoError(t, err)
+
+ second := &config.Config{}
+ require.NoError(t, ensureBootstrapSecrets(context.Background(), client, second, encryptor))
+ require.Equal(t, first.JWT.Secret, second.JWT.Secret)
+ storedAfter, err := client.SecuritySecret.Query().Where(securitysecret.KeyEQ(securitySecretKeyJWT)).Only(context.Background())
+ require.NoError(t, err)
+ require.Equal(t, storedBefore.Value, storedAfter.Value)
+}
+
+func TestEnsureBootstrapSecretsRejectsWrongRootAndWrongDomain(t *testing.T) {
+ t.Run("wrong root", func(t *testing.T) {
+ client := newSecuritySecretTestClient(t)
+ firstCfg := domainKeyTestConfig()
+ firstCfg.JWT.Secret = "configured-jwt-secret-32bytes-long!!"
+ firstEncryptor, err := NewAESEncryptor(firstCfg)
+ require.NoError(t, err)
+ require.NoError(t, ensureBootstrapSecrets(context.Background(), client, firstCfg, firstEncryptor))
+
+ secondCfg := domainKeyTestConfig()
+ secondCfg.SecretEncryption.JWTHMACKey = strings.Repeat("e", 64)
+ secondEncryptor, err := NewAESEncryptor(secondCfg)
+ require.NoError(t, err)
+ err = ensureBootstrapSecrets(context.Background(), client, secondCfg, secondEncryptor)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "decrypt")
+ })
+
+ t.Run("wrong domain", func(t *testing.T) {
+ client := newSecuritySecretTestClient(t)
+ encryptor := newSecuritySecretTestEncryptor(t)
+ wrongDomainCiphertext, err := service.EncryptForSecretDomain(encryptor, service.SecretDomainTOTP, "configured-jwt-secret-32bytes-long!!")
+ require.NoError(t, err)
+ _, err = client.SecuritySecret.Create().
+ SetKey(securitySecretKeyJWT).
+ SetValue(wrongDomainCiphertext).
+ Save(context.Background())
+ require.NoError(t, err)
+
+ err = ensureBootstrapSecrets(context.Background(), client, &config.Config{}, encryptor)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "decrypt")
+ })
}
func TestEnsureBootstrapSecretsConfiguredSecretTooShort(t *testing.T) {
client := newSecuritySecretTestClient(t)
cfg := &config.Config{JWT: config.JWTConfig{Secret: "short"}}
- err := ensureBootstrapSecrets(context.Background(), client, cfg)
+ err := ensureBootstrapSecrets(context.Background(), client, cfg, newSecuritySecretTestEncryptor(t))
require.Error(t, err)
require.Contains(t, err.Error(), "at least 32 bytes")
}
-func TestEnsureBootstrapSecretsConfiguredSecretDuplicateIgnored(t *testing.T) {
+func TestEnsureBootstrapSecretsConfiguredSecretMismatchFailsClosed(t *testing.T) {
client := newSecuritySecretTestClient(t)
_, err := client.SecuritySecret.Create().
SetKey(securitySecretKeyJWT).
@@ -118,138 +181,51 @@ func TestEnsureBootstrapSecretsConfiguredSecretDuplicateIgnored(t *testing.T) {
require.NoError(t, err)
cfg := &config.Config{JWT: config.JWTConfig{Secret: "another-configured-jwt-secret-32!!!!"}}
- err = ensureBootstrapSecrets(context.Background(), client, cfg)
- require.NoError(t, err)
+ err = ensureBootstrapSecrets(context.Background(), client, cfg, newSecuritySecretTestEncryptor(t))
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "mismatches persisted")
stored, err := client.SecuritySecret.Query().Where(securitysecret.KeyEQ(securitySecretKeyJWT)).Only(context.Background())
require.NoError(t, err)
- require.Equal(t, "existing-jwt-secret-32bytes-long!!!!", stored.Value)
- require.Equal(t, "existing-jwt-secret-32bytes-long!!!!", cfg.JWT.Secret)
+ require.True(t, strings.HasPrefix(stored.Value, secretDomainCiphertextPrefix))
+ require.Equal(t, "another-configured-jwt-secret-32!!!!", cfg.JWT.Secret)
}
-func TestGetOrCreateGeneratedSecuritySecretTrimmedExistingValue(t *testing.T) {
+func TestEnsureBootstrapSecretsRejectsUnreadableCiphertext(t *testing.T) {
client := newSecuritySecretTestClient(t)
_, err := client.SecuritySecret.Create().
- SetKey("trimmed_key").
- SetValue(" existing-trimmed-secret-32bytes-long!! ").
+ SetKey(securitySecretKeyJWT).
+ SetValue(secretDomainCiphertextPrefix + "corrupt").
Save(context.Background())
require.NoError(t, err)
- value, created, err := getOrCreateGeneratedSecuritySecret(context.Background(), client, "trimmed_key", 32)
- require.NoError(t, err)
- require.False(t, created)
- require.Equal(t, "existing-trimmed-secret-32bytes-long!!", value)
-}
-
-func TestGetOrCreateGeneratedSecuritySecretQueryError(t *testing.T) {
- client := newSecuritySecretTestClient(t)
- require.NoError(t, client.Close())
-
- _, _, err := getOrCreateGeneratedSecuritySecret(context.Background(), client, "closed_client_key", 32)
- require.Error(t, err)
-}
-
-func TestGetOrCreateGeneratedSecuritySecretCreateValidationError(t *testing.T) {
- client := newSecuritySecretTestClient(t)
- tooLongKey := strings.Repeat("k", 101)
-
- _, _, err := getOrCreateGeneratedSecuritySecret(context.Background(), client, tooLongKey, 32)
- require.Error(t, err)
-}
-
-func TestGetOrCreateGeneratedSecuritySecretConcurrentCreation(t *testing.T) {
- client := newSecuritySecretTestClient(t)
- const goroutines = 8
- key := "concurrent_bootstrap_key"
-
- values := make([]string, goroutines)
- createdFlags := make([]bool, goroutines)
- errs := make([]error, goroutines)
-
- var wg sync.WaitGroup
- for i := 0; i < goroutines; i++ {
- wg.Add(1)
- go func(idx int) {
- defer wg.Done()
- values[idx], createdFlags[idx], errs[idx] = getOrCreateGeneratedSecuritySecret(context.Background(), client, key, 32)
- }(i)
- }
- wg.Wait()
-
- for i := range errs {
- require.NoError(t, errs[i])
- require.NotEmpty(t, values[i])
- }
- for i := 1; i < len(values); i++ {
- require.Equal(t, values[0], values[i])
- }
-
- createdCount := 0
- for _, created := range createdFlags {
- if created {
- createdCount++
- }
- }
- require.GreaterOrEqual(t, createdCount, 1)
- require.LessOrEqual(t, createdCount, 1)
-
- count, err := client.SecuritySecret.Query().Where(securitysecret.KeyEQ(key)).Count(context.Background())
- require.NoError(t, err)
- require.Equal(t, 1, count)
-}
-
-func TestGetOrCreateGeneratedSecuritySecretGenerateError(t *testing.T) {
- client := newSecuritySecretTestClient(t)
- originalRead := readRandomBytes
- readRandomBytes = func([]byte) (int, error) {
- return 0, errors.New("boom")
- }
- t.Cleanup(func() {
- readRandomBytes = originalRead
- })
-
- _, _, err := getOrCreateGeneratedSecuritySecret(context.Background(), client, "gen_error_key", 32)
+ err = ensureBootstrapSecrets(context.Background(), client, &config.Config{}, newSecuritySecretTestEncryptor(t))
require.Error(t, err)
- require.Contains(t, err.Error(), "boom")
+ require.Contains(t, err.Error(), "decrypt")
}
-func TestCreateSecuritySecretIfAbsent(t *testing.T) {
+func TestEnsureBootstrapSecretsRejectsPersistedSigningSecretReusedAsEncryptionRoot(t *testing.T) {
client := newSecuritySecretTestClient(t)
-
- _, err := createSecuritySecretIfAbsent(context.Background(), client, "abc", "short")
- require.Error(t, err)
- require.Contains(t, err.Error(), "at least 32 bytes")
-
- stored, err := createSecuritySecretIfAbsent(context.Background(), client, "abc", "valid-jwt-secret-value-32bytes-long")
- require.NoError(t, err)
- require.Equal(t, "valid-jwt-secret-value-32bytes-long", stored)
-
- stored, err = createSecuritySecretIfAbsent(context.Background(), client, "abc", "another-valid-secret-value-32bytes")
+ cfg := domainKeyTestConfig()
+ cfg.JWT.Secret = ""
+ _, err := client.SecuritySecret.Create().
+ SetKey(securitySecretKeyJWT).
+ SetValue(cfg.SecretEncryption.JWTHMACKey).
+ Save(context.Background())
require.NoError(t, err)
- require.Equal(t, "valid-jwt-secret-value-32bytes-long", stored)
- count, err := client.SecuritySecret.Query().Where(securitysecret.KeyEQ("abc")).Count(context.Background())
+ encryptor, err := NewAESEncryptor(cfg)
require.NoError(t, err)
- require.Equal(t, 1, count)
-}
-
-func TestCreateSecuritySecretIfAbsentValidationError(t *testing.T) {
- client := newSecuritySecretTestClient(t)
- _, err := createSecuritySecretIfAbsent(
- context.Background(),
- client,
- strings.Repeat("k", 101),
- "valid-jwt-secret-value-32bytes-long",
- )
+ err = ensureBootstrapSecrets(context.Background(), client, cfg, encryptor)
require.Error(t, err)
+ require.Contains(t, err.Error(), "must be distinct")
}
-func TestCreateSecuritySecretIfAbsentExecError(t *testing.T) {
- client := newSecuritySecretTestClient(t)
- require.NoError(t, client.Close())
-
- _, err := createSecuritySecretIfAbsent(context.Background(), client, "closed-client-key", "valid-jwt-secret-value-32bytes-long")
- require.Error(t, err)
+func newSecuritySecretTestEncryptor(t *testing.T) service.SecretEncryptor {
+ t.Helper()
+ encryptor, err := NewAESEncryptor(domainKeyTestConfig())
+ require.NoError(t, err)
+ return encryptor
}
func TestQuerySecuritySecretWithRetrySuccess(t *testing.T) {
@@ -305,33 +281,3 @@ func TestSecretNotFoundHelpers(t *testing.T) {
require.True(t, isSecretNotFoundError(errors.New("sql: no rows in result set")))
require.False(t, isSecretNotFoundError(errors.New("some other error")))
}
-
-func TestGenerateHexSecretReadError(t *testing.T) {
- originalRead := readRandomBytes
- readRandomBytes = func([]byte) (int, error) {
- return 0, errors.New("read random failed")
- }
- t.Cleanup(func() {
- readRandomBytes = originalRead
- })
-
- _, err := generateHexSecret(32)
- require.Error(t, err)
- require.Contains(t, err.Error(), "read random failed")
-}
-
-func TestGenerateHexSecretLengths(t *testing.T) {
- v1, err := generateHexSecret(0)
- require.NoError(t, err)
- require.Len(t, v1, 64)
- _, err = hex.DecodeString(v1)
- require.NoError(t, err)
-
- v2, err := generateHexSecret(16)
- require.NoError(t, err)
- require.Len(t, v2, 32)
- _, err = hex.DecodeString(v2)
- require.NoError(t, err)
-
- require.NotEqual(t, v1, v2)
-}
diff --git a/backend/internal/repository/setting_repo.go b/backend/internal/repository/setting_repo.go
index a4550e60206..0383574bd74 100644
--- a/backend/internal/repository/setting_repo.go
+++ b/backend/internal/repository/setting_repo.go
@@ -2,6 +2,7 @@ package repository
import (
"context"
+ "fmt"
"time"
"github.com/Wei-Shaw/sub2api/ent"
@@ -10,11 +11,26 @@ import (
)
type settingRepository struct {
- client *ent.Client
+ client *ent.Client
+ encryptor service.SecretEncryptor
}
-func NewSettingRepository(client *ent.Client) service.SettingRepository {
- return &settingRepository{client: client}
+var sensitiveSettingKeys = map[string]struct{}{
+ service.SettingKeyAdminAPIKey: {},
+ service.SettingKeySMTPPassword: {},
+ service.SettingKeyTurnstileSecretKey: {},
+ service.SettingKeyLinuxDoConnectClientSecret: {},
+ service.SettingKeyWeChatConnectAppSecret: {},
+ service.SettingKeyWeChatConnectOpenAppSecret: {},
+ service.SettingKeyWeChatConnectMPAppSecret: {},
+ service.SettingKeyWeChatConnectMobileAppSecret: {},
+ service.SettingKeyOIDCConnectClientSecret: {},
+ service.SettingKeyGitHubOAuthClientSecret: {},
+ service.SettingKeyGoogleOAuthClientSecret: {},
+}
+
+func NewSettingRepository(client *ent.Client, encryptor service.SecretEncryptor) service.SettingRepository {
+ return &settingRepository{client: client, encryptor: encryptor}
}
func (r *settingRepository) Get(ctx context.Context, key string) (*service.Setting, error) {
@@ -25,10 +41,14 @@ func (r *settingRepository) Get(ctx context.Context, key string) (*service.Setti
}
return nil, err
}
+ value, err := r.openValue(m.Key, m.Value)
+ if err != nil {
+ return nil, err
+ }
return &service.Setting{
ID: m.ID,
Key: m.Key,
- Value: m.Value,
+ Value: value,
UpdatedAt: m.UpdatedAt,
}, nil
}
@@ -42,11 +62,15 @@ func (r *settingRepository) GetValue(ctx context.Context, key string) (string, e
}
func (r *settingRepository) Set(ctx context.Context, key, value string) error {
+ sealed, err := r.sealValue(key, value)
+ if err != nil {
+ return err
+ }
now := time.Now()
return r.client.Setting.
Create().
SetKey(key).
- SetValue(value).
+ SetValue(sealed).
SetUpdatedAt(now).
OnConflictColumns(setting.FieldKey).
UpdateNewValues().
@@ -64,7 +88,11 @@ func (r *settingRepository) GetMultiple(ctx context.Context, keys []string) (map
result := make(map[string]string)
for _, s := range settings {
- result[s.Key] = s.Value
+ value, err := r.openValue(s.Key, s.Value)
+ if err != nil {
+ return nil, err
+ }
+ result[s.Key] = value
}
return result, nil
}
@@ -77,7 +105,11 @@ func (r *settingRepository) SetMultiple(ctx context.Context, settings map[string
now := time.Now()
builders := make([]*ent.SettingCreate, 0, len(settings))
for key, value := range settings {
- builders = append(builders, r.client.Setting.Create().SetKey(key).SetValue(value).SetUpdatedAt(now))
+ sealed, err := r.sealValue(key, value)
+ if err != nil {
+ return err
+ }
+ builders = append(builders, r.client.Setting.Create().SetKey(key).SetValue(sealed).SetUpdatedAt(now))
}
return r.client.Setting.
CreateBulk(builders...).
@@ -94,7 +126,11 @@ func (r *settingRepository) GetAll(ctx context.Context) (map[string]string, erro
result := make(map[string]string)
for _, s := range settings {
- result[s.Key] = s.Value
+ value, err := r.openValue(s.Key, s.Value)
+ if err != nil {
+ return nil, err
+ }
+ result[s.Key] = value
}
return result, nil
}
@@ -103,3 +139,30 @@ func (r *settingRepository) Delete(ctx context.Context, key string) error {
_, err := r.client.Setting.Delete().Where(setting.KeyEQ(key)).Exec(ctx)
return err
}
+
+func isSensitiveSettingKey(key string) bool {
+ _, ok := sensitiveSettingKeys[key]
+ return ok
+}
+
+func (r *settingRepository) sealValue(key, value string) (string, error) {
+ if !isSensitiveSettingKey(key) || value == "" {
+ return value, nil
+ }
+ sealed, err := service.EncryptForSecretDomain(r.encryptor, service.SecretDomainSettingSecret, value)
+ if err != nil {
+ return "", fmt.Errorf("encrypt setting %q: %w", key, err)
+ }
+ return sealed, nil
+}
+
+func (r *settingRepository) openValue(key, value string) (string, error) {
+ if !isSensitiveSettingKey(key) || value == "" {
+ return value, nil
+ }
+ plaintext, err := service.DecryptForSecretDomain(r.encryptor, service.SecretDomainSettingSecret, value)
+ if err != nil {
+ return "", fmt.Errorf("decrypt setting %q: %w", key, err)
+ }
+ return plaintext, nil
+}
diff --git a/backend/internal/repository/setting_repo_encryption_test.go b/backend/internal/repository/setting_repo_encryption_test.go
new file mode 100644
index 00000000000..2f151c07d1e
--- /dev/null
+++ b/backend/internal/repository/setting_repo_encryption_test.go
@@ -0,0 +1,76 @@
+package repository
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/ent/setting"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSettingRepositoryEncryptsSensitiveValuesAtRest(t *testing.T) {
+ ctx := context.Background()
+ client := newSecuritySecretTestClient(t)
+ encryptor := newSecuritySecretTestEncryptor(t)
+ repo := NewSettingRepository(client, encryptor)
+
+ require.NoError(t, repo.Set(ctx, service.SettingKeyAdminAPIKey, "admin-secret-value"))
+ raw, err := client.Setting.Query().Where(setting.KeyEQ(service.SettingKeyAdminAPIKey)).Only(ctx)
+ require.NoError(t, err)
+ require.NotEqual(t, "admin-secret-value", raw.Value)
+ require.True(t, strings.HasPrefix(raw.Value, secretDomainCiphertextPrefix))
+
+ got, err := repo.GetValue(ctx, service.SettingKeyAdminAPIKey)
+ require.NoError(t, err)
+ require.Equal(t, "admin-secret-value", got)
+}
+
+func TestSettingRepositoryBatchEncryptionAndNonSensitiveCompatibility(t *testing.T) {
+ ctx := context.Background()
+ client := newSecuritySecretTestClient(t)
+ repo := NewSettingRepository(client, newSecuritySecretTestEncryptor(t))
+
+ require.NoError(t, repo.SetMultiple(ctx, map[string]string{
+ service.SettingKeySMTPPassword: "smtp-secret",
+ service.SettingKeySiteName: "public-name",
+ }))
+
+ rawSecret, err := client.Setting.Query().Where(setting.KeyEQ(service.SettingKeySMTPPassword)).Only(ctx)
+ require.NoError(t, err)
+ require.True(t, strings.HasPrefix(rawSecret.Value, secretDomainCiphertextPrefix))
+ rawPublic, err := client.Setting.Query().Where(setting.KeyEQ(service.SettingKeySiteName)).Only(ctx)
+ require.NoError(t, err)
+ require.Equal(t, "public-name", rawPublic.Value)
+
+ values, err := repo.GetMultiple(ctx, []string{service.SettingKeySMTPPassword, service.SettingKeySiteName})
+ require.NoError(t, err)
+ require.Equal(t, "smtp-secret", values[service.SettingKeySMTPPassword])
+ require.Equal(t, "public-name", values[service.SettingKeySiteName])
+ all, err := repo.GetAll(ctx)
+ require.NoError(t, err)
+ require.Equal(t, "smtp-secret", all[service.SettingKeySMTPPassword])
+}
+
+func TestSettingRepositorySensitiveEmptyValueAndUnreadableCiphertext(t *testing.T) {
+ ctx := context.Background()
+ client := newSecuritySecretTestClient(t)
+ repo := NewSettingRepository(client, newSecuritySecretTestEncryptor(t))
+
+ require.NoError(t, repo.Set(ctx, service.SettingKeyTurnstileSecretKey, ""))
+ rawEmpty, err := client.Setting.Query().Where(setting.KeyEQ(service.SettingKeyTurnstileSecretKey)).Only(ctx)
+ require.NoError(t, err)
+ require.Empty(t, rawEmpty.Value)
+ got, err := repo.GetValue(ctx, service.SettingKeyTurnstileSecretKey)
+ require.NoError(t, err)
+ require.Empty(t, got)
+
+ require.NoError(t, client.Setting.Create().
+ SetKey(service.SettingKeyAdminAPIKey).
+ SetValue(secretDomainCiphertextPrefix+"corrupt").
+ Exec(ctx))
+ _, err = repo.GetValue(ctx, service.SettingKeyAdminAPIKey)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "decrypt")
+}
diff --git a/backend/internal/repository/setting_repo_integration_test.go b/backend/internal/repository/setting_repo_integration_test.go
index f37b2de1f54..037318e47d7 100644
--- a/backend/internal/repository/setting_repo_integration_test.go
+++ b/backend/internal/repository/setting_repo_integration_test.go
@@ -19,7 +19,7 @@ type SettingRepoSuite struct {
func (s *SettingRepoSuite) SetupTest() {
s.ctx = context.Background()
tx := testEntTx(s.T())
- s.repo = NewSettingRepository(tx.Client()).(*settingRepository)
+ s.repo = NewSettingRepository(tx.Client(), newDomainMigrationTestEncryptor(s.T())).(*settingRepository)
}
func TestSettingRepoSuite(t *testing.T) {
diff --git a/backend/internal/repository/soft_delete_ent_integration_test.go b/backend/internal/repository/soft_delete_ent_integration_test.go
index 8c2b23da3af..27034c1cb4d 100644
--- a/backend/internal/repository/soft_delete_ent_integration_test.go
+++ b/backend/internal/repository/soft_delete_ent_integration_test.go
@@ -41,7 +41,7 @@ func TestEntSoftDelete_ApiKey_DefaultFilterAndSkip(t *testing.T) {
u := createEntUser(t, ctx, client, uniqueSoftDeleteValue(t, "sd-user")+"@example.com")
- repo := NewAPIKeyRepository(client, integrationDB)
+ repo := NewAPIKeyRepository(client, integrationDB, strictAPIKeyTestProtector{})
key := &service.APIKey{
UserID: u.ID,
Key: uniqueSoftDeleteValue(t, "sk-soft-delete"),
@@ -73,7 +73,7 @@ func TestEntSoftDelete_ApiKey_DeleteIdempotent(t *testing.T) {
u := createEntUser(t, ctx, client, uniqueSoftDeleteValue(t, "sd-user2")+"@example.com")
- repo := NewAPIKeyRepository(client, integrationDB)
+ repo := NewAPIKeyRepository(client, integrationDB, strictAPIKeyTestProtector{})
key := &service.APIKey{
UserID: u.ID,
Key: uniqueSoftDeleteValue(t, "sk-soft-delete2"),
@@ -93,7 +93,7 @@ func TestEntSoftDelete_ApiKey_HardDeleteViaSkipSoftDelete(t *testing.T) {
u := createEntUser(t, ctx, client, uniqueSoftDeleteValue(t, "sd-user3")+"@example.com")
- repo := NewAPIKeyRepository(client, integrationDB)
+ repo := NewAPIKeyRepository(client, integrationDB, strictAPIKeyTestProtector{})
key := &service.APIKey{
UserID: u.ID,
Key: uniqueSoftDeleteValue(t, "sk-soft-delete3"),
@@ -122,6 +122,7 @@ func createEntGroup(t *testing.T, ctx context.Context, client *dbent.Client, nam
g, err := client.Group.Create().
SetName(name).
SetStatus(service.StatusActive).
+ SetSubscriptionType(service.SubscriptionTypeSubscription).
Save(ctx)
require.NoError(t, err, "create ent group")
return g
@@ -137,7 +138,7 @@ func TestEntSoftDelete_UserSubscription_DefaultFilterAndSkip(t *testing.T) {
repo := NewUserSubscriptionRepository(client)
sub := &service.UserSubscription{
UserID: u.ID,
- GroupID: g.ID,
+ GroupID: repositoryInt64Ptr(g.ID),
Status: service.SubscriptionStatusActive,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
@@ -169,7 +170,7 @@ func TestEntSoftDelete_UserSubscription_DeleteIdempotent(t *testing.T) {
repo := NewUserSubscriptionRepository(client)
sub := &service.UserSubscription{
UserID: u.ID,
- GroupID: g.ID,
+ GroupID: repositoryInt64Ptr(g.ID),
Status: service.SubscriptionStatusActive,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
@@ -191,7 +192,7 @@ func TestEntSoftDelete_UserSubscription_ListExcludesDeleted(t *testing.T) {
sub1 := &service.UserSubscription{
UserID: u.ID,
- GroupID: g1.ID,
+ GroupID: repositoryInt64Ptr(g1.ID),
Status: service.SubscriptionStatusActive,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
@@ -199,7 +200,7 @@ func TestEntSoftDelete_UserSubscription_ListExcludesDeleted(t *testing.T) {
sub2 := &service.UserSubscription{
UserID: u.ID,
- GroupID: g2.ID,
+ GroupID: repositoryInt64Ptr(g2.ID),
Status: service.SubscriptionStatusActive,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
diff --git a/backend/internal/repository/test_helpers_integration_test.go b/backend/internal/repository/test_helpers_integration_test.go
new file mode 100644
index 00000000000..36c8278e881
--- /dev/null
+++ b/backend/internal/repository/test_helpers_integration_test.go
@@ -0,0 +1,7 @@
+//go:build integration
+
+package repository
+
+func repositoryInt64Ptr(v int64) *int64 {
+ return &v
+}
diff --git a/backend/internal/repository/totp_cache.go b/backend/internal/repository/totp_cache.go
index 2f4a8ab2b3f..09967cf135b 100644
--- a/backend/internal/repository/totp_cache.go
+++ b/backend/internal/repository/totp_cache.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
+ "strings"
"time"
"github.com/redis/go-redis/v9"
@@ -16,16 +17,24 @@ const (
totpLoginKeyPrefix = "totp:login:"
totpAttemptsKeyPrefix = "totp:attempts:"
totpAttemptsTTL = 15 * time.Minute
+
+ // V1 is retained only so stale rolling-upgrade sessions can be rejected
+ // explicitly. New sessions are domain-bound V2 and old sessions must retry.
+ totpSetupEncryptedV1Prefix = "totp-setup-encrypted:v1:"
+ totpLoginEncryptedV1Prefix = "totp-login-encrypted:v1:"
+ totpSetupEncryptedV2Prefix = "totp-setup-encrypted:v2:"
+ totpLoginEncryptedV2Prefix = "totp-login-encrypted:v2:"
)
// TotpCache implements service.TotpCache using Redis
type TotpCache struct {
- rdb *redis.Client
+ rdb *redis.Client
+ encryptor service.SecretEncryptor
}
// NewTotpCache creates a new TOTP cache
-func NewTotpCache(rdb *redis.Client) service.TotpCache {
- return &TotpCache{rdb: rdb}
+func NewTotpCache(rdb *redis.Client, encryptor service.SecretEncryptor) service.TotpCache {
+ return &TotpCache{rdb: rdb, encryptor: encryptor}
}
// GetSetupSession retrieves a TOTP setup session
@@ -39,20 +48,20 @@ func (c *TotpCache) GetSetupSession(ctx context.Context, userID int64) (*service
return nil, fmt.Errorf("get setup session: %w", err)
}
- var session service.TotpSetupSession
- if err := json.Unmarshal(data, &session); err != nil {
- return nil, fmt.Errorf("unmarshal setup session: %w", err)
+ session, err := decodeTotpSetupSession(data, c.encryptor)
+ if err != nil {
+ return nil, err
}
- return &session, nil
+ return session, nil
}
// SetSetupSession stores a TOTP setup session
func (c *TotpCache) SetSetupSession(ctx context.Context, userID int64, session *service.TotpSetupSession, ttl time.Duration) error {
key := fmt.Sprintf("%s%d", totpSetupKeyPrefix, userID)
- data, err := json.Marshal(session)
+ data, err := encodeTotpSetupSession(session, c.encryptor)
if err != nil {
- return fmt.Errorf("marshal setup session: %w", err)
+ return err
}
if err := c.rdb.Set(ctx, key, data, ttl).Err(); err != nil {
@@ -62,6 +71,51 @@ func (c *TotpCache) SetSetupSession(ctx context.Context, userID int64, session *
return nil
}
+func encodeTotpSetupSession(session *service.TotpSetupSession, encryptor service.SecretEncryptor) ([]byte, error) {
+ if session == nil {
+ return nil, fmt.Errorf("encode setup session: session is nil")
+ }
+ if encryptor == nil {
+ return nil, fmt.Errorf("encode setup session: encryptor is nil")
+ }
+
+ plaintext, err := json.Marshal(session)
+ if err != nil {
+ return nil, fmt.Errorf("marshal setup session: %w", err)
+ }
+
+ ciphertext, err := service.EncryptForSecretDomain(encryptor, service.SecretDomainTOTPCache, string(plaintext))
+ if err != nil {
+ return nil, fmt.Errorf("encrypt setup session: %w", err)
+ }
+ if ciphertext == "" {
+ return nil, fmt.Errorf("encrypt setup session: empty ciphertext")
+ }
+
+ return []byte(totpSetupEncryptedV2Prefix + ciphertext), nil
+}
+
+func decodeTotpSetupSession(data []byte, encryptor service.SecretEncryptor) (*service.TotpSetupSession, error) {
+ if !strings.HasPrefix(string(data), totpSetupEncryptedV2Prefix) {
+ return nil, fmt.Errorf("decrypt setup session: legacy or unmarked session is not accepted")
+ }
+ ciphertext := string(data[len(totpSetupEncryptedV2Prefix):])
+ if ciphertext == "" {
+ return nil, fmt.Errorf("decrypt setup session: empty ciphertext")
+ }
+ decrypted, err := service.DecryptForSecretDomain(encryptor, service.SecretDomainTOTPCache, ciphertext)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt setup session: %w", err)
+ }
+
+ var session service.TotpSetupSession
+ if err := json.Unmarshal([]byte(decrypted), &session); err != nil {
+ return nil, fmt.Errorf("unmarshal setup session: %w", err)
+ }
+
+ return &session, nil
+}
+
// DeleteSetupSession deletes a TOTP setup session
func (c *TotpCache) DeleteSetupSession(ctx context.Context, userID int64) error {
key := fmt.Sprintf("%s%d", totpSetupKeyPrefix, userID)
@@ -79,20 +133,15 @@ func (c *TotpCache) GetLoginSession(ctx context.Context, tempToken string) (*ser
return nil, fmt.Errorf("get login session: %w", err)
}
- var session service.TotpLoginSession
- if err := json.Unmarshal(data, &session); err != nil {
- return nil, fmt.Errorf("unmarshal login session: %w", err)
- }
-
- return &session, nil
+ return decodeTotpLoginSession(data, c.encryptor)
}
// SetLoginSession stores a TOTP login session
func (c *TotpCache) SetLoginSession(ctx context.Context, tempToken string, session *service.TotpLoginSession, ttl time.Duration) error {
key := totpLoginKeyPrefix + tempToken
- data, err := json.Marshal(session)
+ data, err := encodeTotpLoginSession(session, c.encryptor)
if err != nil {
- return fmt.Errorf("marshal login session: %w", err)
+ return err
}
if err := c.rdb.Set(ctx, key, data, ttl).Err(); err != nil {
@@ -102,6 +151,72 @@ func (c *TotpCache) SetLoginSession(ctx context.Context, tempToken string, sessi
return nil
}
+func encodeTotpLoginSession(session *service.TotpLoginSession, encryptor service.SecretEncryptor) ([]byte, error) {
+ if session == nil {
+ return nil, fmt.Errorf("encode login session: session is nil")
+ }
+ if encryptor == nil {
+ return nil, fmt.Errorf("encode login session: encryptor is nil")
+ }
+ plaintext, err := json.Marshal(session)
+ if err != nil {
+ return nil, fmt.Errorf("marshal login session: %w", err)
+ }
+ ciphertext, err := service.EncryptForSecretDomain(encryptor, service.SecretDomainTOTPCache, string(plaintext))
+ if err != nil {
+ return nil, fmt.Errorf("encrypt login session: %w", err)
+ }
+ if ciphertext == "" {
+ return nil, fmt.Errorf("encrypt login session: empty ciphertext")
+ }
+ return []byte(totpLoginEncryptedV2Prefix + ciphertext), nil
+}
+
+func decodeTotpLoginSession(data []byte, encryptor service.SecretEncryptor) (*service.TotpLoginSession, error) {
+ if !strings.HasPrefix(string(data), totpLoginEncryptedV2Prefix) {
+ return nil, fmt.Errorf("decrypt login session: legacy or unmarked session is not accepted")
+ }
+ ciphertext := string(data[len(totpLoginEncryptedV2Prefix):])
+ if ciphertext == "" {
+ return nil, fmt.Errorf("decrypt login session: empty ciphertext")
+ }
+ decrypted, err := service.DecryptForSecretDomain(encryptor, service.SecretDomainTOTPCache, ciphertext)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt login session: %w", err)
+ }
+ var session service.TotpLoginSession
+ if err := json.Unmarshal([]byte(decrypted), &session); err != nil {
+ return nil, fmt.Errorf("unmarshal login session: %w", err)
+ }
+ return &session, nil
+}
+
+// ConsumeLoginSession atomically gets and deletes a verified 2FA session so
+// only one concurrent request can continue to token issuance/binding.
+func (c *TotpCache) ConsumeLoginSession(ctx context.Context, tempToken string) (*service.TotpLoginSession, error) {
+ key := totpLoginKeyPrefix + tempToken
+ const consumeScript = `
+ local value = redis.call('GET', KEYS[1])
+ if not value then
+ return nil
+ end
+ redis.call('DEL', KEYS[1])
+ return value`
+ result, err := c.rdb.Eval(ctx, consumeScript, []string{key}).Result()
+ if err != nil {
+ if err == redis.Nil {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("consume login session: %w", err)
+ }
+ encoded, ok := result.(string)
+ if !ok || encoded == "" {
+ return nil, fmt.Errorf("consume login session: invalid stored session")
+ }
+ data := []byte(encoded)
+ return decodeTotpLoginSession(data, c.encryptor)
+}
+
// DeleteLoginSession deletes a TOTP login session
func (c *TotpCache) DeleteLoginSession(ctx context.Context, tempToken string) error {
key := totpLoginKeyPrefix + tempToken
@@ -111,22 +226,11 @@ func (c *TotpCache) DeleteLoginSession(ctx context.Context, tempToken string) er
// IncrementVerifyAttempts increments the verify attempt counter
func (c *TotpCache) IncrementVerifyAttempts(ctx context.Context, userID int64) (int, error) {
key := fmt.Sprintf("%s%d", totpAttemptsKeyPrefix, userID)
-
- // Use pipeline for atomic increment and set TTL
- pipe := c.rdb.Pipeline()
- incrCmd := pipe.Incr(ctx, key)
- pipe.Expire(ctx, key, totpAttemptsTTL)
-
- if _, err := pipe.Exec(ctx); err != nil {
- return 0, fmt.Errorf("increment verify attempts: %w", err)
- }
-
- count, err := incrCmd.Result()
+ count, err := c.incrementWithFixedTTL(ctx, key, totpAttemptsTTL)
if err != nil {
- return 0, fmt.Errorf("get increment result: %w", err)
+ return 0, fmt.Errorf("increment verify attempts: %w", err)
}
-
- return int(count), nil
+ return count, nil
}
// GetVerifyAttempts gets the current verify attempt count
@@ -147,3 +251,42 @@ func (c *TotpCache) ClearVerifyAttempts(ctx context.Context, userID int64) error
key := fmt.Sprintf("%s%d", totpAttemptsKeyPrefix, userID)
return c.rdb.Del(ctx, key).Err()
}
+
+func (c *TotpCache) IncrementScopedVerifyAttempts(ctx context.Context, purpose string, userID int64) (int, error) {
+ key := fmt.Sprintf("%s%s:%d", totpAttemptsKeyPrefix, purpose, userID)
+ count, err := c.incrementWithFixedTTL(ctx, key, totpAttemptsTTL)
+ if err != nil {
+ return 0, fmt.Errorf("increment scoped verify attempts: %w", err)
+ }
+ return count, nil
+}
+
+func (c *TotpCache) ClearScopedVerifyAttempts(ctx context.Context, purpose string, userID int64) error {
+ key := fmt.Sprintf("%s%s:%d", totpAttemptsKeyPrefix, purpose, userID)
+ return c.rdb.Del(ctx, key).Err()
+}
+
+func (c *TotpCache) ConsumeScopedVerification(ctx context.Context, _ string, userID int64, fingerprint string, ttl time.Duration) (bool, error) {
+ // A TOTP code is a single proof, not one proof per endpoint. Deliberately
+ // omit purpose so the same live code cannot be replayed across reveal/create.
+ key := fmt.Sprintf("totp:used:api_key_step_up:%d:%s", userID, fingerprint)
+ consumed, err := c.rdb.SetNX(ctx, key, "1", ttl).Result()
+ if err != nil {
+ return false, fmt.Errorf("consume scoped totp verification: %w", err)
+ }
+ return consumed, nil
+}
+
+func (c *TotpCache) incrementWithFixedTTL(ctx context.Context, key string, ttl time.Duration) (int, error) {
+ const script = `
+ local count = redis.call('INCR', KEYS[1])
+ if count == 1 then
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
+ end
+ return count`
+ count, err := c.rdb.Eval(ctx, script, []string{key}, int64(ttl/time.Second)).Int()
+ if err != nil {
+ return 0, err
+ }
+ return count, nil
+}
diff --git a/backend/internal/repository/totp_cache_integration_test.go b/backend/internal/repository/totp_cache_integration_test.go
new file mode 100644
index 00000000000..2cbaf9a8dc7
--- /dev/null
+++ b/backend/internal/repository/totp_cache_integration_test.go
@@ -0,0 +1,170 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+type totpCacheTestEncryptor struct{}
+
+func (totpCacheTestEncryptor) Encrypt(plaintext string) (string, error) {
+ return base64.RawStdEncoding.EncodeToString([]byte(plaintext)), nil
+}
+
+func (totpCacheTestEncryptor) Decrypt(ciphertext string) (string, error) {
+ plaintext, err := base64.RawStdEncoding.DecodeString(ciphertext)
+ return string(plaintext), err
+}
+
+func (totpCacheTestEncryptor) EncryptForDomain(domain, plaintext string) (string, error) {
+ return base64.RawStdEncoding.EncodeToString([]byte(domain + "\x00" + plaintext)), nil
+}
+
+func (totpCacheTestEncryptor) DecryptForDomain(domain, ciphertext string) (string, error) {
+ decoded, err := base64.RawStdEncoding.DecodeString(ciphertext)
+ if err != nil {
+ return "", err
+ }
+ prefix := domain + "\x00"
+ if !strings.HasPrefix(string(decoded), prefix) {
+ return "", errors.New("ciphertext domain mismatch")
+ }
+ return strings.TrimPrefix(string(decoded), prefix), nil
+}
+
+func TestTotpHighSensitivityCodeCannotReplayAcrossPurposes(t *testing.T) {
+ rdb := testRedis(t)
+ cache := &TotpCache{rdb: rdb}
+ ctx := context.Background()
+
+ consumed, err := cache.ConsumeScopedVerification(ctx, "api_key_reveal", 91, "same-code-fingerprint", 2*time.Minute)
+ require.NoError(t, err)
+ require.True(t, consumed)
+
+ consumed, err = cache.ConsumeScopedVerification(ctx, "api_key_create", 91, "same-code-fingerprint", 2*time.Minute)
+ require.NoError(t, err)
+ require.False(t, consumed, "one TOTP code must be single-use across every API-key step-up purpose")
+}
+
+func TestTotpVerifyLimiterDoesNotSlideItsTTL(t *testing.T) {
+ tests := []struct {
+ name string
+ key string
+ increment func(*TotpCache) (int, error)
+ }{
+ {
+ name: "login",
+ key: fmt.Sprintf("%s%d", totpAttemptsKeyPrefix, 92),
+ increment: func(cache *TotpCache) (int, error) {
+ return cache.IncrementVerifyAttempts(context.Background(), 92)
+ },
+ },
+ {
+ name: "api key step-up",
+ key: fmt.Sprintf("%s%s:%d", totpAttemptsKeyPrefix, "api_key_reveal", 92),
+ increment: func(cache *TotpCache) (int, error) {
+ return cache.IncrementScopedVerifyAttempts(context.Background(), "api_key_reveal", 92)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ rdb := testRedis(t)
+ cache := &TotpCache{rdb: rdb}
+ ctx := context.Background()
+
+ count, err := tt.increment(cache)
+ require.NoError(t, err)
+ require.Equal(t, 1, count)
+ require.NoError(t, rdb.Expire(ctx, tt.key, 2*time.Second).Err())
+
+ count, err = tt.increment(cache)
+ require.NoError(t, err)
+ require.Equal(t, 2, count)
+ ttl, err := rdb.TTL(ctx, tt.key).Result()
+ require.NoError(t, err)
+ require.Positive(t, ttl)
+ require.LessOrEqual(t, ttl, 2*time.Second, "a retry must not extend the original limiter window")
+ })
+ }
+}
+
+func TestTotpLoginSessionIsEncryptedAndConsumedOnce(t *testing.T) {
+ rdb := testRedis(t)
+ cache := &TotpCache{rdb: rdb, encryptor: totpCacheTestEncryptor{}}
+ ctx := context.Background()
+ const tempToken = "temporary-login-token"
+ session := &service.TotpLoginSession{
+ UserID: 93,
+ Email: "oauth@example.com",
+ TokenExpiry: time.Now().Add(5 * time.Minute),
+ PendingOAuthBind: &service.PendingOAuthBindLoginSession{
+ PendingSessionToken: "pending-session-secret",
+ BrowserSessionKey: "browser-session-secret",
+ },
+ }
+
+ require.NoError(t, cache.SetLoginSession(ctx, tempToken, session, 5*time.Minute))
+ raw, err := rdb.Get(ctx, totpLoginKeyPrefix+tempToken).Result()
+ require.NoError(t, err)
+ require.Contains(t, raw, totpLoginEncryptedV2Prefix)
+ require.NotContains(t, raw, session.PendingOAuthBind.PendingSessionToken)
+ require.NotContains(t, raw, session.PendingOAuthBind.BrowserSessionKey)
+
+ got, err := cache.GetLoginSession(ctx, tempToken)
+ require.NoError(t, err)
+ require.Equal(t, session.UserID, got.UserID)
+ require.Equal(t, session.PendingOAuthBind, got.PendingOAuthBind)
+ consumed, err := cache.ConsumeLoginSession(ctx, tempToken)
+ require.NoError(t, err)
+ require.NotNil(t, consumed)
+ require.Equal(t, session.UserID, consumed.UserID)
+ require.NoError(t, cache.SetLoginSession(ctx, tempToken, session, 5*time.Minute))
+
+ const workers = 16
+ winners := make(chan *service.TotpLoginSession, workers)
+ errs := make(chan error, workers)
+ for i := 0; i < workers; i++ {
+ go func() {
+ consumed, consumeErr := cache.ConsumeLoginSession(ctx, tempToken)
+ winners <- consumed
+ errs <- consumeErr
+ }()
+ }
+ winnerCount := 0
+ for i := 0; i < workers; i++ {
+ require.NoError(t, <-errs)
+ if <-winners != nil {
+ winnerCount++
+ }
+ }
+ require.Equal(t, 1, winnerCount, "GETDEL must allow exactly one successful login continuation")
+}
+
+func TestTotpCacheRejectsLegacyUnboundSessionEnvelope(t *testing.T) {
+ rdb := testRedis(t)
+ encryptor := totpCacheTestEncryptor{}
+ cache := &TotpCache{rdb: rdb, encryptor: encryptor}
+ ctx := context.Background()
+ session := &service.TotpLoginSession{UserID: 94, Email: "legacy@example.test"}
+ plaintext, err := json.Marshal(session)
+ require.NoError(t, err)
+ legacy, err := encryptor.Encrypt(string(plaintext))
+ require.NoError(t, err)
+ require.NoError(t, rdb.Set(ctx, totpLoginKeyPrefix+"legacy", totpLoginEncryptedV1Prefix+legacy, time.Minute).Err())
+
+ _, err = cache.GetLoginSession(ctx, "legacy")
+ require.Error(t, err)
+}
diff --git a/backend/internal/repository/usage_billing_admission_quote_unit_test.go b/backend/internal/repository/usage_billing_admission_quote_unit_test.go
new file mode 100644
index 00000000000..919a9eeaaea
--- /dev/null
+++ b/backend/internal/repository/usage_billing_admission_quote_unit_test.go
@@ -0,0 +1,330 @@
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/DATA-DOG/go-sqlmock"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUsageBillingAdmissionCostFamilyCannotMixWalletMonthlyAndBalance(t *testing.T) {
+ subscriptionID := int64(71)
+ walletAdmission := sql.NullInt64{Int64: subscriptionID, Valid: true}
+ monthlyAdmission := sql.NullInt64{}
+ frozenSubscription := sql.NullInt64{Int64: subscriptionID, Valid: true}
+
+ require.True(t, usageBillingAdmissionCostFamilyMatches(walletAdmission, service.BillingTypeSubscription, frozenSubscription,
+ usageBillingCostFamilyEnvelope(t, service.BillingTypeSubscription, &subscriptionID, 0, 0, 1)))
+ require.True(t, usageBillingAdmissionCostFamilyMatches(walletAdmission, service.BillingTypeSubscription, frozenSubscription,
+ usageBillingCostFamilyEnvelope(t, service.BillingTypeSubscription, &subscriptionID, 0, 0, 0)))
+ require.False(t, usageBillingAdmissionCostFamilyMatches(walletAdmission, service.BillingTypeSubscription, frozenSubscription,
+ usageBillingCostFamilyEnvelope(t, service.BillingTypeSubscription, &subscriptionID, 0, 1, 0)))
+
+ require.True(t, usageBillingAdmissionCostFamilyMatches(monthlyAdmission, service.BillingTypeSubscription, frozenSubscription,
+ usageBillingCostFamilyEnvelope(t, service.BillingTypeSubscription, &subscriptionID, 0, 1, 0)))
+ require.False(t, usageBillingAdmissionCostFamilyMatches(monthlyAdmission, service.BillingTypeSubscription, frozenSubscription,
+ usageBillingCostFamilyEnvelope(t, service.BillingTypeSubscription, &subscriptionID, 0, 0, 1)))
+
+ require.True(t, usageBillingAdmissionCostFamilyMatches(monthlyAdmission, service.BillingTypeBalance, sql.NullInt64{},
+ usageBillingCostFamilyEnvelope(t, service.BillingTypeBalance, nil, 1, 0, 0)))
+}
+
+func usageBillingCostFamilyEnvelope(
+ t *testing.T,
+ billingType int8,
+ subscriptionID *int64,
+ balanceCost float64,
+ subscriptionCost float64,
+ walletCost float64,
+) service.UsageBillingEnvelope {
+ t.Helper()
+ groupID := int64(31)
+ envelope, err := service.NewUsageBillingEnvelope(service.UsageBillingEnvelopeInput{
+ RequestID: "usage-cost-family", RequestPayloadHash: strings.Repeat("a", 64),
+ APIKeyID: 11, UserID: 22, AccountID: 33, SubscriptionID: subscriptionID,
+ GroupID: &groupID, EffectiveBillingGroupID: &groupID,
+ AccountType: service.AccountTypeOAuth, BillingModel: "gpt-5.6-terra", BillingType: billingType,
+ PricingSource: service.PricingSourceBuiltinGPT56, PricingRevision: service.GPT56PricingRevision,
+ PricingHash: strings.Repeat("b", 64), RateMultiplier: 1, AccountRateMultiplier: 1,
+ BalanceCost: balanceCost, SubscriptionCost: subscriptionCost, WalletCost: walletCost,
+ })
+ require.NoError(t, err)
+ return envelope
+}
+
+func TestUsageBillingMarkDispatchedAllowsOwnedFailoverAttempt(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+ mock.ExpectBegin()
+ mock.ExpectExec("UPDATE usage_billing_admissions[\\s\\S]+state IN \\('prepared','dispatched'\\)").
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("UPDATE usage_billing_admission_attempts").
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectCommit()
+
+ repo := NewUsageBillingOutboxRepository(db)
+ err = repo.MarkDispatched(context.Background(), service.UsageBillingAdmissionAttemptRef{
+ RequestID: "usage-failover-dispatch", APIKeyID: 11,
+ OwnerToken: strings.Repeat("a", 32), AttemptID: strings.Repeat("b", 32),
+ })
+ require.NoError(t, err)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestUsageBillingAdmissionSettlementRequiresDispatchedFence(t *testing.T) {
+ require.False(t, usageBillingAdmissionSettlementStateMatches(
+ service.UsageBillingAdmissionStatePrepared,
+ service.UsageBillingAttemptStatePrepared,
+ ))
+ require.True(t, usageBillingAdmissionSettlementStateMatches(
+ service.UsageBillingAdmissionStateDispatched,
+ service.UsageBillingAttemptStateDispatched,
+ ))
+ require.True(t, usageBillingAdmissionSettlementStateMatches(
+ service.UsageBillingAdmissionStateOutboxPending,
+ service.UsageBillingAttemptStateFinalized,
+ ))
+ require.False(t, usageBillingAdmissionSettlementStateMatches(
+ service.UsageBillingAdmissionStateDispatched,
+ service.UsageBillingAttemptStatePrepared,
+ ))
+}
+
+func TestUsageBillingAbandonReleasesDispatchedHoldOnlyAfterAllAttemptsFailed(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+ mock.ExpectExec("UPDATE usage_billing_admissions[\\s\\S]+state = 'dispatched' AND NOT EXISTS[\\s\\S]+attempt.state IN \\('prepared','dispatched','finalized'\\)").
+ WillReturnResult(sqlmock.NewResult(0, 1))
+
+ repo := NewUsageBillingOutboxRepository(db)
+ err = repo.Abandon(context.Background(), service.UsageBillingAdmissionAttemptRef{
+ RequestID: "usage-failed-attempts", APIKeyID: 11,
+ OwnerToken: strings.Repeat("a", 32), AttemptID: strings.Repeat("b", 32),
+ })
+ require.NoError(t, err)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestDurableUsageBillingAdmissionRolloutConsumesExistingAdmissionWithoutBreakingLegacyProducers(t *testing.T) {
+ repo := newDurableUsageBillingOutboxInner(nil)
+ require.NotNil(t, repo)
+ require.True(t, repo.useAdmissionIfPresent)
+ require.False(t, repo.requireAdmission, "global enforcement stays off until every producer has pre-dispatch admission")
+}
+
+func TestUsageBillingAdmissionExistsDetectsOptionalHold(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+ mock.ExpectQuery("SELECT EXISTS").WithArgs("request-1", int64(11)).
+ WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
+
+ exists, err := usageBillingAdmissionExists(context.Background(), db, "request-1", 11)
+ require.NoError(t, err)
+ require.True(t, exists)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestUsageBillingUniversalWalletAuthorizationUsesExactBusinessGroups(t *testing.T) {
+ openAIDefault := service.Group{
+ ID: 3, Name: service.WalletDefaultOpenAIGroupName, Platform: service.PlatformOpenAI,
+ Status: service.StatusActive, Hydrated: true, SubscriptionType: service.SubscriptionTypeStandard,
+ }
+ require.True(t, usageBillingUniversalWalletGroupAuthorized(openAIDefault, false))
+
+ vip := service.Group{
+ ID: 22, Name: service.WalletDefaultVIPGroupName, Platform: service.PlatformAnthropic,
+ Status: service.StatusActive, Hydrated: true, IsExclusive: true, SubscriptionType: service.SubscriptionTypeStandard,
+ }
+ require.False(t, usageBillingUniversalWalletGroupAuthorized(vip, false))
+ require.True(t, usageBillingUniversalWalletGroupAuthorized(vip, true))
+
+ wrongName := openAIDefault
+ wrongName.Name = "openai_default"
+ require.False(t, usageBillingUniversalWalletGroupAuthorized(wrongName, true))
+ wrongPlatform := vip
+ wrongPlatform.Platform = service.PlatformOpenAI
+ require.False(t, usageBillingUniversalWalletGroupAuthorized(wrongPlatform, true))
+ drifted := openAIDefault
+ drifted.ClaudeCodeOnly = true
+ require.False(t, usageBillingUniversalWalletGroupAuthorized(drifted, true))
+ fallbackID := int64(99)
+ drifted = vip
+ drifted.FallbackGroupID = &fallbackID
+ require.False(t, usageBillingUniversalWalletGroupAuthorized(drifted, true))
+}
+
+func TestUsageBillingUniversalWalletKeyRequiresPurposeNameAndNullGroup(t *testing.T) {
+ require.True(t, validUsageBillingUniversalWalletKeyShape(service.WalletUniversalAPIKeyName, service.APIKeyPurposeWalletUniversal, false))
+ require.False(t, validUsageBillingUniversalWalletKeyShape(service.WalletUniversalAPIKeyName, "", false))
+ require.False(t, validUsageBillingUniversalWalletKeyShape("renamed", service.APIKeyPurposeWalletUniversal, false))
+ require.False(t, validUsageBillingUniversalWalletKeyShape(service.WalletUniversalAPIKeyName, service.APIKeyPurposeWalletUniversal, true))
+}
+
+func TestUsageBillingWalletGroupPolicyAppliesToGroupedAndLegacyKeys(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ openAIDefault := service.Group{
+ ID: 3, Name: service.WalletDefaultOpenAIGroupName, Platform: service.PlatformOpenAI,
+ Status: service.StatusActive, Hydrated: true, SubscriptionType: service.SubscriptionTypeStandard,
+ }
+ legacyEnvelope := usageBillingWalletPolicyEnvelope(t, openAIDefault.ID, "")
+ require.NoError(t, validateUsageBillingWalletGroupAuthorization(context.Background(), db, legacyEnvelope, openAIDefault, false))
+
+ arbitrary := openAIDefault
+ arbitrary.Name = "arbitrary-standard"
+ require.ErrorIs(t,
+ validateUsageBillingWalletGroupAuthorization(context.Background(), db, legacyEnvelope, arbitrary, false),
+ service.ErrUsageBillingCrossTenant,
+ )
+
+ vip := service.Group{
+ ID: 22, Name: service.WalletDefaultVIPGroupName, Platform: service.PlatformAnthropic,
+ Status: service.StatusActive, Hydrated: true, IsExclusive: true, SubscriptionType: service.SubscriptionTypeStandard,
+ }
+ vipEnvelope := usageBillingWalletPolicyEnvelope(t, vip.ID, "")
+ mock.ExpectQuery("SELECT TRUE[\\s\\S]+FROM user_allowed_groups").
+ WithArgs(vipEnvelope.UserID(), vip.ID).WillReturnError(sql.ErrNoRows)
+ require.ErrorIs(t,
+ validateUsageBillingWalletGroupAuthorization(context.Background(), db, vipEnvelope, vip, false),
+ service.ErrUsageBillingCrossTenant,
+ )
+
+ mock.ExpectQuery("SELECT TRUE[\\s\\S]+FROM user_allowed_groups").
+ WithArgs(vipEnvelope.UserID(), vip.ID).
+ WillReturnRows(sqlmock.NewRows([]string{"allowed"}).AddRow(true))
+ require.NoError(t, validateUsageBillingWalletGroupAuthorization(context.Background(), db, vipEnvelope, vip, false))
+
+ frozenEnvelope := usageBillingWalletPolicyEnvelope(t, vip.ID, strings.Repeat("c", 32))
+ mock.ExpectQuery("SELECT EXISTS[\\s\\S]+FROM usage_billing_admissions").
+ WithArgs(frozenEnvelope.RequestID(), frozenEnvelope.APIKeyID(), frozenEnvelope.AdmissionAttemptID()).
+ WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
+ require.NoError(t, validateUsageBillingWalletGroupAuthorization(context.Background(), db, frozenEnvelope, vip, false))
+
+ spoofedEnvelope := usageBillingWalletPolicyEnvelope(t, vip.ID, strings.Repeat("d", 32))
+ mock.ExpectQuery("SELECT EXISTS[\\s\\S]+FROM usage_billing_admissions").
+ WithArgs(spoofedEnvelope.RequestID(), spoofedEnvelope.APIKeyID(), spoofedEnvelope.AdmissionAttemptID()).
+ WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
+ mock.ExpectQuery("SELECT TRUE[\\s\\S]+FROM user_allowed_groups").
+ WithArgs(spoofedEnvelope.UserID(), vip.ID).WillReturnError(sql.ErrNoRows)
+ require.ErrorIs(t,
+ validateUsageBillingWalletGroupAuthorization(context.Background(), db, spoofedEnvelope, vip, false),
+ service.ErrUsageBillingCrossTenant,
+ )
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func usageBillingWalletPolicyEnvelope(t *testing.T, groupID int64, attemptID string) service.UsageBillingEnvelope {
+ t.Helper()
+ subscriptionID := int64(71)
+ envelope, err := service.NewUsageBillingEnvelope(service.UsageBillingEnvelopeInput{
+ RequestID: "wallet-policy", RequestPayloadHash: strings.Repeat("a", 64), AdmissionAttemptID: attemptID,
+ APIKeyID: 11, UserID: 22, AccountID: 33, SubscriptionID: &subscriptionID,
+ GroupID: &groupID, EffectiveBillingGroupID: &groupID,
+ AccountType: service.AccountTypeOAuth, BillingModel: "gpt-5.6-terra", BillingType: service.BillingTypeSubscription,
+ PricingSource: service.PricingSourceBuiltinGPT56, PricingRevision: service.GPT56PricingRevision,
+ PricingHash: strings.Repeat("b", 64), RateMultiplier: 1, AccountRateMultiplier: 1, WalletCost: 1,
+ })
+ require.NoError(t, err)
+ return envelope
+}
+
+func repositoryUsageBillingAdmission(t *testing.T, worstCaseCostUSD float64) service.UsageBillingAdmission {
+ t.Helper()
+ groupID := int64(31)
+ subscriptionID := int64(71)
+ admission, err := service.NewUsageBillingAdmission(service.UsageBillingAdmissionInput{
+ RequestID: "usage-admission-repository-quote",
+ APIKeyID: 11,
+ AuthCacheLocator: strings.Repeat("a", 64),
+ UserID: 22,
+ SubscriptionID: &subscriptionID,
+ GroupID: &groupID,
+ EffectiveBillingGroupID: &groupID,
+ BillingType: service.BillingTypeSubscription,
+ OwnerToken: strings.Repeat("b", 32),
+ AttemptID: strings.Repeat("c", 32),
+ AccountID: 33,
+ AccountType: service.AccountTypeOAuth,
+ BillingModel: "gpt-5.6-terra",
+ RequestPayloadHash: strings.Repeat("d", 64),
+ PricingSource: service.PricingSourceBuiltinGPT56,
+ PricingRevision: service.GPT56PricingRevision,
+ PricingHash: strings.Repeat("e", 64),
+ RateMultiplier: 1.25,
+ WorstCaseCostUSD: worstCaseCostUSD,
+ })
+ require.NoError(t, err)
+ return admission
+}
+
+func TestUsageBillingAdmissionValidationEnvelopeCopiesFrozenQuote(t *testing.T) {
+ admission := repositoryUsageBillingAdmission(t, 0.75)
+ envelope, err := usageBillingAdmissionValidationEnvelope(admission)
+ require.NoError(t, err)
+ require.True(t, admission.MatchesEnvelopeBase(envelope))
+
+ raw, err := envelope.MarshalJSON()
+ require.NoError(t, err)
+ var payload map[string]any
+ require.NoError(t, json.Unmarshal(raw, &payload))
+ require.Equal(t, admission.RequestPayloadHash(), payload["request_payload_hash"])
+ require.Equal(t, admission.BillingModel(), payload["billing_model"])
+ require.Equal(t, admission.PricingSource(), payload["pricing_source"])
+ require.Equal(t, admission.PricingRevision(), payload["pricing_revision"])
+ require.Equal(t, admission.PricingHash(), payload["pricing_hash"])
+ require.Equal(t, admission.RateMultiplier(), payload["rate_multiplier"])
+}
+
+func TestPrepareUsageBillingWalletHoldReservesOnlyWorstCaseCost(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+ mock.ExpectBegin()
+ tx, err := db.BeginTx(context.Background(), nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = tx.Rollback() })
+
+ admission := repositoryUsageBillingAdmission(t, 0.75)
+ mock.ExpectQuery("SELECT COALESCE\\(SUM\\(wallet_reserved_usd\\), 0\\)").
+ WithArgs(int64(71), admission.RequestID(), admission.APIKeyID()).
+ WillReturnRows(sqlmock.NewRows([]string{"outstanding_usd"}).AddRow(0))
+
+ walletID, reservedUSD, _, err := prepareUsageBillingWalletHold(
+ context.Background(), tx, admission, &usageBillingAdmissionWallet{id: 71, balance: 25}, nil,
+ )
+ require.NoError(t, err)
+ require.NotNil(t, walletID)
+ require.Equal(t, int64(71), *walletID)
+ require.InDelta(t, 0.75, reservedUSD, 0.0000001)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestPrepareUsageBillingWalletHoldRejectsUnaffordableQuote(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+ mock.ExpectBegin()
+ tx, err := db.BeginTx(context.Background(), nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = tx.Rollback() })
+
+ admission := repositoryUsageBillingAdmission(t, 25.01)
+ mock.ExpectQuery("SELECT COALESCE\\(SUM\\(wallet_reserved_usd\\), 0\\)").
+ WithArgs(int64(71), admission.RequestID(), admission.APIKeyID()).
+ WillReturnRows(sqlmock.NewRows([]string{"outstanding_usd"}).AddRow(0))
+ _, _, _, err = prepareUsageBillingWalletHold(
+ context.Background(), tx, admission, &usageBillingAdmissionWallet{id: 71, balance: 25}, nil,
+ )
+ require.ErrorIs(t, err, service.ErrWalletInsufficient)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
diff --git a/backend/internal/repository/usage_billing_admission_settlement_unit_test.go b/backend/internal/repository/usage_billing_admission_settlement_unit_test.go
new file mode 100644
index 00000000000..a10e4a67e67
--- /dev/null
+++ b/backend/internal/repository/usage_billing_admission_settlement_unit_test.go
@@ -0,0 +1,87 @@
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+
+ "github.com/DATA-DOG/go-sqlmock"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSettleUsageBillingWalletAdmissionRejectsCostAboveReservation(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+ mock.ExpectBegin()
+ tx, err := db.BeginTx(context.Background(), nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = tx.Rollback() })
+
+ subscriptionID := int64(71)
+ cmd := &service.UsageBillingCommand{
+ RequestID: "wallet-over-reservation", APIKeyID: 11,
+ SubscriptionID: &subscriptionID, WalletCost: 2, BindingsFrozen: true,
+ }
+ mock.ExpectQuery("SELECT wallet_subscription_id").
+ WithArgs(cmd.RequestID, cmd.APIKeyID).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_subscription_id"}).AddRow(subscriptionID))
+ mock.ExpectQuery("SELECT wallet_balance_usd").
+ WithArgs(subscriptionID, true).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd"}).AddRow(10.0))
+ mock.ExpectQuery("SELECT state, wallet_subscription_id, wallet_reserved_usd").
+ WithArgs(cmd.RequestID, cmd.APIKeyID).
+ WillReturnRows(sqlmock.NewRows([]string{
+ "state", "wallet_subscription_id", "wallet_reserved_usd", "wallet_consumed_at", "wallet_released_at",
+ }).AddRow(service.UsageBillingAdmissionStateOutboxPending, subscriptionID, 1.0, nil, nil))
+
+ handled, _, _, err := settleUsageBillingWalletAdmission(context.Background(), tx, cmd)
+ require.False(t, handled)
+ require.ErrorIs(t, err, service.ErrUsageBillingRequestConflict)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestSettleUsageBillingNonWalletAdmissionMarksSettled(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+ mock.ExpectBegin()
+ tx, err := db.BeginTx(context.Background(), nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = tx.Rollback() })
+
+ cmd := &service.UsageBillingCommand{
+ RequestID: "balance-admission-settlement", APIKeyID: 11, BindingsFrozen: true,
+ }
+ mock.ExpectQuery("SELECT state, wallet_subscription_id").
+ WithArgs(cmd.RequestID, cmd.APIKeyID).
+ WillReturnRows(sqlmock.NewRows([]string{"state", "wallet_subscription_id"}).
+ AddRow(service.UsageBillingAdmissionStateOutboxPending, nil))
+ mock.ExpectExec("UPDATE usage_billing_admissions").
+ WithArgs(cmd.RequestID, cmd.APIKeyID).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+
+ require.NoError(t, settleUsageBillingNonWalletAdmission(context.Background(), tx, cmd))
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestSettleUsageBillingNonWalletAdmissionAllowsLegacyEventWithoutAdmission(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+ mock.ExpectBegin()
+ tx, err := db.BeginTx(context.Background(), nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = tx.Rollback() })
+
+ cmd := &service.UsageBillingCommand{
+ RequestID: "legacy-before-admission", APIKeyID: 11, BindingsFrozen: true,
+ }
+ mock.ExpectQuery("SELECT state, wallet_subscription_id").
+ WithArgs(cmd.RequestID, cmd.APIKeyID).
+ WillReturnError(sql.ErrNoRows)
+
+ require.NoError(t, settleUsageBillingNonWalletAdmission(context.Background(), tx, cmd))
+ require.NoError(t, mock.ExpectationsWereMet())
+}
diff --git a/backend/internal/repository/usage_billing_outbox_processor_integration_test.go b/backend/internal/repository/usage_billing_outbox_processor_integration_test.go
new file mode 100644
index 00000000000..1b95249ea2f
--- /dev/null
+++ b/backend/internal/repository/usage_billing_outbox_processor_integration_test.go
@@ -0,0 +1,385 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+)
+
+type failOnceCompleteOutboxRepository struct {
+ service.UsageBillingOutboxRepository
+ failed bool
+}
+
+func (r *failOnceCompleteOutboxRepository) Complete(ctx context.Context, id int64, owner, leaseToken, resultCode string) error {
+ if !r.failed {
+ r.failed = true
+ return errors.New("simulated ack crash")
+ }
+ return r.UsageBillingOutboxRepository.Complete(ctx, id, owner, leaseToken, resultCode)
+}
+
+type integrationUsageBillingReplayWriter struct {
+ repo service.UsageLogRepository
+}
+
+func (w *integrationUsageBillingReplayWriter) WriteUsageBillingReplay(ctx context.Context, envelope service.UsageBillingEnvelope) error {
+ cmd := envelope.Command()
+ actualCost := cmd.BalanceCost
+ if cmd.SubscriptionCost > 0 {
+ actualCost = cmd.SubscriptionCost
+ }
+ if cmd.WalletCost > 0 {
+ actualCost = cmd.WalletCost
+ }
+ billingModel := cmd.Model
+ _, err := w.repo.Create(ctx, &service.UsageLog{
+ UserID: cmd.UserID,
+ APIKeyID: cmd.APIKeyID,
+ AccountID: cmd.AccountID,
+ RequestID: cmd.RequestID,
+ Model: cmd.Model,
+ BillingModel: &billingModel,
+ InputTokens: cmd.InputTokens,
+ OutputTokens: cmd.OutputTokens,
+ ActualCost: actualCost,
+ TotalCost: actualCost,
+ RateMultiplier: 1,
+ BillingType: cmd.BillingType,
+ SubscriptionID: cmd.SubscriptionID,
+ CreatedAt: time.Now().UTC(),
+ })
+ return err
+}
+
+func integrationOutboxEnvelope(t *testing.T, input service.UsageBillingEnvelopeInput) service.UsageBillingEnvelope {
+ t.Helper()
+ if input.RequestPayloadHash == "" {
+ input.RequestPayloadHash = integrationUsageRequestPayloadHash
+ }
+ input.PricingSource = service.PricingSourceBuiltinGPT56
+ input.PricingRevision = service.GPT56PricingRevision
+ input.PricingHash = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
+ input.RateMultiplier = 1
+ input.AccountRateMultiplier = 1
+ envelope, err := service.NewUsageBillingEnvelope(input)
+ require.NoError(t, err)
+ return envelope
+}
+
+func TestUsageBillingOutboxProcessorIntegration_BalanceAckCrashReplayChargesAndLogsOnce(t *testing.T) {
+ ctx := context.Background()
+ _, _ = integrationDB.ExecContext(ctx, "TRUNCATE usage_billing_outbox RESTART IDENTITY")
+ client := testEntClient(t)
+ user := mustCreateUser(t, client, &service.User{Email: fmt.Sprintf("outbox-balance-%s@example.com", uuid.NewString()), Balance: 100})
+ group := mustCreateGroup(t, client, &service.Group{Name: "outbox-balance-" + uuid.NewString()})
+ apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, GroupID: &group.ID, Key: "sk-outbox-balance-" + uuid.NewString()})
+ account := mustCreateAccount(t, client, &service.Account{Name: "outbox-balance-" + uuid.NewString(), Type: service.AccountTypeAPIKey})
+ mustBindAccountToGroup(t, client, account.ID, group.ID, 1)
+
+ outboxRepo := NewUsageBillingOutboxRepository(integrationDB)
+ billingRepo := NewUsageBillingRepository(client, integrationDB)
+ usageRepo := NewUsageLogRepository(client, integrationDB)
+ envelope := integrationOutboxEnvelope(t, service.UsageBillingEnvelopeInput{
+ RequestID: "outbox-ack-" + uuid.NewString(),
+ APIKeyID: apiKey.ID,
+ AuthCacheLocator: apiKey.KeyHash,
+ UserID: user.ID,
+ AccountID: account.ID,
+ GroupID: &group.ID,
+ AccountType: service.AccountTypeAPIKey,
+ BillingModel: "gpt-5.6-sol",
+ BillingType: service.BillingTypeBalance,
+ InputTokens: 100,
+ OutputTokens: 10,
+ BalanceCost: 1.25,
+ APIKeyQuotaCost: 1.25,
+ APIKeyRateLimitCost: 1.25,
+ AccountQuotaCost: 1.25,
+ })
+ event, _, err := outboxRepo.Enqueue(ctx, envelope)
+ require.NoError(t, err)
+ _, err = integrationDB.ExecContext(ctx, "UPDATE users SET deleted_at = NOW() WHERE id = $1", user.ID)
+ require.NoError(t, err)
+ _, err = integrationDB.ExecContext(ctx, "UPDATE api_keys SET deleted_at = NOW() WHERE id = $1", apiKey.ID)
+ require.NoError(t, err)
+ _, err = integrationDB.ExecContext(ctx, "UPDATE accounts SET deleted_at = NOW() WHERE id = $1", account.ID)
+ require.NoError(t, err)
+ _, err = integrationDB.ExecContext(ctx, "UPDATE usage_billing_outbox SET attempt_count = max_attempts - 1 WHERE id = $1", event.ID)
+ require.NoError(t, err)
+ claimed, err := outboxRepo.Claim(ctx, "worker-one", 1, service.UsageBillingOutboxLeaseDuration)
+ require.NoError(t, err)
+ require.Len(t, claimed, 1)
+ require.Equal(t, claimed[0].MaxAttempts, claimed[0].AttemptCount)
+
+ failingAckRepo := &failOnceCompleteOutboxRepository{UsageBillingOutboxRepository: outboxRepo}
+ processor := service.NewUsageBillingOutboxProcessor(failingAckRepo, outboxRepo, billingRepo, &integrationUsageBillingReplayWriter{repo: usageRepo})
+ require.Error(t, processor.ProcessEvent(ctx, claimed[0]))
+
+ _, err = integrationDB.ExecContext(ctx, "UPDATE usage_billing_outbox SET locked_at = NOW() - INTERVAL '31 seconds' WHERE id = $1", claimed[0].ID)
+ require.NoError(t, err)
+ replayed, err := outboxRepo.Claim(ctx, "worker-two", 1, service.UsageBillingOutboxLeaseDuration)
+ require.NoError(t, err)
+ require.Len(t, replayed, 1)
+ processor = service.NewUsageBillingOutboxProcessor(outboxRepo, outboxRepo, billingRepo, &integrationUsageBillingReplayWriter{repo: usageRepo})
+ require.NoError(t, processor.ProcessEvent(ctx, replayed[0]))
+
+ var balance float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT balance FROM users WHERE id = $1", user.ID).Scan(&balance))
+ require.InDelta(t, 98.75, balance, 0.000001)
+ var usageCount, dedupCount int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM usage_logs WHERE request_id = $1 AND api_key_id = $2", envelope.RequestID(), apiKey.ID).Scan(&usageCount))
+ require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM usage_billing_dedup WHERE request_id = $1 AND api_key_id = $2", envelope.RequestID(), apiKey.ID).Scan(&dedupCount))
+ require.Equal(t, 1, usageCount)
+ require.Equal(t, 1, dedupCount)
+ var status, resultCode string
+ require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT status, result_code FROM usage_billing_outbox WHERE id = $1", claimed[0].ID).Scan(&status, &resultCode))
+ require.Equal(t, service.UsageBillingOutboxStatusCompleted, status)
+ require.Equal(t, service.UsageBillingOutboxResultDuplicate, resultCode)
+}
+
+func TestUsageBillingOutboxProcessorIntegration_MonthlyAndWalletPreserveFrozenSubscription(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+
+ t.Run("monthly", func(t *testing.T) {
+ _, _ = integrationDB.ExecContext(ctx, "TRUNCATE usage_billing_outbox RESTART IDENTITY")
+ user := mustCreateUser(t, client, &service.User{Email: fmt.Sprintf("outbox-monthly-%s@example.com", uuid.NewString())})
+ group := mustCreateGroup(t, client, &service.Group{Name: "outbox-monthly-" + uuid.NewString(), SubscriptionType: service.SubscriptionTypeSubscription})
+ apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, GroupID: &group.ID, Key: "sk-outbox-monthly-" + uuid.NewString()})
+ account := mustCreateAccount(t, client, &service.Account{Name: "outbox-monthly-account-" + uuid.NewString(), Type: service.AccountTypeOAuth})
+ mustBindAccountToGroup(t, client, account.ID, group.ID, 1)
+ subscription := mustCreateSubscription(t, client, &service.UserSubscription{UserID: user.ID, GroupID: &group.ID})
+ envelope := integrationOutboxEnvelope(t, service.UsageBillingEnvelopeInput{
+ RequestID: "outbox-monthly-" + uuid.NewString(),
+ APIKeyID: apiKey.ID,
+ UserID: user.ID,
+ AccountID: account.ID,
+ SubscriptionID: &subscription.ID,
+ GroupID: &group.ID,
+ AccountType: service.AccountTypeOAuth,
+ BillingModel: "gpt-5.6-sol",
+ BillingType: service.BillingTypeSubscription,
+ SubscriptionCost: 2.5,
+ })
+ processIntegrationEnvelopeAfterEnqueue(t, ctx, envelope, func() {
+ _, err := integrationDB.ExecContext(ctx, "UPDATE user_subscriptions SET deleted_at = NOW() WHERE id = $1", subscription.ID)
+ require.NoError(t, err)
+ })
+
+ var daily, weekly, monthly float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT daily_usage_usd, weekly_usage_usd, monthly_usage_usd
+ FROM user_subscriptions WHERE id = $1
+ `, subscription.ID).Scan(&daily, &weekly, &monthly))
+ require.InDelta(t, 2.5, daily, 0.000001)
+ require.InDelta(t, 2.5, weekly, 0.000001)
+ require.InDelta(t, 2.5, monthly, 0.000001)
+ })
+
+ t.Run("monthly plan coverage", func(t *testing.T) {
+ _, _ = integrationDB.ExecContext(ctx, "TRUNCATE usage_billing_outbox RESTART IDENTITY")
+ user := mustCreateUser(t, client, &service.User{Email: fmt.Sprintf("outbox-monthly-coverage-%s@example.com", uuid.NewString())})
+ anchorGroup := mustCreateGroup(t, client, &service.Group{
+ Name: "outbox-monthly-anchor-" + uuid.NewString(),
+ SubscriptionType: service.SubscriptionTypeSubscription,
+ })
+ routingGroup := mustCreateGroup(t, client, &service.Group{
+ Name: "outbox-monthly-routing-" + uuid.NewString(),
+ SubscriptionType: service.SubscriptionTypeStandard,
+ })
+ plan, err := client.SubscriptionPlan.Create().
+ SetName("outbox-monthly-coverage-" + uuid.NewString()).
+ SetPrice(99).
+ SetGroupID(anchorGroup.ID).
+ SetValidityDays(30).
+ SetValidityUnit("day").
+ SetPlanType(service.PlanTypeSubscription).
+ Save(ctx)
+ require.NoError(t, err)
+ for _, groupID := range []int64{anchorGroup.ID, routingGroup.ID} {
+ _, err = client.SubscriptionPlanGroup.Create().SetPlanID(plan.ID).SetGroupID(groupID).Save(ctx)
+ require.NoError(t, err)
+ }
+ apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, GroupID: &routingGroup.ID, Key: "sk-outbox-monthly-coverage-" + uuid.NewString()})
+ account := mustCreateAccount(t, client, &service.Account{Name: "outbox-monthly-coverage-account-" + uuid.NewString(), Type: service.AccountTypeOAuth})
+ mustBindAccountToGroup(t, client, account.ID, routingGroup.ID, 1)
+ subscription := mustCreateSubscription(t, client, &service.UserSubscription{UserID: user.ID, GroupID: &anchorGroup.ID})
+ envelope := integrationOutboxEnvelope(t, service.UsageBillingEnvelopeInput{
+ RequestID: "outbox-monthly-coverage-" + uuid.NewString(),
+ APIKeyID: apiKey.ID,
+ UserID: user.ID,
+ AccountID: account.ID,
+ SubscriptionID: &subscription.ID,
+ GroupID: &routingGroup.ID,
+ EffectiveBillingGroupID: &anchorGroup.ID,
+ AccountType: service.AccountTypeOAuth,
+ BillingModel: "gpt-5.6-sol",
+ BillingType: service.BillingTypeSubscription,
+ SubscriptionCost: 2.5,
+ })
+
+ processIntegrationEnvelopeAfterEnqueue(t, ctx, envelope, nil)
+
+ var daily, weekly, monthly float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT daily_usage_usd, weekly_usage_usd, monthly_usage_usd
+ FROM user_subscriptions WHERE id = $1
+ `, subscription.ID).Scan(&daily, &weekly, &monthly))
+ require.InDelta(t, 2.5, daily, 0.000001)
+ require.InDelta(t, 2.5, weekly, 0.000001)
+ require.InDelta(t, 2.5, monthly, 0.000001)
+ })
+
+ t.Run("wallet", func(t *testing.T) {
+ _, _ = integrationDB.ExecContext(ctx, "TRUNCATE usage_billing_outbox RESTART IDENTITY")
+ user := mustCreateUser(t, client, &service.User{Email: fmt.Sprintf("outbox-wallet-%s@example.com", uuid.NewString())})
+ group := mustGetOrCreateWalletBusinessGroup(t, client,
+ service.WalletDefaultOpenAIGroupName, service.PlatformOpenAI, false, 1)
+ wallet := mustCreateCreditsWallet(t, client, user.ID, 10)
+ apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, Name: service.WalletUniversalAPIKeyName, Purpose: service.APIKeyPurposeWalletUniversal, Key: "sk-outbox-wallet-" + uuid.NewString()})
+ account := mustCreateAccount(t, client, &service.Account{Name: "outbox-wallet-account-" + uuid.NewString(), Type: service.AccountTypeOAuth})
+ mustBindAccountToGroup(t, client, account.ID, group.ID, 1)
+ walletID := wallet.ID
+ requestID := "outbox-wallet-" + uuid.NewString()
+ ownerToken := strings.Repeat("a", 32)
+ attemptID := strings.Repeat("b", 32)
+ admission, err := service.NewUsageBillingAdmission(service.UsageBillingAdmissionInput{
+ RequestID: requestID, RequestPayloadHash: integrationUsageRequestPayloadHash,
+ APIKeyID: apiKey.ID, AuthCacheLocator: apiKey.KeyHash,
+ UserID: user.ID, AccountID: account.ID, SubscriptionID: &walletID,
+ GroupID: &group.ID, EffectiveBillingGroupID: &group.ID,
+ AccountType: account.Type, BillingType: service.BillingTypeSubscription,
+ OwnerToken: ownerToken, AttemptID: attemptID, BillingModel: "gpt-5.6-sol",
+ PricingSource: service.PricingSourceBuiltinGPT56, PricingRevision: service.GPT56PricingRevision,
+ PricingHash: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ RateMultiplier: 1, WorstCaseCostUSD: 2.5,
+ })
+ require.NoError(t, err)
+ admissionRepo := NewUsageBillingOutboxRepository(integrationDB)
+ require.NoError(t, admissionRepo.Admit(ctx, admission))
+ require.NoError(t, admissionRepo.MarkDispatched(ctx, service.UsageBillingAdmissionAttemptRef{
+ RequestID: admission.RequestID(), APIKeyID: admission.APIKeyID(),
+ OwnerToken: admission.OwnerToken(), AttemptID: admission.AttemptID(),
+ }))
+ envelope := integrationOutboxEnvelope(t, service.UsageBillingEnvelopeInput{
+ RequestID: requestID,
+ RequestPayloadHash: integrationUsageRequestPayloadHash,
+ AdmissionAttemptID: attemptID,
+ APIKeyID: apiKey.ID,
+ AuthCacheLocator: apiKey.KeyHash,
+ UserID: user.ID,
+ AccountID: account.ID,
+ SubscriptionID: &walletID,
+ GroupID: &group.ID,
+ AccountType: service.AccountTypeOAuth,
+ BillingModel: "gpt-5.6-sol",
+ BillingType: service.BillingTypeSubscription,
+ WalletCost: 2.5,
+ })
+ processIntegrationEnvelopeAfterEnqueue(t, ctx, envelope, nil)
+
+ var balance float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT wallet_balance_usd FROM user_subscriptions WHERE id = $1", walletID).Scan(&balance))
+ require.InDelta(t, 7.5, balance, 0.000001)
+ var ledgerCount int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT COUNT(*) FROM subscription_wallet_ledger WHERE subscription_id = $1 AND reason = 'usage'", walletID).Scan(&ledgerCount))
+ require.Equal(t, 1, ledgerCount)
+ })
+}
+
+func TestUsageBillingOutboxProcessorIntegration_PendingEventSurvivesWorkerRestart(t *testing.T) {
+ ctx := context.Background()
+ _, _ = integrationDB.ExecContext(ctx, "TRUNCATE usage_billing_outbox RESTART IDENTITY")
+ client := testEntClient(t)
+ user := mustCreateUser(t, client, &service.User{Email: fmt.Sprintf("outbox-restart-%s@example.com", uuid.NewString()), Balance: 100})
+ group := mustCreateGroup(t, client, &service.Group{Name: "outbox-restart-" + uuid.NewString()})
+ apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, GroupID: &group.ID, Key: "sk-outbox-restart-" + uuid.NewString()})
+ account := mustCreateAccount(t, client, &service.Account{Name: "outbox-restart-" + uuid.NewString(), Type: service.AccountTypeAPIKey})
+ mustBindAccountToGroup(t, client, account.ID, group.ID, 1)
+ billingModel := "gpt-5.6-sol"
+ upstreamModel := billingModel
+ pricingSource := service.PricingSourceBuiltinGPT56
+ pricingRevision := service.GPT56PricingRevision
+ pricingHash := "abababababababababababababababababababababababababababababababab"
+ accountRate := 1.0
+ requestID := "outbox-restart-" + uuid.NewString()
+ usageLog := &service.UsageLog{
+ UserID: user.ID, APIKeyID: apiKey.ID, AccountID: account.ID, RequestID: requestID,
+ Model: billingModel, RequestedModel: "claude-sonnet-4-6", UpstreamModel: &upstreamModel,
+ BillingModel: &billingModel, PricingSource: &pricingSource, PricingRevision: &pricingRevision,
+ PricingHash: &pricingHash, GroupID: &group.ID, InputTokens: 100, OutputTokens: 10,
+ InputCost: 1, OutputCost: 0.25, TotalCost: 1.25, ActualCost: 1.25,
+ RateMultiplier: 1, AccountRateMultiplier: &accountRate, BillingType: service.BillingTypeBalance,
+ RequestType: service.RequestTypeStream, CreatedAt: time.Now().UTC(),
+ }
+ cmd := &service.UsageBillingCommand{
+ RequestID: requestID, APIKeyID: apiKey.ID, UserID: user.ID, AccountID: account.ID,
+ RequestPayloadHash: integrationUsageRequestPayloadHash,
+ AccountType: service.AccountTypeAPIKey, Model: billingModel, BillingType: service.BillingTypeBalance,
+ InputTokens: 100, OutputTokens: 10, BalanceCost: 1.25,
+ }
+ envelope, err := service.NewUsageBillingEnvelopeFromUsageLog(usageLog, cmd)
+ require.NoError(t, err)
+
+ firstProcessRepo := NewUsageBillingOutboxRepository(integrationDB)
+ _, inserted, err := firstProcessRepo.Enqueue(ctx, envelope)
+ require.NoError(t, err)
+ require.True(t, inserted)
+ // Simulate process exit before the in-memory wake hint or worker can run.
+ firstProcessRepo = nil
+
+ restartedOutboxRepo := NewUsageBillingOutboxRepository(integrationDB)
+ billingRepo := NewUsageBillingRepository(client, integrationDB)
+ usageRepo := NewUsageLogRepository(client, integrationDB)
+ processor := service.NewUsageBillingOutboxProcessor(
+ restartedOutboxRepo,
+ restartedOutboxRepo,
+ billingRepo,
+ service.NewUsageBillingReplayWriter(usageRepo),
+ )
+ processed, err := processor.ProcessBatch(ctx, "restart-worker", 10)
+ require.NoError(t, err)
+ require.Equal(t, 1, processed)
+
+ var balance float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT balance FROM users WHERE id = $1", user.ID).Scan(&balance))
+ require.InDelta(t, 98.75, balance, 0.000001)
+ var count int
+ var model, requested, storedBilling string
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*), MAX(model), MAX(requested_model), MAX(billing_model)
+ FROM usage_logs WHERE request_id = $1 AND api_key_id = $2
+ `, requestID, apiKey.ID).Scan(&count, &model, &requested, &storedBilling))
+ require.Equal(t, 1, count)
+ require.Equal(t, billingModel, model)
+ require.Equal(t, "claude-sonnet-4-6", requested)
+ require.Equal(t, billingModel, storedBilling)
+}
+
+func processIntegrationEnvelopeAfterEnqueue(t *testing.T, ctx context.Context, envelope service.UsageBillingEnvelope, afterEnqueue func()) {
+ t.Helper()
+ entClient := testEntClient(t)
+ outboxRepo := NewUsageBillingOutboxRepository(integrationDB)
+ billingRepo := NewUsageBillingRepository(entClient, integrationDB)
+ event, _, err := outboxRepo.Enqueue(ctx, envelope)
+ require.NoError(t, err)
+ if afterEnqueue != nil {
+ afterEnqueue()
+ }
+ claimed, err := outboxRepo.Claim(ctx, "integration-worker", 1, service.UsageBillingOutboxLeaseDuration)
+ require.NoError(t, err)
+ require.Len(t, claimed, 1)
+ require.Equal(t, event.ID, claimed[0].ID)
+ processor := service.NewUsageBillingOutboxProcessor(outboxRepo, outboxRepo, billingRepo, nil)
+ require.NoError(t, processor.ProcessEvent(ctx, claimed[0]))
+}
diff --git a/backend/internal/repository/usage_billing_outbox_repo.go b/backend/internal/repository/usage_billing_outbox_repo.go
new file mode 100644
index 00000000000..97c4422ca7d
--- /dev/null
+++ b/backend/internal/repository/usage_billing_outbox_repo.go
@@ -0,0 +1,1646 @@
+package repository
+
+import (
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "math"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+const usageBillingOutboxSelectColumns = `
+ id, request_id, api_key_id, request_fingerprint, envelope_version,
+ envelope, status, attempt_count, max_attempts, available_at,
+ locked_at, locked_by, lease_token, last_attempt_at, completed_at, dead_lettered_at,
+ result_code, last_error_code, last_error, created_at, updated_at
+`
+
+const walletAdmissionInitialLease = 2 * time.Minute
+
+type usageBillingOutboxRepository struct {
+ db *sql.DB
+ requireAdmission bool
+ useAdmissionIfPresent bool
+}
+
+func NewUsageBillingOutboxRepository(db *sql.DB) *usageBillingOutboxRepository {
+ return &usageBillingOutboxRepository{db: db}
+}
+
+func (r *usageBillingOutboxRepository) Enqueue(ctx context.Context, envelope service.UsageBillingEnvelope) (*service.UsageBillingOutboxEvent, bool, error) {
+ if r == nil || r.db == nil {
+ return nil, false, errors.New("usage billing outbox repository db is nil")
+ }
+ if err := envelope.Validate(); err != nil {
+ return nil, false, err
+ }
+ raw, err := envelope.MarshalJSON()
+ if err != nil {
+ return nil, false, err
+ }
+ if len(raw) > service.UsageBillingEnvelopeMaxBytes {
+ return nil, false, service.ErrUsageBillingEnvelopeInvalid
+ }
+ tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
+ if err != nil {
+ return nil, false, markUsageBillingOutboxAdmissionStorageError(err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ existing, err := selectUsageBillingOutboxByIdentity(ctx, tx, envelope.RequestID(), envelope.APIKeyID())
+ if err == nil {
+ if err := validateExistingUsageBillingOutbox(existing, envelope); err != nil {
+ return nil, false, err
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, false, markUsageBillingOutboxAdmissionStorageError(err)
+ }
+ return existing, false, nil
+ }
+ if !errors.Is(err, sql.ErrNoRows) {
+ return nil, false, markUsageBillingOutboxAdmissionStorageError(err)
+ }
+ useAdmission := r.requireAdmission || envelope.WalletCost() > 0 || envelope.AdmissionAttemptID() != ""
+ if !useAdmission && r.useAdmissionIfPresent {
+ useAdmission, err = usageBillingAdmissionExists(ctx, tx, envelope.RequestID(), envelope.APIKeyID())
+ if err != nil {
+ return nil, false, markUsageBillingOutboxAdmissionStorageError(err)
+ }
+ }
+ if useAdmission {
+ if err := validateUsageBillingAdmissionAtEnqueue(ctx, tx, envelope); err != nil {
+ return nil, false, markUsageBillingOutboxAdmissionStorageError(err)
+ }
+ } else {
+ if err := validateUsageBillingBindingsAtEnqueue(ctx, tx, envelope); err != nil {
+ return nil, false, markUsageBillingOutboxAdmissionStorageError(err)
+ }
+ }
+
+ row := tx.QueryRowContext(ctx, `
+ INSERT INTO usage_billing_outbox (
+ request_id, api_key_id, request_fingerprint, envelope_version, envelope
+ )
+ VALUES ($1, $2, $3, $4, $5::jsonb)
+ ON CONFLICT (request_id, api_key_id) DO NOTHING
+ RETURNING `+usageBillingOutboxSelectColumns,
+ envelope.RequestID(), envelope.APIKeyID(), envelope.RequestFingerprint(), envelope.Version(), raw)
+ event, err := scanUsageBillingOutboxEvent(row)
+ if err == nil {
+ if useAdmission {
+ if err := finalizeUsageBillingAdmission(ctx, tx, envelope); err != nil {
+ return nil, false, markUsageBillingOutboxAdmissionStorageError(err)
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, false, markUsageBillingOutboxAdmissionStorageError(err)
+ }
+ return event, true, nil
+ }
+ if !errors.Is(err, sql.ErrNoRows) {
+ return nil, false, markUsageBillingOutboxAdmissionStorageError(err)
+ }
+
+ event, err = selectUsageBillingOutboxByIdentity(ctx, tx, envelope.RequestID(), envelope.APIKeyID())
+ if err != nil {
+ return nil, false, markUsageBillingOutboxAdmissionStorageError(err)
+ }
+ if err := validateExistingUsageBillingOutbox(event, envelope); err != nil {
+ return nil, false, err
+ }
+ if useAdmission {
+ if err := finalizeUsageBillingAdmission(ctx, tx, envelope); err != nil {
+ return nil, false, markUsageBillingOutboxAdmissionStorageError(err)
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, false, markUsageBillingOutboxAdmissionStorageError(err)
+ }
+ return event, false, nil
+}
+
+func usageBillingAdmissionExists(ctx context.Context, q usageBillingBindingQuerier, requestID string, apiKeyID int64) (bool, error) {
+ var exists bool
+ err := q.QueryRowContext(ctx, `
+ SELECT EXISTS (
+ SELECT 1 FROM usage_billing_admissions
+ WHERE request_id = $1 AND api_key_id = $2
+ )
+ `, strings.TrimSpace(requestID), apiKeyID).Scan(&exists)
+ return exists, err
+}
+
+func markUsageBillingOutboxAdmissionStorageError(err error) error {
+ if err == nil {
+ return nil
+ }
+ // These errors describe a permanently invalid billing fact/binding and must
+ // be surfaced to the caller. All other errors originate from the database
+ // admission transaction and are safe to retry because Enqueue is idempotent
+ // on (request_id, api_key_id).
+ for _, permanent := range []error{
+ service.ErrUsageBillingEnvelopeInvalid,
+ service.ErrUsageBillingEnvelopeVersion,
+ service.ErrUsageBillingEnvelopeFingerprintMismatch,
+ service.ErrUsageBillingRequestConflict,
+ service.ErrUsageBillingCrossTenant,
+ service.ErrUsageBillingOutboxTargetNotFound,
+ service.ErrUsageBillingAdmissionInvalid,
+ service.ErrUsageBillingAdmissionMissing,
+ service.ErrUsageBillingAdmissionFinalized,
+ service.ErrUsageBillingAdmissionLeaseLost,
+ service.ErrWalletInsufficient,
+ service.ErrWalletAdmissionBusy,
+ } {
+ if errors.Is(err, permanent) {
+ return err
+ }
+ }
+ return service.MarkUsageBillingOutboxAdmissionRetryable(err)
+}
+
+func (r *usageBillingOutboxRepository) Admit(ctx context.Context, admission service.UsageBillingAdmission) error {
+ if r == nil || r.db == nil {
+ return service.ErrUsageBillingOutboxUnavailable
+ }
+ if err := admission.Validate(); err != nil {
+ return err
+ }
+ tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
+ if err != nil {
+ return markUsageBillingOutboxAdmissionStorageError(err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ // Lock the subscription before any shared binding reads. All wallet
+ // admission and settlement paths use the order wallet -> admission, which
+ // serializes multiple application instances without lock-upgrade deadlocks.
+ wallet, err := lockUsageBillingAdmissionWallet(ctx, tx, admission)
+ if err != nil {
+ return err
+ }
+ validationEnvelope, err := usageBillingAdmissionValidationEnvelope(admission)
+ if err != nil {
+ return err
+ }
+ if err := validateUsageBillingBindingsAtAdmission(ctx, tx, validationEnvelope); err != nil {
+ return markUsageBillingOutboxAdmissionStorageError(err)
+ }
+
+ existing, err := lockUsageBillingAdmission(ctx, tx, admission.RequestID(), admission.APIKeyID())
+ switch {
+ case err == nil && (existing.state == service.UsageBillingAdmissionStateOutboxPending || existing.state == service.UsageBillingAdmissionStateSettled):
+ return service.ErrUsageBillingAdmissionFinalized
+ case err == nil && existing.state != service.UsageBillingAdmissionStatePrepared && existing.state != service.UsageBillingAdmissionStateDispatched:
+ return service.ErrUsageBillingAdmissionLeaseLost
+ case err == nil && !existing.matchesStableBinding(admission):
+ return service.ErrUsageBillingRequestConflict
+ case err != nil && !errors.Is(err, sql.ErrNoRows):
+ return markUsageBillingOutboxAdmissionStorageError(err)
+ }
+
+ walletSubscriptionID, reservedUSD, leaseExpiresAt, err := prepareUsageBillingWalletHold(
+ ctx, tx, admission, wallet, existing,
+ )
+ if err != nil {
+ return err
+ }
+
+ result, err := tx.ExecContext(ctx, `
+ INSERT INTO usage_billing_admissions (
+ request_id, api_key_id, binding_fingerprint, owner_token, request_payload_hash, auth_cache_locator,
+ user_id, subscription_id, group_id, effective_billing_group_id,
+ billing_type, billing_model, pricing_source, pricing_revision, pricing_hash,
+ rate_multiplier, worst_case_cost_usd,
+ alternate_billing_model, alternate_pricing_source, alternate_pricing_revision,
+ alternate_pricing_hash, alternate_rate_multiplier, state,
+ wallet_subscription_id, wallet_reserved_usd, wallet_lease_expires_at,
+ wallet_consumed_at, wallet_released_at, updated_at
+ ) VALUES (
+ $1,$2,$3,$4,$5,NULLIF($6,''),$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,
+ NULLIF($18,''),NULLIF($19,''),NULLIF($20,''),NULLIF($21,''),NULLIF($22,0),'prepared',
+ $23,$24,$25,NULL,NULL,NOW()
+ )
+ ON CONFLICT (request_id, api_key_id) DO UPDATE SET
+ wallet_lease_expires_at = EXCLUDED.wallet_lease_expires_at,
+ updated_at = NOW()
+ WHERE usage_billing_admissions.state IN ('prepared','dispatched')
+ AND usage_billing_admissions.binding_fingerprint = EXCLUDED.binding_fingerprint
+ AND usage_billing_admissions.owner_token = EXCLUDED.owner_token
+ `, admission.RequestID(), admission.APIKeyID(), admission.Fingerprint(), admission.OwnerToken(), admission.RequestPayloadHash(), admission.AuthCacheLocator(),
+ admission.UserID(), admission.SubscriptionID(), admission.GroupID(),
+ admission.EffectiveBillingGroupID(), admission.BillingType(),
+ admission.BillingModel(), admission.PricingSource(), admission.PricingRevision(), admission.PricingHash(),
+ admission.RateMultiplier(), admission.WorstCaseCostUSD(),
+ admission.AlternateBillingModel(), admission.AlternatePricingSource(), admission.AlternatePricingRevision(),
+ admission.AlternatePricingHash(), admission.AlternateRateMultiplier(),
+ walletSubscriptionID, reservedUSD, leaseExpiresAt)
+ if err != nil {
+ return markUsageBillingOutboxAdmissionStorageError(err)
+ }
+ updated, err := result.RowsAffected()
+ if err != nil {
+ return markUsageBillingOutboxAdmissionStorageError(err)
+ }
+ if updated != 1 {
+ return service.ErrUsageBillingAdmissionLeaseLost
+ }
+ if _, err := tx.ExecContext(ctx, `
+ INSERT INTO usage_billing_admission_attempts (
+ request_id, api_key_id, attempt_id, owner_token, attempt_fingerprint,
+ account_id, account_type, state, updated_at
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,'prepared',NOW())
+ ON CONFLICT (request_id, api_key_id, attempt_id) DO UPDATE SET
+ updated_at = NOW()
+ WHERE usage_billing_admission_attempts.owner_token = EXCLUDED.owner_token
+ AND usage_billing_admission_attempts.attempt_fingerprint = EXCLUDED.attempt_fingerprint
+ AND usage_billing_admission_attempts.state = 'prepared'
+ `, admission.RequestID(), admission.APIKeyID(), admission.AttemptID(), admission.OwnerToken(),
+ admission.AttemptFingerprint(), admission.AccountID(), admission.AccountType()); err != nil {
+ return markUsageBillingOutboxAdmissionStorageError(err)
+ }
+ if err := tx.Commit(); err != nil {
+ return markUsageBillingOutboxAdmissionStorageError(err)
+ }
+ return nil
+}
+
+type usageBillingAdmissionWallet struct {
+ id int64
+ balance float64
+}
+
+func lockUsageBillingAdmissionWallet(ctx context.Context, tx *sql.Tx, admission service.UsageBillingAdmission) (*usageBillingAdmissionWallet, error) {
+ subscriptionID := admission.SubscriptionID()
+ if subscriptionID == nil {
+ return nil, nil
+ }
+ var (
+ userID int64
+ balance sql.NullFloat64
+ status string
+ expiresAt time.Time
+ )
+ err := tx.QueryRowContext(ctx, `
+ SELECT user_id, wallet_balance_usd, status, expires_at
+ FROM user_subscriptions
+ WHERE id = $1 AND deleted_at IS NULL
+ FOR UPDATE
+ `, *subscriptionID).Scan(&userID, &balance, &status, &expiresAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ // The authoritative binding validator below returns the public target
+ // error. A missing row is not treated as a wallet here.
+ return nil, nil
+ }
+ if err != nil {
+ return nil, markUsageBillingOutboxAdmissionStorageError(err)
+ }
+ if !balance.Valid {
+ return nil, nil
+ }
+ if userID != admission.UserID() {
+ return nil, service.ErrUsageBillingCrossTenant
+ }
+ if strings.TrimSpace(status) != service.SubscriptionStatusActive || !expiresAt.After(time.Now()) {
+ return nil, service.ErrWalletInsufficient
+ }
+ return &usageBillingAdmissionWallet{id: *subscriptionID, balance: balance.Float64}, nil
+}
+
+type usageBillingAdmissionState struct {
+ bindingFingerprint string
+ ownerToken string
+ requestPayloadHash string
+ state string
+ authCacheLocator string
+ userID int64
+ subscriptionID sql.NullInt64
+ groupID int64
+ effectiveBillingGroupID int64
+ billingType int8
+ billingModel string
+ pricingSource string
+ pricingRevision string
+ pricingHash string
+ rateMultiplier float64
+ worstCaseCostUSD float64
+ alternateBillingModel string
+ alternatePricingSource string
+ alternatePricingRevision string
+ alternatePricingHash string
+ alternateRateMultiplier float64
+ walletSubscriptionID sql.NullInt64
+ walletReservedUSD float64
+ walletConsumedAt sql.NullTime
+ walletReleasedAt sql.NullTime
+}
+
+func lockUsageBillingAdmission(ctx context.Context, tx *sql.Tx, requestID string, apiKeyID int64) (*usageBillingAdmissionState, error) {
+ state := &usageBillingAdmissionState{}
+ err := tx.QueryRowContext(ctx, `
+ SELECT binding_fingerprint, owner_token, request_payload_hash,
+ state, COALESCE(auth_cache_locator, ''), user_id, subscription_id,
+ group_id, effective_billing_group_id, billing_type,
+ billing_model, pricing_source, pricing_revision, pricing_hash,
+ rate_multiplier, worst_case_cost_usd,
+ COALESCE(alternate_billing_model, ''), COALESCE(alternate_pricing_source, ''),
+ COALESCE(alternate_pricing_revision, ''), COALESCE(alternate_pricing_hash, ''),
+ COALESCE(alternate_rate_multiplier, 0),
+ wallet_subscription_id, wallet_reserved_usd,
+ wallet_consumed_at, wallet_released_at
+ FROM usage_billing_admissions
+ WHERE request_id = $1 AND api_key_id = $2
+ FOR UPDATE
+ `, requestID, apiKeyID).Scan(
+ &state.bindingFingerprint, &state.ownerToken, &state.requestPayloadHash,
+ &state.state, &state.authCacheLocator, &state.userID, &state.subscriptionID,
+ &state.groupID, &state.effectiveBillingGroupID, &state.billingType,
+ &state.billingModel, &state.pricingSource, &state.pricingRevision, &state.pricingHash,
+ &state.rateMultiplier, &state.worstCaseCostUSD,
+ &state.alternateBillingModel, &state.alternatePricingSource, &state.alternatePricingRevision,
+ &state.alternatePricingHash, &state.alternateRateMultiplier,
+ &state.walletSubscriptionID, &state.walletReservedUSD,
+ &state.walletConsumedAt, &state.walletReleasedAt,
+ )
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, err
+ }
+ return state, err
+}
+
+func (s *usageBillingAdmissionState) matchesStableBinding(admission service.UsageBillingAdmission) bool {
+ if s == nil {
+ return false
+ }
+ return strings.EqualFold(s.bindingFingerprint, admission.Fingerprint()) &&
+ strings.EqualFold(s.ownerToken, admission.OwnerToken()) &&
+ strings.EqualFold(s.requestPayloadHash, admission.RequestPayloadHash()) &&
+ strings.EqualFold(s.authCacheLocator, admission.AuthCacheLocator()) &&
+ s.userID == admission.UserID() &&
+ nullInt64Matches(s.subscriptionID, admission.SubscriptionID()) &&
+ s.groupID == int64Value(admission.GroupID()) &&
+ s.effectiveBillingGroupID == int64Value(admission.EffectiveBillingGroupID()) &&
+ s.billingType == admission.BillingType() &&
+ s.billingModel == admission.BillingModel() &&
+ s.pricingSource == admission.PricingSource() &&
+ s.pricingRevision == admission.PricingRevision() &&
+ strings.EqualFold(s.pricingHash, admission.PricingHash()) &&
+ math.Abs(s.rateMultiplier-admission.RateMultiplier()) <= 1e-12 &&
+ s.alternateBillingModel == admission.AlternateBillingModel() &&
+ s.alternatePricingSource == admission.AlternatePricingSource() &&
+ s.alternatePricingRevision == admission.AlternatePricingRevision() &&
+ strings.EqualFold(s.alternatePricingHash, admission.AlternatePricingHash()) &&
+ math.Abs(s.alternateRateMultiplier-admission.AlternateRateMultiplier()) <= 1e-12 &&
+ math.Abs(s.worstCaseCostUSD-admission.WorstCaseCostUSD()) <= 1e-12
+}
+
+func nullInt64Matches(stored sql.NullInt64, value *int64) bool {
+ if value == nil {
+ return !stored.Valid
+ }
+ return stored.Valid && stored.Int64 == *value
+}
+
+func int64Value(value *int64) int64 {
+ if value == nil {
+ return 0
+ }
+ return *value
+}
+
+func prepareUsageBillingWalletHold(
+ ctx context.Context,
+ tx *sql.Tx,
+ admission service.UsageBillingAdmission,
+ wallet *usageBillingAdmissionWallet,
+ existing *usageBillingAdmissionState,
+) (*int64, float64, any, error) {
+ if wallet == nil {
+ if existing != nil && existing.walletSubscriptionID.Valid {
+ return nil, 0, nil, service.ErrUsageBillingRequestConflict
+ }
+ return nil, 0, nil, nil
+ }
+ if existing != nil {
+ if !existing.walletSubscriptionID.Valid || existing.walletSubscriptionID.Int64 != wallet.id ||
+ existing.walletConsumedAt.Valid || existing.walletReleasedAt.Valid || existing.walletReservedUSD <= 0 {
+ return nil, 0, nil, service.ErrUsageBillingRequestConflict
+ }
+ }
+
+ var outstandingUSD float64
+ err := tx.QueryRowContext(ctx, `
+ SELECT COALESCE(SUM(wallet_reserved_usd), 0)
+ FROM usage_billing_admissions
+ WHERE wallet_subscription_id = $1
+ AND wallet_consumed_at IS NULL
+ AND wallet_released_at IS NULL
+ AND NOT (request_id = $2 AND api_key_id = $3)
+ `, wallet.id, admission.RequestID(), admission.APIKeyID()).Scan(&outstandingUSD)
+ if err != nil {
+ return nil, 0, nil, markUsageBillingOutboxAdmissionStorageError(err)
+ }
+
+ reservedUSD := admission.WorstCaseCostUSD()
+ if existing != nil {
+ reservedUSD = existing.walletReservedUSD
+ }
+ if reservedUSD <= 0 || wallet.balance-outstandingUSD+1e-12 < reservedUSD {
+ return nil, 0, nil, service.ErrWalletInsufficient
+ }
+ walletID := wallet.id
+ return &walletID, reservedUSD, time.Now().Add(walletAdmissionInitialLease), nil
+}
+
+func (r *usageBillingOutboxRepository) MarkDispatched(ctx context.Context, ref service.UsageBillingAdmissionAttemptRef) error {
+ if r == nil || r.db == nil {
+ return service.ErrUsageBillingOutboxUnavailable
+ }
+ if err := ref.Validate(); err != nil {
+ return err
+ }
+ tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback() }()
+ result, err := tx.ExecContext(ctx, `
+ UPDATE usage_billing_admissions
+ SET state = 'dispatched', dispatched_at = COALESCE(dispatched_at, NOW()), updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2 AND owner_token = $3
+ AND state IN ('prepared','dispatched')
+ `, strings.TrimSpace(ref.RequestID), ref.APIKeyID, strings.TrimSpace(ref.OwnerToken))
+ if err != nil {
+ return err
+ }
+ updated, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if updated != 1 {
+ return service.ErrUsageBillingAdmissionLeaseLost
+ }
+ attemptResult, err := tx.ExecContext(ctx, `
+ UPDATE usage_billing_admission_attempts
+ SET state = 'dispatched', dispatched_at = COALESCE(dispatched_at, NOW()), updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2 AND attempt_id = $3
+ AND owner_token = $4 AND state = 'prepared'
+ `, strings.TrimSpace(ref.RequestID), ref.APIKeyID, strings.TrimSpace(ref.AttemptID), strings.TrimSpace(ref.OwnerToken))
+ if err != nil {
+ return err
+ }
+ attemptUpdated, err := attemptResult.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if attemptUpdated != 1 {
+ return service.ErrUsageBillingAdmissionLeaseLost
+ }
+ return tx.Commit()
+}
+
+func (r *usageBillingOutboxRepository) MarkAttemptFailed(ctx context.Context, ref service.UsageBillingAdmissionAttemptRef) error {
+ if r == nil || r.db == nil {
+ return service.ErrUsageBillingOutboxUnavailable
+ }
+ if err := ref.Validate(); err != nil {
+ return err
+ }
+ result, err := r.db.ExecContext(ctx, `
+ UPDATE usage_billing_admission_attempts
+ SET state = 'failed', failed_at = COALESCE(failed_at, NOW()), updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2 AND attempt_id = $3
+ AND owner_token = $4 AND state IN ('prepared','dispatched')
+ `, strings.TrimSpace(ref.RequestID), ref.APIKeyID, strings.TrimSpace(ref.AttemptID), strings.TrimSpace(ref.OwnerToken))
+ if err != nil {
+ return err
+ }
+ updated, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if updated != 1 {
+ return service.ErrUsageBillingAdmissionLeaseLost
+ }
+ return nil
+}
+
+func (r *usageBillingOutboxRepository) MarkOrphaned(ctx context.Context, ref service.UsageBillingAdmissionAttemptRef) error {
+ if r == nil || r.db == nil {
+ return service.ErrUsageBillingOutboxUnavailable
+ }
+ if err := ref.Validate(); err != nil {
+ return err
+ }
+ result, err := r.db.ExecContext(ctx, `
+ UPDATE usage_billing_admissions
+ SET state = 'orphaned', orphaned_at = COALESCE(orphaned_at, NOW()), updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2 AND owner_token = $3
+ AND state IN ('prepared','dispatched')
+ `, strings.TrimSpace(ref.RequestID), ref.APIKeyID, strings.TrimSpace(ref.OwnerToken))
+ if err != nil {
+ return err
+ }
+ updated, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if updated != 1 {
+ return service.ErrUsageBillingAdmissionLeaseLost
+ }
+ return nil
+}
+
+func (r *usageBillingOutboxRepository) Heartbeat(ctx context.Context, ref service.UsageBillingAdmissionAttemptRef, lease time.Duration) error {
+ if r == nil || r.db == nil {
+ return service.ErrUsageBillingOutboxUnavailable
+ }
+ if err := ref.Validate(); err != nil {
+ return err
+ }
+ if lease <= 0 {
+ lease = 30 * time.Second
+ }
+ result, err := r.db.ExecContext(ctx, `
+ UPDATE usage_billing_admissions
+ SET wallet_lease_expires_at = CASE WHEN wallet_subscription_id IS NULL THEN wallet_lease_expires_at ELSE NOW() + ($3 * INTERVAL '1 millisecond') END,
+ updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2 AND owner_token = $4
+ AND state IN ('prepared','dispatched')
+ `, strings.TrimSpace(ref.RequestID), ref.APIKeyID, lease.Milliseconds(), strings.TrimSpace(ref.OwnerToken))
+ if err != nil {
+ return err
+ }
+ updated, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if updated != 1 {
+ return service.ErrUsageBillingAdmissionLeaseLost
+ }
+ return nil
+}
+
+func (r *usageBillingOutboxRepository) Abandon(ctx context.Context, ref service.UsageBillingAdmissionAttemptRef) error {
+ if r == nil || r.db == nil {
+ return service.ErrUsageBillingOutboxUnavailable
+ }
+ if err := ref.Validate(); err != nil {
+ return err
+ }
+ result, err := r.db.ExecContext(ctx, `
+ UPDATE usage_billing_admissions
+ SET state = 'abandoned', abandoned_at = NOW(),
+ wallet_released_at = CASE WHEN wallet_subscription_id IS NULL THEN wallet_released_at ELSE COALESCE(wallet_released_at, NOW()) END,
+ updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2 AND owner_token = $3
+ AND (
+ state = 'prepared'
+ OR (state = 'dispatched' AND NOT EXISTS (
+ SELECT 1 FROM usage_billing_admission_attempts attempt
+ WHERE attempt.request_id = usage_billing_admissions.request_id
+ AND attempt.api_key_id = usage_billing_admissions.api_key_id
+ AND attempt.state IN ('prepared','dispatched','finalized')
+ ))
+ )
+ `, strings.TrimSpace(ref.RequestID), ref.APIKeyID, strings.TrimSpace(ref.OwnerToken))
+ if err != nil {
+ return err
+ }
+ updated, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if updated != 1 {
+ return service.ErrUsageBillingAdmissionLeaseLost
+ }
+ return nil
+}
+
+func (r *usageBillingOutboxRepository) WaitSettled(ctx context.Context, ref service.UsageBillingAdmissionAttemptRef) error {
+ if r == nil || r.db == nil {
+ return service.ErrUsageBillingOutboxUnavailable
+ }
+ if err := ref.Validate(); err != nil {
+ return err
+ }
+ var state string
+ err := r.db.QueryRowContext(ctx, `
+ SELECT state
+ FROM usage_billing_admissions
+ WHERE request_id = $1 AND api_key_id = $2 AND owner_token = $3
+ `, strings.TrimSpace(ref.RequestID), ref.APIKeyID, strings.TrimSpace(ref.OwnerToken)).Scan(&state)
+ if errors.Is(err, sql.ErrNoRows) {
+ return service.ErrUsageBillingAdmissionMissing
+ }
+ if err != nil {
+ return err
+ }
+ if state == service.UsageBillingAdmissionStateSettled || state == service.UsageBillingAdmissionStateOutboxPending {
+ return nil
+ }
+ if state == service.UsageBillingAdmissionStateOrphaned || state == service.UsageBillingAdmissionStateReconcile {
+ return service.ErrUsageBillingAdmissionOrphaned
+ }
+ return service.ErrUsageBillingAdmissionLeaseLost
+}
+
+func (r *usageBillingOutboxRepository) ReconcileStaleAdmissions(
+ ctx context.Context,
+ preparedGrace time.Duration,
+ dispatchedGrace time.Duration,
+ limit int,
+) (service.UsageBillingAdmissionReconcileResult, error) {
+ var reconciled service.UsageBillingAdmissionReconcileResult
+ if r == nil || r.db == nil {
+ return reconciled, service.ErrUsageBillingOutboxUnavailable
+ }
+ if limit <= 0 {
+ return reconciled, nil
+ }
+ if preparedGrace <= 0 || dispatchedGrace <= 0 {
+ return reconciled, service.ErrUsageBillingAdmissionInvalid
+ }
+ if limit > 100 {
+ limit = 100
+ }
+ tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
+ if err != nil {
+ return reconciled, err
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ remaining := limit
+ if reconciled.AbandonedPrepared, err = reconcilePreparedAdmissions(ctx, tx, preparedGrace, remaining); err != nil {
+ return service.UsageBillingAdmissionReconcileResult{}, err
+ }
+ remaining -= reconciled.AbandonedPrepared
+ if remaining > 0 {
+ reconciled.AbandonedFailedDispatched, err = reconcileFailedDispatchedAdmissions(ctx, tx, preparedGrace, remaining)
+ if err != nil {
+ return service.UsageBillingAdmissionReconcileResult{}, err
+ }
+ remaining -= reconciled.AbandonedFailedDispatched
+ }
+ if remaining > 0 {
+ reconciled.OrphanedDispatched, err = reconcileUnknownDispatchedAdmissions(ctx, tx, dispatchedGrace, remaining)
+ if err != nil {
+ return service.UsageBillingAdmissionReconcileResult{}, err
+ }
+ remaining -= reconciled.OrphanedDispatched
+ }
+ if remaining > 0 {
+ reconciled.FlaggedDeadLetter, err = reconcileDeadLetterAdmissions(ctx, tx, remaining)
+ if err != nil {
+ return service.UsageBillingAdmissionReconcileResult{}, err
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ return service.UsageBillingAdmissionReconcileResult{}, err
+ }
+ return reconciled, nil
+}
+
+func reconcilePreparedAdmissions(ctx context.Context, tx *sql.Tx, grace time.Duration, limit int) (int, error) {
+ result, err := tx.ExecContext(ctx, `
+ WITH candidates AS (
+ SELECT admission.request_id, admission.api_key_id
+ FROM usage_billing_admissions admission
+ WHERE admission.state = 'prepared'
+ AND admission.updated_at <= NOW() - ($1 * INTERVAL '1 millisecond')
+ AND (admission.wallet_lease_expires_at IS NULL OR admission.wallet_lease_expires_at <= NOW())
+ AND NOT EXISTS (
+ SELECT 1 FROM usage_billing_outbox outbox
+ WHERE outbox.request_id = admission.request_id
+ AND outbox.api_key_id = admission.api_key_id
+ )
+ AND NOT EXISTS (
+ SELECT 1 FROM usage_billing_admission_attempts attempt
+ WHERE attempt.request_id = admission.request_id
+ AND attempt.api_key_id = admission.api_key_id
+ AND attempt.state IN ('dispatched','finalized')
+ )
+ ORDER BY admission.updated_at
+ FOR UPDATE SKIP LOCKED
+ LIMIT $2
+ ), failed_attempts AS (
+ UPDATE usage_billing_admission_attempts attempt
+ SET state = 'failed', failed_at = COALESCE(failed_at, NOW()), updated_at = NOW()
+ FROM candidates candidate
+ WHERE attempt.request_id = candidate.request_id
+ AND attempt.api_key_id = candidate.api_key_id
+ AND attempt.state = 'prepared'
+ RETURNING attempt.request_id
+ )
+ UPDATE usage_billing_admissions admission
+ SET state = 'abandoned', abandoned_at = COALESCE(abandoned_at, NOW()),
+ wallet_released_at = CASE WHEN wallet_subscription_id IS NULL THEN wallet_released_at ELSE COALESCE(wallet_released_at, NOW()) END,
+ updated_at = NOW()
+ FROM candidates candidate
+ WHERE admission.request_id = candidate.request_id
+ AND admission.api_key_id = candidate.api_key_id
+ `, grace.Milliseconds(), limit)
+ if err != nil {
+ return 0, err
+ }
+ return usageBillingReconcileRowsAffected(result)
+}
+
+func reconcileFailedDispatchedAdmissions(ctx context.Context, tx *sql.Tx, grace time.Duration, limit int) (int, error) {
+ result, err := tx.ExecContext(ctx, `
+ WITH candidates AS (
+ SELECT admission.request_id, admission.api_key_id
+ FROM usage_billing_admissions admission
+ WHERE admission.state = 'dispatched'
+ AND admission.updated_at <= NOW() - ($1 * INTERVAL '1 millisecond')
+ AND NOT EXISTS (
+ SELECT 1 FROM usage_billing_outbox outbox
+ WHERE outbox.request_id = admission.request_id
+ AND outbox.api_key_id = admission.api_key_id
+ )
+ AND EXISTS (
+ SELECT 1 FROM usage_billing_admission_attempts attempt
+ WHERE attempt.request_id = admission.request_id
+ AND attempt.api_key_id = admission.api_key_id
+ AND attempt.state = 'failed'
+ )
+ AND NOT EXISTS (
+ SELECT 1 FROM usage_billing_admission_attempts attempt
+ WHERE attempt.request_id = admission.request_id
+ AND attempt.api_key_id = admission.api_key_id
+ AND attempt.state IN ('prepared','dispatched','finalized')
+ )
+ ORDER BY admission.updated_at
+ FOR UPDATE SKIP LOCKED
+ LIMIT $2
+ )
+ UPDATE usage_billing_admissions admission
+ SET state = 'abandoned', abandoned_at = COALESCE(abandoned_at, NOW()),
+ wallet_released_at = CASE WHEN wallet_subscription_id IS NULL THEN wallet_released_at ELSE COALESCE(wallet_released_at, NOW()) END,
+ updated_at = NOW()
+ FROM candidates candidate
+ WHERE admission.request_id = candidate.request_id
+ AND admission.api_key_id = candidate.api_key_id
+ `, grace.Milliseconds(), limit)
+ if err != nil {
+ return 0, err
+ }
+ return usageBillingReconcileRowsAffected(result)
+}
+
+func reconcileUnknownDispatchedAdmissions(ctx context.Context, tx *sql.Tx, grace time.Duration, limit int) (int, error) {
+ result, err := tx.ExecContext(ctx, `
+ WITH candidates AS (
+ SELECT admission.request_id, admission.api_key_id
+ FROM usage_billing_admissions admission
+ WHERE admission.state = 'dispatched'
+ AND admission.updated_at <= NOW() - ($1 * INTERVAL '1 millisecond')
+ AND NOT EXISTS (
+ SELECT 1 FROM usage_billing_outbox outbox
+ WHERE outbox.request_id = admission.request_id
+ AND outbox.api_key_id = admission.api_key_id
+ )
+ AND (
+ EXISTS (
+ SELECT 1 FROM usage_billing_admission_attempts attempt
+ WHERE attempt.request_id = admission.request_id
+ AND attempt.api_key_id = admission.api_key_id
+ AND attempt.state IN ('prepared','dispatched','finalized')
+ )
+ OR NOT EXISTS (
+ SELECT 1 FROM usage_billing_admission_attempts attempt
+ WHERE attempt.request_id = admission.request_id
+ AND attempt.api_key_id = admission.api_key_id
+ )
+ )
+ ORDER BY admission.updated_at
+ FOR UPDATE SKIP LOCKED
+ LIMIT $2
+ )
+ UPDATE usage_billing_admissions admission
+ SET state = 'orphaned', orphaned_at = COALESCE(orphaned_at, NOW()), updated_at = NOW()
+ FROM candidates candidate
+ WHERE admission.request_id = candidate.request_id
+ AND admission.api_key_id = candidate.api_key_id
+ `, grace.Milliseconds(), limit)
+ if err != nil {
+ return 0, err
+ }
+ return usageBillingReconcileRowsAffected(result)
+}
+
+func reconcileDeadLetterAdmissions(ctx context.Context, tx *sql.Tx, limit int) (int, error) {
+ result, err := tx.ExecContext(ctx, `
+ WITH candidates AS (
+ SELECT admission.request_id, admission.api_key_id
+ FROM usage_billing_admissions admission
+ JOIN usage_billing_outbox outbox
+ ON outbox.request_id = admission.request_id
+ AND outbox.api_key_id = admission.api_key_id
+ WHERE admission.state = 'outbox_pending'
+ AND outbox.status = 'dead_letter'
+ ORDER BY outbox.dead_lettered_at, outbox.id
+ FOR UPDATE OF admission SKIP LOCKED
+ LIMIT $1
+ )
+ UPDATE usage_billing_admissions admission
+ SET state = 'reconcile', reconcile_started_at = COALESCE(reconcile_started_at, NOW()), updated_at = NOW()
+ FROM candidates candidate
+ WHERE admission.request_id = candidate.request_id
+ AND admission.api_key_id = candidate.api_key_id
+ `, limit)
+ if err != nil {
+ return 0, err
+ }
+ return usageBillingReconcileRowsAffected(result)
+}
+
+func usageBillingReconcileRowsAffected(result sql.Result) (int, error) {
+ count, err := result.RowsAffected()
+ return int(count), err
+}
+
+func usageBillingAdmissionValidationEnvelope(admission service.UsageBillingAdmission) (service.UsageBillingEnvelope, error) {
+ return service.NewUsageBillingEnvelope(service.UsageBillingEnvelopeInput{
+ RequestID: admission.RequestID(), RequestPayloadHash: admission.RequestPayloadHash(),
+ AdmissionAttemptID: admission.AttemptID(), APIKeyID: admission.APIKeyID(),
+ AuthCacheLocator: admission.AuthCacheLocator(), UserID: admission.UserID(), AccountID: admission.AccountID(),
+ SubscriptionID: admission.SubscriptionID(), GroupID: admission.GroupID(),
+ EffectiveBillingGroupID: admission.EffectiveBillingGroupID(), AccountType: admission.AccountType(),
+ BillingModel: admission.BillingModel(), BillingType: admission.BillingType(),
+ PricingSource: admission.PricingSource(), PricingRevision: admission.PricingRevision(),
+ PricingHash: admission.PricingHash(), RateMultiplier: admission.RateMultiplier(), AccountRateMultiplier: 1,
+ })
+}
+
+func validateUsageBillingAdmissionAtEnqueue(ctx context.Context, q usageBillingBindingQuerier, envelope service.UsageBillingEnvelope) error {
+ return validateUsageBillingAdmissionForEnvelope(ctx, q, envelope, usageBillingAdmissionSettlementStateMatches)
+}
+
+func validateUsageBillingAdmissionAtReconciliation(
+ ctx context.Context,
+ q usageBillingBindingQuerier,
+ envelope service.UsageBillingEnvelope,
+) error {
+ return validateUsageBillingAdmissionForEnvelope(ctx, q, envelope, usageBillingAdmissionReconciliationStateMatches)
+}
+
+func validateUsageBillingAdmissionForEnvelope(
+ ctx context.Context,
+ q usageBillingBindingQuerier,
+ envelope service.UsageBillingEnvelope,
+ stateMatches func(string, string) bool,
+) error {
+ var (
+ fingerprint, ownerToken, requestPayloadHash, authLocator, state, attemptID, accountType, attemptState string
+ billingModel, pricingSource, pricingRevision, pricingHash string
+ alternateBillingModel, alternatePricingSource, alternatePricingRevision, alternatePricingHash string
+ userID, accountID, groupID, effectiveGroupID int64
+ subscriptionID, walletSubscriptionID sql.NullInt64
+ billingType int8
+ rateMultiplier, worstCaseCostUSD, alternateRateMultiplier float64
+ )
+ err := q.QueryRowContext(ctx, `
+ SELECT a.binding_fingerprint, a.owner_token, a.request_payload_hash, COALESCE(a.auth_cache_locator, ''),
+ a.user_id, a.subscription_id, a.group_id, a.effective_billing_group_id,
+ a.billing_type, COALESCE(a.billing_model, ''), COALESCE(a.pricing_source, ''),
+ COALESCE(a.pricing_revision, ''), COALESCE(a.pricing_hash, ''),
+ a.rate_multiplier, a.worst_case_cost_usd,
+ COALESCE(a.alternate_billing_model, ''), COALESCE(a.alternate_pricing_source, ''),
+ COALESCE(a.alternate_pricing_revision, ''), COALESCE(a.alternate_pricing_hash, ''),
+ COALESCE(a.alternate_rate_multiplier, 0), a.wallet_subscription_id, a.state,
+ attempt.attempt_id, attempt.account_id, attempt.account_type, attempt.state
+ FROM usage_billing_admissions a
+ JOIN usage_billing_admission_attempts attempt
+ ON attempt.request_id = a.request_id
+ AND attempt.api_key_id = a.api_key_id
+ AND attempt.owner_token = a.owner_token
+ AND attempt.account_id = $3
+ AND attempt.account_type = $4
+ AND attempt.attempt_id = $5
+ WHERE a.request_id = $1 AND a.api_key_id = $2
+ FOR UPDATE
+ `, envelope.RequestID(), envelope.APIKeyID(), envelope.AccountID(), envelope.AccountType(), envelope.AdmissionAttemptID()).Scan(
+ &fingerprint, &ownerToken, &requestPayloadHash, &authLocator, &userID, &subscriptionID, &groupID,
+ &effectiveGroupID, &billingType, &billingModel, &pricingSource, &pricingRevision,
+ &pricingHash, &rateMultiplier, &worstCaseCostUSD,
+ &alternateBillingModel, &alternatePricingSource, &alternatePricingRevision,
+ &alternatePricingHash, &alternateRateMultiplier, &walletSubscriptionID, &state,
+ &attemptID, &accountID, &accountType, &attemptState,
+ )
+ if errors.Is(err, sql.ErrNoRows) {
+ return service.ErrUsageBillingAdmissionMissing
+ }
+ if err != nil {
+ return err
+ }
+ if stateMatches == nil || !stateMatches(state, attemptState) {
+ return service.ErrUsageBillingAdmissionLeaseLost
+ }
+ var subID *int64
+ if subscriptionID.Valid {
+ value := subscriptionID.Int64
+ subID = &value
+ }
+ admission, err := service.NewUsageBillingAdmission(service.UsageBillingAdmissionInput{
+ RequestID: envelope.RequestID(), APIKeyID: envelope.APIKeyID(), AuthCacheLocator: authLocator,
+ UserID: userID, AccountID: accountID, SubscriptionID: subID,
+ GroupID: &groupID, EffectiveBillingGroupID: &effectiveGroupID,
+ AccountType: accountType, BillingType: billingType,
+ OwnerToken: ownerToken, AttemptID: attemptID,
+ BillingModel: billingModel, RequestPayloadHash: requestPayloadHash, PricingSource: pricingSource,
+ PricingRevision: pricingRevision, PricingHash: pricingHash,
+ RateMultiplier: rateMultiplier, WorstCaseCostUSD: worstCaseCostUSD,
+ AlternateBillingModel: alternateBillingModel, AlternatePricingSource: alternatePricingSource,
+ AlternatePricingRevision: alternatePricingRevision, AlternatePricingHash: alternatePricingHash,
+ AlternateRateMultiplier: alternateRateMultiplier,
+ })
+ if err != nil {
+ return err
+ }
+ if admission.Fingerprint() != fingerprint || !admission.MatchesEnvelope(envelope) ||
+ envelope.PrimaryBillingCost() > admission.WorstCaseCostUSD()+1e-12 ||
+ !usageBillingAdmissionCostFamilyMatches(walletSubscriptionID, billingType, subscriptionID, envelope) {
+ return service.ErrUsageBillingRequestConflict
+ }
+ return nil
+}
+
+func usageBillingAdmissionSettlementStateMatches(admissionState, attemptState string) bool {
+ return (admissionState == service.UsageBillingAdmissionStateDispatched &&
+ attemptState == service.UsageBillingAttemptStateDispatched) ||
+ (admissionState == service.UsageBillingAdmissionStateOutboxPending &&
+ attemptState == service.UsageBillingAttemptStateFinalized)
+}
+
+func usageBillingAdmissionReconciliationStateMatches(admissionState, attemptState string) bool {
+ return (admissionState == service.UsageBillingAdmissionStateOrphaned ||
+ admissionState == service.UsageBillingAdmissionStateReconcile) &&
+ attemptState == service.UsageBillingAttemptStateDispatched
+}
+
+func usageBillingAdmissionCostFamilyMatches(
+ walletSubscriptionID sql.NullInt64,
+ billingType int8,
+ subscriptionID sql.NullInt64,
+ envelope service.UsageBillingEnvelope,
+) bool {
+ switch {
+ case walletSubscriptionID.Valid:
+ return billingType == service.BillingTypeSubscription && subscriptionID.Valid &&
+ walletSubscriptionID.Int64 == subscriptionID.Int64 &&
+ envelope.BalanceCost() == 0 && envelope.SubscriptionCost() == 0
+ case billingType == service.BillingTypeSubscription:
+ return subscriptionID.Valid && envelope.BalanceCost() == 0 && envelope.WalletCost() == 0
+ case billingType == service.BillingTypeBalance:
+ return !subscriptionID.Valid && envelope.SubscriptionCost() == 0 && envelope.WalletCost() == 0
+ default:
+ return false
+ }
+}
+
+func finalizeUsageBillingAdmission(ctx context.Context, q usageBillingBindingQuerier, envelope service.UsageBillingEnvelope) error {
+ result, err := q.ExecContext(ctx, `
+ UPDATE usage_billing_admissions
+ SET state = 'outbox_pending', outbox_pending_at = COALESCE(outbox_pending_at, NOW()), updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2 AND state IN ('dispatched', 'outbox_pending')
+ `, envelope.RequestID(), envelope.APIKeyID())
+ if err != nil {
+ return err
+ }
+ updated, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if updated != 1 {
+ return service.ErrUsageBillingAdmissionMissing
+ }
+ attemptResult, err := q.ExecContext(ctx, `
+ UPDATE usage_billing_admission_attempts
+ SET state = 'finalized', finalized_at = COALESCE(finalized_at, NOW()), updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2
+ AND attempt_id = $3
+ AND state IN ('dispatched', 'finalized')
+ `, envelope.RequestID(), envelope.APIKeyID(), envelope.AdmissionAttemptID())
+ if err != nil {
+ return err
+ }
+ attemptUpdated, err := attemptResult.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if attemptUpdated != 1 {
+ return service.ErrUsageBillingAdmissionMissing
+ }
+ return nil
+}
+
+func selectUsageBillingOutboxByIdentity(ctx context.Context, q usageBillingBindingQuerier, requestID string, apiKeyID int64) (*service.UsageBillingOutboxEvent, error) {
+ row := q.QueryRowContext(ctx, `
+ SELECT `+usageBillingOutboxSelectColumns+`
+ FROM usage_billing_outbox
+ WHERE request_id = $1 AND api_key_id = $2
+ `, requestID, apiKeyID)
+ return scanUsageBillingOutboxEvent(row)
+}
+
+func validateExistingUsageBillingOutbox(event *service.UsageBillingOutboxEvent, envelope service.UsageBillingEnvelope) error {
+ if event == nil {
+ return sql.ErrNoRows
+ }
+ if event.EnvelopeError != nil {
+ return event.EnvelopeError
+ }
+ if event.Envelope.RequestFingerprint() != envelope.RequestFingerprint() {
+ return service.ErrUsageBillingRequestConflict
+ }
+ return nil
+}
+
+func (r *usageBillingOutboxRepository) Claim(ctx context.Context, owner string, limit int, lease time.Duration) ([]service.UsageBillingOutboxEvent, error) {
+ if r == nil || r.db == nil {
+ return nil, errors.New("usage billing outbox repository db is nil")
+ }
+ owner = strings.TrimSpace(owner)
+ if owner == "" || len(owner) > 128 {
+ return nil, errors.New("usage billing outbox owner is invalid")
+ }
+ if limit <= 0 {
+ limit = 1
+ }
+ if limit > 100 {
+ limit = 100
+ }
+ if lease <= 0 {
+ lease = service.UsageBillingOutboxLeaseDuration
+ }
+ leaseSeconds := int64(lease / time.Second)
+ if leaseSeconds < 1 {
+ leaseSeconds = 1
+ }
+ leaseToken, err := newUsageBillingLeaseToken()
+ if err != nil {
+ return nil, err
+ }
+
+ rows, err := r.db.QueryContext(ctx, `
+ WITH candidates AS (
+ SELECT id
+ FROM usage_billing_outbox
+ WHERE status IN ('pending', 'retry')
+ AND available_at <= NOW()
+ AND (
+ status = 'retry'
+ OR attempt_count < max_attempts
+ OR (
+ attempt_count >= max_attempts
+ AND locked_at IS NOT NULL
+ AND locked_at <= NOW() - ($3 * INTERVAL '1 second')
+ )
+ )
+ AND (locked_at IS NULL OR locked_at <= NOW() - ($3 * INTERVAL '1 second'))
+ ORDER BY available_at, id
+ LIMIT $1
+ FOR UPDATE SKIP LOCKED
+ )
+ UPDATE usage_billing_outbox AS outbox
+ SET locked_at = NOW(),
+ locked_by = $2,
+ lease_token = $4,
+ last_attempt_at = NOW(),
+ attempt_count = LEAST(outbox.attempt_count + 1, outbox.max_attempts),
+ updated_at = NOW()
+ FROM candidates
+ WHERE outbox.id = candidates.id
+ RETURNING `+prefixedUsageBillingOutboxColumns("outbox"),
+ limit, owner, leaseSeconds, leaseToken)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ events := make([]service.UsageBillingOutboxEvent, 0, limit)
+ for rows.Next() {
+ event, err := scanUsageBillingOutboxEvent(rows)
+ if err != nil {
+ return nil, err
+ }
+ events = append(events, *event)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return events, nil
+}
+
+func (r *usageBillingOutboxRepository) Complete(ctx context.Context, id int64, owner, leaseToken, resultCode string) error {
+ resultCode = truncateUsageBillingOutboxText(resultCode, 64)
+ return r.updateOwned(ctx, id, owner, leaseToken, `
+ status = 'completed',
+ completed_at = NOW(),
+ result_code = $4,
+ last_error_code = NULL,
+ last_error = NULL,
+ locked_at = NULL,
+ locked_by = NULL,
+ lease_token = NULL,
+ updated_at = NOW()
+ `, resultCode)
+}
+
+func (r *usageBillingOutboxRepository) Retry(ctx context.Context, id int64, owner, leaseToken string, availableAt time.Time, errorCode, errorMessage string) error {
+ if availableAt.IsZero() {
+ availableAt = time.Now().UTC()
+ }
+ return r.updateOwned(ctx, id, owner, leaseToken, `
+ status = 'retry',
+ available_at = $4,
+ last_error_code = $5,
+ last_error = $6,
+ locked_at = NULL,
+ locked_by = NULL,
+ lease_token = NULL,
+ updated_at = NOW()
+ `, availableAt.UTC(), truncateUsageBillingOutboxText(errorCode, 64), truncateUsageBillingOutboxText(errorMessage, 1000))
+}
+
+func (r *usageBillingOutboxRepository) DeadLetter(ctx context.Context, id int64, owner, leaseToken, errorCode, errorMessage string) error {
+ return r.updateOwned(ctx, id, owner, leaseToken, `
+ status = 'dead_letter',
+ dead_lettered_at = NOW(),
+ last_error_code = $4,
+ last_error = $5,
+ locked_at = NULL,
+ locked_by = NULL,
+ lease_token = NULL,
+ updated_at = NOW()
+ `, truncateUsageBillingOutboxText(errorCode, 64), truncateUsageBillingOutboxText(errorMessage, 1000))
+}
+
+func (r *usageBillingOutboxRepository) ValidateBindings(ctx context.Context, envelope service.UsageBillingEnvelope) error {
+ // Database ownership and mode are atomically validated and frozen by
+ // Enqueue. Replay deliberately validates only the immutable envelope so a
+ // later soft-delete or reassignment cannot erase an already accepted charge.
+ return envelope.Validate()
+}
+
+func (r *usageBillingOutboxRepository) updateOwned(ctx context.Context, id int64, owner, leaseToken, assignments string, args ...any) error {
+ if r == nil || r.db == nil {
+ return errors.New("usage billing outbox repository db is nil")
+ }
+ owner = strings.TrimSpace(owner)
+ leaseToken = strings.TrimSpace(leaseToken)
+ if id <= 0 || owner == "" || leaseToken == "" {
+ return service.ErrUsageBillingOutboxLeaseLost
+ }
+ query := `UPDATE usage_billing_outbox SET ` + assignments + `
+ WHERE id = $1
+ AND locked_by = $2
+ AND lease_token = $3
+ AND status IN ('pending', 'retry')`
+ queryArgs := make([]any, 0, len(args)+3)
+ queryArgs = append(queryArgs, id, owner, leaseToken)
+ queryArgs = append(queryArgs, args...)
+ result, err := r.db.ExecContext(ctx, query, queryArgs...)
+ if err != nil {
+ return err
+ }
+ affected, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if affected != 1 {
+ return service.ErrUsageBillingOutboxLeaseLost
+ }
+ return nil
+}
+
+type usageBillingOutboxScanner interface {
+ Scan(dest ...any) error
+}
+
+func scanUsageBillingOutboxEvent(scanner usageBillingOutboxScanner) (*service.UsageBillingOutboxEvent, error) {
+ var (
+ event service.UsageBillingOutboxEvent
+ outerRequestID string
+ outerAPIKeyID int64
+ outerFingerprint string
+ outerVersion int16
+ rawEnvelope []byte
+ lockedAt sql.NullTime
+ lockedBy sql.NullString
+ leaseToken sql.NullString
+ lastAttemptAt sql.NullTime
+ completedAt sql.NullTime
+ deadLetteredAt sql.NullTime
+ resultCode sql.NullString
+ lastErrorCode sql.NullString
+ lastError sql.NullString
+ )
+ if err := scanner.Scan(
+ &event.ID, &outerRequestID, &outerAPIKeyID, &outerFingerprint, &outerVersion,
+ &rawEnvelope, &event.Status, &event.AttemptCount, &event.MaxAttempts, &event.AvailableAt,
+ &lockedAt, &lockedBy, &leaseToken, &lastAttemptAt, &completedAt, &deadLetteredAt,
+ &resultCode, &lastErrorCode, &lastError, &event.CreatedAt, &event.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ envelope, err := service.DecodeUsageBillingEnvelope(rawEnvelope)
+ if err != nil {
+ event.EnvelopeError = err
+ } else if envelope.RequestID() != outerRequestID ||
+ envelope.APIKeyID() != outerAPIKeyID ||
+ !strings.EqualFold(envelope.RequestFingerprint(), outerFingerprint) ||
+ envelope.Version() != outerVersion {
+ event.EnvelopeError = service.ErrUsageBillingEnvelopeFingerprintMismatch
+ } else {
+ event.Envelope = envelope
+ }
+ event.LockedAt = nullTimePointer(lockedAt)
+ event.LockedBy = lockedBy.String
+ event.LeaseToken = leaseToken.String
+ event.LastAttemptAt = nullTimePointer(lastAttemptAt)
+ event.CompletedAt = nullTimePointer(completedAt)
+ event.DeadLetteredAt = nullTimePointer(deadLetteredAt)
+ event.ResultCode = resultCode.String
+ event.LastErrorCode = lastErrorCode.String
+ event.LastError = lastError.String
+ return &event, nil
+}
+
+type usageBillingBindingQuerier interface {
+ QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
+ ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
+}
+
+func validateUsageBillingBindingsAtEnqueue(ctx context.Context, q usageBillingBindingQuerier, envelope service.UsageBillingEnvelope) error {
+ return validateUsageBillingBindings(ctx, q, envelope, false)
+}
+
+func validateUsageBillingBindingsAtAdmission(ctx context.Context, q usageBillingBindingQuerier, envelope service.UsageBillingEnvelope) error {
+ return validateUsageBillingBindings(ctx, q, envelope, true)
+}
+
+func validateUsageBillingBindings(ctx context.Context, q usageBillingBindingQuerier, envelope service.UsageBillingEnvelope, requireCurrentWalletGrant bool) error {
+ groupID := envelope.GroupID()
+ effectiveBillingGroupID := envelope.EffectiveBillingGroupID()
+ if groupID == nil || effectiveBillingGroupID == nil {
+ return service.ErrUsageBillingEnvelopeInvalid
+ }
+
+ var apiKeyUserID int64
+ var apiKeyGroupID sql.NullInt64
+ var apiKeyName, apiKeyPurpose string
+ var apiKeyHash sql.NullString
+ err := q.QueryRowContext(ctx, `
+ SELECT user_id, group_id, name, purpose, key_hash FROM api_keys
+ WHERE id = $1 AND deleted_at IS NULL
+ FOR SHARE
+ `, envelope.APIKeyID()).Scan(&apiKeyUserID, &apiKeyGroupID, &apiKeyName, &apiKeyPurpose, &apiKeyHash)
+ if errors.Is(err, sql.ErrNoRows) {
+ return service.ErrUsageBillingOutboxTargetNotFound
+ }
+ if err != nil {
+ return err
+ }
+ if apiKeyUserID != envelope.UserID() {
+ return service.ErrUsageBillingCrossTenant
+ }
+ if locator := envelope.AuthCacheLocator(); locator != "" {
+ expectedLocator := strings.TrimSpace(apiKeyHash.String)
+ if !apiKeyHash.Valid || !validAPIKeyAuthCacheLocator(expectedLocator) || !strings.EqualFold(expectedLocator, locator) {
+ return service.ErrUsageBillingCrossTenant
+ }
+ }
+ universalWalletKey := !apiKeyGroupID.Valid
+ if universalWalletKey {
+ if !validUsageBillingUniversalWalletKeyShape(apiKeyName, apiKeyPurpose, apiKeyGroupID.Valid) || envelope.SubscriptionID() == nil {
+ return service.ErrUsageBillingCrossTenant
+ }
+ } else if apiKeyPurpose == service.APIKeyPurposeWalletUniversal || apiKeyGroupID.Int64 != *groupID {
+ return service.ErrUsageBillingCrossTenant
+ }
+
+ var (
+ groupStatus, groupName, groupPlatform, groupSubscriptionType string
+ groupExclusive bool
+ groupClaudeCodeOnly bool
+ groupFallbackID, groupInvalidRequestFallbackID sql.NullInt64
+ )
+ err = q.QueryRowContext(ctx, `
+ SELECT status, name, platform, is_exclusive, subscription_type,
+ claude_code_only, fallback_group_id, fallback_group_id_on_invalid_request
+ FROM groups
+ WHERE id = $1 AND deleted_at IS NULL
+ FOR SHARE
+ `, *groupID).Scan(
+ &groupStatus, &groupName, &groupPlatform, &groupExclusive, &groupSubscriptionType,
+ &groupClaudeCodeOnly, &groupFallbackID, &groupInvalidRequestFallbackID,
+ )
+ if errors.Is(err, sql.ErrNoRows) {
+ return service.ErrUsageBillingOutboxTargetNotFound
+ }
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(groupStatus) != service.StatusActive {
+ return service.ErrUsageBillingCrossTenant
+ }
+ billingGroup := service.Group{
+ ID: *groupID, Name: groupName, Platform: groupPlatform, Status: groupStatus,
+ Hydrated: true, IsExclusive: groupExclusive, SubscriptionType: groupSubscriptionType,
+ ClaudeCodeOnly: groupClaudeCodeOnly,
+ FallbackGroupID: nullInt64Pointer(groupFallbackID),
+ FallbackGroupIDOnInvalidRequest: nullInt64Pointer(groupInvalidRequestFallbackID),
+ }
+
+ var accountType string
+ err = q.QueryRowContext(ctx, `
+ SELECT type FROM accounts
+ WHERE id = $1 AND deleted_at IS NULL
+ FOR SHARE
+ `, envelope.AccountID()).Scan(&accountType)
+ if errors.Is(err, sql.ErrNoRows) {
+ return service.ErrUsageBillingOutboxTargetNotFound
+ }
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(accountType) != envelope.AccountType() {
+ return service.ErrUsageBillingCrossTenant
+ }
+ var accountGroupExists bool
+ err = q.QueryRowContext(ctx, `
+ SELECT TRUE
+ FROM account_groups
+ WHERE account_id = $1 AND group_id = $2
+ FOR SHARE
+ `, envelope.AccountID(), *groupID).Scan(&accountGroupExists)
+ if errors.Is(err, sql.ErrNoRows) {
+ return service.ErrUsageBillingCrossTenant
+ }
+ if err != nil {
+ return err
+ }
+ if !accountGroupExists {
+ return service.ErrUsageBillingCrossTenant
+ }
+
+ subscriptionID := envelope.SubscriptionID()
+ if subscriptionID == nil {
+ if *effectiveBillingGroupID != *groupID {
+ return service.ErrUsageBillingCrossTenant
+ }
+ return nil
+ }
+ var subscriptionUserID int64
+ var subscriptionGroupID sql.NullInt64
+ var walletBalance sql.NullFloat64
+ err = q.QueryRowContext(ctx, `
+ SELECT user_id, group_id, wallet_balance_usd
+ FROM user_subscriptions
+ WHERE id = $1 AND deleted_at IS NULL
+ FOR SHARE
+ `, *subscriptionID).Scan(&subscriptionUserID, &subscriptionGroupID, &walletBalance)
+ if errors.Is(err, sql.ErrNoRows) {
+ return service.ErrUsageBillingOutboxTargetNotFound
+ }
+ if err != nil {
+ return err
+ }
+ if subscriptionUserID != envelope.UserID() {
+ return service.ErrUsageBillingCrossTenant
+ }
+ if universalWalletKey && !walletBalance.Valid {
+ return service.ErrUsageBillingCrossTenant
+ }
+ if walletBalance.Valid {
+ if subscriptionGroupID.Valid || envelope.SubscriptionCost() > 0 || *effectiveBillingGroupID != *groupID {
+ return service.ErrUsageBillingCrossTenant
+ }
+ if err := validateUsageBillingWalletGroupAuthorization(
+ ctx, q, envelope, billingGroup, requireCurrentWalletGrant,
+ ); err != nil {
+ return err
+ }
+ return nil
+ }
+ if envelope.WalletCost() > 0 || !subscriptionGroupID.Valid || subscriptionGroupID.Int64 != *effectiveBillingGroupID {
+ return service.ErrUsageBillingCrossTenant
+ }
+ if *effectiveBillingGroupID != *groupID {
+ if err := lockActiveUsageBillingGroup(ctx, q, *effectiveBillingGroupID); err != nil {
+ return err
+ }
+ covered, err := lockUsageBillingPlanCoverage(ctx, q, *subscriptionID, *effectiveBillingGroupID, *groupID)
+ if err != nil {
+ return err
+ }
+ if !covered {
+ return service.ErrUsageBillingCrossTenant
+ }
+ }
+ return nil
+}
+
+func validAPIKeyAuthCacheLocator(value string) bool {
+ if len(value) != 64 {
+ return false
+ }
+ _, err := hex.DecodeString(value)
+ return err == nil
+}
+
+func validUsageBillingUniversalWalletKeyShape(name, purpose string, groupIDValid bool) bool {
+ return !groupIDValid && name == service.WalletUniversalAPIKeyName && purpose == service.APIKeyPurposeWalletUniversal
+}
+
+func usageBillingUniversalWalletGroupAuthorized(group service.Group, vipGranted bool) bool {
+ user := &service.User{ID: 1}
+ if vipGranted {
+ user.AllowedGroups = []int64{group.ID}
+ }
+ return service.CanUseWalletGroup(user, &group)
+}
+
+func validateUsageBillingWalletGroupAuthorization(
+ ctx context.Context,
+ q usageBillingBindingQuerier,
+ envelope service.UsageBillingEnvelope,
+ group service.Group,
+ requireCurrentGrant bool,
+) error {
+ vipGranted := false
+ if group.Name == service.WalletDefaultVIPGroupName {
+ grantFrozen := false
+ if !requireCurrentGrant && envelope.AdmissionAttemptID() != "" {
+ err := q.QueryRowContext(ctx, `
+ SELECT EXISTS (
+ SELECT 1
+ FROM usage_billing_admissions admission
+ JOIN usage_billing_admission_attempts attempt
+ ON attempt.request_id = admission.request_id
+ AND attempt.api_key_id = admission.api_key_id
+ WHERE admission.request_id = $1 AND admission.api_key_id = $2
+ AND attempt.attempt_id = $3
+ )
+ `, envelope.RequestID(), envelope.APIKeyID(), envelope.AdmissionAttemptID()).Scan(&grantFrozen)
+ if err != nil {
+ return err
+ }
+ }
+ if grantFrozen {
+ vipGranted = true
+ } else {
+ err := q.QueryRowContext(ctx, `
+ SELECT TRUE
+ FROM user_allowed_groups
+ WHERE user_id = $1 AND group_id = $2
+ FOR SHARE
+ `, envelope.UserID(), group.ID).Scan(&vipGranted)
+ if errors.Is(err, sql.ErrNoRows) {
+ vipGranted = false
+ } else if err != nil {
+ return err
+ }
+ }
+ }
+ if !usageBillingUniversalWalletGroupAuthorized(group, vipGranted) {
+ return service.ErrUsageBillingCrossTenant
+ }
+ return nil
+}
+
+func nullInt64Pointer(value sql.NullInt64) *int64 {
+ if !value.Valid {
+ return nil
+ }
+ result := value.Int64
+ return &result
+}
+
+func lockActiveUsageBillingGroup(ctx context.Context, q usageBillingBindingQuerier, groupID int64) error {
+ var status string
+ err := q.QueryRowContext(ctx, `
+ SELECT status FROM groups
+ WHERE id = $1 AND deleted_at IS NULL
+ FOR SHARE
+ `, groupID).Scan(&status)
+ if errors.Is(err, sql.ErrNoRows) {
+ return service.ErrUsageBillingOutboxTargetNotFound
+ }
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(status) != service.StatusActive {
+ return service.ErrUsageBillingCrossTenant
+ }
+ return nil
+}
+
+func lockUsageBillingPlanCoverage(ctx context.Context, q usageBillingBindingQuerier, subscriptionID, anchorGroupID, routedGroupID int64) (bool, error) {
+ var covered bool
+ err := q.QueryRowContext(ctx, `
+ WITH attached_snapshot AS (
+ SELECT snapshot.snapshot, snapshot.grant_starts_at, snapshot.grant_expires_at
+ FROM subscription_plan_fulfillment_snapshots snapshot
+ WHERE snapshot.user_subscription_id = $1
+ AND snapshot.grant_starts_at IS NOT NULL
+ AND snapshot.grant_expires_at IS NOT NULL
+ ),
+ anchor_plans AS (
+ SELECT plan.id
+ FROM subscription_plans plan
+ JOIN groups anchor_group
+ ON anchor_group.id = plan.group_id
+ AND anchor_group.subscription_type = $4
+ AND anchor_group.deleted_at IS NULL
+ WHERE plan.plan_type = $5
+ AND plan.group_id = $2
+
+ UNION
+
+ SELECT plan.id
+ FROM subscription_plans plan
+ JOIN subscription_plan_groups anchor
+ ON anchor.plan_id = plan.id
+ AND anchor.group_id = $2
+ JOIN groups anchor_group
+ ON anchor_group.id = anchor.group_id
+ AND anchor_group.subscription_type = $4
+ AND anchor_group.deleted_at IS NULL
+ WHERE plan.plan_type = $5
+ AND plan.group_id IS NULL
+ )
+ SELECT CASE
+ WHEN EXISTS (
+ SELECT 1
+ FROM attached_snapshot snapshot
+ WHERE snapshot.grant_starts_at <= NOW()
+ AND snapshot.grant_expires_at > NOW()
+ AND snapshot.snapshot->'covered_group_ids' @> jsonb_build_array($3::bigint)
+ ) THEN TRUE
+ WHEN EXISTS (SELECT 1 FROM attached_snapshot) THEN FALSE
+ ELSE EXISTS (SELECT 1 FROM anchor_plans)
+ AND NOT EXISTS (
+ SELECT 1
+ FROM anchor_plans plan
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM subscription_plan_groups target
+ WHERE target.plan_id = plan.id
+ AND target.group_id = $3
+ )
+ )
+ END
+ `, subscriptionID, anchorGroupID, routedGroupID, service.SubscriptionTypeSubscription, service.PlanTypeSubscription).Scan(&covered)
+ return covered, err
+}
+
+func newUsageBillingLeaseToken() (string, error) {
+ var token [16]byte
+ if _, err := rand.Read(token[:]); err != nil {
+ return "", fmt.Errorf("generate usage billing lease token: %w", err)
+ }
+ return hex.EncodeToString(token[:]), nil
+}
+
+func prefixedUsageBillingOutboxColumns(prefix string) string {
+ columns := strings.Split(strings.TrimSpace(usageBillingOutboxSelectColumns), ",")
+ for i := range columns {
+ columns[i] = prefix + "." + strings.TrimSpace(columns[i])
+ }
+ return strings.Join(columns, ", ")
+}
+
+func nullTimePointer(value sql.NullTime) *time.Time {
+ if !value.Valid {
+ return nil
+ }
+ copyValue := value.Time
+ return ©Value
+}
+
+func truncateUsageBillingOutboxText(value string, limit int) string {
+ value = strings.Map(func(r rune) rune {
+ if r == '\n' || r == '\r' || r == '\t' {
+ return ' '
+ }
+ if r < 0x20 || r == 0x7f {
+ return -1
+ }
+ return r
+ }, strings.TrimSpace(value))
+ if len(value) <= limit {
+ return value
+ }
+ return value[:limit]
+}
+
+var _ service.UsageBillingOutboxRepository = (*usageBillingOutboxRepository)(nil)
+var _ service.UsageBillingBindingValidator = (*usageBillingOutboxRepository)(nil)
diff --git a/backend/internal/repository/usage_billing_outbox_repo_integration_test.go b/backend/internal/repository/usage_billing_outbox_repo_integration_test.go
new file mode 100644
index 00000000000..86e37ea5f43
--- /dev/null
+++ b/backend/internal/repository/usage_billing_outbox_repo_integration_test.go
@@ -0,0 +1,376 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+)
+
+type usageBillingOutboxBindingFixture struct {
+ userID int64
+ apiKeyID int64
+ authCacheLocator string
+ accountID int64
+ groupID int64
+}
+
+const integrationUsageRequestPayloadHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
+
+func newUsageBillingOutboxBindingFixture(t *testing.T) usageBillingOutboxBindingFixture {
+ t.Helper()
+ client := testEntClient(t)
+ user := mustCreateUser(t, client, &service.User{Email: fmt.Sprintf("outbox-fixture-%s@example.com", uuid.NewString()), Balance: 100})
+ group := mustCreateGroup(t, client, &service.Group{Name: "outbox-fixture-" + uuid.NewString()})
+ apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, GroupID: &group.ID, Key: "sk-outbox-fixture-" + uuid.NewString()})
+ account := mustCreateAccount(t, client, &service.Account{Name: "outbox-fixture-" + uuid.NewString(), Type: service.AccountTypeAPIKey})
+ mustBindAccountToGroup(t, client, account.ID, group.ID, 1)
+ return usageBillingOutboxBindingFixture{
+ userID: user.ID, apiKeyID: apiKey.ID, authCacheLocator: apiKey.KeyHash,
+ accountID: account.ID, groupID: group.ID,
+ }
+}
+
+func newOutboxEnvelope(t *testing.T, fixture usageBillingOutboxBindingFixture, requestID string, balanceCost float64) service.UsageBillingEnvelope {
+ t.Helper()
+ envelope, err := service.NewUsageBillingEnvelope(service.UsageBillingEnvelopeInput{
+ RequestID: requestID,
+ RequestPayloadHash: integrationUsageRequestPayloadHash,
+ APIKeyID: fixture.apiKeyID,
+ AuthCacheLocator: fixture.authCacheLocator,
+ UserID: fixture.userID,
+ AccountID: fixture.accountID,
+ GroupID: &fixture.groupID,
+ AccountType: service.AccountTypeAPIKey,
+ BillingModel: "gpt-5.6-sol",
+ BillingType: service.BillingTypeBalance,
+ InputTokens: 100,
+ OutputTokens: 20,
+ PricingSource: service.PricingSourceBuiltinGPT56,
+ PricingRevision: service.GPT56PricingRevision,
+ PricingHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ RateMultiplier: 1,
+ AccountRateMultiplier: 1,
+ BalanceCost: balanceCost,
+ APIKeyQuotaCost: balanceCost,
+ })
+ require.NoError(t, err)
+ return envelope
+}
+
+func TestUsageBillingOutboxRepository_EnqueueIdempotencyAndConflict(t *testing.T) {
+ ctx := context.Background()
+ _, _ = integrationDB.ExecContext(ctx, "TRUNCATE usage_billing_outbox RESTART IDENTITY")
+ repo := NewUsageBillingOutboxRepository(integrationDB)
+ fixture := newUsageBillingOutboxBindingFixture(t)
+
+ requestID := "outbox-enqueue-" + uuid.NewString()
+ firstEnvelope := newOutboxEnvelope(t, fixture, requestID, 1.25)
+ first, inserted, err := repo.Enqueue(ctx, firstEnvelope)
+ require.NoError(t, err)
+ require.True(t, inserted)
+ require.Equal(t, service.UsageBillingOutboxStatusPending, first.Status)
+
+ duplicate, inserted, err := repo.Enqueue(ctx, firstEnvelope)
+ require.NoError(t, err)
+ require.False(t, inserted)
+ require.Equal(t, first.ID, duplicate.ID)
+
+ conflictingEnvelope := newOutboxEnvelope(t, fixture, requestID, 2.5)
+ _, _, err = repo.Enqueue(ctx, conflictingEnvelope)
+ require.ErrorIs(t, err, service.ErrUsageBillingRequestConflict)
+
+ _, err = integrationDB.ExecContext(ctx, "UPDATE api_keys SET deleted_at = NOW() WHERE id = $1", fixture.apiKeyID)
+ require.NoError(t, err)
+ duplicateAfterDelete, inserted, err := repo.Enqueue(ctx, firstEnvelope)
+ require.NoError(t, err)
+ require.False(t, inserted)
+ require.Equal(t, first.ID, duplicateAfterDelete.ID, "an already durable event must remain idempotent after target soft-delete")
+ _, _, err = repo.Enqueue(ctx, conflictingEnvelope)
+ require.ErrorIs(t, err, service.ErrUsageBillingRequestConflict)
+
+ var storedCost float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT (envelope->>'balance_cost')::double precision
+ FROM usage_billing_outbox WHERE id = $1
+ `, first.ID).Scan(&storedCost))
+ require.InDelta(t, 1.25, storedCost, 0.000001, "conflict must never overwrite the original envelope")
+}
+
+func TestUsageBillingOutboxRepository_ClaimLeaseAndStaleOwner(t *testing.T) {
+ ctx := context.Background()
+ _, _ = integrationDB.ExecContext(ctx, "TRUNCATE usage_billing_outbox RESTART IDENTITY")
+ repo := NewUsageBillingOutboxRepository(integrationDB)
+ fixture := newUsageBillingOutboxBindingFixture(t)
+
+ first, _, err := repo.Enqueue(ctx, newOutboxEnvelope(t, fixture, "outbox-claim-1-"+uuid.NewString(), 1))
+ require.NoError(t, err)
+ _, _, err = repo.Enqueue(ctx, newOutboxEnvelope(t, fixture, "outbox-claim-2-"+uuid.NewString(), 1))
+ require.NoError(t, err)
+
+ claimedByOne, err := repo.Claim(ctx, "worker-one", 1, service.UsageBillingOutboxLeaseDuration)
+ require.NoError(t, err)
+ require.Len(t, claimedByOne, 1)
+ claimedByTwo, err := repo.Claim(ctx, "worker-two", 1, service.UsageBillingOutboxLeaseDuration)
+ require.NoError(t, err)
+ require.Len(t, claimedByTwo, 1)
+ require.NotEqual(t, claimedByOne[0].ID, claimedByTwo[0].ID, "leased rows must not be claimed by another worker")
+
+ _, _, err = repo.Enqueue(ctx, newOutboxEnvelope(t, fixture, "outbox-lease-expiry-"+uuid.NewString(), 1))
+ require.NoError(t, err)
+ third, err := repo.Claim(ctx, "worker-old", 1, service.UsageBillingOutboxLeaseDuration)
+ require.NoError(t, err)
+ require.Len(t, third, 1)
+
+ _, err = integrationDB.ExecContext(ctx, `
+ UPDATE usage_billing_outbox SET locked_at = NOW() - INTERVAL '31 seconds' WHERE id = $1
+ `, third[0].ID)
+ require.NoError(t, err)
+ reclaimed, err := repo.Claim(ctx, "worker-new", 1, service.UsageBillingOutboxLeaseDuration)
+ require.NoError(t, err)
+ require.Len(t, reclaimed, 1)
+ require.Equal(t, third[0].ID, reclaimed[0].ID)
+
+ err = repo.Complete(ctx, reclaimed[0].ID, "worker-old", third[0].LeaseToken, service.UsageBillingOutboxResultApplied)
+ require.ErrorIs(t, err, service.ErrUsageBillingOutboxLeaseLost)
+ require.NoError(t, repo.Complete(ctx, reclaimed[0].ID, "worker-new", reclaimed[0].LeaseToken, service.UsageBillingOutboxResultApplied))
+
+ var status string
+ require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT status FROM usage_billing_outbox WHERE id = $1", reclaimed[0].ID).Scan(&status))
+ require.Equal(t, service.UsageBillingOutboxStatusCompleted, status)
+ require.NotZero(t, first.ID)
+}
+
+func TestUsageBillingOutboxRepository_RetryAndDeadLetterLifecycle(t *testing.T) {
+ ctx := context.Background()
+ _, _ = integrationDB.ExecContext(ctx, "TRUNCATE usage_billing_outbox RESTART IDENTITY")
+ repo := NewUsageBillingOutboxRepository(integrationDB)
+ fixture := newUsageBillingOutboxBindingFixture(t)
+
+ _, _, err := repo.Enqueue(ctx, newOutboxEnvelope(t, fixture, "outbox-retry-"+uuid.NewString(), 1))
+ require.NoError(t, err)
+ claimed, err := repo.Claim(ctx, "retry-worker", 1, service.UsageBillingOutboxLeaseDuration)
+ require.NoError(t, err)
+ require.Len(t, claimed, 1)
+
+ retryAt := time.Now().UTC().Add(time.Minute)
+ require.NoError(t, repo.Retry(ctx, claimed[0].ID, "retry-worker", claimed[0].LeaseToken, retryAt, "db_unavailable", "temporary failure"))
+
+ none, err := repo.Claim(ctx, "other-worker", 1, service.UsageBillingOutboxLeaseDuration)
+ require.NoError(t, err)
+ require.Empty(t, none)
+ _, err = integrationDB.ExecContext(ctx, "UPDATE usage_billing_outbox SET available_at = NOW() - INTERVAL '1 second' WHERE id = $1", claimed[0].ID)
+ require.NoError(t, err)
+
+ retried, err := repo.Claim(ctx, "dead-worker", 1, service.UsageBillingOutboxLeaseDuration)
+ require.NoError(t, err)
+ require.Len(t, retried, 1)
+ require.Equal(t, int16(2), retried[0].AttemptCount)
+ require.NoError(t, repo.DeadLetter(ctx, retried[0].ID, "dead-worker", retried[0].LeaseToken, "invalid_envelope", "poison"))
+
+ var status, code string
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT status, last_error_code FROM usage_billing_outbox WHERE id = $1
+ `, retried[0].ID).Scan(&status, &code))
+ require.Equal(t, service.UsageBillingOutboxStatusDeadLetter, status)
+ require.Equal(t, "invalid_envelope", code)
+}
+
+func TestUsageBillingOutboxRepository_EnqueueRejectsCrossTenantAndInvalidBillingMode(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ repo := NewUsageBillingOutboxRepository(integrationDB)
+
+ userOne := mustCreateUser(t, client, &service.User{Email: fmt.Sprintf("outbox-owner-one-%s@example.com", uuid.NewString())})
+ userTwo := mustCreateUser(t, client, &service.User{Email: fmt.Sprintf("outbox-owner-two-%s@example.com", uuid.NewString())})
+ apiKeyOne := mustCreateApiKey(t, client, &service.APIKey{UserID: userOne.ID, Key: "sk-outbox-" + uuid.NewString()})
+ account := mustCreateAccount(t, client, &service.Account{Name: "outbox-account-" + uuid.NewString(), Type: service.AccountTypeAPIKey})
+ group := mustCreateGroup(t, client, &service.Group{Name: "outbox-group-" + uuid.NewString(), SubscriptionType: service.SubscriptionTypeSubscription})
+ apiKeyOne.GroupID = &group.ID
+ _, err := integrationDB.ExecContext(ctx, "UPDATE api_keys SET group_id = $1 WHERE id = $2", group.ID, apiKeyOne.ID)
+ require.NoError(t, err)
+ mustBindAccountToGroup(t, client, account.ID, group.ID, 1)
+ subscriptionTwo := mustCreateSubscription(t, client, &service.UserSubscription{UserID: userTwo.ID, GroupID: &group.ID})
+
+ fixture := usageBillingOutboxBindingFixture{
+ userID: userOne.ID, apiKeyID: apiKeyOne.ID, authCacheLocator: apiKeyOne.KeyHash,
+ accountID: account.ID, groupID: group.ID,
+ }
+ valid := newOutboxEnvelope(t, fixture, "outbox-owner-valid-"+uuid.NewString(), 1)
+ _, _, err = repo.Enqueue(ctx, valid)
+ require.NoError(t, err)
+
+ wrongLocatorFixture := fixture
+ wrongLocatorFixture.authCacheLocator = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
+ _, _, err = repo.Enqueue(ctx, newOutboxEnvelope(t, wrongLocatorFixture, "outbox-owner-locator-"+uuid.NewString(), 1))
+ require.ErrorIs(t, err, service.ErrUsageBillingCrossTenant)
+
+ wrongOwnerFixture := fixture
+ wrongOwnerFixture.userID = userTwo.ID
+ wrongKeyOwner := newOutboxEnvelope(t, wrongOwnerFixture, "outbox-owner-key-"+uuid.NewString(), 1)
+ _, _, err = repo.Enqueue(ctx, wrongKeyOwner)
+ require.ErrorIs(t, err, service.ErrUsageBillingCrossTenant)
+
+ subscriptionID := subscriptionTwo.ID
+ wrongSubscriptionOwner, err := service.NewUsageBillingEnvelope(service.UsageBillingEnvelopeInput{
+ RequestID: "outbox-owner-sub-" + uuid.NewString(),
+ RequestPayloadHash: integrationUsageRequestPayloadHash,
+ APIKeyID: apiKeyOne.ID,
+ UserID: userOne.ID,
+ AccountID: account.ID,
+ SubscriptionID: &subscriptionID,
+ GroupID: &group.ID,
+ AccountType: service.AccountTypeAPIKey,
+ BillingModel: "gpt-5.6-sol",
+ BillingType: service.BillingTypeSubscription,
+ PricingSource: service.PricingSourceBuiltinGPT56,
+ PricingRevision: service.GPT56PricingRevision,
+ PricingHash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ RateMultiplier: 1,
+ AccountRateMultiplier: 1,
+ SubscriptionCost: 1,
+ })
+ require.NoError(t, err)
+ _, _, err = repo.Enqueue(ctx, wrongSubscriptionOwner)
+ require.ErrorIs(t, err, service.ErrUsageBillingCrossTenant)
+
+ uncoveredAnchorGroup := mustCreateGroup(t, client, &service.Group{
+ Name: "outbox-uncovered-anchor-" + uuid.NewString(),
+ SubscriptionType: service.SubscriptionTypeSubscription,
+ })
+ uncoveredSubscription := mustCreateSubscription(t, client, &service.UserSubscription{UserID: userOne.ID, GroupID: &uncoveredAnchorGroup.ID})
+ uncoveredSubscriptionEnvelope, err := service.NewUsageBillingEnvelope(service.UsageBillingEnvelopeInput{
+ RequestID: "outbox-uncovered-sub-" + uuid.NewString(),
+ RequestPayloadHash: integrationUsageRequestPayloadHash,
+ APIKeyID: apiKeyOne.ID,
+ UserID: userOne.ID,
+ AccountID: account.ID,
+ SubscriptionID: &uncoveredSubscription.ID,
+ GroupID: &group.ID,
+ EffectiveBillingGroupID: &uncoveredAnchorGroup.ID,
+ AccountType: service.AccountTypeAPIKey,
+ BillingModel: "gpt-5.6-sol",
+ BillingType: service.BillingTypeSubscription,
+ PricingSource: service.PricingSourceBuiltinGPT56,
+ PricingRevision: service.GPT56PricingRevision,
+ PricingHash: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+ RateMultiplier: 1,
+ AccountRateMultiplier: 1,
+ SubscriptionCost: 1,
+ })
+ require.NoError(t, err)
+ _, _, err = repo.Enqueue(ctx, uncoveredSubscriptionEnvelope)
+ require.ErrorIs(t, err, service.ErrUsageBillingCrossTenant)
+
+ otherGroup := mustCreateGroup(t, client, &service.Group{Name: "outbox-other-group-" + uuid.NewString()})
+ wrongGroupFixture := fixture
+ wrongGroupFixture.groupID = otherGroup.ID
+ _, _, err = repo.Enqueue(ctx, newOutboxEnvelope(t, wrongGroupFixture, "outbox-owner-group-"+uuid.NewString(), 1))
+ require.ErrorIs(t, err, service.ErrUsageBillingCrossTenant)
+
+ unboundAccount := mustCreateAccount(t, client, &service.Account{Name: "outbox-unbound-" + uuid.NewString(), Type: service.AccountTypeAPIKey})
+ unboundFixture := fixture
+ unboundFixture.accountID = unboundAccount.ID
+ _, _, err = repo.Enqueue(ctx, newOutboxEnvelope(t, unboundFixture, "outbox-owner-account-group-"+uuid.NewString(), 1))
+ require.ErrorIs(t, err, service.ErrUsageBillingCrossTenant)
+
+ wrongTypeAccount := mustCreateAccount(t, client, &service.Account{Name: "outbox-wrong-type-" + uuid.NewString(), Type: service.AccountTypeOAuth})
+ mustBindAccountToGroup(t, client, wrongTypeAccount.ID, group.ID, 1)
+ wrongTypeFixture := fixture
+ wrongTypeFixture.accountID = wrongTypeAccount.ID
+ _, _, err = repo.Enqueue(ctx, newOutboxEnvelope(t, wrongTypeFixture, "outbox-owner-account-type-"+uuid.NewString(), 1))
+ require.ErrorIs(t, err, service.ErrUsageBillingCrossTenant)
+
+ monthly := mustCreateSubscription(t, client, &service.UserSubscription{UserID: userOne.ID, GroupID: &group.ID})
+ monthlyID := monthly.ID
+ walletCostOnMonthly, err := service.NewUsageBillingEnvelope(service.UsageBillingEnvelopeInput{
+ RequestID: "outbox-wallet-on-monthly-" + uuid.NewString(), APIKeyID: apiKeyOne.ID,
+ RequestPayloadHash: integrationUsageRequestPayloadHash,
+ UserID: userOne.ID, AccountID: account.ID, SubscriptionID: &monthlyID, GroupID: &group.ID,
+ AccountType: service.AccountTypeAPIKey, BillingModel: "gpt-5.6-sol", BillingType: service.BillingTypeSubscription,
+ PricingSource: service.PricingSourceBuiltinGPT56, PricingRevision: service.GPT56PricingRevision,
+ PricingHash: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
+ RateMultiplier: 1, AccountRateMultiplier: 1, WalletCost: 1,
+ })
+ require.NoError(t, err)
+ _, _, err = repo.Enqueue(ctx, walletCostOnMonthly)
+ require.ErrorIs(t, err, service.ErrUsageBillingAdmissionMissing)
+
+ wallet := mustCreateCreditsWallet(t, client, userOne.ID, 10)
+ walletID := wallet.ID
+ monthlyCostOnWallet, err := service.NewUsageBillingEnvelope(service.UsageBillingEnvelopeInput{
+ RequestID: "outbox-monthly-on-wallet-" + uuid.NewString(), APIKeyID: apiKeyOne.ID,
+ RequestPayloadHash: integrationUsageRequestPayloadHash,
+ UserID: userOne.ID, AccountID: account.ID, SubscriptionID: &walletID, GroupID: &group.ID,
+ AccountType: service.AccountTypeAPIKey, BillingModel: "gpt-5.6-sol", BillingType: service.BillingTypeSubscription,
+ PricingSource: service.PricingSourceBuiltinGPT56, PricingRevision: service.GPT56PricingRevision,
+ PricingHash: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+ RateMultiplier: 1, AccountRateMultiplier: 1, SubscriptionCost: 1,
+ })
+ require.NoError(t, err)
+ _, _, err = repo.Enqueue(ctx, monthlyCostOnWallet)
+ require.ErrorIs(t, err, service.ErrUsageBillingCrossTenant)
+
+ randomUnboundKey := mustCreateApiKey(t, client, &service.APIKey{UserID: userOne.ID, Name: "not-a-wallet-key", Key: "sk-outbox-unbound-" + uuid.NewString()})
+ randomKeyWalletEnvelope, err := service.NewUsageBillingEnvelope(service.UsageBillingEnvelopeInput{
+ RequestID: "outbox-random-unbound-wallet-" + uuid.NewString(), APIKeyID: randomUnboundKey.ID,
+ RequestPayloadHash: integrationUsageRequestPayloadHash,
+ UserID: userOne.ID, AccountID: account.ID, SubscriptionID: &walletID, GroupID: &group.ID,
+ AccountType: service.AccountTypeAPIKey, BillingModel: "gpt-5.6-sol", BillingType: service.BillingTypeSubscription,
+ PricingSource: service.PricingSourceBuiltinGPT56, PricingRevision: service.GPT56PricingRevision,
+ PricingHash: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
+ RateMultiplier: 1, AccountRateMultiplier: 1, WalletCost: 1,
+ })
+ require.NoError(t, err)
+ _, _, err = repo.Enqueue(ctx, randomKeyWalletEnvelope)
+ require.ErrorIs(t, err, service.ErrUsageBillingAdmissionMissing)
+
+ universalKey := mustCreateApiKey(t, client, &service.APIKey{UserID: userOne.ID, Name: service.WalletUniversalAPIKeyName, Purpose: service.APIKeyPurposeWalletUniversal, Key: "sk-outbox-universal-" + uuid.NewString()})
+ universalKeyMonthlyEnvelope, err := service.NewUsageBillingEnvelope(service.UsageBillingEnvelopeInput{
+ RequestID: "outbox-universal-monthly-" + uuid.NewString(), APIKeyID: universalKey.ID,
+ RequestPayloadHash: integrationUsageRequestPayloadHash,
+ UserID: userOne.ID, AccountID: account.ID, SubscriptionID: &monthlyID, GroupID: &group.ID,
+ AccountType: service.AccountTypeAPIKey, BillingModel: "gpt-5.6-sol", BillingType: service.BillingTypeSubscription,
+ PricingSource: service.PricingSourceBuiltinGPT56, PricingRevision: service.GPT56PricingRevision,
+ PricingHash: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
+ RateMultiplier: 1, AccountRateMultiplier: 1, SubscriptionCost: 1,
+ })
+ require.NoError(t, err)
+ _, _, err = repo.Enqueue(ctx, universalKeyMonthlyEnvelope)
+ require.ErrorIs(t, err, service.ErrUsageBillingCrossTenant)
+}
+
+func TestUsageBillingOutboxRepository_FinalAttemptCanBeReclaimedWithFencingToken(t *testing.T) {
+ ctx := context.Background()
+ _, _ = integrationDB.ExecContext(ctx, "TRUNCATE usage_billing_outbox RESTART IDENTITY")
+ repo := NewUsageBillingOutboxRepository(integrationDB)
+ fixture := newUsageBillingOutboxBindingFixture(t)
+ event, _, err := repo.Enqueue(ctx, newOutboxEnvelope(t, fixture, "outbox-final-attempt-"+uuid.NewString(), 1))
+ require.NoError(t, err)
+ _, err = integrationDB.ExecContext(ctx, `
+ UPDATE usage_billing_outbox
+ SET attempt_count = max_attempts - 1
+ WHERE id = $1
+ `, event.ID)
+ require.NoError(t, err)
+
+ firstClaim, err := repo.Claim(ctx, "same-worker", 1, service.UsageBillingOutboxLeaseDuration)
+ require.NoError(t, err)
+ require.Len(t, firstClaim, 1)
+ require.Equal(t, firstClaim[0].MaxAttempts, firstClaim[0].AttemptCount)
+ _, err = integrationDB.ExecContext(ctx, "UPDATE usage_billing_outbox SET locked_at = NOW() - INTERVAL '31 seconds' WHERE id = $1", event.ID)
+ require.NoError(t, err)
+
+ reclaimed, err := repo.Claim(ctx, "same-worker", 1, service.UsageBillingOutboxLeaseDuration)
+ require.NoError(t, err)
+ require.Len(t, reclaimed, 1)
+ require.NotEqual(t, firstClaim[0].LeaseToken, reclaimed[0].LeaseToken)
+ require.ErrorIs(t, repo.Complete(ctx, event.ID, "same-worker", firstClaim[0].LeaseToken, service.UsageBillingOutboxResultApplied), service.ErrUsageBillingOutboxLeaseLost)
+ require.NoError(t, repo.Complete(ctx, event.ID, "same-worker", reclaimed[0].LeaseToken, service.UsageBillingOutboxResultApplied))
+}
diff --git a/backend/internal/repository/usage_billing_outbox_repo_unit_test.go b/backend/internal/repository/usage_billing_outbox_repo_unit_test.go
new file mode 100644
index 00000000000..70fe50acd1c
--- /dev/null
+++ b/backend/internal/repository/usage_billing_outbox_repo_unit_test.go
@@ -0,0 +1,180 @@
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/DATA-DOG/go-sqlmock"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUsageBillingOutboxRepository_EnqueueClassifiesStorageFailureForRetry(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ sentinel := errors.New("database temporarily unavailable")
+ mock.ExpectBegin().WillReturnError(sentinel)
+ repo := NewUsageBillingOutboxRepository(db)
+ groupID := int64(4)
+ envelope, err := service.NewUsageBillingEnvelope(service.UsageBillingEnvelopeInput{
+ RequestID: "outbox-admission-retry", RequestPayloadHash: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
+ APIKeyID: 1, UserID: 2, AccountID: 3,
+ GroupID: &groupID, AccountType: service.AccountTypeAPIKey,
+ BillingModel: "gpt-5.6-sol", ExecutionModel: "gpt-5.6-sol",
+ BillingType: service.BillingTypeBalance, InputTokens: 1,
+ OccurredAtUnixMs: time.Now().UnixMilli(),
+ PricingSource: service.PricingSourceBuiltinGPT56, PricingRevision: service.GPT56PricingRevision,
+ PricingHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ RateMultiplier: 1, AccountRateMultiplier: 1,
+ })
+ require.NoError(t, err)
+
+ _, _, err = repo.Enqueue(context.Background(), envelope)
+ require.ErrorIs(t, err, service.ErrUsageBillingOutboxAdmissionRetryable)
+ require.ErrorIs(t, err, sentinel)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestUsageBillingOutboxRepository_EnqueueKeepsInvalidEnvelopePermanent(t *testing.T) {
+ db, _, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ _, _, err = NewUsageBillingOutboxRepository(db).Enqueue(context.Background(), service.UsageBillingEnvelope{})
+ require.ErrorIs(t, err, service.ErrUsageBillingEnvelopeVersion)
+ require.NotErrorIs(t, err, service.ErrUsageBillingOutboxAdmissionRetryable)
+}
+
+func TestUsageBillingOutboxRepository_EnqueueRequiresAdmissionForWalletCharge(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ envelope := usageBillingWalletPolicyEnvelope(t, 31, "")
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT[\\s\\S]+FROM usage_billing_outbox").
+ WithArgs(envelope.RequestID(), envelope.APIKeyID()).
+ WillReturnError(sql.ErrNoRows)
+ mock.ExpectQuery("SELECT a.binding_fingerprint").
+ WithArgs(envelope.RequestID(), envelope.APIKeyID(), envelope.AccountID(), envelope.AccountType(), envelope.AdmissionAttemptID()).
+ WillReturnError(sql.ErrNoRows)
+ mock.ExpectRollback()
+
+ _, _, err = NewUsageBillingOutboxRepository(db).Enqueue(context.Background(), envelope)
+ require.ErrorIs(t, err, service.ErrUsageBillingAdmissionMissing)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestUsageBillingOutboxRepository_ReconcileStaleAdmissions(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ preparedGrace := 5 * time.Minute
+ dispatchedGrace := 24 * time.Hour
+ mock.ExpectBegin()
+ mock.ExpectExec("WITH candidates AS").
+ WithArgs(preparedGrace.Milliseconds(), 5).
+ WillReturnResult(sqlmock.NewResult(0, 2))
+ mock.ExpectExec("WITH candidates AS").
+ WithArgs(preparedGrace.Milliseconds(), 3).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("WITH candidates AS").
+ WithArgs(dispatchedGrace.Milliseconds(), 2).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("WITH candidates AS").
+ WithArgs(1).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectCommit()
+
+ result, err := NewUsageBillingOutboxRepository(db).ReconcileStaleAdmissions(
+ context.Background(),
+ preparedGrace,
+ dispatchedGrace,
+ 5,
+ )
+
+ require.NoError(t, err)
+ require.Equal(t, service.UsageBillingAdmissionReconcileResult{
+ AbandonedPrepared: 2,
+ AbandonedFailedDispatched: 1,
+ OrphanedDispatched: 1,
+ FlaggedDeadLetter: 1,
+ }, result)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestUsageBillingOutboxRepository_ReconcileStaleAdmissionsStopsAtSharedLimit(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ preparedGrace := 5 * time.Minute
+ mock.ExpectBegin()
+ mock.ExpectExec("WITH candidates AS").
+ WithArgs(preparedGrace.Milliseconds(), 2).
+ WillReturnResult(sqlmock.NewResult(0, 2))
+ mock.ExpectCommit()
+
+ result, err := NewUsageBillingOutboxRepository(db).ReconcileStaleAdmissions(
+ context.Background(),
+ preparedGrace,
+ 24*time.Hour,
+ 2,
+ )
+
+ require.NoError(t, err)
+ require.Equal(t, service.UsageBillingAdmissionReconcileResult{AbandonedPrepared: 2}, result)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestUsageBillingOutboxRepository_ReconcileStaleAdmissionsReturnsZeroWhenTransactionRollsBack(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ preparedGrace := 5 * time.Minute
+ dispatchedGrace := 24 * time.Hour
+ sentinel := errors.New("orphan reconciliation failed")
+ mock.ExpectBegin()
+ mock.ExpectExec("WITH candidates AS").
+ WithArgs(preparedGrace.Milliseconds(), 3).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("WITH candidates AS").
+ WithArgs(preparedGrace.Milliseconds(), 2).
+ WillReturnError(sentinel)
+ mock.ExpectRollback()
+
+ result, err := NewUsageBillingOutboxRepository(db).ReconcileStaleAdmissions(
+ context.Background(),
+ preparedGrace,
+ dispatchedGrace,
+ 3,
+ )
+
+ require.ErrorIs(t, err, sentinel)
+ require.Zero(t, result.Total())
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestUsageBillingOutboxRepository_ReconcileStaleAdmissionsZeroLimitIsNoOp(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ result, err := NewUsageBillingOutboxRepository(db).ReconcileStaleAdmissions(
+ context.Background(),
+ 5*time.Minute,
+ 24*time.Hour,
+ 0,
+ )
+
+ require.NoError(t, err)
+ require.Zero(t, result.Total())
+ require.NoError(t, mock.ExpectationsWereMet())
+}
diff --git a/backend/internal/repository/usage_billing_outbox_schema_integration_test.go b/backend/internal/repository/usage_billing_outbox_schema_integration_test.go
new file mode 100644
index 00000000000..2c698efebc4
--- /dev/null
+++ b/backend/internal/repository/usage_billing_outbox_schema_integration_test.go
@@ -0,0 +1,72 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestUsageBillingOutboxMigration_SchemaAndIndexes(t *testing.T) {
+ tx := testTx(t)
+ ctx := context.Background()
+
+ var table sql.NullString
+ require.NoError(t, tx.QueryRowContext(ctx, "SELECT to_regclass('public.usage_billing_outbox')").Scan(&table))
+ require.True(t, table.Valid, "expected usage_billing_outbox table to exist")
+
+ requireColumn(t, tx, "usage_billing_outbox", "request_id", "character varying", 255, false)
+ requireColumn(t, tx, "usage_billing_outbox", "api_key_id", "bigint", 0, false)
+ requireColumn(t, tx, "usage_billing_outbox", "request_fingerprint", "character varying", 64, false)
+ requireColumn(t, tx, "usage_billing_outbox", "envelope_version", "smallint", 0, false)
+ requireColumn(t, tx, "usage_billing_outbox", "envelope", "jsonb", 0, false)
+ requireColumn(t, tx, "usage_billing_outbox", "status", "character varying", 16, false)
+ requireColumn(t, tx, "usage_billing_outbox", "attempt_count", "smallint", 0, false)
+ requireColumn(t, tx, "usage_billing_outbox", "max_attempts", "smallint", 0, false)
+ requireColumn(t, tx, "usage_billing_outbox", "available_at", "timestamp with time zone", 0, false)
+ requireColumn(t, tx, "usage_billing_outbox", "locked_at", "timestamp with time zone", 0, true)
+ requireColumn(t, tx, "usage_billing_outbox", "locked_by", "character varying", 128, true)
+ requireColumn(t, tx, "usage_billing_outbox", "lease_token", "character varying", 64, true)
+ requireColumn(t, tx, "usage_billing_outbox", "completed_at", "timestamp with time zone", 0, true)
+ requireColumn(t, tx, "usage_billing_outbox", "dead_lettered_at", "timestamp with time zone", 0, true)
+
+ requireIndex(t, tx, "usage_billing_outbox", "usage_billing_outbox_request_api_key_key")
+ requireIndex(t, tx, "usage_billing_outbox", "idx_usage_billing_outbox_claim")
+ requireIndex(t, tx, "usage_billing_outbox", "idx_usage_billing_outbox_dead_letter")
+
+ var businessForeignKeys int
+ require.NoError(t, tx.QueryRowContext(ctx, `
+ SELECT COUNT(*)
+ FROM information_schema.table_constraints
+ WHERE table_schema = 'public'
+ AND table_name = 'usage_billing_outbox'
+ AND constraint_type = 'FOREIGN KEY'
+ `).Scan(&businessForeignKeys))
+ require.Zero(t, businessForeignKeys, "durable accounting events must not have business foreign keys")
+
+ var constraintCount int
+ require.NoError(t, tx.QueryRowContext(ctx, `
+ SELECT COUNT(*)
+ FROM pg_constraint
+ WHERE conrelid = 'usage_billing_outbox'::regclass
+ AND conname IN (
+ 'usage_billing_outbox_envelope_size_check',
+ 'usage_billing_outbox_outer_identity_check'
+ )
+ `).Scan(&constraintCount))
+ require.Equal(t, 2, constraintCount)
+
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO usage_billing_outbox (
+ request_id, api_key_id, request_fingerprint, envelope_version, envelope
+ ) VALUES (
+ 'outer-request', 7,
+ 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 1,
+ '{"request_id":"different-request","api_key_id":7,"request_fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","version":1}'::jsonb
+ )
+ `)
+ require.Error(t, err, "outer identity must never diverge from the immutable envelope")
+}
diff --git a/backend/internal/repository/usage_billing_plan_coverage_security_test.go b/backend/internal/repository/usage_billing_plan_coverage_security_test.go
new file mode 100644
index 00000000000..8a6b66165a0
--- /dev/null
+++ b/backend/internal/repository/usage_billing_plan_coverage_security_test.go
@@ -0,0 +1,42 @@
+package repository
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/DATA-DOG/go-sqlmock"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLockUsageBillingPlanCoverageUsesSubscriptionBoundDecision(t *testing.T) {
+ for _, want := range []bool{false, true} {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+ mock.ExpectQuery("WITH attached_snapshot AS").
+ WithArgs(int64(41), int64(42), int64(43), service.SubscriptionTypeSubscription, service.PlanTypeSubscription).
+ WillReturnRows(sqlmock.NewRows([]string{"covered"}).AddRow(want))
+
+ covered, err := lockUsageBillingPlanCoverage(context.Background(), db, 41, 42, 43)
+ require.NoError(t, err)
+ require.Equal(t, want, covered)
+ require.NoError(t, mock.ExpectationsWereMet())
+ }
+}
+
+func TestLockUsageBillingPlanCoverageFailsClosedOnQueryError(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+ sentinel := errors.New("coverage query unavailable")
+ mock.ExpectQuery("WITH attached_snapshot AS").
+ WithArgs(int64(41), int64(42), int64(43), service.SubscriptionTypeSubscription, service.PlanTypeSubscription).
+ WillReturnError(sentinel)
+
+ covered, err := lockUsageBillingPlanCoverage(context.Background(), db, 41, 42, 43)
+ require.False(t, covered)
+ require.ErrorIs(t, err, sentinel)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
diff --git a/backend/internal/repository/usage_billing_reconciliation_repo.go b/backend/internal/repository/usage_billing_reconciliation_repo.go
new file mode 100644
index 00000000000..dd0a0866a6b
--- /dev/null
+++ b/backend/internal/repository/usage_billing_reconciliation_repo.go
@@ -0,0 +1,688 @@
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "math"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+type usageBillingReconciliationAdmission struct {
+ state string
+ userID int64
+ walletSubscriptionID sql.NullInt64
+ walletReservedUSD float64
+ everDispatched bool
+}
+
+type usageBillingReconciliationWallet struct {
+ userID int64
+ balance float64
+}
+
+type usageBillingReconciliationOutbox struct {
+ id int64
+ status string
+ lastErrorCode string
+}
+
+type usageBillingReconciliationAttempt struct {
+ id string
+ state string
+ everDispatched bool
+}
+
+func (r *usageBillingOutboxRepository) ListUsageBillingReconciliationCases(
+ ctx context.Context,
+ limit int,
+) ([]service.UsageBillingReconciliationCase, error) {
+ if r == nil || r.db == nil {
+ return nil, service.ErrUsageBillingReconciliationUnavailable
+ }
+ if limit <= 0 {
+ limit = 50
+ }
+ if limit > 100 {
+ limit = 100
+ }
+ rows, err := r.db.QueryContext(ctx, `
+ SELECT admission.request_id, admission.api_key_id, admission.state, admission.user_id,
+ admission.subscription_id, admission.wallet_subscription_id,
+ admission.wallet_reserved_usd::double precision,
+ outbox.id, COALESCE(outbox.status, ''), COALESCE(outbox.last_error_code, ''),
+ COUNT(attempt.attempt_id) FILTER (WHERE attempt.state = 'prepared'),
+ COUNT(attempt.attempt_id) FILTER (WHERE attempt.state = 'dispatched'),
+ COUNT(attempt.attempt_id) FILTER (WHERE attempt.state = 'failed'),
+ COUNT(attempt.attempt_id) FILTER (WHERE attempt.state = 'finalized'),
+ COUNT(attempt.attempt_id) FILTER (
+ WHERE attempt.dispatched_at IS NOT NULL OR attempt.state IN ('dispatched', 'finalized')
+ ),
+ admission.prepared_at, admission.dispatched_at, admission.orphaned_at,
+ admission.reconcile_started_at
+ FROM usage_billing_admissions AS admission
+ LEFT JOIN usage_billing_outbox AS outbox
+ ON outbox.request_id = admission.request_id
+ AND outbox.api_key_id = admission.api_key_id
+ LEFT JOIN usage_billing_admission_attempts AS attempt
+ ON attempt.request_id = admission.request_id
+ AND attempt.api_key_id = admission.api_key_id
+ WHERE (
+ admission.state = 'reconcile' AND outbox.status = 'dead_letter'
+ ) OR (
+ admission.state IN ('orphaned', 'reconcile')
+ AND admission.wallet_subscription_id IS NOT NULL
+ AND outbox.id IS NULL
+ AND EXISTS (
+ SELECT 1 FROM usage_billing_admission_attempts present_attempt
+ WHERE present_attempt.request_id = admission.request_id
+ AND present_attempt.api_key_id = admission.api_key_id
+ )
+ AND NOT EXISTS (
+ SELECT 1 FROM usage_billing_admission_attempts finalized_attempt
+ WHERE finalized_attempt.request_id = admission.request_id
+ AND finalized_attempt.api_key_id = admission.api_key_id
+ AND finalized_attempt.state = 'finalized'
+ )
+ )
+ GROUP BY admission.request_id, admission.api_key_id, admission.state, admission.user_id,
+ admission.subscription_id, admission.wallet_subscription_id, admission.wallet_reserved_usd,
+ outbox.id, outbox.status, outbox.last_error_code, admission.prepared_at,
+ admission.dispatched_at, admission.orphaned_at, admission.reconcile_started_at,
+ admission.updated_at
+ ORDER BY COALESCE(admission.orphaned_at, admission.reconcile_started_at, admission.updated_at),
+ admission.request_id, admission.api_key_id
+ LIMIT $1
+ `, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ items := make([]service.UsageBillingReconciliationCase, 0, limit)
+ for rows.Next() {
+ item, err := scanUsageBillingReconciliationCase(rows)
+ if err != nil {
+ return nil, err
+ }
+ items = append(items, item)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+func scanUsageBillingReconciliationCase(scanner interface{ Scan(...any) error }) (service.UsageBillingReconciliationCase, error) {
+ var (
+ item service.UsageBillingReconciliationCase
+ subscriptionID sql.NullInt64
+ walletID sql.NullInt64
+ outboxID sql.NullInt64
+ dispatchedAt sql.NullTime
+ orphanedAt sql.NullTime
+ reconcileAt sql.NullTime
+ )
+ err := scanner.Scan(
+ &item.RequestID, &item.APIKeyID, &item.State, &item.UserID,
+ &subscriptionID, &walletID, &item.WalletReservedUSD,
+ &outboxID, &item.OutboxStatus, &item.LastErrorCode,
+ &item.PreparedAttempts, &item.DispatchedAttempts, &item.FailedAttempts, &item.FinalizedAttempts,
+ &item.EverDispatchedAttempts,
+ &item.PreparedAt, &dispatchedAt, &orphanedAt, &reconcileAt,
+ )
+ if err != nil {
+ return service.UsageBillingReconciliationCase{}, err
+ }
+ item.SubscriptionID = usageBillingReconciliationInt64Ptr(subscriptionID)
+ item.WalletSubscriptionID = usageBillingReconciliationInt64Ptr(walletID)
+ item.OutboxID = usageBillingReconciliationInt64Ptr(outboxID)
+ item.DispatchedAt = usageBillingReconciliationTimePtr(dispatchedAt)
+ item.OrphanedAt = usageBillingReconciliationTimePtr(orphanedAt)
+ item.ReconcileStartedAt = usageBillingReconciliationTimePtr(reconcileAt)
+ item.AllowedActions = usageBillingReconciliationAllowedActions(item)
+ return item, nil
+}
+
+func (r *usageBillingOutboxRepository) ResolveUsageBillingReconciliation(
+ ctx context.Context,
+ input service.UsageBillingReconciliationResolveInput,
+) error {
+ if r == nil || r.db == nil {
+ return service.ErrUsageBillingReconciliationUnavailable
+ }
+ tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ walletID, err := lookupUsageBillingReconciliationWallet(ctx, tx, input)
+ if errors.Is(err, sql.ErrNoRows) {
+ return service.ErrUsageBillingReconciliationNotFound
+ }
+ if err != nil {
+ return err
+ }
+ var wallet *usageBillingReconciliationWallet
+ if walletID.Valid {
+ wallet, err = lockUsageBillingReconciliationWallet(ctx, tx, walletID.Int64)
+ if err != nil {
+ return err
+ }
+ }
+ admission, err := lockUsageBillingReconciliationAdmission(ctx, tx, input)
+ if errors.Is(err, sql.ErrNoRows) {
+ return service.ErrUsageBillingReconciliationNotFound
+ }
+ if err != nil {
+ return err
+ }
+ if !usageBillingReconciliationWalletIDsMatch(walletID, admission.walletSubscriptionID) ||
+ (wallet != nil && wallet.userID != admission.userID) {
+ return service.ErrUsageBillingReconciliationConflict
+ }
+
+ switch input.Action {
+ case service.UsageBillingReconciliationActionRetryDeadLetter:
+ err = resolveUsageBillingDeadLetterRetry(ctx, tx, input, admission)
+ case service.UsageBillingReconciliationActionReleaseUndelivered:
+ err = resolveUsageBillingUndeliveredRelease(ctx, tx, input, admission, wallet)
+ case service.UsageBillingReconciliationActionSettleDelivered:
+ err = resolveUsageBillingDeliveredSettlement(ctx, tx, input, admission, wallet)
+ default:
+ err = service.ErrUsageBillingReconciliationInvalid
+ }
+ if err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+func resolveUsageBillingDeadLetterRetry(
+ ctx context.Context,
+ tx *sql.Tx,
+ input service.UsageBillingReconciliationResolveInput,
+ admission *usageBillingReconciliationAdmission,
+) error {
+ if admission.state != service.UsageBillingAdmissionStateReconcile {
+ return service.ErrUsageBillingReconciliationConflict
+ }
+ outbox, err := lockUsageBillingReconciliationOutbox(ctx, tx, input)
+ if err != nil {
+ return usageBillingReconciliationConflictOnNoRows(err)
+ }
+ if outbox.status != service.UsageBillingOutboxStatusDeadLetter {
+ return service.ErrUsageBillingReconciliationConflict
+ }
+ result, err := tx.ExecContext(ctx, `
+ UPDATE usage_billing_outbox
+ SET status = 'retry', available_at = NOW(), locked_at = NULL, locked_by = NULL,
+ lease_token = NULL, completed_at = NULL, dead_lettered_at = NULL,
+ result_code = NULL, last_error_code = NULL, last_error = NULL, updated_at = NOW()
+ WHERE id = $1 AND status = 'dead_letter'
+ `, outbox.id)
+ if err != nil {
+ return err
+ }
+ if err := requireUsageBillingReconciliationRow(result); err != nil {
+ return err
+ }
+ result, err = tx.ExecContext(ctx, `
+ UPDATE usage_billing_admissions
+ SET state = 'outbox_pending', outbox_pending_at = COALESCE(outbox_pending_at, NOW()), updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2 AND state = 'reconcile'
+ `, input.RequestID, input.APIKeyID)
+ if err != nil {
+ return err
+ }
+ if err := requireUsageBillingReconciliationRow(result); err != nil {
+ return err
+ }
+ return appendUsageBillingReconciliationAudit(
+ ctx, tx, input, admission.state, service.UsageBillingAdmissionStateOutboxPending,
+ nil, nil, outbox.lastErrorCode,
+ )
+}
+
+func resolveUsageBillingUndeliveredRelease(
+ ctx context.Context,
+ tx *sql.Tx,
+ input service.UsageBillingReconciliationResolveInput,
+ admission *usageBillingReconciliationAdmission,
+ wallet *usageBillingReconciliationWallet,
+) error {
+ if err := validateUsageBillingManualWalletResolution(admission, wallet); err != nil {
+ return err
+ }
+ if admission.state != service.UsageBillingAdmissionStateOrphaned &&
+ admission.state != service.UsageBillingAdmissionStateReconcile {
+ return service.ErrUsageBillingReconciliationConflict
+ }
+ if err := requireNoUsageBillingReconciliationOutbox(ctx, tx, input); err != nil {
+ return err
+ }
+ attempts, err := lockUsageBillingReconciliationAttempts(ctx, tx, input)
+ if err != nil {
+ return err
+ }
+ if len(attempts) == 0 || admission.everDispatched ||
+ usageBillingReconciliationHasFinalizedAttempt(attempts) ||
+ usageBillingReconciliationHasEverDispatchedAttempt(attempts) {
+ return service.ErrUsageBillingReconciliationConflict
+ }
+ if _, err := tx.ExecContext(ctx, `
+ UPDATE usage_billing_admission_attempts
+ SET state = 'failed', failed_at = NOW(), updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2 AND state IN ('prepared', 'dispatched')
+ `, input.RequestID, input.APIKeyID); err != nil {
+ return err
+ }
+ if err := promoteUsageBillingReconciliationState(ctx, tx, input, admission.state); err != nil {
+ return err
+ }
+ result, err := tx.ExecContext(ctx, `
+ UPDATE usage_billing_admissions
+ SET state = 'abandoned', abandoned_at = COALESCE(abandoned_at, NOW()),
+ wallet_released_at = NOW(), updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2 AND state = 'reconcile'
+ AND wallet_consumed_at IS NULL AND wallet_released_at IS NULL
+ `, input.RequestID, input.APIKeyID)
+ if err != nil {
+ return err
+ }
+ if err := requireUsageBillingReconciliationRow(result); err != nil {
+ return err
+ }
+ return appendUsageBillingReconciliationAudit(
+ ctx, tx, input, admission.state, service.UsageBillingAdmissionStateAbandoned,
+ nil, nil, "",
+ )
+}
+
+func resolveUsageBillingDeliveredSettlement(
+ ctx context.Context,
+ tx *sql.Tx,
+ input service.UsageBillingReconciliationResolveInput,
+ admission *usageBillingReconciliationAdmission,
+ wallet *usageBillingReconciliationWallet,
+) error {
+ if err := validateUsageBillingManualWalletResolution(admission, wallet); err != nil {
+ return err
+ }
+ cost := input.Envelope.WalletCost()
+ if (admission.state != service.UsageBillingAdmissionStateOrphaned &&
+ admission.state != service.UsageBillingAdmissionStateReconcile) ||
+ service.ValidateUsageBillingReconciliationWalletCosts(input.Envelope) != nil ||
+ cost > admission.walletReservedUSD+1e-12 ||
+ math.IsNaN(cost) || math.IsInf(cost, 0) {
+ return service.ErrUsageBillingReconciliationConflict
+ }
+ if err := requireNoUsageBillingReconciliationOutbox(ctx, tx, input); err != nil {
+ return err
+ }
+ attempts, err := lockUsageBillingReconciliationAttempts(ctx, tx, input)
+ if err != nil {
+ return err
+ }
+ if !usageBillingReconciliationAttemptCanSettle(attempts, input.AttemptID) {
+ return service.ErrUsageBillingReconciliationConflict
+ }
+ if err := validateUsageBillingAdmissionAtReconciliation(ctx, tx, input.Envelope); err != nil {
+ return usageBillingReconciliationEnvelopeConflict(err)
+ }
+ if _, err := tx.ExecContext(ctx, `
+ UPDATE usage_billing_admission_attempts
+ SET state = 'failed', failed_at = NOW(), updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2 AND attempt_id <> $3
+ AND state IN ('prepared', 'dispatched')
+ `, input.RequestID, input.APIKeyID, input.AttemptID); err != nil {
+ return err
+ }
+ result, err := tx.ExecContext(ctx, `
+ UPDATE usage_billing_admission_attempts
+ SET state = 'finalized', finalized_at = NOW(), updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2 AND attempt_id = $3 AND state = 'dispatched'
+ `, input.RequestID, input.APIKeyID, input.AttemptID)
+ if err != nil {
+ return err
+ }
+ if err := requireUsageBillingReconciliationRow(result); err != nil {
+ return err
+ }
+ if err := insertUsageBillingReconciliationOutbox(ctx, tx, input.Envelope); err != nil {
+ return err
+ }
+ if err := promoteUsageBillingReconciliationState(ctx, tx, input, admission.state); err != nil {
+ return err
+ }
+ result, err = tx.ExecContext(ctx, `
+ UPDATE usage_billing_admissions
+ SET state = 'outbox_pending', outbox_pending_at = COALESCE(outbox_pending_at, NOW()),
+ updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2 AND state = 'reconcile'
+ AND wallet_consumed_at IS NULL AND wallet_released_at IS NULL
+ `, input.RequestID, input.APIKeyID)
+ if err != nil {
+ return err
+ }
+ if err := requireUsageBillingReconciliationRow(result); err != nil {
+ return err
+ }
+ return appendUsageBillingReconciliationAudit(
+ ctx, tx, input, admission.state, service.UsageBillingAdmissionStateOutboxPending,
+ &input.AttemptID, &cost, "",
+ )
+}
+
+func insertUsageBillingReconciliationOutbox(
+ ctx context.Context,
+ tx *sql.Tx,
+ envelope service.UsageBillingEnvelope,
+) error {
+ raw, err := envelope.MarshalJSON()
+ if err != nil || len(raw) > service.UsageBillingEnvelopeMaxBytes {
+ return service.ErrUsageBillingReconciliationInvalid
+ }
+ result, err := tx.ExecContext(ctx, `
+ INSERT INTO usage_billing_outbox (
+ request_id, api_key_id, request_fingerprint, envelope_version, envelope
+ ) VALUES ($1, $2, $3, $4, $5::jsonb)
+ ON CONFLICT (request_id, api_key_id) DO NOTHING
+ `, envelope.RequestID(), envelope.APIKeyID(), envelope.RequestFingerprint(), envelope.Version(), raw)
+ if err != nil {
+ return err
+ }
+ return requireUsageBillingReconciliationRow(result)
+}
+
+func lookupUsageBillingReconciliationWallet(
+ ctx context.Context,
+ tx *sql.Tx,
+ input service.UsageBillingReconciliationResolveInput,
+) (sql.NullInt64, error) {
+ var walletID sql.NullInt64
+ err := tx.QueryRowContext(ctx, `
+ SELECT wallet_subscription_id
+ FROM usage_billing_admissions
+ WHERE request_id = $1 AND api_key_id = $2
+ `, input.RequestID, input.APIKeyID).Scan(&walletID)
+ return walletID, err
+}
+
+func lockUsageBillingReconciliationWallet(
+ ctx context.Context,
+ tx *sql.Tx,
+ walletID int64,
+) (*usageBillingReconciliationWallet, error) {
+ var wallet usageBillingReconciliationWallet
+ var balance sql.NullFloat64
+ err := tx.QueryRowContext(ctx, `
+ SELECT user_id, wallet_balance_usd
+ FROM user_subscriptions
+ WHERE id = $1
+ FOR UPDATE
+ `, walletID).Scan(&wallet.userID, &balance)
+ if errors.Is(err, sql.ErrNoRows) || (err == nil && !balance.Valid) {
+ return nil, service.ErrUsageBillingReconciliationConflict
+ }
+ if err != nil {
+ return nil, err
+ }
+ wallet.balance = balance.Float64
+ return &wallet, nil
+}
+
+func lockUsageBillingReconciliationAdmission(
+ ctx context.Context,
+ tx *sql.Tx,
+ input service.UsageBillingReconciliationResolveInput,
+) (*usageBillingReconciliationAdmission, error) {
+ var admission usageBillingReconciliationAdmission
+ err := tx.QueryRowContext(ctx, `
+ SELECT state, user_id, wallet_subscription_id, wallet_reserved_usd::double precision,
+ dispatched_at IS NOT NULL AS ever_dispatched
+ FROM usage_billing_admissions
+ WHERE request_id = $1 AND api_key_id = $2
+ FOR UPDATE
+ `, input.RequestID, input.APIKeyID).Scan(
+ &admission.state, &admission.userID, &admission.walletSubscriptionID,
+ &admission.walletReservedUSD, &admission.everDispatched,
+ )
+ return &admission, err
+}
+
+func lockUsageBillingReconciliationOutbox(
+ ctx context.Context,
+ tx *sql.Tx,
+ input service.UsageBillingReconciliationResolveInput,
+) (*usageBillingReconciliationOutbox, error) {
+ var outbox usageBillingReconciliationOutbox
+ err := tx.QueryRowContext(ctx, `
+ SELECT id, status, COALESCE(last_error_code, '')
+ FROM usage_billing_outbox
+ WHERE request_id = $1 AND api_key_id = $2
+ FOR UPDATE
+ `, input.RequestID, input.APIKeyID).Scan(&outbox.id, &outbox.status, &outbox.lastErrorCode)
+ return &outbox, err
+}
+
+func requireNoUsageBillingReconciliationOutbox(
+ ctx context.Context,
+ tx *sql.Tx,
+ input service.UsageBillingReconciliationResolveInput,
+) error {
+ _, err := lockUsageBillingReconciliationOutbox(ctx, tx, input)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ return service.ErrUsageBillingReconciliationConflict
+}
+
+func lockUsageBillingReconciliationAttempts(
+ ctx context.Context,
+ tx *sql.Tx,
+ input service.UsageBillingReconciliationResolveInput,
+) ([]usageBillingReconciliationAttempt, error) {
+ rows, err := tx.QueryContext(ctx, `
+ SELECT attempt_id, state,
+ (dispatched_at IS NOT NULL OR state IN ('dispatched', 'finalized')) AS ever_dispatched
+ FROM usage_billing_admission_attempts
+ WHERE request_id = $1 AND api_key_id = $2
+ ORDER BY attempt_id
+ FOR UPDATE
+ `, input.RequestID, input.APIKeyID)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = rows.Close() }()
+ attempts := make([]usageBillingReconciliationAttempt, 0, 2)
+ for rows.Next() {
+ var attempt usageBillingReconciliationAttempt
+ if err := rows.Scan(&attempt.id, &attempt.state, &attempt.everDispatched); err != nil {
+ return nil, err
+ }
+ attempts = append(attempts, attempt)
+ }
+ return attempts, rows.Err()
+}
+
+func promoteUsageBillingReconciliationState(
+ ctx context.Context,
+ tx *sql.Tx,
+ input service.UsageBillingReconciliationResolveInput,
+ state string,
+) error {
+ if state == service.UsageBillingAdmissionStateReconcile {
+ return nil
+ }
+ if state != service.UsageBillingAdmissionStateOrphaned {
+ return service.ErrUsageBillingReconciliationConflict
+ }
+ result, err := tx.ExecContext(ctx, `
+ UPDATE usage_billing_admissions
+ SET state = 'reconcile', reconcile_started_at = COALESCE(reconcile_started_at, NOW()), updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2 AND state = 'orphaned'
+ `, input.RequestID, input.APIKeyID)
+ if err != nil {
+ return err
+ }
+ return requireUsageBillingReconciliationRow(result)
+}
+
+func appendUsageBillingReconciliationAudit(
+ ctx context.Context,
+ tx *sql.Tx,
+ input service.UsageBillingReconciliationResolveInput,
+ fromState string,
+ toState string,
+ attemptID *string,
+ actualCostUSD *float64,
+ previousErrorCode string,
+) error {
+ var attempt any
+ if attemptID != nil {
+ attempt = *attemptID
+ }
+ var cost any
+ if actualCostUSD != nil {
+ cost = *actualCostUSD
+ }
+ var previousError any
+ if strings.TrimSpace(previousErrorCode) != "" {
+ previousError = strings.TrimSpace(previousErrorCode)
+ }
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO usage_billing_reconciliation_audits (
+ request_id, api_key_id, action, operator_id, evidence_ref,
+ attempt_id, actual_cost_usd, from_state, to_state, previous_error_code
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
+ `, input.RequestID, input.APIKeyID, input.Action, input.OperatorID, input.EvidenceRef,
+ attempt, cost, fromState, toState, previousError)
+ return err
+}
+
+func validateUsageBillingManualWalletResolution(
+ admission *usageBillingReconciliationAdmission,
+ wallet *usageBillingReconciliationWallet,
+) error {
+ if admission == nil || wallet == nil || !admission.walletSubscriptionID.Valid ||
+ admission.walletReservedUSD <= 0 || math.IsNaN(admission.walletReservedUSD) ||
+ math.IsInf(admission.walletReservedUSD, 0) {
+ return service.ErrUsageBillingReconciliationConflict
+ }
+ return nil
+}
+
+func usageBillingReconciliationAttemptCanSettle(
+ attempts []usageBillingReconciliationAttempt,
+ attemptID string,
+) bool {
+ selectedDispatched := false
+ for _, attempt := range attempts {
+ if attempt.state == "finalized" {
+ return false
+ }
+ if attempt.id == attemptID && attempt.state == "dispatched" {
+ selectedDispatched = true
+ }
+ }
+ return selectedDispatched
+}
+
+func usageBillingReconciliationHasFinalizedAttempt(attempts []usageBillingReconciliationAttempt) bool {
+ for _, attempt := range attempts {
+ if attempt.state == "finalized" {
+ return true
+ }
+ }
+ return false
+}
+
+func usageBillingReconciliationHasEverDispatchedAttempt(attempts []usageBillingReconciliationAttempt) bool {
+ for _, attempt := range attempts {
+ if attempt.everDispatched {
+ return true
+ }
+ }
+ return false
+}
+
+func usageBillingReconciliationConflictOnNoRows(err error) error {
+ if errors.Is(err, sql.ErrNoRows) {
+ return service.ErrUsageBillingReconciliationConflict
+ }
+ return err
+}
+
+func usageBillingReconciliationEnvelopeConflict(err error) error {
+ for _, conflict := range []error{
+ service.ErrUsageBillingEnvelopeInvalid,
+ service.ErrUsageBillingEnvelopeFingerprintMismatch,
+ service.ErrUsageBillingRequestConflict,
+ service.ErrUsageBillingCrossTenant,
+ service.ErrUsageBillingAdmissionMissing,
+ service.ErrUsageBillingAdmissionLeaseLost,
+ } {
+ if errors.Is(err, conflict) {
+ return service.ErrUsageBillingReconciliationConflict
+ }
+ }
+ return err
+}
+
+func usageBillingReconciliationAllowedActions(item service.UsageBillingReconciliationCase) []string {
+ if item.State == service.UsageBillingAdmissionStateReconcile &&
+ item.OutboxStatus == service.UsageBillingOutboxStatusDeadLetter {
+ return []string{service.UsageBillingReconciliationActionRetryDeadLetter}
+ }
+ if item.WalletSubscriptionID == nil || item.OutboxID != nil || item.FinalizedAttempts != 0 ||
+ item.PreparedAttempts+item.DispatchedAttempts+item.FailedAttempts == 0 {
+ return []string{}
+ }
+ actions := make([]string, 0, 2)
+ if item.EverDispatchedAttempts == 0 && item.DispatchedAt == nil {
+ actions = append(actions, service.UsageBillingReconciliationActionReleaseUndelivered)
+ }
+ if item.DispatchedAttempts > 0 {
+ actions = append(actions, service.UsageBillingReconciliationActionSettleDelivered)
+ }
+ return actions
+}
+
+func requireUsageBillingReconciliationRow(result sql.Result) error {
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if rows != 1 {
+ return service.ErrUsageBillingReconciliationConflict
+ }
+ return nil
+}
+
+func usageBillingReconciliationWalletIDsMatch(left, right sql.NullInt64) bool {
+ return left.Valid == right.Valid && (!left.Valid || left.Int64 == right.Int64)
+}
+
+func usageBillingReconciliationInt64Ptr(value sql.NullInt64) *int64 {
+ if !value.Valid {
+ return nil
+ }
+ result := value.Int64
+ return &result
+}
+
+func usageBillingReconciliationTimePtr(value sql.NullTime) *time.Time {
+ if !value.Valid {
+ return nil
+ }
+ result := value.Time
+ return &result
+}
diff --git a/backend/internal/repository/usage_billing_reconciliation_repo_test.go b/backend/internal/repository/usage_billing_reconciliation_repo_test.go
new file mode 100644
index 00000000000..e18c61bdd07
--- /dev/null
+++ b/backend/internal/repository/usage_billing_reconciliation_repo_test.go
@@ -0,0 +1,496 @@
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "database/sql/driver"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/DATA-DOG/go-sqlmock"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUsageBillingReconciliationRepositoryListsActionableCases(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ now := time.Now().UTC()
+ mock.ExpectQuery("SELECT[\\s\\S]+FROM usage_billing_admissions AS admission").
+ WithArgs(25).
+ WillReturnRows(sqlmock.NewRows([]string{
+ "request_id", "api_key_id", "state", "user_id", "subscription_id",
+ "wallet_subscription_id", "wallet_reserved_usd", "outbox_id", "outbox_status",
+ "last_error_code", "prepared_attempts", "dispatched_attempts", "failed_attempts",
+ "finalized_attempts", "ever_dispatched_attempts", "prepared_at", "dispatched_at", "orphaned_at", "reconcile_started_at",
+ }).AddRow(
+ "req-list", int64(4), service.UsageBillingAdmissionStateReconcile, int64(7), int64(11),
+ int64(11), 1.25, int64(31), service.UsageBillingOutboxStatusDeadLetter,
+ "billing_failed", 0, 1, 2, 0, 1, now, now, nil, now,
+ ))
+
+ items, err := NewUsageBillingOutboxRepository(db).ListUsageBillingReconciliationCases(context.Background(), 25)
+ require.NoError(t, err)
+ require.Len(t, items, 1)
+ require.Equal(t, "req-list", items[0].RequestID)
+ require.Equal(t, service.UsageBillingOutboxStatusDeadLetter, items[0].OutboxStatus)
+ require.NotNil(t, items[0].WalletSubscriptionID)
+ require.Equal(t, int64(11), *items[0].WalletSubscriptionID)
+ require.Equal(t, []string{service.UsageBillingReconciliationActionRetryDeadLetter}, items[0].AllowedActions)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestUsageBillingReconciliationAllowedActionsExcludeUncloseableNonWalletCase(t *testing.T) {
+ require.Empty(t, usageBillingReconciliationAllowedActions(service.UsageBillingReconciliationCase{
+ State: service.UsageBillingAdmissionStateOrphaned, DispatchedAttempts: 1,
+ }))
+
+ walletID := int64(71)
+ require.Equal(t, []string{
+ service.UsageBillingReconciliationActionSettleDelivered,
+ }, usageBillingReconciliationAllowedActions(service.UsageBillingReconciliationCase{
+ State: service.UsageBillingAdmissionStateOrphaned,
+ WalletSubscriptionID: &walletID, DispatchedAttempts: 1, EverDispatchedAttempts: 1,
+ }))
+ require.Equal(t, []string{
+ service.UsageBillingReconciliationActionReleaseUndelivered,
+ }, usageBillingReconciliationAllowedActions(service.UsageBillingReconciliationCase{
+ State: service.UsageBillingAdmissionStateOrphaned,
+ WalletSubscriptionID: &walletID, PreparedAttempts: 1,
+ }))
+ dispatchedAt := time.Now().UTC()
+ require.Empty(t, usageBillingReconciliationAllowedActions(service.UsageBillingReconciliationCase{
+ State: service.UsageBillingAdmissionStateOrphaned,
+ WalletSubscriptionID: &walletID, FailedAttempts: 1, DispatchedAt: &dispatchedAt,
+ }), "admission-level dispatch evidence must fail closed even when attempt history is incomplete")
+}
+
+func TestUsageBillingReconciliationRepositoryRetriesDeadLetterAtomically(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ input := service.UsageBillingReconciliationResolveInput{
+ RequestID: "req-retry", APIKeyID: 4, OperatorID: 9,
+ Action: service.UsageBillingReconciliationActionRetryDeadLetter,
+ EvidenceRef: "incident:HFC-2026-0712",
+ }
+ mock.ExpectBegin()
+ expectReconciliationWalletLookup(mock, input, nil)
+ expectReconciliationAdmissionLock(mock, input, service.UsageBillingAdmissionStateReconcile, 7, nil, 0)
+ mock.ExpectQuery("SELECT id, status,[\\s\\S]+last_error_code[\\s\\S]+FROM usage_billing_outbox").
+ WithArgs(input.RequestID, input.APIKeyID).
+ WillReturnRows(sqlmock.NewRows([]string{"id", "status", "last_error_code"}).
+ AddRow(int64(31), service.UsageBillingOutboxStatusDeadLetter, "billing_failed"))
+ mock.ExpectExec("UPDATE usage_billing_outbox[\\s\\S]+status = 'retry'").
+ WithArgs(int64(31)).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("UPDATE usage_billing_admissions[\\s\\S]+state = 'outbox_pending'").
+ WithArgs(input.RequestID, input.APIKeyID).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ expectReconciliationAudit(mock, input, service.UsageBillingAdmissionStateReconcile,
+ service.UsageBillingAdmissionStateOutboxPending, nil, nil, "billing_failed")
+ mock.ExpectCommit()
+
+ err = NewUsageBillingOutboxRepository(db).ResolveUsageBillingReconciliation(context.Background(), input)
+ require.NoError(t, err)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestUsageBillingReconciliationRepositoryReleasesOnlyNeverDispatchedHold(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ walletID := int64(71)
+ input := service.UsageBillingReconciliationResolveInput{
+ RequestID: "req-release", APIKeyID: 4, OperatorID: 9,
+ Action: service.UsageBillingReconciliationActionReleaseUndelivered,
+ EvidenceRef: "incident:HFC-2026-0712",
+ }
+ mock.ExpectBegin()
+ expectReconciliationWalletLookup(mock, input, &walletID)
+ expectReconciliationWalletLock(mock, walletID, 7, 10)
+ expectReconciliationAdmissionLock(mock, input, service.UsageBillingAdmissionStateOrphaned, 7, &walletID, 1.25)
+ expectNoReconciliationOutbox(mock, input)
+ expectReconciliationAttempts(mock, input,
+ []string{"attempt_id", "state", "ever_dispatched"},
+ []driver.Value{"0123456789abcdef0123456789abcdef", service.UsageBillingAdmissionStatePrepared, false},
+ )
+ mock.ExpectExec("UPDATE usage_billing_admission_attempts[\\s\\S]+state = 'failed'").
+ WithArgs(input.RequestID, input.APIKeyID).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("UPDATE usage_billing_admissions[\\s\\S]+state = 'reconcile'").
+ WithArgs(input.RequestID, input.APIKeyID).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("UPDATE usage_billing_admissions[\\s\\S]+state = 'abandoned'").
+ WithArgs(input.RequestID, input.APIKeyID).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ expectReconciliationAudit(mock, input, service.UsageBillingAdmissionStateOrphaned,
+ service.UsageBillingAdmissionStateAbandoned, nil, nil, "")
+ mock.ExpectCommit()
+
+ err = NewUsageBillingOutboxRepository(db).ResolveUsageBillingReconciliation(context.Background(), input)
+ require.NoError(t, err)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestUsageBillingReconciliationRepositoryRejectsReleaseAfterAnyDispatch(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ walletID := int64(71)
+ input := service.UsageBillingReconciliationResolveInput{
+ RequestID: "req-unknown-delivery", APIKeyID: 4, OperatorID: 9,
+ Action: service.UsageBillingReconciliationActionReleaseUndelivered,
+ EvidenceRef: "incident:HFC-2026-0712",
+ }
+ mock.ExpectBegin()
+ expectReconciliationWalletLookup(mock, input, &walletID)
+ expectReconciliationWalletLock(mock, walletID, 7, 10)
+ expectReconciliationAdmissionLock(mock, input, service.UsageBillingAdmissionStateOrphaned, 7, &walletID, 1.25)
+ expectNoReconciliationOutbox(mock, input)
+ expectReconciliationAttempts(mock, input,
+ []string{"attempt_id", "state", "ever_dispatched"},
+ []driver.Value{"0123456789abcdef0123456789abcdef", service.UsageBillingAttemptStateFailed, true},
+ )
+ mock.ExpectRollback()
+
+ err = NewUsageBillingOutboxRepository(db).ResolveUsageBillingReconciliation(context.Background(), input)
+ require.ErrorIs(t, err, service.ErrUsageBillingReconciliationConflict)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestUsageBillingReconciliationRepositoryRejectsReleaseWhenOnlyAdmissionShowsDispatch(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ walletID := int64(71)
+ input := service.UsageBillingReconciliationResolveInput{
+ RequestID: "req-incomplete-history", APIKeyID: 4, OperatorID: 9,
+ Action: service.UsageBillingReconciliationActionReleaseUndelivered,
+ EvidenceRef: "incident:HFC-2026-0712",
+ }
+ mock.ExpectBegin()
+ expectReconciliationWalletLookup(mock, input, &walletID)
+ expectReconciliationWalletLock(mock, walletID, 7, 10)
+ expectReconciliationAdmissionLock(mock, input, service.UsageBillingAdmissionStateOrphaned, 7, &walletID, 1.25, true)
+ expectNoReconciliationOutbox(mock, input)
+ expectReconciliationAttempts(mock, input,
+ []string{"attempt_id", "state", "ever_dispatched"},
+ []driver.Value{"0123456789abcdef0123456789abcdef", service.UsageBillingAttemptStateFailed, false},
+ )
+ mock.ExpectRollback()
+
+ err = NewUsageBillingOutboxRepository(db).ResolveUsageBillingReconciliation(context.Background(), input)
+ require.ErrorIs(t, err, service.ErrUsageBillingReconciliationConflict)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestUsageBillingReconciliationRepositorySettlesDeliveredRequestAgainstFrozenReservation(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ walletID := int64(71)
+ envelope := reconciliationRepositoryWalletEnvelope(t, "req-settle", 4,
+ "0123456789abcdef0123456789abcdef", 1)
+ input := service.UsageBillingReconciliationResolveInput{
+ RequestID: "req-settle", APIKeyID: 4, OperatorID: 9,
+ Action: service.UsageBillingReconciliationActionSettleDelivered,
+ AttemptID: "0123456789abcdef0123456789abcdef", Envelope: envelope,
+ EvidenceRef: "incident:HFC-2026-0712",
+ }
+ mock.ExpectBegin()
+ expectReconciliationWalletLookup(mock, input, &walletID)
+ expectReconciliationWalletLock(mock, walletID, 7, 0.25)
+ expectReconciliationAdmissionLock(mock, input, service.UsageBillingAdmissionStateReconcile, 7, &walletID, 1.25)
+ expectNoReconciliationOutbox(mock, input)
+ expectReconciliationAttempts(mock, input,
+ []string{"attempt_id", "state", "ever_dispatched"},
+ []driver.Value{input.AttemptID, service.UsageBillingAdmissionStateDispatched, true},
+ []driver.Value{"fedcba9876543210fedcba9876543210", service.UsageBillingAdmissionStatePrepared, false},
+ )
+ expectReconciliationEnvelopeValidation(mock, envelope, service.UsageBillingAdmissionStateReconcile, 1.25)
+ mock.ExpectExec("UPDATE usage_billing_admission_attempts[\\s\\S]+state = 'failed'").
+ WithArgs(input.RequestID, input.APIKeyID, input.AttemptID).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("UPDATE usage_billing_admission_attempts[\\s\\S]+state = 'finalized'").
+ WithArgs(input.RequestID, input.APIKeyID, input.AttemptID).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("INSERT INTO usage_billing_outbox").
+ WithArgs(input.RequestID, input.APIKeyID, envelope.RequestFingerprint(), envelope.Version(), sqlmock.AnyArg()).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("UPDATE usage_billing_admissions[\\s\\S]+state = 'outbox_pending'").
+ WithArgs(input.RequestID, input.APIKeyID).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ cost := envelope.WalletCost()
+ expectReconciliationAudit(mock, input, service.UsageBillingAdmissionStateReconcile,
+ service.UsageBillingAdmissionStateOutboxPending, &input.AttemptID, &cost, "")
+ mock.ExpectCommit()
+
+ err = NewUsageBillingOutboxRepository(db).ResolveUsageBillingReconciliation(context.Background(), input)
+ require.NoError(t, err)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestUsageBillingReconciliationRepositoryRejectsSettlementAboveReservation(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ walletID := int64(71)
+ envelope := reconciliationRepositoryWalletEnvelope(t, "req-over", 4,
+ "0123456789abcdef0123456789abcdef", 1.01)
+ input := service.UsageBillingReconciliationResolveInput{
+ RequestID: "req-over", APIKeyID: 4, OperatorID: 9,
+ Action: service.UsageBillingReconciliationActionSettleDelivered,
+ AttemptID: "0123456789abcdef0123456789abcdef", Envelope: envelope,
+ EvidenceRef: "incident:HFC-2026-0712",
+ }
+ mock.ExpectBegin()
+ expectReconciliationWalletLookup(mock, input, &walletID)
+ expectReconciliationWalletLock(mock, walletID, 7, 10)
+ expectReconciliationAdmissionLock(mock, input, service.UsageBillingAdmissionStateReconcile, 7, &walletID, 1)
+ mock.ExpectRollback()
+
+ err = NewUsageBillingOutboxRepository(db).ResolveUsageBillingReconciliation(context.Background(), input)
+ require.ErrorIs(t, err, service.ErrUsageBillingReconciliationConflict)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestUsageBillingReconciliationRepositoryRejectsZeroCostSettlement(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ walletID := int64(71)
+ envelope := reconciliationRepositoryWalletEnvelope(t, "req-zero", 4,
+ "0123456789abcdef0123456789abcdef", 0)
+ input := service.UsageBillingReconciliationResolveInput{
+ RequestID: "req-zero", APIKeyID: 4, OperatorID: 9,
+ Action: service.UsageBillingReconciliationActionSettleDelivered,
+ AttemptID: "0123456789abcdef0123456789abcdef", Envelope: envelope,
+ EvidenceRef: "incident:HFC-2026-0712",
+ }
+ mock.ExpectBegin()
+ expectReconciliationWalletLookup(mock, input, &walletID)
+ expectReconciliationWalletLock(mock, walletID, 7, 10)
+ expectReconciliationAdmissionLock(mock, input, service.UsageBillingAdmissionStateReconcile, 7, &walletID, 1)
+ mock.ExpectRollback()
+
+ err = NewUsageBillingOutboxRepository(db).ResolveUsageBillingReconciliation(context.Background(), input)
+ require.ErrorIs(t, err, service.ErrUsageBillingReconciliationConflict)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestUsageBillingReconciliationRepositoryRejectsInflatedSecondarySettlementCost(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ walletID := int64(71)
+ envelope := reconciliationRepositoryWalletEnvelopeWithCosts(t, "req-secondary", 4,
+ "0123456789abcdef0123456789abcdef", 1, 99, 1, 0.88)
+ input := service.UsageBillingReconciliationResolveInput{
+ RequestID: "req-secondary", APIKeyID: 4, OperatorID: 9,
+ Action: service.UsageBillingReconciliationActionSettleDelivered,
+ AttemptID: "0123456789abcdef0123456789abcdef", Envelope: envelope,
+ EvidenceRef: "incident:HFC-2026-0712",
+ }
+ mock.ExpectBegin()
+ expectReconciliationWalletLookup(mock, input, &walletID)
+ expectReconciliationWalletLock(mock, walletID, 7, 10)
+ expectReconciliationAdmissionLock(mock, input, service.UsageBillingAdmissionStateReconcile, 7, &walletID, 1)
+ mock.ExpectRollback()
+
+ err = NewUsageBillingOutboxRepository(db).ResolveUsageBillingReconciliation(context.Background(), input)
+ require.ErrorIs(t, err, service.ErrUsageBillingReconciliationConflict)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func expectReconciliationEnvelopeValidation(
+ mock sqlmock.Sqlmock,
+ envelope service.UsageBillingEnvelope,
+ state string,
+ worstCaseCost float64,
+) {
+ ownerToken := "11111111111111111111111111111111"
+ admission, err := service.NewUsageBillingAdmission(service.UsageBillingAdmissionInput{
+ RequestID: envelope.RequestID(), APIKeyID: envelope.APIKeyID(),
+ AuthCacheLocator: envelope.AuthCacheLocator(), UserID: envelope.UserID(),
+ AccountID: envelope.AccountID(), SubscriptionID: envelope.SubscriptionID(),
+ GroupID: envelope.GroupID(), EffectiveBillingGroupID: envelope.EffectiveBillingGroupID(),
+ AccountType: envelope.AccountType(), BillingType: envelope.BillingType(),
+ OwnerToken: ownerToken, AttemptID: envelope.AdmissionAttemptID(),
+ BillingModel: envelope.BillingModel(), RequestPayloadHash: envelope.RequestPayloadHash(),
+ PricingSource: envelope.PricingSource(), PricingRevision: envelope.PricingRevision(),
+ PricingHash: envelope.PricingHash(), RateMultiplier: envelope.RateMultiplier(),
+ WorstCaseCostUSD: worstCaseCost,
+ })
+ if err != nil {
+ panic(err)
+ }
+ walletID := *envelope.SubscriptionID()
+ groupID := *envelope.GroupID()
+ effectiveGroupID := *envelope.EffectiveBillingGroupID()
+ mock.ExpectQuery("SELECT a.binding_fingerprint[\\s\\S]+FROM usage_billing_admissions a").
+ WithArgs(envelope.RequestID(), envelope.APIKeyID(), envelope.AccountID(), envelope.AccountType(), envelope.AdmissionAttemptID()).
+ WillReturnRows(sqlmock.NewRows([]string{
+ "binding_fingerprint", "owner_token", "request_payload_hash", "auth_cache_locator",
+ "user_id", "subscription_id", "group_id", "effective_billing_group_id", "billing_type",
+ "billing_model", "pricing_source", "pricing_revision", "pricing_hash", "rate_multiplier",
+ "worst_case_cost_usd", "alternate_billing_model", "alternate_pricing_source",
+ "alternate_pricing_revision", "alternate_pricing_hash", "alternate_rate_multiplier",
+ "wallet_subscription_id", "state", "attempt_id", "account_id", "account_type", "attempt_state",
+ }).AddRow(
+ admission.Fingerprint(), ownerToken, envelope.RequestPayloadHash(), envelope.AuthCacheLocator(),
+ envelope.UserID(), walletID, groupID, effectiveGroupID, envelope.BillingType(),
+ envelope.BillingModel(), envelope.PricingSource(), envelope.PricingRevision(), envelope.PricingHash(),
+ envelope.RateMultiplier(), worstCaseCost, "", "", "", "", 0,
+ walletID, state, envelope.AdmissionAttemptID(), envelope.AccountID(), envelope.AccountType(),
+ service.UsageBillingAttemptStateDispatched,
+ ))
+}
+
+func reconciliationRepositoryWalletEnvelope(
+ t *testing.T,
+ requestID string,
+ apiKeyID int64,
+ attemptID string,
+ cost float64,
+) service.UsageBillingEnvelope {
+ return reconciliationRepositoryWalletEnvelopeWithCosts(
+ t, requestID, apiKeyID, attemptID, cost, cost, cost, (cost/1.25)*1.1,
+ )
+}
+
+func reconciliationRepositoryWalletEnvelopeWithCosts(
+ t *testing.T,
+ requestID string,
+ apiKeyID int64,
+ attemptID string,
+ cost float64,
+ apiKeyQuotaCost float64,
+ apiKeyRateLimitCost float64,
+ accountQuotaCost float64,
+) service.UsageBillingEnvelope {
+ t.Helper()
+ walletID := int64(71)
+ groupID := int64(44)
+ envelope, err := service.NewUsageBillingEnvelope(service.UsageBillingEnvelopeInput{
+ RequestID: requestID, RequestPayloadHash: strings.Repeat("d", 64),
+ AdmissionAttemptID: attemptID, APIKeyID: apiKeyID,
+ AuthCacheLocator: strings.Repeat("c", 64), UserID: 7, AccountID: 33,
+ SubscriptionID: &walletID, GroupID: &groupID, EffectiveBillingGroupID: &groupID,
+ AccountType: service.AccountTypeAPIKey, BillingModel: "gpt-5.6-sol",
+ BillingType: service.BillingTypeSubscription, InputTokens: 100, OutputTokens: 20,
+ PricingSource: service.PricingSourceBuiltinGPT56,
+ PricingRevision: service.GPT56PricingRevision, PricingHash: strings.Repeat("a", 64),
+ RateMultiplier: 1.25, AccountRateMultiplier: 1.1,
+ WalletCost: cost, APIKeyQuotaCost: apiKeyQuotaCost, APIKeyRateLimitCost: apiKeyRateLimitCost,
+ AccountQuotaCost: accountQuotaCost, ActualCost: cost, TotalCost: cost / 1.25,
+ })
+ require.NoError(t, err)
+ return envelope
+}
+
+func expectReconciliationWalletLookup(
+ mock sqlmock.Sqlmock,
+ input service.UsageBillingReconciliationResolveInput,
+ walletID *int64,
+) {
+ rows := sqlmock.NewRows([]string{"wallet_subscription_id"})
+ if walletID == nil {
+ rows.AddRow(nil)
+ } else {
+ rows.AddRow(*walletID)
+ }
+ mock.ExpectQuery("SELECT wallet_subscription_id[\\s\\S]+FROM usage_billing_admissions").
+ WithArgs(input.RequestID, input.APIKeyID).
+ WillReturnRows(rows)
+}
+
+func expectReconciliationWalletLock(mock sqlmock.Sqlmock, walletID, userID int64, balance float64) {
+ mock.ExpectQuery("SELECT user_id, wallet_balance_usd[\\s\\S]+FROM user_subscriptions").
+ WithArgs(walletID).
+ WillReturnRows(sqlmock.NewRows([]string{"user_id", "wallet_balance_usd"}).AddRow(userID, balance))
+}
+
+func expectReconciliationAdmissionLock(
+ mock sqlmock.Sqlmock,
+ input service.UsageBillingReconciliationResolveInput,
+ state string,
+ userID int64,
+ walletID *int64,
+ reservedUSD float64,
+ everDispatched ...bool,
+) {
+ var wallet any
+ if walletID != nil {
+ wallet = *walletID
+ }
+ wasDispatched := false
+ if len(everDispatched) > 0 {
+ wasDispatched = everDispatched[0]
+ }
+ mock.ExpectQuery("SELECT state, user_id, wallet_subscription_id, wallet_reserved_usd[\\s\\S]+dispatched_at IS NOT NULL[\\s\\S]+FROM usage_billing_admissions").
+ WithArgs(input.RequestID, input.APIKeyID).
+ WillReturnRows(sqlmock.NewRows([]string{"state", "user_id", "wallet_subscription_id", "wallet_reserved_usd", "ever_dispatched"}).
+ AddRow(state, userID, wallet, reservedUSD, wasDispatched))
+}
+
+func expectNoReconciliationOutbox(mock sqlmock.Sqlmock, input service.UsageBillingReconciliationResolveInput) {
+ mock.ExpectQuery("SELECT id, status,[\\s\\S]+last_error_code[\\s\\S]+FROM usage_billing_outbox").
+ WithArgs(input.RequestID, input.APIKeyID).
+ WillReturnError(sql.ErrNoRows)
+}
+
+func expectReconciliationAttempts(
+ mock sqlmock.Sqlmock,
+ input service.UsageBillingReconciliationResolveInput,
+ columns []string,
+ values ...[]driver.Value,
+) {
+ rows := sqlmock.NewRows(columns)
+ for _, row := range values {
+ rows.AddRow(row...)
+ }
+ mock.ExpectQuery("SELECT attempt_id, state,[\\s\\S]+dispatched_at IS NOT NULL[\\s\\S]+FROM usage_billing_admission_attempts").
+ WithArgs(input.RequestID, input.APIKeyID).
+ WillReturnRows(rows)
+}
+
+func expectReconciliationAudit(
+ mock sqlmock.Sqlmock,
+ input service.UsageBillingReconciliationResolveInput,
+ fromState string,
+ toState string,
+ attemptID *string,
+ actualCostUSD *float64,
+ previousErrorCode string,
+) {
+ var attempt any
+ if attemptID != nil {
+ attempt = *attemptID
+ }
+ var cost any
+ if actualCostUSD != nil {
+ cost = *actualCostUSD
+ }
+ var previousError any
+ if previousErrorCode != "" {
+ previousError = previousErrorCode
+ }
+ mock.ExpectExec("INSERT INTO usage_billing_reconciliation_audits").
+ WithArgs(
+ input.RequestID, input.APIKeyID, input.Action, input.OperatorID, input.EvidenceRef,
+ attempt, cost, fromState, toState, previousError,
+ ).
+ WillReturnResult(sqlmock.NewResult(1, 1))
+}
diff --git a/backend/internal/repository/usage_billing_repo.go b/backend/internal/repository/usage_billing_repo.go
index 62f48b58f4c..a2aa3c5df27 100644
--- a/backend/internal/repository/usage_billing_repo.go
+++ b/backend/internal/repository/usage_billing_repo.go
@@ -106,14 +106,39 @@ func (r *usageBillingRepository) claimUsageBillingKey(ctx context.Context, tx *s
}
func (r *usageBillingRepository) applyUsageBillingEffects(ctx context.Context, tx *sql.Tx, cmd *service.UsageBillingCommand, result *service.UsageBillingApplyResult) error {
+ walletAdmissionHandled, walletBalance, walletInsufficient, err := settleUsageBillingWalletAdmission(ctx, tx, cmd)
+ if err != nil {
+ return err
+ }
+ if walletAdmissionHandled {
+ result.NewWalletBalance = &walletBalance
+ result.WalletInsufficient = walletInsufficient
+ }
+
if cmd.SubscriptionCost > 0 && cmd.SubscriptionID != nil {
- if err := incrementUsageBillingSubscription(ctx, tx, *cmd.SubscriptionID, cmd.SubscriptionCost); err != nil {
+ if err := incrementUsageBillingSubscription(ctx, tx, *cmd.SubscriptionID, cmd.SubscriptionCost, cmd.BindingsFrozen); err != nil {
return err
}
}
+ // 钱包模式 (v4) 扣款:FOR UPDATE 锁住 user_subscriptions 行扣减 wallet_balance_usd
+ // 并落 ledger 流水。SubscriptionCost 与 WalletCost 由 buildUsageBillingCommand 保证互斥。
+ if !walletAdmissionHandled && cmd.WalletCost > 0 && cmd.SubscriptionID != nil {
+ newBalance, insufficient, err := deductUsageBillingWallet(ctx, tx, *cmd.SubscriptionID, cmd.WalletCost, cmd.BindingsFrozen)
+ if err != nil {
+ return err
+ }
+ if insufficient {
+ // 余额不足发生在上游响应之后,仍然完整结算为负数债务;否则保留
+ // 正余额会让相同请求继续通过预检并无限免费调用。下一次预检看到
+ // balance <= 0 会直接 402,flag 同时供告警/运营对账使用。
+ result.WalletInsufficient = true
+ }
+ result.NewWalletBalance = &newBalance
+ }
+
if cmd.BalanceCost > 0 {
- newBalance, err := deductUsageBillingBalance(ctx, tx, cmd.UserID, cmd.BalanceCost)
+ newBalance, err := deductUsageBillingBalance(ctx, tx, cmd.UserID, cmd.BalanceCost, cmd.BindingsFrozen)
if err != nil {
return err
}
@@ -121,7 +146,7 @@ func (r *usageBillingRepository) applyUsageBillingEffects(ctx context.Context, t
}
if cmd.APIKeyQuotaCost > 0 {
- exhausted, err := incrementUsageBillingAPIKeyQuota(ctx, tx, cmd.APIKeyID, cmd.APIKeyQuotaCost)
+ exhausted, err := incrementUsageBillingAPIKeyQuota(ctx, tx, cmd.APIKeyID, cmd.APIKeyQuotaCost, cmd.BindingsFrozen)
if err != nil {
return err
}
@@ -129,23 +154,207 @@ func (r *usageBillingRepository) applyUsageBillingEffects(ctx context.Context, t
}
if cmd.APIKeyRateLimitCost > 0 {
- if err := incrementUsageBillingAPIKeyRateLimit(ctx, tx, cmd.APIKeyID, cmd.APIKeyRateLimitCost); err != nil {
+ if err := incrementUsageBillingAPIKeyRateLimit(ctx, tx, cmd.APIKeyID, cmd.APIKeyRateLimitCost, cmd.BindingsFrozen); err != nil {
return err
}
}
if cmd.AccountQuotaCost > 0 && (strings.EqualFold(cmd.AccountType, service.AccountTypeAPIKey) || strings.EqualFold(cmd.AccountType, service.AccountTypeBedrock)) {
- quotaState, err := incrementUsageBillingAccountQuota(ctx, tx, cmd.AccountID, cmd.AccountQuotaCost)
+ quotaState, err := incrementUsageBillingAccountQuota(ctx, tx, cmd.AccountID, cmd.AccountQuotaCost, cmd.BindingsFrozen)
if err != nil {
return err
}
result.QuotaState = quotaState
}
+ return settleUsageBillingNonWalletAdmission(ctx, tx, cmd)
+}
+
+// settleUsageBillingWalletAdmission consumes the pre-upstream hold in the same
+// transaction as the authoritative wallet debit. Missing admissions are
+// treated as legacy events created before admission enforcement was enabled;
+// production Enqueue requires an admission before it can create a new outbox
+// event. A present wallet admission is always fail-closed.
+func settleUsageBillingWalletAdmission(
+ ctx context.Context,
+ tx *sql.Tx,
+ cmd *service.UsageBillingCommand,
+) (handled bool, newBalance float64, insufficient bool, err error) {
+ if cmd == nil || !cmd.BindingsFrozen {
+ return false, 0, false, nil
+ }
+
+ var walletSubscriptionID sql.NullInt64
+ err = tx.QueryRowContext(ctx, `
+ SELECT wallet_subscription_id
+ FROM usage_billing_admissions
+ WHERE request_id = $1 AND api_key_id = $2
+ `, cmd.RequestID, cmd.APIKeyID).Scan(&walletSubscriptionID)
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, 0, false, nil
+ }
+ if err != nil {
+ return false, 0, false, err
+ }
+ if !walletSubscriptionID.Valid {
+ if cmd.WalletCost > 0 {
+ return false, 0, false, service.ErrUsageBillingRequestConflict
+ }
+ return false, 0, false, nil
+ }
+ if cmd.SubscriptionID == nil || *cmd.SubscriptionID != walletSubscriptionID.Int64 {
+ return false, 0, false, service.ErrUsageBillingRequestConflict
+ }
+
+ var balance sql.NullFloat64
+ err = tx.QueryRowContext(ctx, `
+ SELECT wallet_balance_usd
+ FROM user_subscriptions
+ WHERE id = $1 AND ($2 OR deleted_at IS NULL)
+ FOR UPDATE
+ `, walletSubscriptionID.Int64, cmd.BindingsFrozen).Scan(&balance)
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, 0, false, service.ErrSubscriptionNotFound
+ }
+ if err != nil {
+ return false, 0, false, err
+ }
+ if !balance.Valid {
+ return false, 0, false, service.ErrSubscriptionNotFound
+ }
+
+ var (
+ state string
+ reservedUSD float64
+ consumedAt sql.NullTime
+ releasedAt sql.NullTime
+ lockedWallet sql.NullInt64
+ )
+ err = tx.QueryRowContext(ctx, `
+ SELECT state, wallet_subscription_id, wallet_reserved_usd,
+ wallet_consumed_at, wallet_released_at
+ FROM usage_billing_admissions
+ WHERE request_id = $1 AND api_key_id = $2
+ FOR UPDATE
+ `, cmd.RequestID, cmd.APIKeyID).Scan(
+ &state, &lockedWallet, &reservedUSD, &consumedAt, &releasedAt,
+ )
+ if errors.Is(err, sql.ErrNoRows) {
+ return false, 0, false, service.ErrUsageBillingAdmissionMissing
+ }
+ if err != nil {
+ return false, 0, false, err
+ }
+ if state != service.UsageBillingAdmissionStateOutboxPending ||
+ !lockedWallet.Valid || lockedWallet.Int64 != walletSubscriptionID.Int64 ||
+ reservedUSD <= 0 || consumedAt.Valid || releasedAt.Valid {
+ return false, 0, false, service.ErrUsageBillingAdmissionLeaseLost
+ }
+ if cmd.WalletCost > reservedUSD+1e-12 {
+ return false, 0, false, service.ErrUsageBillingRequestConflict
+ }
+
+ newBalance = balance.Float64
+ if cmd.WalletCost > 0 {
+ insufficient = balance.Float64 < cmd.WalletCost
+ newBalance = balance.Float64 - cmd.WalletCost
+ if _, err = tx.ExecContext(ctx, `
+ UPDATE user_subscriptions
+ SET wallet_balance_usd = $1, updated_at = NOW()
+ WHERE id = $2
+ `, newBalance, walletSubscriptionID.Int64); err != nil {
+ return false, 0, false, err
+ }
+ if _, err = tx.ExecContext(ctx, `
+ INSERT INTO subscription_wallet_ledger
+ (subscription_id, delta_usd, balance_after, reason, usage_log_id, operator_id, notes)
+ VALUES ($1, $2, $3, 'usage', NULL, NULL, NULL)
+ `, walletSubscriptionID.Int64, -cmd.WalletCost, newBalance); err != nil {
+ return false, 0, false, err
+ }
+ }
+
+ result, err := tx.ExecContext(ctx, `
+ UPDATE usage_billing_admissions
+ SET state = 'settled',
+ settled_at = COALESCE(settled_at, NOW()),
+ wallet_consumed_at = NOW(),
+ updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2
+ AND state = 'outbox_pending'
+ AND wallet_consumed_at IS NULL
+ AND wallet_released_at IS NULL
+ `, cmd.RequestID, cmd.APIKeyID)
+ if err != nil {
+ return false, 0, false, err
+ }
+ updated, err := result.RowsAffected()
+ if err != nil {
+ return false, 0, false, err
+ }
+ if updated != 1 {
+ return false, 0, false, service.ErrUsageBillingAdmissionLeaseLost
+ }
+ return true, newBalance, insufficient, nil
+}
+
+// settleUsageBillingNonWalletAdmission closes the admission in the same
+// transaction as all balance, quota and usage effects. Events written before
+// admission enforcement intentionally remain replayable without a base row.
+func settleUsageBillingNonWalletAdmission(
+ ctx context.Context,
+ tx *sql.Tx,
+ cmd *service.UsageBillingCommand,
+) error {
+ if cmd == nil || !cmd.BindingsFrozen {
+ return nil
+ }
+ var (
+ state string
+ walletSubscriptionID sql.NullInt64
+ )
+ err := tx.QueryRowContext(ctx, `
+ SELECT state, wallet_subscription_id
+ FROM usage_billing_admissions
+ WHERE request_id = $1 AND api_key_id = $2
+ FOR UPDATE
+ `, cmd.RequestID, cmd.APIKeyID).Scan(&state, &walletSubscriptionID)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if walletSubscriptionID.Valid {
+ if state == service.UsageBillingAdmissionStateSettled {
+ return nil
+ }
+ return service.ErrUsageBillingAdmissionLeaseLost
+ }
+ if state != service.UsageBillingAdmissionStateOutboxPending {
+ return service.ErrUsageBillingAdmissionLeaseLost
+ }
+ result, err := tx.ExecContext(ctx, `
+ UPDATE usage_billing_admissions
+ SET state = 'settled', settled_at = COALESCE(settled_at, NOW()), updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2
+ AND state = 'outbox_pending'
+ AND wallet_subscription_id IS NULL
+ `, cmd.RequestID, cmd.APIKeyID)
+ if err != nil {
+ return err
+ }
+ updated, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if updated != 1 {
+ return service.ErrUsageBillingAdmissionLeaseLost
+ }
return nil
}
-func incrementUsageBillingSubscription(ctx context.Context, tx *sql.Tx, subscriptionID int64, costUSD float64) error {
+func incrementUsageBillingSubscription(ctx context.Context, tx *sql.Tx, subscriptionID int64, costUSD float64, bindingsFrozen bool) error {
const updateSQL = `
UPDATE user_subscriptions us
SET
@@ -155,11 +364,11 @@ func incrementUsageBillingSubscription(ctx context.Context, tx *sql.Tx, subscrip
updated_at = NOW()
FROM groups g
WHERE us.id = $2
- AND us.deleted_at IS NULL
+ AND ($3 OR us.deleted_at IS NULL)
AND us.group_id = g.id
- AND g.deleted_at IS NULL
+ AND ($3 OR g.deleted_at IS NULL)
`
- res, err := tx.ExecContext(ctx, updateSQL, costUSD, subscriptionID)
+ res, err := tx.ExecContext(ctx, updateSQL, costUSD, subscriptionID, bindingsFrozen)
if err != nil {
return err
}
@@ -173,15 +382,64 @@ func incrementUsageBillingSubscription(ctx context.Context, tx *sql.Tx, subscrip
return service.ErrSubscriptionNotFound
}
-func deductUsageBillingBalance(ctx context.Context, tx *sql.Tx, userID int64, amount float64) (float64, error) {
+// deductUsageBillingWallet 钱包模式 (v4) 在共享 billing 事务内扣款 + 落 ledger。
+//
+// 返回值:
+// - newBalance: 完整结算后的余额;可能为负,表示已交付请求产生的欠费。
+// - insufficient: 扣款前 balance < cost 时为 true,供调用方告警。
+//
+// 不会返回 service.ErrWalletInsufficient — 这一层不拒事务,把决策权交给调用方
+// (usage_logs 已经写入,钱包扣款失败应当作可观察异常而非阻断 billing 提交)。
+func deductUsageBillingWallet(ctx context.Context, tx *sql.Tx, subscriptionID int64, costUSD float64, bindingsFrozen bool) (float64, bool, error) {
+ var balance sql.NullFloat64
+ err := tx.QueryRowContext(ctx, `
+ SELECT wallet_balance_usd
+ FROM user_subscriptions
+ WHERE id = $1 AND ($2 OR deleted_at IS NULL)
+ FOR UPDATE
+ `, subscriptionID, bindingsFrozen).Scan(&balance)
+ if errors.Is(err, sql.ErrNoRows) {
+ return 0, false, service.ErrSubscriptionNotFound
+ }
+ if err != nil {
+ return 0, false, err
+ }
+ if !balance.Valid {
+ // wallet_balance_usd IS NULL → 老的 group 订阅,不该走到这条路径。
+ // 上游 buildUsageBillingCommand 已经按 IsWalletMode 分流;这里兜底返错。
+ return 0, false, service.ErrSubscriptionNotFound
+ }
+
+ insufficient := balance.Float64 < costUSD
+ newBalance := balance.Float64 - costUSD
+ if _, err := tx.ExecContext(ctx, `
+ UPDATE user_subscriptions
+ SET wallet_balance_usd = $1, updated_at = NOW()
+ WHERE id = $2
+ `, newBalance, subscriptionID); err != nil {
+ return 0, false, err
+ }
+
+ var notes sql.NullString
+ if _, err := tx.ExecContext(ctx, `
+ INSERT INTO subscription_wallet_ledger
+ (subscription_id, delta_usd, balance_after, reason, usage_log_id, operator_id, notes)
+ VALUES ($1, $2, $3, 'usage', NULL, NULL, $4)
+ `, subscriptionID, -costUSD, newBalance, notes); err != nil {
+ return 0, false, err
+ }
+ return newBalance, insufficient, nil
+}
+
+func deductUsageBillingBalance(ctx context.Context, tx *sql.Tx, userID int64, amount float64, bindingsFrozen bool) (float64, error) {
var newBalance float64
err := tx.QueryRowContext(ctx, `
UPDATE users
SET balance = balance - $1,
updated_at = NOW()
- WHERE id = $2 AND deleted_at IS NULL
+ WHERE id = $2 AND ($3 OR deleted_at IS NULL)
RETURNING balance
- `, amount, userID).Scan(&newBalance)
+ `, amount, userID, bindingsFrozen).Scan(&newBalance)
if errors.Is(err, sql.ErrNoRows) {
return 0, service.ErrUserNotFound
}
@@ -191,7 +449,7 @@ func deductUsageBillingBalance(ctx context.Context, tx *sql.Tx, userID int64, am
return newBalance, nil
}
-func incrementUsageBillingAPIKeyQuota(ctx context.Context, tx *sql.Tx, apiKeyID int64, amount float64) (bool, error) {
+func incrementUsageBillingAPIKeyQuota(ctx context.Context, tx *sql.Tx, apiKeyID int64, amount float64, bindingsFrozen bool) (bool, error) {
var exhausted bool
err := tx.QueryRowContext(ctx, `
UPDATE api_keys
@@ -205,9 +463,9 @@ func incrementUsageBillingAPIKeyQuota(ctx context.Context, tx *sql.Tx, apiKeyID
ELSE status
END,
updated_at = NOW()
- WHERE id = $2 AND deleted_at IS NULL
+ WHERE id = $2 AND ($5 OR deleted_at IS NULL)
RETURNING quota > 0 AND quota_used >= quota AND quota_used - $1 < quota
- `, amount, apiKeyID, service.StatusAPIKeyActive, service.StatusAPIKeyQuotaExhausted).Scan(&exhausted)
+ `, amount, apiKeyID, service.StatusAPIKeyActive, service.StatusAPIKeyQuotaExhausted, bindingsFrozen).Scan(&exhausted)
if errors.Is(err, sql.ErrNoRows) {
return false, service.ErrAPIKeyNotFound
}
@@ -217,7 +475,7 @@ func incrementUsageBillingAPIKeyQuota(ctx context.Context, tx *sql.Tx, apiKeyID
return exhausted, nil
}
-func incrementUsageBillingAPIKeyRateLimit(ctx context.Context, tx *sql.Tx, apiKeyID int64, cost float64) error {
+func incrementUsageBillingAPIKeyRateLimit(ctx context.Context, tx *sql.Tx, apiKeyID int64, cost float64, bindingsFrozen bool) error {
res, err := tx.ExecContext(ctx, `
UPDATE api_keys SET
usage_5h = CASE WHEN window_5h_start IS NOT NULL AND window_5h_start + INTERVAL '5 hours' <= NOW() THEN $1 ELSE usage_5h + $1 END,
@@ -227,8 +485,8 @@ func incrementUsageBillingAPIKeyRateLimit(ctx context.Context, tx *sql.Tx, apiKe
window_1d_start = CASE WHEN window_1d_start IS NULL OR window_1d_start + INTERVAL '24 hours' <= NOW() THEN date_trunc('day', NOW()) ELSE window_1d_start END,
window_7d_start = CASE WHEN window_7d_start IS NULL OR window_7d_start + INTERVAL '7 days' <= NOW() THEN date_trunc('day', NOW()) ELSE window_7d_start END,
updated_at = NOW()
- WHERE id = $2 AND deleted_at IS NULL
- `, cost, apiKeyID)
+ WHERE id = $2 AND ($3 OR deleted_at IS NULL)
+ `, cost, apiKeyID, bindingsFrozen)
if err != nil {
return err
}
@@ -242,7 +500,7 @@ func incrementUsageBillingAPIKeyRateLimit(ctx context.Context, tx *sql.Tx, apiKe
return nil
}
-func incrementUsageBillingAccountQuota(ctx context.Context, tx *sql.Tx, accountID int64, amount float64) (*service.AccountQuotaState, error) {
+func incrementUsageBillingAccountQuota(ctx context.Context, tx *sql.Tx, accountID int64, amount float64, bindingsFrozen bool) (*service.AccountQuotaState, error) {
rows, err := tx.QueryContext(ctx,
`UPDATE accounts SET extra = (
COALESCE(extra, '{}'::jsonb)
@@ -278,7 +536,7 @@ func incrementUsageBillingAccountQuota(ctx context.Context, tx *sql.Tx, accountI
ELSE '{}'::jsonb END
ELSE '{}'::jsonb END
), updated_at = NOW()
- WHERE id = $2 AND deleted_at IS NULL
+ WHERE id = $2 AND ($3 OR deleted_at IS NULL)
RETURNING
COALESCE((extra->>'quota_used')::numeric, 0),
COALESCE((extra->>'quota_limit')::numeric, 0),
@@ -286,7 +544,7 @@ func incrementUsageBillingAccountQuota(ctx context.Context, tx *sql.Tx, accountI
COALESCE((extra->>'quota_daily_limit')::numeric, 0),
COALESCE((extra->>'quota_weekly_used')::numeric, 0),
COALESCE((extra->>'quota_weekly_limit')::numeric, 0)`,
- amount, accountID)
+ amount, accountID, bindingsFrozen)
if err != nil {
return nil, err
}
diff --git a/backend/internal/repository/usage_billing_repo_integration_test.go b/backend/internal/repository/usage_billing_repo_integration_test.go
index e8d4d32707f..c3471a94e28 100644
--- a/backend/internal/repository/usage_billing_repo_integration_test.go
+++ b/backend/internal/repository/usage_billing_repo_integration_test.go
@@ -5,10 +5,12 @@ package repository
import (
"context"
"fmt"
+ "io/fs"
"strings"
"testing"
"time"
+ "github.com/Wei-Shaw/sub2api/migrations"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
@@ -102,7 +104,7 @@ func TestUsageBillingRepositoryApply_DeduplicatesSubscriptionBilling(t *testing.
})
subscription := mustCreateSubscription(t, client, &service.UserSubscription{
UserID: user.ID,
- GroupID: group.ID,
+ GroupID: repositoryInt64Ptr(group.ID),
})
requestID := uuid.NewString()
@@ -128,6 +130,60 @@ func TestUsageBillingRepositoryApply_DeduplicatesSubscriptionBilling(t *testing.
require.InDelta(t, 2.5, dailyUsage, 0.000001)
}
+func TestUsageBillingRepositoryApply_WalletOverdraftIsFullySettledAndDeduplicated(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ repo := NewUsageBillingRepository(client, integrationDB)
+
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("usage-billing-wallet-overdraft-%s@example.com", uuid.NewString()),
+ PasswordHash: "hash",
+ })
+ wallet := mustCreateCreditsWallet(t, client, user.ID, 0.5)
+ apiKey := mustCreateApiKey(t, client, &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-wallet-overdraft-" + uuid.NewString(),
+ Name: service.WalletUniversalAPIKeyName,
+ Purpose: service.APIKeyPurposeWalletUniversal,
+ })
+
+ requestID := "wallet-overdraft-" + uuid.NewString()
+ cmd := &service.UsageBillingCommand{
+ RequestID: requestID,
+ APIKeyID: apiKey.ID,
+ UserID: user.ID,
+ SubscriptionID: &wallet.ID,
+ WalletCost: 1.0,
+ }
+ result, err := repo.Apply(ctx, cmd)
+ require.NoError(t, err)
+ require.True(t, result.Applied)
+ require.True(t, result.WalletInsufficient)
+ require.NotNil(t, result.NewWalletBalance)
+ require.InDelta(t, -0.5, *result.NewWalletBalance, 0.000001)
+
+ duplicate, err := repo.Apply(ctx, cmd)
+ require.NoError(t, err)
+ require.False(t, duplicate.Applied)
+
+ var balance, ledgerSum, usageDelta, usageBalanceAfter float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT wallet_balance_usd FROM user_subscriptions WHERE id=$1
+ `, wallet.ID).Scan(&balance))
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COALESCE(SUM(delta_usd),0) FROM subscription_wallet_ledger WHERE subscription_id=$1
+ `, wallet.ID).Scan(&ledgerSum))
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT delta_usd, balance_after
+ FROM subscription_wallet_ledger
+ WHERE subscription_id=$1 AND reason='usage'
+ `, wallet.ID).Scan(&usageDelta, &usageBalanceAfter))
+ require.InDelta(t, -0.5, balance, 0.000001)
+ require.InDelta(t, -0.5, ledgerSum, 0.000001)
+ require.InDelta(t, -1.0, usageDelta, 0.000001)
+ require.InDelta(t, -0.5, usageBalanceAfter, 0.000001)
+}
+
func TestUsageBillingRepositoryApply_RequestFingerprintConflict(t *testing.T) {
ctx := context.Background()
client := testEntClient(t)
@@ -365,3 +421,378 @@ func TestUsageBillingRepositoryApply_DeduplicatesAgainstArchivedKey(t *testing.T
require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT balance FROM users WHERE id = $1", user.ID).Scan(&balance))
require.InDelta(t, 98.75, balance, 0.000001)
}
+
+func TestUsageLogInsert_WritesBillingUsageLedgerEntry(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("usage-ledger-user-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hash",
+ })
+ apiKey := mustCreateApiKey(t, client, &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-usage-ledger-" + uuid.NewString(),
+ Name: "billing-ledger",
+ })
+ account := mustCreateAccount(t, client, &service.Account{
+ Name: "usage-ledger-account-" + uuid.NewString(),
+ Type: service.AccountTypeAPIKey,
+ })
+
+ var usageLogID int64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ INSERT INTO usage_logs (
+ user_id, api_key_id, account_id, request_id, model,
+ input_tokens, output_tokens, total_cost, actual_cost, billing_type, created_at
+ )
+ VALUES ($1, $2, $3, $4, 'gpt-5.4-mini', 12, 4, 1.25, 0.75, $5, NOW())
+ RETURNING id
+ `, user.ID, apiKey.ID, account.ID, "usage-ledger-"+uuid.NewString(), service.BillingTypeBalance).Scan(&usageLogID))
+
+ var (
+ ledgerUserID int64
+ ledgerAPIKeyID int64
+ ledgerType int16
+ ledgerApplied bool
+ ledgerDeltaUSD float64
+ ledgerEntryCount int
+ )
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT user_id, api_key_id, billing_type, applied, delta_usd::double precision
+ FROM billing_usage_entries
+ WHERE usage_log_id = $1
+ `, usageLogID).Scan(&ledgerUserID, &ledgerAPIKeyID, &ledgerType, &ledgerApplied, &ledgerDeltaUSD))
+ require.Equal(t, user.ID, ledgerUserID)
+ require.Equal(t, apiKey.ID, ledgerAPIKeyID)
+ require.Equal(t, int16(service.BillingTypeBalance), ledgerType)
+ require.True(t, ledgerApplied)
+ require.InDelta(t, 0.75, ledgerDeltaUSD, 0.000001)
+
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM billing_usage_entries WHERE usage_log_id = $1
+ `, usageLogID).Scan(&ledgerEntryCount))
+ require.Equal(t, 1, ledgerEntryCount)
+}
+
+func TestUsageBillingLedgerMigration_BackfillsMissingEntryIdempotently(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("usage-ledger-backfill-user-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hash",
+ })
+ apiKey := mustCreateApiKey(t, client, &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-usage-ledger-backfill-" + uuid.NewString(),
+ Name: "billing-ledger-backfill",
+ })
+ account := mustCreateAccount(t, client, &service.Account{
+ Name: "usage-ledger-backfill-account-" + uuid.NewString(),
+ Type: service.AccountTypeAPIKey,
+ })
+
+ var usageLogID int64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ INSERT INTO usage_logs (
+ user_id, api_key_id, account_id, request_id, model,
+ input_tokens, output_tokens, total_cost, actual_cost, billing_type, created_at
+ )
+ VALUES ($1, $2, $3, $4, 'gpt-5.4-mini', 12, 4, 1.25, 0.75, $5, NOW())
+ RETURNING id
+ `, user.ID, apiKey.ID, account.ID, "usage-ledger-backfill-"+uuid.NewString(), service.BillingTypeBalance).Scan(&usageLogID))
+
+ _, err := integrationDB.ExecContext(ctx, "DELETE FROM billing_usage_entries WHERE usage_log_id = $1", usageLogID)
+ require.NoError(t, err)
+
+ migrationSQL, err := fs.ReadFile(migrations.FS, "134_usage_billing_ledger_trigger.sql")
+ require.NoError(t, err)
+ _, err = integrationDB.ExecContext(ctx, string(migrationSQL))
+ require.NoError(t, err)
+ _, err = integrationDB.ExecContext(ctx, string(migrationSQL))
+ require.NoError(t, err)
+
+ var (
+ ledgerEntryCount int
+ ledgerDeltaUSD float64
+ )
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*), COALESCE(SUM(delta_usd), 0)::double precision
+ FROM billing_usage_entries
+ WHERE usage_log_id = $1
+ `, usageLogID).Scan(&ledgerEntryCount, &ledgerDeltaUSD))
+ require.Equal(t, 1, ledgerEntryCount)
+ require.InDelta(t, 0.75, ledgerDeltaUSD, 0.000001)
+}
+
+func TestBillingUsageEntry_PostsBalancedDoubleEntryLedger(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("double-entry-usage-user-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hash",
+ })
+ apiKey := mustCreateApiKey(t, client, &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-double-entry-usage-" + uuid.NewString(),
+ Name: "double-entry-usage",
+ })
+ account := mustCreateAccount(t, client, &service.Account{
+ Name: "double-entry-usage-account-" + uuid.NewString(),
+ Type: service.AccountTypeAPIKey,
+ })
+
+ var usageLogID int64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ INSERT INTO usage_logs (
+ user_id, api_key_id, account_id, request_id, model,
+ input_tokens, output_tokens, total_cost, actual_cost, billing_type, created_at
+ )
+ VALUES ($1, $2, $3, $4, 'gpt-5.4-mini', 12, 4, 1.25, 0.75, $5, NOW())
+ RETURNING id
+ `, user.ID, apiKey.ID, account.ID, "double-entry-usage-"+uuid.NewString(), service.BillingTypeBalance).Scan(&usageLogID))
+
+ var (
+ billingEntryID int64
+ transactionID int64
+ txType string
+ txAmount float64
+ )
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT b.id, lt.id, lt.transaction_type, lt.amount_usd::double precision
+ FROM billing_usage_entries b
+ JOIN ledger_transactions lt
+ ON lt.idempotency_key = 'billing_usage_entry:' || b.id::text
+ WHERE b.usage_log_id = $1
+ `, usageLogID).Scan(&billingEntryID, &transactionID, &txType, &txAmount))
+ require.NotZero(t, billingEntryID)
+ require.NotZero(t, transactionID)
+ require.Equal(t, "usage_charge", txType)
+ require.InDelta(t, 0.75, txAmount, 0.000001)
+
+ assertBalancedLedgerTransaction(t, ctx, transactionID, user.ID, 0.75, "liability", "revenue:api_usage")
+}
+
+func TestRedeemCodeBalance_PostsWalletDoubleEntryLedger(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("double-entry-redeem-user-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hash",
+ })
+
+ var redeemCodeID int64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ INSERT INTO redeem_codes (code, type, value, status, used_by, used_at, created_at)
+ VALUES ($1, 'admin_balance', 12.34, 'used', $2, NOW(), NOW())
+ RETURNING id
+ `, "DOUBLE-ENTRY-"+uuid.NewString()[:12], user.ID).Scan(&redeemCodeID))
+
+ var transactionID int64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT id
+ FROM ledger_transactions
+ WHERE idempotency_key = 'redeem_code:' || $1::bigint::text || ':wallet'
+ `, redeemCodeID).Scan(&transactionID))
+
+ assertBalancedLedgerTransaction(t, ctx, transactionID, user.ID, 12.34, "expense", "user_wallet")
+}
+
+func TestPaymentRefundAudit_PostsWalletDebitDoubleEntryLedger(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("double-entry-refund-user-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hash",
+ })
+
+ var orderID int64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ INSERT INTO payment_orders (
+ user_id, user_email, user_name, amount, pay_amount, recharge_code,
+ order_type, status, expires_at, paid_at, completed_at, created_at, updated_at
+ )
+ VALUES ($1, $2, '', 20.00, 20.00, $3, 'balance', 'COMPLETED', NOW() + INTERVAL '1 hour', NOW(), NOW(), NOW(), NOW())
+ RETURNING id
+ `, user.ID, user.Email, "REFUND-"+uuid.NewString()[:12]).Scan(&orderID))
+
+ var auditID int64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ INSERT INTO payment_audit_logs (order_id, action, detail, operator, created_at)
+ VALUES ($1, 'REFUND_SUCCESS', '{"refundAmount":5.00,"balanceDeducted":3.21,"force":false}', 'admin', NOW())
+ RETURNING id
+ `, fmt.Sprintf("%d", orderID)).Scan(&auditID))
+
+ var transactionID int64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT id
+ FROM ledger_transactions
+ WHERE idempotency_key = 'payment_audit_log:' || $1::bigint::text || ':refund_balance_deduction'
+ `, auditID).Scan(&transactionID))
+
+ assertBalancedLedgerTransaction(t, ctx, transactionID, user.ID, 3.21, "liability", "asset:payment_cash_clearing")
+}
+
+func TestDoubleEntryLedgerMigration_BackfillsExistingSourcesIdempotently(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("double-entry-backfill-user-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hash",
+ })
+ apiKey := mustCreateApiKey(t, client, &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-double-entry-backfill-" + uuid.NewString(),
+ Name: "double-entry-backfill",
+ })
+ account := mustCreateAccount(t, client, &service.Account{
+ Name: "double-entry-backfill-account-" + uuid.NewString(),
+ Type: service.AccountTypeAPIKey,
+ })
+
+ var usageLogID int64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ INSERT INTO usage_logs (
+ user_id, api_key_id, account_id, request_id, model,
+ input_tokens, output_tokens, total_cost, actual_cost, billing_type, created_at
+ )
+ VALUES ($1, $2, $3, $4, 'gpt-5.4-mini', 12, 4, 1.25, 0.75, $5, NOW())
+ RETURNING id
+ `, user.ID, apiKey.ID, account.ID, "double-entry-backfill-"+uuid.NewString(), service.BillingTypeBalance).Scan(&usageLogID))
+
+ var billingEntryID int64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT id FROM billing_usage_entries WHERE usage_log_id = $1
+ `, usageLogID).Scan(&billingEntryID))
+
+ _, err := integrationDB.ExecContext(ctx, `
+ DELETE FROM ledger_transactions
+ WHERE idempotency_key = 'billing_usage_entry:' || $1::bigint::text
+ `, billingEntryID)
+ require.NoError(t, err)
+
+ migrationSQL, err := fs.ReadFile(migrations.FS, "135_double_entry_billing_ledger.sql")
+ require.NoError(t, err)
+ _, err = integrationDB.ExecContext(ctx, string(migrationSQL))
+ require.NoError(t, err)
+ _, err = integrationDB.ExecContext(ctx, string(migrationSQL))
+ require.NoError(t, err)
+
+ var (
+ transactionCount int
+ lineCount int
+ debitTotal float64
+ creditTotal float64
+ )
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT
+ COUNT(DISTINCT lt.id),
+ COUNT(ll.id),
+ COALESCE(SUM(ll.amount_usd) FILTER (WHERE ll.side = 'debit'), 0)::double precision,
+ COALESCE(SUM(ll.amount_usd) FILTER (WHERE ll.side = 'credit'), 0)::double precision
+ FROM ledger_transactions lt
+ LEFT JOIN ledger_lines ll ON ll.transaction_id = lt.id
+ WHERE lt.idempotency_key = 'billing_usage_entry:' || $1::bigint::text
+ `, billingEntryID).Scan(&transactionCount, &lineCount, &debitTotal, &creditTotal))
+ require.Equal(t, 1, transactionCount)
+ require.Equal(t, 2, lineCount)
+ require.InDelta(t, 0.75, debitTotal, 0.000001)
+ require.InDelta(t, 0.75, creditTotal, 0.000001)
+}
+
+func TestDoubleEntryLedgerMigration_PostsOpeningBalanceAdjustment(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("double-entry-opening-user-%d@example.com", time.Now().UnixNano()),
+ PasswordHash: "hash",
+ Balance: 9.99,
+ })
+
+ migrationSQL, err := fs.ReadFile(migrations.FS, "135_double_entry_billing_ledger.sql")
+ require.NoError(t, err)
+ _, err = integrationDB.ExecContext(ctx, string(migrationSQL))
+ require.NoError(t, err)
+ _, err = integrationDB.ExecContext(ctx, string(migrationSQL))
+ require.NoError(t, err)
+
+ var (
+ transactionCount int
+ walletBalance float64
+ difference float64
+ unbalancedCount int
+ )
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*)
+ FROM ledger_transactions
+ WHERE idempotency_key = 'opening_wallet_balance:user:' || $1::bigint::text || ':135'
+ `, user.ID).Scan(&transactionCount))
+ require.Equal(t, 1, transactionCount)
+
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT ledger_wallet_balance_usd::double precision, difference_usd::double precision
+ FROM ledger_wallet_reconciliation
+ WHERE user_id = $1
+ `, user.ID).Scan(&walletBalance, &difference))
+ require.InDelta(t, 9.99, walletBalance, 0.000001)
+ require.InDelta(t, 0, difference, 0.000001)
+
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM ledger_unbalanced_transactions
+ `).Scan(&unbalancedCount))
+ require.Equal(t, 0, unbalancedCount)
+}
+
+func assertBalancedLedgerTransaction(t *testing.T, ctx context.Context, transactionID, userID int64, expectedAmount float64, debitAccountType, creditAccountCode string) {
+ t.Helper()
+
+ var (
+ lineCount int
+ debitTotal float64
+ creditTotal float64
+ hasDebitAccount bool
+ hasCreditAccount bool
+ unbalancedCount int
+ )
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT
+ COUNT(*),
+ COALESCE(SUM(ll.amount_usd) FILTER (WHERE ll.side = 'debit'), 0)::double precision,
+ COALESCE(SUM(ll.amount_usd) FILTER (WHERE ll.side = 'credit'), 0)::double precision,
+ COALESCE(BOOL_OR(ll.side = 'debit' AND (
+ ($2 = 'user_wallet' AND la.owner_type = 'user' AND la.owner_id = $3)
+ OR la.account_type = $2
+ OR la.account_code = $2
+ )), FALSE),
+ COALESCE(BOOL_OR(ll.side = 'credit' AND (
+ ($4 = 'user_wallet' AND la.owner_type = 'user' AND la.owner_id = $3)
+ OR la.account_type = $4
+ OR la.account_code = $4
+ )), FALSE)
+ FROM ledger_lines ll
+ JOIN ledger_accounts la ON la.id = ll.account_id
+ WHERE ll.transaction_id = $1
+ `, transactionID, debitAccountType, userID, creditAccountCode).Scan(
+ &lineCount,
+ &debitTotal,
+ &creditTotal,
+ &hasDebitAccount,
+ &hasCreditAccount,
+ ))
+ require.Equal(t, 2, lineCount)
+ require.InDelta(t, expectedAmount, debitTotal, 0.000001)
+ require.InDelta(t, expectedAmount, creditTotal, 0.000001)
+ require.True(t, hasDebitAccount)
+ require.True(t, hasCreditAccount)
+
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM ledger_unbalanced_transactions WHERE transaction_id = $1
+ `, transactionID).Scan(&unbalancedCount))
+ require.Equal(t, 0, unbalancedCount)
+}
diff --git a/backend/internal/repository/usage_billing_repo_unit_test.go b/backend/internal/repository/usage_billing_repo_unit_test.go
new file mode 100644
index 00000000000..3729f92d00e
--- /dev/null
+++ b/backend/internal/repository/usage_billing_repo_unit_test.go
@@ -0,0 +1,100 @@
+//go:build unit
+
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+
+ sqlmock "github.com/DATA-DOG/go-sqlmock"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+// TestDeductUsageBillingWallet_HappyPath unified billing 事务内的钱包扣款 +
+// ledger 落库。
+func TestDeductUsageBillingWallet_HappyPath(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_balance_usd FROM user_subscriptions").
+ WithArgs(int64(42), false).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd"}).AddRow(100.0))
+ mock.ExpectExec("UPDATE user_subscriptions").
+ WithArgs(98.5, int64(42)).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("INSERT INTO subscription_wallet_ledger").
+ WithArgs(int64(42), -1.5, 98.5, sql.NullString{}).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectCommit()
+
+ tx, err := db.BeginTx(context.Background(), nil)
+ require.NoError(t, err)
+
+ newBalance, insufficient, err := deductUsageBillingWallet(context.Background(), tx, 42, 1.5, false)
+ require.NoError(t, err)
+ require.False(t, insufficient)
+ require.InDelta(t, 98.5, newBalance, 0.0001)
+
+ require.NoError(t, tx.Commit())
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+// TestDeductUsageBillingWallet_Insufficient 余额不足发生在上游响应之后时,
+// 仍须完整记账并让余额进入负数债务;否则原余额保持正数会让后续请求反复
+// 通过预检,形成无限免费调用。
+func TestDeductUsageBillingWallet_Insufficient(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_balance_usd FROM user_subscriptions").
+ WithArgs(int64(42), false).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd"}).AddRow(0.5))
+ mock.ExpectExec("UPDATE user_subscriptions").
+ WithArgs(-0.5, int64(42)).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("INSERT INTO subscription_wallet_ledger").
+ WithArgs(int64(42), -1.0, -0.5, sql.NullString{}).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectCommit()
+
+ tx, err := db.BeginTx(context.Background(), nil)
+ require.NoError(t, err)
+
+ balance, insufficient, err := deductUsageBillingWallet(context.Background(), tx, 42, 1.0, false)
+ require.NoError(t, err)
+ require.True(t, insufficient)
+ require.InDelta(t, -0.5, balance, 0.0001)
+
+ require.NoError(t, tx.Commit())
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+// TestDeductUsageBillingWallet_NotWalletMode wallet_balance_usd 为 NULL 视为
+// 老 group 订阅,不该走到这条路径,返回 ErrSubscriptionNotFound。
+func TestDeductUsageBillingWallet_NotWalletMode(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_balance_usd FROM user_subscriptions").
+ WithArgs(int64(42), false).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd"}).AddRow(nil))
+ mock.ExpectRollback()
+
+ tx, err := db.BeginTx(context.Background(), nil)
+ require.NoError(t, err)
+
+ _, _, err = deductUsageBillingWallet(context.Background(), tx, 42, 1.0, false)
+ require.ErrorIs(t, err, service.ErrSubscriptionNotFound)
+
+ require.NoError(t, tx.Rollback())
+ require.NoError(t, mock.ExpectationsWereMet())
+}
diff --git a/backend/internal/repository/usage_billing_wallet_admission_unit_test.go b/backend/internal/repository/usage_billing_wallet_admission_unit_test.go
new file mode 100644
index 00000000000..938e045d3a7
--- /dev/null
+++ b/backend/internal/repository/usage_billing_wallet_admission_unit_test.go
@@ -0,0 +1,122 @@
+//go:build unit
+
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+
+ sqlmock "github.com/DATA-DOG/go-sqlmock"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSettleUsageBillingWalletAdmission_ConsumesHoldWithActualCost(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_subscription_id FROM usage_billing_admissions").
+ WithArgs("wallet-request", int64(7)).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_subscription_id"}).AddRow(int64(42)))
+ mock.ExpectQuery("SELECT wallet_balance_usd FROM user_subscriptions").
+ WithArgs(int64(42), true).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd"}).AddRow(0.5))
+ mock.ExpectQuery("SELECT state, wallet_subscription_id, wallet_reserved_usd").
+ WithArgs("wallet-request", int64(7)).
+ WillReturnRows(sqlmock.NewRows([]string{
+ "state", "wallet_subscription_id", "wallet_reserved_usd", "wallet_consumed_at", "wallet_released_at",
+ }).AddRow(service.UsageBillingAdmissionStateOutboxPending, int64(42), 1.0, nil, nil))
+ mock.ExpectExec("UPDATE user_subscriptions").
+ WithArgs(-0.5, int64(42)).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("INSERT INTO subscription_wallet_ledger").
+ WithArgs(int64(42), -1.0, -0.5).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectExec("UPDATE usage_billing_admissions").
+ WithArgs("wallet-request", int64(7)).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectCommit()
+
+ tx, err := db.BeginTx(context.Background(), nil)
+ require.NoError(t, err)
+ subscriptionID := int64(42)
+ handled, balance, insufficient, err := settleUsageBillingWalletAdmission(context.Background(), tx, &service.UsageBillingCommand{
+ RequestID: "wallet-request",
+ APIKeyID: 7,
+ SubscriptionID: &subscriptionID,
+ WalletCost: 1,
+ BindingsFrozen: true,
+ })
+ require.NoError(t, err)
+ require.True(t, handled)
+ require.True(t, insufficient)
+ require.InDelta(t, -0.5, balance, 0.000001)
+ require.NoError(t, tx.Commit())
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestSettleUsageBillingWalletAdmission_ZeroCostStillReleasesSlot(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_subscription_id FROM usage_billing_admissions").
+ WithArgs("free-wallet-request", int64(8)).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_subscription_id"}).AddRow(int64(43)))
+ mock.ExpectQuery("SELECT wallet_balance_usd FROM user_subscriptions").
+ WithArgs(int64(43), true).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd"}).AddRow(9.0))
+ mock.ExpectQuery("SELECT state, wallet_subscription_id, wallet_reserved_usd").
+ WithArgs("free-wallet-request", int64(8)).
+ WillReturnRows(sqlmock.NewRows([]string{
+ "state", "wallet_subscription_id", "wallet_reserved_usd", "wallet_consumed_at", "wallet_released_at",
+ }).AddRow(service.UsageBillingAdmissionStateOutboxPending, int64(43), 9.0, nil, nil))
+ mock.ExpectExec("UPDATE usage_billing_admissions").
+ WithArgs("free-wallet-request", int64(8)).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectCommit()
+
+ tx, err := db.BeginTx(context.Background(), nil)
+ require.NoError(t, err)
+ subscriptionID := int64(43)
+ handled, balance, insufficient, err := settleUsageBillingWalletAdmission(context.Background(), tx, &service.UsageBillingCommand{
+ RequestID: "free-wallet-request",
+ APIKeyID: 8,
+ SubscriptionID: &subscriptionID,
+ BindingsFrozen: true,
+ })
+ require.NoError(t, err)
+ require.True(t, handled)
+ require.False(t, insufficient)
+ require.InDelta(t, 9, balance, 0.000001)
+ require.NoError(t, tx.Commit())
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestSettleUsageBillingWalletAdmission_MissingAdmissionUsesLegacyPath(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_subscription_id FROM usage_billing_admissions").
+ WithArgs("legacy-wallet-request", int64(9)).
+ WillReturnError(sql.ErrNoRows)
+ mock.ExpectRollback()
+
+ tx, err := db.BeginTx(context.Background(), nil)
+ require.NoError(t, err)
+ handled, _, _, err := settleUsageBillingWalletAdmission(context.Background(), tx, &service.UsageBillingCommand{
+ RequestID: "legacy-wallet-request",
+ APIKeyID: 9,
+ BindingsFrozen: true,
+ })
+ require.NoError(t, err)
+ require.False(t, handled)
+ require.NoError(t, tx.Rollback())
+ require.NoError(t, mock.ExpectationsWereMet())
+}
diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go
index f2fb87da33e..aee025e73fc 100644
--- a/backend/internal/repository/usage_log_repo.go
+++ b/backend/internal/repository/usage_log_repo.go
@@ -28,7 +28,7 @@ import (
gocache "github.com/patrickmn/go-cache"
)
-const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at"
+const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, billing_model, pricing_source, pricing_revision, pricing_hash, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at"
// usageLogInsertArgTypes must stay in the same order as:
// 1. prepareUsageLogInsert().args
@@ -45,6 +45,10 @@ var usageLogInsertArgTypes = [...]string{
"text", // model
"text", // requested_model
"text", // upstream_model
+ "text", // billing_model
+ "text", // pricing_source
+ "text", // pricing_revision
+ "text", // pricing_hash
"bigint", // group_id
"bigint", // subscription_id
"integer", // input_tokens
@@ -87,10 +91,13 @@ var usageLogInsertArgTypes = [...]string{
}
const rawUsageLogModelColumn = "model"
+const requestedUsageLogModelExpr = "COALESCE(NULLIF(TRIM(requested_model), ''), model)"
+const visibleUsageLogModelExpr = "COALESCE(NULLIF(TRIM(upstream_model), ''), model)"
// rawUsageLogModelColumn preserves the exact stored usage_logs.model semantics for direct filters.
-// Historical rows may contain upstream/billing model values, while newer rows store requested_model.
-// Requested/upstream/mapping analytics must use resolveModelDimensionExpression instead.
+// Historical rows may contain requested compatibility model values, while newer rows keep requested_model
+// only for audit and store the product-visible execution model in model when a GPT group uses Claude
+// protocol compatibility. Requested-model analytics should ask for requested explicitly.
// dateFormatWhitelist 将 granularity 参数映射为 PostgreSQL TO_CHAR 格式字符串,防止外部输入直接拼入 SQL
var dateFormatWhitelist = map[string]string{
@@ -294,8 +301,6 @@ func (r *usageLogRepository) CreateBestEffort(ctx context.Context, log *service.
case r.bestEffortBatchCh <- req:
case <-ctx.Done():
return service.MarkUsageLogCreateDropped(ctx.Err())
- default:
- return service.MarkUsageLogCreateDropped(errors.New("usage log best-effort queue full"))
}
select {
@@ -324,6 +329,10 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
model,
requested_model,
upstream_model,
+ billing_model,
+ pricing_source,
+ pricing_revision,
+ pricing_hash,
group_id,
subscription_id,
input_tokens,
@@ -364,12 +373,12 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
account_stats_cost,
created_at
) VALUES (
- $1, $2, $3, $4, $5, $6, $7,
- $8, $9,
- $10, $11, $12, $13,
- $14, $15, $16, $17,
- $18, $19, $20, $21, $22, $23,
- $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46
+ $1, $2, $3, $4, $5, $6, $7, $8,
+ $9, $10,
+ $11, $12, $13, $14,
+ $15, $16, $17, $18,
+ $19, $20, $21, $22, $23, $24,
+ $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50
)
ON CONFLICT (request_id, api_key_id) DO NOTHING
RETURNING id, created_at
@@ -411,8 +420,6 @@ func (r *usageLogRepository) createBatched(ctx context.Context, log *service.Usa
case r.createBatchCh <- req:
case <-ctx.Done():
return false, service.MarkUsageLogCreateNotPersisted(ctx.Err())
- default:
- return false, service.MarkUsageLogCreateNotPersisted(errors.New("usage log create batch queue full"))
}
select {
@@ -434,22 +441,26 @@ func (r *usageLogRepository) createBatched(ctx context.Context, log *service.Usa
}
func (r *usageLogRepository) ensureCreateBatcher() {
- if r == nil || r.db == nil || r.createBatchCh != nil {
+ if r == nil || r.db == nil {
return
}
r.createBatchOnce.Do(func() {
- r.createBatchCh = make(chan usageLogCreateRequest, usageLogCreateBatchQueueCap)
- go r.runCreateBatcher(r.db)
+ if r.createBatchCh == nil {
+ r.createBatchCh = make(chan usageLogCreateRequest, usageLogCreateBatchQueueCap)
+ go r.runCreateBatcher(r.db)
+ }
})
}
func (r *usageLogRepository) ensureBestEffortBatcher() {
- if r == nil || r.db == nil || r.bestEffortBatchCh != nil {
+ if r == nil || r.db == nil {
return
}
r.bestEffortBatchOnce.Do(func() {
- r.bestEffortBatchCh = make(chan usageLogBestEffortRequest, usageLogBestEffortBatchQueueCap)
- go r.runBestEffortBatcher(r.db)
+ if r.bestEffortBatchCh == nil {
+ r.bestEffortBatchCh = make(chan usageLogBestEffortRequest, usageLogBestEffortBatchQueueCap)
+ go r.runBestEffortBatcher(r.db)
+ }
})
}
@@ -762,6 +773,10 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
model,
requested_model,
upstream_model,
+ billing_model,
+ pricing_source,
+ pricing_revision,
+ pricing_hash,
group_id,
subscription_id,
input_tokens,
@@ -803,7 +818,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
created_at
) AS (VALUES `)
- args := make([]any, 0, len(keys)*46)
+ args := make([]any, 0, len(keys)*50)
argPos := 1
for idx, key := range keys {
if idx > 0 {
@@ -839,6 +854,10 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
model,
requested_model,
upstream_model,
+ billing_model,
+ pricing_source,
+ pricing_revision,
+ pricing_hash,
group_id,
subscription_id,
input_tokens,
@@ -887,6 +906,10 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
model,
requested_model,
upstream_model,
+ billing_model,
+ pricing_source,
+ pricing_revision,
+ pricing_hash,
group_id,
subscription_id,
input_tokens,
@@ -975,6 +998,10 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
model,
requested_model,
upstream_model,
+ billing_model,
+ pricing_source,
+ pricing_revision,
+ pricing_hash,
group_id,
subscription_id,
input_tokens,
@@ -1016,7 +1043,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
created_at
) AS (VALUES `)
- args := make([]any, 0, len(preparedList)*46)
+ args := make([]any, 0, len(preparedList)*50)
argPos := 1
for idx, prepared := range preparedList {
if idx > 0 {
@@ -1049,6 +1076,10 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
model,
requested_model,
upstream_model,
+ billing_model,
+ pricing_source,
+ pricing_revision,
+ pricing_hash,
group_id,
subscription_id,
input_tokens,
@@ -1097,6 +1128,10 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
model,
requested_model,
upstream_model,
+ billing_model,
+ pricing_source,
+ pricing_revision,
+ pricing_hash,
group_id,
subscription_id,
input_tokens,
@@ -1153,6 +1188,10 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
model,
requested_model,
upstream_model,
+ billing_model,
+ pricing_source,
+ pricing_revision,
+ pricing_hash,
group_id,
subscription_id,
input_tokens,
@@ -1193,12 +1232,12 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
account_stats_cost,
created_at
) VALUES (
- $1, $2, $3, $4, $5, $6, $7,
- $8, $9,
- $10, $11, $12, $13,
- $14, $15, $16, $17,
- $18, $19, $20, $21, $22, $23,
- $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46
+ $1, $2, $3, $4, $5, $6, $7, $8,
+ $9, $10,
+ $11, $12, $13, $14,
+ $15, $16, $17, $18,
+ $19, $20, $21, $22, $23, $24,
+ $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50
)
ON CONFLICT (request_id, api_key_id) DO NOTHING
`, prepared.args...)
@@ -1238,6 +1277,10 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
requestedModel = strings.TrimSpace(log.Model)
}
upstreamModel := nullString(log.UpstreamModel)
+ billingModel := nullString(log.BillingModel)
+ pricingSource := nullString(log.PricingSource)
+ pricingRevision := nullString(log.PricingRevision)
+ pricingHash := nullString(log.PricingHash)
var requestIDArg any
if requestID != "" {
@@ -1257,6 +1300,10 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
log.Model,
nullString(&requestedModel),
upstreamModel,
+ billingModel,
+ pricingSource,
+ pricingRevision,
+ pricingHash,
groupID,
subscriptionID,
log.InputTokens,
@@ -1324,7 +1371,16 @@ func (r *usageLogRepository) bestEffortRecentKey(requestID string, apiKeyID int6
func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) {
query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1"
- rows, err := r.sql.QueryContext(ctx, query, id)
+ return r.getOneUsageLog(ctx, query, id)
+}
+
+func (r *usageLogRepository) GetByIDForUser(ctx context.Context, id, userID int64) (log *service.UsageLog, err error) {
+ query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1 AND user_id = $2"
+ return r.getOneUsageLog(ctx, query, id, userID)
+}
+
+func (r *usageLogRepository) getOneUsageLog(ctx context.Context, query string, args ...any) (log *service.UsageLog, err error) {
+ rows, err := r.sql.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
@@ -2594,9 +2650,9 @@ func (r *usageLogRepository) GetUserUsageTrendByUserID(ctx context.Context, user
// GetUserModelStats 获取指定用户的模型统计
func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64, startTime, endTime time.Time) (results []ModelStat, err error) {
- query := `
+ query := fmt.Sprintf(`
SELECT
- model,
+ %s as model,
COUNT(*) as requests,
COALESCE(SUM(input_tokens), 0) as input_tokens,
COALESCE(SUM(output_tokens), 0) as output_tokens,
@@ -2608,9 +2664,9 @@ func (r *usageLogRepository) GetUserModelStats(ctx context.Context, userID int64
COALESCE(SUM(COALESCE(account_stats_cost, total_cost) * COALESCE(account_rate_multiplier, 1)), 0) as account_cost
FROM usage_logs
WHERE user_id = $1 AND created_at >= $2 AND created_at < $3
- GROUP BY model
+ GROUP BY %s
ORDER BY total_tokens DESC
- `
+ `, visibleUsageLogModelExpr, visibleUsageLogModelExpr)
rows, err := r.sql.QueryContext(ctx, query, userID, startTime, endTime)
if err != nil {
@@ -2697,11 +2753,7 @@ func (r *usageLogRepository) ListWithFilters(ctx context.Context, params paginat
}
func shouldUseFastUsageLogTotal(filters UsageLogFilters) bool {
- if filters.ExactTotal {
- return false
- }
- // 强选择过滤下记录集通常较小,保留精确总数。
- return filters.UserID == 0 && filters.APIKeyID == 0 && filters.AccountID == 0
+ return !filters.ExactTotal
}
// UsageStats represents usage statistics
@@ -2997,7 +3049,7 @@ func (r *usageLogRepository) getUsageTrendFromAggregates(ctx context.Context, st
// GetModelStatsWithFilters returns model statistics with optional filters
func (r *usageLogRepository) GetModelStatsWithFilters(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8) (results []ModelStat, err error) {
- return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, usagestats.ModelSourceRequested)
+ return r.getModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, usagestats.ModelSourceUpstream)
}
// GetModelStatsWithFiltersBySource returns model statistics with optional filters and model source dimension.
@@ -3278,14 +3330,15 @@ func (r *usageLogRepository) GetAllGroupUsageSummary(ctx context.Context, todayS
// resolveModelDimensionExpression maps model source type to a safe SQL expression.
func resolveModelDimensionExpression(modelType string) string {
- requestedExpr := "COALESCE(NULLIF(TRIM(requested_model), ''), model)"
- switch usagestats.NormalizeModelSource(modelType) {
+ switch strings.ToLower(strings.TrimSpace(modelType)) {
+ case usagestats.ModelSourceRequested:
+ return requestedUsageLogModelExpr
case usagestats.ModelSourceUpstream:
- return fmt.Sprintf("COALESCE(NULLIF(TRIM(upstream_model), ''), %s)", requestedExpr)
+ return visibleUsageLogModelExpr
case usagestats.ModelSourceMapping:
- return fmt.Sprintf("(%s || ' -> ' || COALESCE(NULLIF(TRIM(upstream_model), ''), %s))", requestedExpr, requestedExpr)
+ return fmt.Sprintf("(%s || ' -> ' || COALESCE(NULLIF(TRIM(upstream_model), ''), %s))", requestedUsageLogModelExpr, requestedUsageLogModelExpr)
default:
- return requestedExpr
+ return visibleUsageLogModelExpr
}
}
@@ -3839,7 +3892,7 @@ func usageLogOrderBy(params pagination.PaginationParams) string {
var column string
switch sortBy {
case "model":
- column = "COALESCE(NULLIF(TRIM(requested_model), ''), model)"
+ column = visibleUsageLogModelExpr
case "created_at":
column = "created_at"
default:
@@ -4056,6 +4109,10 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
model string
requestedModel sql.NullString
upstreamModel sql.NullString
+ billingModel sql.NullString
+ pricingSource sql.NullString
+ pricingRevision sql.NullString
+ pricingHash sql.NullString
groupID sql.NullInt64
subscriptionID sql.NullInt64
inputTokens int
@@ -4106,6 +4163,10 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
&model,
&requestedModel,
&upstreamModel,
+ &billingModel,
+ &pricingSource,
+ &pricingRevision,
+ &pricingHash,
&groupID,
&subscriptionID,
&inputTokens,
@@ -4227,6 +4288,18 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
if upstreamModel.Valid {
log.UpstreamModel = &upstreamModel.String
}
+ if billingModel.Valid {
+ log.BillingModel = &billingModel.String
+ }
+ if pricingSource.Valid {
+ log.PricingSource = &pricingSource.String
+ }
+ if pricingRevision.Valid {
+ log.PricingRevision = &pricingRevision.String
+ }
+ if pricingHash.Valid {
+ log.PricingHash = &pricingHash.String
+ }
if channelID.Valid {
value := channelID.Int64
log.ChannelID = &value
diff --git a/backend/internal/repository/usage_log_repo_breakdown_test.go b/backend/internal/repository/usage_log_repo_breakdown_test.go
index da62e8dd562..2a056a31400 100644
--- a/backend/internal/repository/usage_log_repo_breakdown_test.go
+++ b/backend/internal/repository/usage_log_repo_breakdown_test.go
@@ -35,10 +35,10 @@ func TestResolveModelDimensionExpression(t *testing.T) {
want string
}{
{usagestats.ModelSourceRequested, "COALESCE(NULLIF(TRIM(requested_model), ''), model)"},
- {usagestats.ModelSourceUpstream, "COALESCE(NULLIF(TRIM(upstream_model), ''), COALESCE(NULLIF(TRIM(requested_model), ''), model))"},
+ {usagestats.ModelSourceUpstream, "COALESCE(NULLIF(TRIM(upstream_model), ''), model)"},
{usagestats.ModelSourceMapping, "(COALESCE(NULLIF(TRIM(requested_model), ''), model) || ' -> ' || COALESCE(NULLIF(TRIM(upstream_model), ''), COALESCE(NULLIF(TRIM(requested_model), ''), model)))"},
- {"", "COALESCE(NULLIF(TRIM(requested_model), ''), model)"},
- {"invalid", "COALESCE(NULLIF(TRIM(requested_model), ''), model)"},
+ {"", "COALESCE(NULLIF(TRIM(upstream_model), ''), model)"},
+ {"invalid", "COALESCE(NULLIF(TRIM(upstream_model), ''), model)"},
}
for _, tc := range tests {
diff --git a/backend/internal/repository/usage_log_repo_integration_test.go b/backend/internal/repository/usage_log_repo_integration_test.go
index ed3050d89c1..c8397049940 100644
--- a/backend/internal/repository/usage_log_repo_integration_test.go
+++ b/backend/internal/repository/usage_log_repo_integration_test.go
@@ -34,6 +34,22 @@ func (s *UsageLogRepoSuite) SetupTest() {
s.tx = tx
s.client = tx.Client()
s.repo = newUsageLogRepositoryWithSQL(s.client, tx)
+ s.resetUsageLogTestData()
+}
+
+func (s *UsageLogRepoSuite) resetUsageLogTestData() {
+ tables := []string{
+ "usage_dashboard_hourly_users",
+ "usage_dashboard_daily_users",
+ "usage_dashboard_hourly",
+ "usage_dashboard_daily",
+ "usage_dashboard_aggregation_watermark",
+ "usage_logs",
+ }
+ for _, table := range tables {
+ _, err := s.tx.ExecContext(s.ctx, "DELETE FROM "+table)
+ s.Require().NoError(err, "reset %s", table)
+ }
}
func TestUsageLogRepoSuite(t *testing.T) {
@@ -288,21 +304,20 @@ func TestUsageLogRepositoryCreateBestEffort_BatchPathDuplicateRequestID(t *testi
}, 3*time.Second, 20*time.Millisecond)
}
-func TestUsageLogRepositoryCreateBestEffort_QueueFullReturnsDropped(t *testing.T) {
- ctx := context.Background()
+func TestUsageLogRepositoryCreateBestEffort_QueueFullBlocksUntilCtxDeadline(t *testing.T) {
client := testEntClient(t)
repo := newUsageLogRepositoryWithSQL(client, integrationDB)
repo.bestEffortBatchCh = make(chan usageLogBestEffortRequest, 1)
repo.bestEffortBatchCh <- usageLogBestEffortRequest{}
- user := mustCreateUser(t, client, &service.User{Email: fmt.Sprintf("usage-best-effort-full-%d@example.com", time.Now().UnixNano())})
- apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, Key: "sk-usage-best-effort-full-" + uuid.NewString(), Name: "k"})
- account := mustCreateAccount(t, client, &service.Account{Name: "acc-usage-best-effort-full-" + uuid.NewString()})
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ startedAt := time.Now()
err := repo.CreateBestEffort(ctx, &service.UsageLog{
- UserID: user.ID,
- APIKeyID: apiKey.ID,
- AccountID: account.ID,
+ UserID: 1,
+ APIKeyID: 2,
+ AccountID: 3,
RequestID: uuid.NewString(),
Model: "claude-3",
InputTokens: 10,
@@ -314,6 +329,29 @@ func TestUsageLogRepositoryCreateBestEffort_QueueFullReturnsDropped(t *testing.T
require.Error(t, err)
require.True(t, service.IsUsageLogCreateDropped(err))
+ require.GreaterOrEqual(t, time.Since(startedAt), 150*time.Millisecond)
+}
+
+func TestUsageLogRepositoryCreateBestEffort_QueueFullWaitsForDrain(t *testing.T) {
+ client := testEntClient(t)
+ repo := newUsageLogRepositoryWithSQL(client, integrationDB)
+ repo.bestEffortBatchCh = make(chan usageLogBestEffortRequest, 1)
+ repo.bestEffortBatchCh <- usageLogBestEffortRequest{}
+
+ go func() {
+ time.Sleep(100 * time.Millisecond)
+ <-repo.bestEffortBatchCh
+ req := <-repo.bestEffortBatchCh
+ sendUsageLogBestEffortResult(req.resultCh, nil)
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+ err := repo.CreateBestEffort(ctx, &service.UsageLog{
+ UserID: 1, APIKeyID: 2, AccountID: 3, RequestID: uuid.NewString(), Model: "claude-3",
+ InputTokens: 10, OutputTokens: 20, TotalCost: 0.5, ActualCost: 0.5, CreatedAt: time.Now().UTC(),
+ })
+ require.NoError(t, err)
}
func TestUsageLogRepositoryCreate_BatchPathCanceledContextMarksNotPersisted(t *testing.T) {
@@ -346,7 +384,6 @@ func TestUsageLogRepositoryCreate_BatchPathCanceledContextMarksNotPersisted(t *t
}
func TestUsageLogRepositoryCreate_BatchPathQueueFullMarksNotPersisted(t *testing.T) {
- ctx := context.Background()
client := testEntClient(t)
repo := newUsageLogRepositoryWithSQL(client, integrationDB)
repo.createBatchCh = make(chan usageLogCreateRequest, 1)
@@ -356,6 +393,9 @@ func TestUsageLogRepositoryCreate_BatchPathQueueFullMarksNotPersisted(t *testing
apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, Key: "sk-usage-create-full-" + uuid.NewString(), Name: "k"})
account := mustCreateAccount(t, client, &service.Account{Name: "acc-usage-create-full-" + uuid.NewString()})
+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
+ defer cancel()
+ startedAt := time.Now()
inserted, err := repo.Create(ctx, &service.UsageLog{
UserID: user.ID,
APIKeyID: apiKey.ID,
@@ -372,6 +412,7 @@ func TestUsageLogRepositoryCreate_BatchPathQueueFullMarksNotPersisted(t *testing
require.False(t, inserted)
require.Error(t, err)
require.True(t, service.IsUsageLogCreateNotPersisted(err))
+ require.GreaterOrEqual(t, time.Since(startedAt), 150*time.Millisecond)
}
func TestUsageLogRepositoryCreate_BatchPathCanceledAfterQueueMarksNotPersisted(t *testing.T) {
diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go
index a5ff4bc177a..d72148c45c0 100644
--- a/backend/internal/repository/usage_log_repo_request_type_test.go
+++ b/backend/internal/repository/usage_log_repo_request_type_test.go
@@ -48,6 +48,10 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) {
log.Model,
log.RequestedModel,
sqlmock.AnyArg(), // upstream_model
+ sqlmock.AnyArg(), // billing_model
+ sqlmock.AnyArg(), // pricing_source
+ sqlmock.AnyArg(), // pricing_revision
+ sqlmock.AnyArg(), // pricing_hash
sqlmock.AnyArg(), // group_id
sqlmock.AnyArg(), // subscription_id
log.InputTokens,
@@ -101,6 +105,21 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) {
require.NoError(t, mock.ExpectationsWereMet())
}
+func TestUsageLogRepositoryGetByIDForUserBindsOwnerInSQL(t *testing.T) {
+ db, mock := newSQLMock(t)
+ repo := &usageLogRepository{sql: db}
+
+ mock.ExpectQuery(`SELECT .* FROM usage_logs WHERE id = \$1 AND user_id = \$2`).
+ WithArgs(int64(41), int64(7)).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}))
+
+ record, err := repo.GetByIDForUser(context.Background(), 41, 7)
+
+ require.Nil(t, record)
+ require.ErrorIs(t, err, service.ErrUsageLogNotFound)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) {
db, mock := newSQLMock(t)
repo := &usageLogRepository{sql: db}
@@ -129,6 +148,10 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) {
sqlmock.AnyArg(),
sqlmock.AnyArg(),
sqlmock.AnyArg(),
+ sqlmock.AnyArg(),
+ sqlmock.AnyArg(),
+ sqlmock.AnyArg(),
+ sqlmock.AnyArg(),
log.InputTokens,
log.OutputTokens,
log.CacheCreationTokens,
@@ -271,6 +294,26 @@ func TestUsageLogRepositoryListWithFiltersRequestTypePriority(t *testing.T) {
require.NoError(t, mock.ExpectationsWereMet())
}
+func TestUsageLogRepositoryUserFilterDefaultsToFastPagination(t *testing.T) {
+ db, mock := newSQLMock(t)
+ repo := &usageLogRepository{sql: db}
+
+ mock.ExpectQuery("SELECT .* FROM usage_logs WHERE user_id = \\$1 ORDER BY id DESC LIMIT \\$2 OFFSET \\$3").
+ WithArgs(int64(42), 21, 0).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}))
+
+ logs, page, err := repo.ListWithFilters(
+ context.Background(),
+ pagination.PaginationParams{Page: 1, PageSize: 20},
+ usagestats.UsageLogFilters{UserID: 42},
+ )
+ require.NoError(t, err)
+ require.Empty(t, logs)
+ require.NotNil(t, page)
+ require.Zero(t, page.Total)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
func TestUsageLogRepositoryGetUsageTrendWithFiltersRequestTypePriority(t *testing.T) {
db, mock := newSQLMock(t)
repo := &usageLogRepository{sql: db}
@@ -538,7 +581,11 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
sql.NullString{Valid: true, String: "req-1"},
"gpt-5", // model
sql.NullString{Valid: true, String: "gpt-5"}, // requested_model
- sql.NullString{}, // upstream_model
+ sql.NullString{}, // upstream_model
+ sql.NullString{},
+ sql.NullString{}, // pricing_source
+ sql.NullString{}, // pricing_revision
+ sql.NullString{}, // pricing_hash
sql.NullInt64{}, // group_id
sql.NullInt64{}, // subscription_id
1, // input_tokens
@@ -598,6 +645,10 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
"gpt-5",
sql.NullString{Valid: true, String: "gpt-5"},
sql.NullString{},
+ sql.NullString{},
+ sql.NullString{},
+ sql.NullString{},
+ sql.NullString{},
sql.NullInt64{},
sql.NullInt64{},
1, 2, 3, 4, 5, 6,
@@ -646,6 +697,10 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
"gpt-5.4",
sql.NullString{Valid: true, String: "gpt-5.4"},
sql.NullString{},
+ sql.NullString{},
+ sql.NullString{},
+ sql.NullString{},
+ sql.NullString{},
sql.NullInt64{},
sql.NullInt64{},
1, 2, 3, 4, 5, 6,
diff --git a/backend/internal/repository/usage_log_repo_unit_test.go b/backend/internal/repository/usage_log_repo_unit_test.go
index 0458902d23e..7fef9004811 100644
--- a/backend/internal/repository/usage_log_repo_unit_test.go
+++ b/backend/internal/repository/usage_log_repo_unit_test.go
@@ -3,10 +3,13 @@
package repository
import (
+ "context"
+ "database/sql"
"strings"
"testing"
"time"
+ "github.com/DATA-DOG/go-sqlmock"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
@@ -65,3 +68,164 @@ func TestBuildUsageLogBatchInsertQuery_UsesConflictDoNothing(t *testing.T) {
require.Contains(t, query, "ON CONFLICT (request_id, api_key_id) DO NOTHING")
require.NotContains(t, strings.ToUpper(query), "DO UPDATE")
}
+
+func TestUsageLogRepositoryCreateBestEffort_QueuePressureWaitsForDrain(t *testing.T) {
+ db, _, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+ repo := newUsageLogRepositoryWithSQL(nil, db)
+ repo.bestEffortBatchCh = make(chan usageLogBestEffortRequest, 1)
+ repo.bestEffortBatchCh <- usageLogBestEffortRequest{}
+
+ drainStarted := make(chan struct{})
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ close(drainStarted)
+ <-repo.bestEffortBatchCh
+ req := <-repo.bestEffortBatchCh
+ sendUsageLogBestEffortResult(req.resultCh, nil)
+ }()
+
+ startedAt := time.Now()
+ err = repo.CreateBestEffort(context.Background(), &service.UsageLog{
+ UserID: 1, APIKeyID: 2, AccountID: 3, RequestID: "queue-pressure", Model: "gpt-5.6-sol",
+ InputTokens: 1, CreatedAt: time.Now().UTC(),
+ })
+ require.NoError(t, err)
+ <-drainStarted
+ require.GreaterOrEqual(t, time.Since(startedAt), 40*time.Millisecond)
+}
+
+func TestUsageLogRepositoryBillingModelPersistenceContract(t *testing.T) {
+ billingModel := "gpt-5.4"
+ log := &service.UsageLog{
+ UserID: 1,
+ APIKeyID: 2,
+ AccountID: 3,
+ RequestID: "req-billing-model-contract",
+ Model: "gpt-5.4",
+ BillingModel: &billingModel,
+ InputTokens: 10,
+ OutputTokens: 5,
+ CreatedAt: time.Now().UTC(),
+ }
+ prepared := prepareUsageLogInsert(log)
+
+ require.Contains(t, usageLogSelectColumns, "requested_model, upstream_model, billing_model, pricing_source, pricing_revision, pricing_hash, group_id")
+ require.Len(t, prepared.args, len(usageLogInsertArgTypes))
+ require.Equal(t, "text", usageLogInsertArgTypes[7])
+ require.Equal(t, sql.NullString{String: billingModel, Valid: true}, prepared.args[7])
+
+ key := usageLogBatchKey(log.RequestID, log.APIKeyID)
+ batchQuery, _ := buildUsageLogBatchInsertQuery([]string{key}, map[string]usageLogInsertPrepared{key: prepared})
+ bestEffortQuery, _ := buildUsageLogBestEffortInsertQuery([]usageLogInsertPrepared{prepared})
+ for _, query := range []string{batchQuery, bestEffortQuery} {
+ require.Contains(t, query, "requested_model,\n\t\t\tupstream_model,\n\t\t\tbilling_model,\n\t\t\tpricing_source,\n\t\t\tpricing_revision,\n\t\t\tpricing_hash,")
+ require.Contains(t, query, "billing_model")
+ }
+}
+
+func TestUsageLogRepositoryPricingEvidencePersistenceContract(t *testing.T) {
+ pricingSource := service.PricingSourceBuiltinGPT56
+ pricingRevision := service.GPT56PricingRevision
+ pricingHash := strings.Repeat("a", 64)
+ log := &service.UsageLog{
+ UserID: 1, APIKeyID: 2, AccountID: 3,
+ RequestID: "req-pricing-evidence", Model: "gpt-5.6-terra",
+ PricingSource: &pricingSource, PricingRevision: &pricingRevision, PricingHash: &pricingHash,
+ CreatedAt: time.Now().UTC(),
+ }
+ prepared := prepareUsageLogInsert(log)
+
+ require.Contains(t, usageLogSelectColumns, "billing_model, pricing_source, pricing_revision, pricing_hash, group_id")
+ require.Len(t, prepared.args, len(usageLogInsertArgTypes))
+ require.Equal(t, sql.NullString{String: pricingSource, Valid: true}, prepared.args[8])
+ require.Equal(t, sql.NullString{String: pricingRevision, Valid: true}, prepared.args[9])
+ require.Equal(t, sql.NullString{String: pricingHash, Valid: true}, prepared.args[10])
+
+ key := usageLogBatchKey(log.RequestID, log.APIKeyID)
+ batchQuery, _ := buildUsageLogBatchInsertQuery([]string{key}, map[string]usageLogInsertPrepared{key: prepared})
+ bestEffortQuery, _ := buildUsageLogBestEffortInsertQuery([]usageLogInsertPrepared{prepared})
+ for _, query := range []string{batchQuery, bestEffortQuery} {
+ require.Contains(t, query, "pricing_source")
+ require.Contains(t, query, "pricing_revision")
+ require.Contains(t, query, "pricing_hash")
+ }
+}
+
+func TestUsageLogRepositorySingleInsertPathsIncludeBillingModel(t *testing.T) {
+ billingModel := "gpt-5.6-terra"
+ log := &service.UsageLog{
+ UserID: 1,
+ APIKeyID: 2,
+ AccountID: 3,
+ RequestID: "req-billing-model-single",
+ Model: billingModel,
+ BillingModel: &billingModel,
+ CreatedAt: time.Now().UTC(),
+ }
+ queryPattern := `(?s)INSERT INTO usage_logs \(.*requested_model,\s*upstream_model,\s*billing_model,.*VALUES`
+
+ t.Run("returning insert", func(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+ mock.ExpectQuery(queryPattern).
+ WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(int64(9), log.CreatedAt))
+
+ repo := newUsageLogRepositoryWithSQL(nil, db)
+ inserted, err := repo.createSingle(context.Background(), db, log)
+ require.NoError(t, err)
+ require.True(t, inserted)
+ require.NoError(t, mock.ExpectationsWereMet())
+ })
+
+ t.Run("no-result insert", func(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = db.Close() })
+ mock.ExpectExec(queryPattern).WillReturnResult(sqlmock.NewResult(0, 1))
+
+ err = execUsageLogInsertNoResult(context.Background(), db, prepareUsageLogInsert(log))
+ require.NoError(t, err)
+ require.NoError(t, mock.ExpectationsWereMet())
+ })
+}
+
+func TestScanUsageLogBillingModelPreservesValueAndHistoricalNull(t *testing.T) {
+ now := time.Now().UTC()
+ values := []any{
+ int64(1), int64(10), int64(20), int64(30),
+ sql.NullString{Valid: true, String: "req-scan-billing"},
+ "gpt-5.4",
+ sql.NullString{Valid: true, String: "claude-sonnet-4-6"},
+ sql.NullString{Valid: true, String: "gpt-5.4"},
+ sql.NullString{Valid: true, String: "gpt-5.4"},
+ sql.NullString{}, sql.NullString{}, sql.NullString{},
+ sql.NullInt64{}, sql.NullInt64{},
+ 1, 2, 3, 4, 5, 6,
+ 0, 0.0,
+ 0.1, 0.2, 0.3, 0.4, 1.0, 0.9, 1.0,
+ sql.NullFloat64{},
+ int16(service.BillingTypeBalance), int16(service.RequestTypeSync),
+ false, false,
+ sql.NullInt64{}, sql.NullInt64{},
+ sql.NullString{}, sql.NullString{},
+ 0, sql.NullString{},
+ sql.NullString{}, sql.NullString{}, sql.NullString{}, sql.NullString{},
+ false,
+ sql.NullInt64{}, sql.NullString{}, sql.NullString{}, sql.NullString{},
+ sql.NullFloat64{},
+ now,
+ }
+
+ log, err := scanUsageLog(usageLogScannerStub{values: values})
+ require.NoError(t, err)
+ require.NotNil(t, log.BillingModel)
+ require.Equal(t, "gpt-5.4", *log.BillingModel)
+
+ values[8] = sql.NullString{}
+ historical, err := scanUsageLog(usageLogScannerStub{values: values})
+ require.NoError(t, err)
+ require.Nil(t, historical.BillingModel)
+}
diff --git a/backend/internal/repository/user_group_rate_repo.go b/backend/internal/repository/user_group_rate_repo.go
index 74d25cb0b6f..3cbf91fb3f6 100644
--- a/backend/internal/repository/user_group_rate_repo.go
+++ b/backend/internal/repository/user_group_rate_repo.go
@@ -3,19 +3,69 @@ package repository
import (
"context"
"database/sql"
+ "fmt"
+ "math"
"time"
+ dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/lib/pq"
)
type userGroupRateRepository struct {
sql sqlExecutor
+ db *sql.DB
}
// NewUserGroupRateRepository 创建用户专属分组倍率/RPM 仓储
func NewUserGroupRateRepository(sqlDB *sql.DB) service.UserGroupRateRepository {
- return &userGroupRateRepository{sql: sqlDB}
+ return &userGroupRateRepository{sql: sqlDB, db: sqlDB}
+}
+
+// withMutation makes every multi-statement replacement atomic on its own and
+// also joins an Ent transaction supplied by the service layer. The latter is
+// required when a user update spans users, user_allowed_groups and this table.
+func (r *userGroupRateRepository) withMutation(ctx context.Context, mutate func(sqlQueryExecutor) error) error {
+ if mutate == nil {
+ return fmt.Errorf("user group rate mutation is nil")
+ }
+ if existingTx := dbent.TxFromContext(ctx); existingTx != nil {
+ exec := sqlExecutorFromEntClient(existingTx.Client())
+ if exec == nil {
+ return fmt.Errorf("user group rate transaction executor is unavailable")
+ }
+ return mutate(exec)
+ }
+ if r.db == nil {
+ return fmt.Errorf("user group rate database is not configured")
+ }
+
+ tx, err := r.db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("begin user group rate transaction: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ if err := mutate(tx); err != nil {
+ return err
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit user group rate transaction: %w", err)
+ }
+ return nil
+}
+
+func (r *userGroupRateRepository) writeExecutor(ctx context.Context) (sqlQueryExecutor, error) {
+ if existingTx := dbent.TxFromContext(ctx); existingTx != nil {
+ exec := sqlExecutorFromEntClient(existingTx.Client())
+ if exec == nil {
+ return nil, fmt.Errorf("user group rate transaction executor is unavailable")
+ }
+ return exec, nil
+ }
+ if r.sql == nil {
+ return nil, fmt.Errorf("user group rate database is not configured")
+ }
+ return r.sql, nil
}
// GetByUserID 获取用户所有专属分组 rate_multiplier(仅返回非 NULL 的条目)
@@ -174,15 +224,21 @@ func (r *userGroupRateRepository) GetRPMOverrideByUserAndGroup(ctx context.Conte
// - 值为 nil:清空对应行的 rate_multiplier(保留 rpm_override)。
// - 值非 nil:upsert rate_multiplier(保留已有 rpm_override)。
func (r *userGroupRateRepository) SyncUserGroupRates(ctx context.Context, userID int64, rates map[int64]*float64) error {
+ return r.withMutation(ctx, func(exec sqlQueryExecutor) error {
+ return syncUserGroupRatesWithExecutor(ctx, exec, userID, rates)
+ })
+}
+
+func syncUserGroupRatesWithExecutor(ctx context.Context, exec sqlQueryExecutor, userID int64, rates map[int64]*float64) error {
if len(rates) == 0 {
- if _, err := r.sql.ExecContext(ctx, `
+ if _, err := exec.ExecContext(ctx, `
UPDATE user_group_rate_multipliers
SET rate_multiplier = NULL, updated_at = NOW()
WHERE user_id = $1
`, userID); err != nil {
return err
}
- _, err := r.sql.ExecContext(ctx,
+ _, err := exec.ExecContext(ctx,
`DELETE FROM user_group_rate_multipliers WHERE user_id = $1 AND rate_multiplier IS NULL AND rpm_override IS NULL`,
userID)
return err
@@ -201,14 +257,14 @@ func (r *userGroupRateRepository) SyncUserGroupRates(ctx context.Context, userID
}
if len(clearGroupIDs) > 0 {
- if _, err := r.sql.ExecContext(ctx, `
+ if _, err := exec.ExecContext(ctx, `
UPDATE user_group_rate_multipliers
SET rate_multiplier = NULL, updated_at = NOW()
WHERE user_id = $1 AND group_id = ANY($2)
`, userID, pq.Array(clearGroupIDs)); err != nil {
return err
}
- if _, err := r.sql.ExecContext(ctx,
+ if _, err := exec.ExecContext(ctx,
`DELETE FROM user_group_rate_multipliers WHERE user_id = $1 AND group_id = ANY($2) AND rate_multiplier IS NULL AND rpm_override IS NULL`,
userID, pq.Array(clearGroupIDs)); err != nil {
return err
@@ -217,7 +273,7 @@ func (r *userGroupRateRepository) SyncUserGroupRates(ctx context.Context, userID
if len(upsertGroupIDs) > 0 {
now := time.Now()
- _, err := r.sql.ExecContext(ctx, `
+ _, err := exec.ExecContext(ctx, `
INSERT INTO user_group_rate_multipliers (user_id, group_id, rate_multiplier, created_at, updated_at)
SELECT
$1::bigint,
@@ -244,6 +300,12 @@ func (r *userGroupRateRepository) SyncUserGroupRates(ctx context.Context, userID
// - 未出现在 entries 中的用户行:rate_multiplier 归 NULL;若 rpm_override 也为 NULL 则整行删除。
// - 出现的用户行:upsert rate_multiplier。
func (r *userGroupRateRepository) SyncGroupRateMultipliers(ctx context.Context, groupID int64, entries []service.GroupRateMultiplierInput) error {
+ return r.withMutation(ctx, func(exec sqlQueryExecutor) error {
+ return syncGroupRateMultipliersWithExecutor(ctx, exec, groupID, entries)
+ })
+}
+
+func syncGroupRateMultipliersWithExecutor(ctx context.Context, exec sqlQueryExecutor, groupID int64, entries []service.GroupRateMultiplierInput) error {
keepUserIDs := make([]int64, 0, len(entries))
for _, e := range entries {
keepUserIDs = append(keepUserIDs, e.UserID)
@@ -251,7 +313,7 @@ func (r *userGroupRateRepository) SyncGroupRateMultipliers(ctx context.Context,
// 未在 entries 列表中的行:清空 rate_multiplier。
if len(keepUserIDs) == 0 {
- if _, err := r.sql.ExecContext(ctx, `
+ if _, err := exec.ExecContext(ctx, `
UPDATE user_group_rate_multipliers
SET rate_multiplier = NULL, updated_at = NOW()
WHERE group_id = $1
@@ -259,7 +321,7 @@ func (r *userGroupRateRepository) SyncGroupRateMultipliers(ctx context.Context,
return err
}
} else {
- if _, err := r.sql.ExecContext(ctx, `
+ if _, err := exec.ExecContext(ctx, `
UPDATE user_group_rate_multipliers
SET rate_multiplier = NULL, updated_at = NOW()
WHERE group_id = $1 AND user_id <> ALL($2)
@@ -269,7 +331,7 @@ func (r *userGroupRateRepository) SyncGroupRateMultipliers(ctx context.Context,
}
// 清空后若整行 NULL 则删除。
- if _, err := r.sql.ExecContext(ctx, `
+ if _, err := exec.ExecContext(ctx, `
DELETE FROM user_group_rate_multipliers
WHERE group_id = $1 AND rate_multiplier IS NULL AND rpm_override IS NULL
`, groupID); err != nil {
@@ -287,7 +349,7 @@ func (r *userGroupRateRepository) SyncGroupRateMultipliers(ctx context.Context,
rates[i] = e.RateMultiplier
}
now := time.Now()
- _, err := r.sql.ExecContext(ctx, `
+ _, err := exec.ExecContext(ctx, `
INSERT INTO user_group_rate_multipliers (user_id, group_id, rate_multiplier, created_at, updated_at)
SELECT data.user_id, $1::bigint, data.rate_multiplier, $2::timestamptz, $2::timestamptz
FROM unnest($3::bigint[], $4::double precision[]) AS data(user_id, rate_multiplier)
@@ -302,6 +364,12 @@ func (r *userGroupRateRepository) SyncGroupRateMultipliers(ctx context.Context,
// - 未出现的用户行:rpm_override 归 NULL;若 rate_multiplier 也为 NULL 则整行删除。
// - 出现的用户行:若 RPMOverride 为 nil 则清空;非 nil 则 upsert。
func (r *userGroupRateRepository) SyncGroupRPMOverrides(ctx context.Context, groupID int64, entries []service.GroupRPMOverrideInput) error {
+ return r.withMutation(ctx, func(exec sqlQueryExecutor) error {
+ return syncGroupRPMOverridesWithExecutor(ctx, exec, groupID, entries)
+ })
+}
+
+func syncGroupRPMOverridesWithExecutor(ctx context.Context, exec sqlQueryExecutor, groupID int64, entries []service.GroupRPMOverrideInput) error {
keepUserIDs := make([]int64, 0, len(entries))
var clearUserIDs []int64
upsertUserIDs := make([]int64, 0, len(entries))
@@ -311,6 +379,9 @@ func (r *userGroupRateRepository) SyncGroupRPMOverrides(ctx context.Context, gro
if e.RPMOverride == nil {
clearUserIDs = append(clearUserIDs, e.UserID)
} else {
+ if *e.RPMOverride < 0 || *e.RPMOverride > math.MaxInt32 {
+ return fmt.Errorf("rpm_override for user %d must fit PostgreSQL integer", e.UserID)
+ }
upsertUserIDs = append(upsertUserIDs, e.UserID)
upsertValues = append(upsertValues, int32(*e.RPMOverride))
}
@@ -318,7 +389,7 @@ func (r *userGroupRateRepository) SyncGroupRPMOverrides(ctx context.Context, gro
// 未在 entries 列表中的行:清空 rpm_override。
if len(keepUserIDs) == 0 {
- if _, err := r.sql.ExecContext(ctx, `
+ if _, err := exec.ExecContext(ctx, `
UPDATE user_group_rate_multipliers
SET rpm_override = NULL, updated_at = NOW()
WHERE group_id = $1
@@ -326,7 +397,7 @@ func (r *userGroupRateRepository) SyncGroupRPMOverrides(ctx context.Context, gro
return err
}
} else {
- if _, err := r.sql.ExecContext(ctx, `
+ if _, err := exec.ExecContext(ctx, `
UPDATE user_group_rate_multipliers
SET rpm_override = NULL, updated_at = NOW()
WHERE group_id = $1 AND user_id <> ALL($2)
@@ -337,7 +408,7 @@ func (r *userGroupRateRepository) SyncGroupRPMOverrides(ctx context.Context, gro
// 显式 clear 的行。
if len(clearUserIDs) > 0 {
- if _, err := r.sql.ExecContext(ctx, `
+ if _, err := exec.ExecContext(ctx, `
UPDATE user_group_rate_multipliers
SET rpm_override = NULL, updated_at = NOW()
WHERE group_id = $1 AND user_id = ANY($2)
@@ -347,7 +418,7 @@ func (r *userGroupRateRepository) SyncGroupRPMOverrides(ctx context.Context, gro
}
// 清空后若整行 NULL 则删除。
- if _, err := r.sql.ExecContext(ctx, `
+ if _, err := exec.ExecContext(ctx, `
DELETE FROM user_group_rate_multipliers
WHERE group_id = $1 AND rate_multiplier IS NULL AND rpm_override IS NULL
`, groupID); err != nil {
@@ -356,7 +427,7 @@ func (r *userGroupRateRepository) SyncGroupRPMOverrides(ctx context.Context, gro
if len(upsertUserIDs) > 0 {
now := time.Now()
- _, err := r.sql.ExecContext(ctx, `
+ _, err := exec.ExecContext(ctx, `
INSERT INTO user_group_rate_multipliers (user_id, group_id, rpm_override, created_at, updated_at)
SELECT data.user_id, $1::bigint, data.rpm_override, $2::timestamptz, $2::timestamptz
FROM unnest($3::bigint[], $4::integer[]) AS data(user_id, rpm_override)
@@ -373,28 +444,38 @@ func (r *userGroupRateRepository) SyncGroupRPMOverrides(ctx context.Context, gro
// ClearGroupRPMOverrides 清空指定分组所有行的 rpm_override。
func (r *userGroupRateRepository) ClearGroupRPMOverrides(ctx context.Context, groupID int64) error {
- if _, err := r.sql.ExecContext(ctx, `
- UPDATE user_group_rate_multipliers
- SET rpm_override = NULL, updated_at = NOW()
- WHERE group_id = $1
- `, groupID); err != nil {
+ return r.withMutation(ctx, func(exec sqlQueryExecutor) error {
+ if _, err := exec.ExecContext(ctx, `
+ UPDATE user_group_rate_multipliers
+ SET rpm_override = NULL, updated_at = NOW()
+ WHERE group_id = $1
+ `, groupID); err != nil {
+ return err
+ }
+ _, err := exec.ExecContext(ctx, `
+ DELETE FROM user_group_rate_multipliers
+ WHERE group_id = $1 AND rate_multiplier IS NULL AND rpm_override IS NULL
+ `, groupID)
return err
- }
- _, err := r.sql.ExecContext(ctx, `
- DELETE FROM user_group_rate_multipliers
- WHERE group_id = $1 AND rate_multiplier IS NULL AND rpm_override IS NULL
- `, groupID)
- return err
+ })
}
// DeleteByGroupID 删除指定分组的所有用户专属条目
func (r *userGroupRateRepository) DeleteByGroupID(ctx context.Context, groupID int64) error {
- _, err := r.sql.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE group_id = $1`, groupID)
+ exec, err := r.writeExecutor(ctx)
+ if err != nil {
+ return err
+ }
+ _, err = exec.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE group_id = $1`, groupID)
return err
}
// DeleteByUserID 删除指定用户的所有专属条目
func (r *userGroupRateRepository) DeleteByUserID(ctx context.Context, userID int64) error {
- _, err := r.sql.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE user_id = $1`, userID)
+ exec, err := r.writeExecutor(ctx)
+ if err != nil {
+ return err
+ }
+ _, err = exec.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE user_id = $1`, userID)
return err
}
diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go
index d1f10cbdcce..48a089eda8b 100644
--- a/backend/internal/repository/user_repo.go
+++ b/backend/internal/repository/user_repo.go
@@ -19,6 +19,7 @@ import (
dbuser "github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/lib/pq"
@@ -94,6 +95,7 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error
SetNillableLastLoginAt(userIn.LastLoginAt).
SetNillableLastActiveAt(userIn.LastActiveAt).
SetRpmLimit(userIn.RPMLimit).
+ SetTokenVersion(userIn.TokenVersion).
Save(txCtx)
if err != nil {
return translatePersistenceError(err, nil, service.ErrEmailExists)
@@ -111,13 +113,48 @@ func (r *userRepository) Create(ctx context.Context, userIn *service.User) error
return err
}
}
+ if err := r.persistSignupRiskFields(ctx, r.client, created.ID, userIn); err != nil {
+ logger.LegacyPrintf("repository.user", "[UserRepo] Failed to persist signup risk fields user_id=%d: %v", created.ID, err)
+ }
applyUserEntityToService(userIn, created)
return nil
}
+func (r *userRepository) persistSignupRiskFields(ctx context.Context, client *dbent.Client, userID int64, userIn *service.User) error {
+ if userIn == nil || !userIn.SignupRiskRecorded || userID <= 0 {
+ return nil
+ }
+ exec := txAwareSQLExecutor(ctx, r.sql, client)
+ if exec == nil {
+ return fmt.Errorf("sql executor is not configured")
+ }
+ _, err := exec.ExecContext(ctx, `
+UPDATE users
+SET signup_ip = $2,
+ signup_ip_prefix = $3,
+ signup_user_agent_hash = $4,
+ signup_device_fingerprint_hash = $5,
+ trial_bonus_eligible = $6,
+ trial_bonus_hold_reason = $7,
+ trial_bonus_risk_score = $8,
+ updated_at = NOW()
+WHERE id = $1
+`, userID,
+ userIn.SignupIP,
+ userIn.SignupIPPrefix,
+ userIn.SignupUserAgentHash,
+ userIn.SignupDeviceFingerprintHash,
+ userIn.TrialBonusEligible,
+ userIn.TrialBonusHoldReason,
+ userIn.TrialBonusRiskScore,
+ )
+ return err
+}
+
func (r *userRepository) GetByID(ctx context.Context, id int64) (*service.User, error) {
- m, err := r.client.User.Query().Where(dbuser.IDEQ(id)).Only(ctx)
+ client := clientFromContext(ctx, r.client)
+ m, err := client.User.Query().Where(dbuser.IDEQ(id)).Only(ctx)
if err != nil {
return nil, translatePersistenceError(err, service.ErrUserNotFound, nil)
}
@@ -166,24 +203,22 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
}
// 使用 ent 事务包裹用户更新与 allowed_groups 同步,避免跨层事务不一致。
- tx, err := r.client.Tx(ctx)
- if err != nil && !errors.Is(err, dbent.ErrTxStarted) {
- return err
- }
-
+ var tx *dbent.Tx
var txClient *dbent.Client
txCtx := ctx
- if err == nil {
+ if existingTx := dbent.TxFromContext(ctx); existingTx != nil {
+ // UpdateUser 还可能同时更新 user_group_rate_multipliers。复用 service
+ // 开启的外层事务,确保用户行、allowed_groups 与专属策略同成同败。
+ txClient = existingTx.Client()
+ } else {
+ var err error
+ tx, err = r.client.Tx(ctx)
+ if err != nil {
+ return err
+ }
defer func() { _ = tx.Rollback() }()
txClient = tx.Client()
txCtx = dbent.NewTxContext(ctx, tx)
- } else {
- // 已处于外部事务中(ErrTxStarted),复用当前事务 client 并由调用方负责提交/回滚。
- if existingTx := dbent.TxFromContext(ctx); existingTx != nil {
- txClient = existingTx.Client()
- } else {
- txClient = r.client
- }
}
releaseEmailLock, err := lockRepositoryScopedKeys(
@@ -221,7 +256,8 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
SetNillableBalanceNotifyThreshold(userIn.BalanceNotifyThreshold).
SetBalanceNotifyExtraEmails(marshalExtraEmails(userIn.BalanceNotifyExtraEmails)).
SetTotalRecharged(userIn.TotalRecharged).
- SetRpmLimit(userIn.RPMLimit)
+ SetRpmLimit(userIn.RPMLimit).
+ SetTokenVersion(userIn.TokenVersion)
if userIn.SignupSource != "" {
updateOp = updateOp.SetSignupSource(userIn.SignupSource)
}
@@ -418,7 +454,7 @@ func (r *userRepository) ListWithFilters(ctx context.Context, params pagination.
dbuser.EmailContainsFold(filters.Search),
dbuser.UsernameContainsFold(filters.Search),
dbuser.NotesContainsFold(filters.Search),
- dbuser.HasAPIKeysWith(apikey.KeyContainsFold(filters.Search)),
+ dbuser.HasAPIKeysWith(apikey.KeyPrefixContainsFold(filters.Search)),
),
)
}
@@ -707,6 +743,49 @@ func (r *userRepository) UpdateBalance(ctx context.Context, id int64, amount flo
return nil
}
+// AdjustAdminBalance serializes an administrator balance mutation on the user
+// row. The caller must provide an outer transaction so the balance change and
+// its immutable adjustment record can commit or roll back together.
+func (r *userRepository) AdjustAdminBalance(ctx context.Context, id int64, amount float64, operation string) (*service.User, float64, error) {
+ if dbent.TxFromContext(ctx) == nil {
+ return nil, 0, errors.New("admin balance adjustment requires a transaction")
+ }
+ client := clientFromContext(ctx, r.client)
+ current, err := client.User.Query().
+ Where(dbuser.IDEQ(id)).
+ ForUpdate().
+ Only(ctx)
+ if err != nil {
+ return nil, 0, translatePersistenceError(err, service.ErrUserNotFound, nil)
+ }
+
+ oldBalance := current.Balance
+ newBalance := oldBalance
+ switch operation {
+ case "set":
+ newBalance = amount
+ case "add":
+ newBalance += amount
+ case "subtract":
+ newBalance -= amount
+ default:
+ return nil, 0, errors.New("invalid balance operation")
+ }
+ if newBalance < 0 {
+ return nil, 0, fmt.Errorf(
+ "balance cannot be negative, current balance: %.2f, requested operation would result in: %.2f",
+ oldBalance,
+ newBalance,
+ )
+ }
+
+ updated, err := client.User.UpdateOneID(id).SetBalance(newBalance).Save(ctx)
+ if err != nil {
+ return nil, 0, translatePersistenceError(err, service.ErrUserNotFound, nil)
+ }
+ return userEntityToService(updated), newBalance - oldBalance, nil
+}
+
// DeductBalance 扣除用户余额
// 透支策略:允许余额变为负数,确保当前请求能够完成
// 中间件会阻止余额 <= 0 的用户发起后续请求
@@ -737,6 +816,37 @@ func (r *userRepository) UpdateConcurrency(ctx context.Context, id int64, amount
return nil
}
+func (r *userRepository) BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error) {
+ if len(userIDs) == 0 {
+ return 0, nil
+ }
+ if value < 0 {
+ value = 0
+ }
+ res, err := r.sql.ExecContext(ctx,
+ "UPDATE users SET concurrency = $1, updated_at = NOW() WHERE id = ANY($2) AND deleted_at IS NULL",
+ value, pq.Array(userIDs))
+ if err != nil {
+ return 0, fmt.Errorf("batch set concurrency: %w", err)
+ }
+ affected, _ := res.RowsAffected()
+ return int(affected), nil
+}
+
+func (r *userRepository) BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) {
+ if len(userIDs) == 0 {
+ return 0, nil
+ }
+ res, err := r.sql.ExecContext(ctx,
+ "UPDATE users SET concurrency = GREATEST(concurrency + $1, 0), updated_at = NOW() WHERE id = ANY($2) AND deleted_at IS NULL",
+ delta, pq.Array(userIDs))
+ if err != nil {
+ return 0, fmt.Errorf("batch add concurrency: %w", err)
+ }
+ affected, _ := res.RowsAffected()
+ return int(affected), nil
+}
+
func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) {
return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx)
}
@@ -851,7 +961,8 @@ func (r *userRepository) loadAllowedGroups(ctx context.Context, userIDs []int64)
return out, nil
}
- rows, err := r.client.UserAllowedGroup.Query().
+ client := clientFromContext(ctx, r.client)
+ rows, err := client.UserAllowedGroup.Query().
Where(userallowedgroup.UserIDIn(userIDs...)).
All(ctx)
if err != nil {
diff --git a/backend/internal/repository/user_repo_integration_test.go b/backend/internal/repository/user_repo_integration_test.go
index 13a605a2f54..27d198a52b1 100644
--- a/backend/internal/repository/user_repo_integration_test.go
+++ b/backend/internal/repository/user_repo_integration_test.go
@@ -23,16 +23,21 @@ type UserRepoSuite struct {
}
func (s *UserRepoSuite) SetupTest() {
- s.ctx = context.Background()
- s.client = testEntClient(s.T())
- s.repo = newUserRepositoryWithSQL(s.client, integrationDB)
-
- // 清理测试数据,确保每个测试从干净状态开始
- _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM auth_identity_channels")
- _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM auth_identities")
- _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM user_subscriptions")
- _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM user_allowed_groups")
- _, _ = integrationDB.ExecContext(s.ctx, "DELETE FROM users")
+ tx := testEntTx(s.T())
+ s.ctx = dbent.NewTxContext(context.Background(), tx)
+ s.client = tx.Client()
+ s.repo = newUserRepositoryWithSQL(s.client, tx)
+ _, err := tx.ExecContext(s.ctx, `
+ SET LOCAL session_replication_role = 'replica';
+ DELETE FROM user_subscriptions;
+ DELETE FROM user_allowed_groups;
+ DELETE FROM auth_identity_channels;
+ DELETE FROM auth_identities;
+ DELETE FROM users;
+ DELETE FROM groups;
+ SET LOCAL session_replication_role = 'origin';
+ `)
+ s.Require().NoError(err, "isolate user repository test transaction")
}
func TestUserRepoSuite(t *testing.T) {
@@ -68,6 +73,7 @@ func (s *UserRepoSuite) mustCreateGroup(name string) *service.Group {
g, err := s.client.Group.Create().
SetName(name).
SetStatus(service.StatusActive).
+ SetSubscriptionType(service.SubscriptionTypeSubscription).
Save(s.ctx)
s.Require().NoError(err, "create group")
return groupEntityToService(g)
@@ -160,6 +166,21 @@ func (s *UserRepoSuite) TestUpdate() {
s.Require().Equal("updated", updated.Username)
}
+func (s *UserRepoSuite) TestUpdatePersistsTokenVersionForSessionRevocation() {
+ user := s.mustCreateUser(&service.User{Email: "token-version@test.com"})
+
+ loaded, err := s.repo.GetByID(s.ctx, user.ID)
+ s.Require().NoError(err)
+ loaded.TokenVersion = 9
+ loaded.TokenVersionResolved = true
+ s.Require().NoError(s.repo.Update(s.ctx, loaded), "persist token version")
+
+ reloaded, err := s.repo.GetByID(s.ctx, user.ID)
+ s.Require().NoError(err)
+ s.Require().True(reloaded.TokenVersionResolved)
+ s.Require().Equal(int64(9), reloaded.TokenVersion)
+}
+
func (s *UserRepoSuite) TestUpdateIgnoresNoRowsFromConflictingEmailIdentityUpsert() {
user := s.mustCreateUser(&service.User{Email: "update-existing-identity@test.com", Username: "original"})
@@ -279,6 +300,27 @@ func (s *UserRepoSuite) TestListWithFilters_SearchByUsername() {
s.Require().Equal("JohnDoe", users[0].Username)
}
+func (s *UserRepoSuite) TestListWithFilters_SearchesAPIKeyPrefixNotCiphertext() {
+ user := s.mustCreateUser(&service.User{Email: "prefix-owner@test.com", Username: "PrefixOwner"})
+ _, err := s.client.APIKey.Create().
+ SetUserID(user.ID).
+ SetKey("enc:v1:opaque-ciphertext-that-must-not-be-searched").
+ SetKeyPrefix("sk-ab123").
+ SetName("prefix key").
+ SetStatus(service.StatusActive).
+ Save(s.ctx)
+ s.Require().NoError(err)
+
+ users, _, err := s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.UserListFilters{Search: "sk-ab123"})
+ s.Require().NoError(err)
+ s.Require().Len(users, 1)
+ s.Require().Equal(user.ID, users[0].ID)
+
+ users, _, err = s.repo.ListWithFilters(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, service.UserListFilters{Search: "opaque-ciphertext"})
+ s.Require().NoError(err)
+ s.Require().Empty(users)
+}
+
func (s *UserRepoSuite) TestListWithFilters_LoadsActiveSubscriptions() {
user := s.mustCreateUser(&service.User{Email: "sub@test.com", Status: service.StatusActive})
groupActive := s.mustCreateGroup("g-sub-active")
diff --git a/backend/internal/repository/user_repo_sort_integration_test.go b/backend/internal/repository/user_repo_sort_integration_test.go
index 3a15bc1024b..9807650a96a 100644
--- a/backend/internal/repository/user_repo_sort_integration_test.go
+++ b/backend/internal/repository/user_repo_sort_integration_test.go
@@ -16,7 +16,7 @@ func (s *UserRepoSuite) mustInsertUsageLog(userID int64, createdAt time.Time) {
account := mustCreateAccount(s.T(), s.client, &service.Account{Name: "usage-log-account"})
apiKey := mustCreateApiKey(s.T(), s.client, &service.APIKey{UserID: userID})
- _, err := integrationDB.ExecContext(
+ _, err := s.repo.sql.ExecContext(
s.ctx,
`INSERT INTO usage_logs (user_id, api_key_id, account_id, model, input_tokens, output_tokens, total_cost, actual_cost, created_at)
VALUES ($1, $2, $3, 'gpt-test', 1, 1, 0.01, 0.01, $4)`,
diff --git a/backend/internal/repository/user_subscription_entitlement_security_test.go b/backend/internal/repository/user_subscription_entitlement_security_test.go
new file mode 100644
index 00000000000..9953288acce
--- /dev/null
+++ b/backend/internal/repository/user_subscription_entitlement_security_test.go
@@ -0,0 +1,100 @@
+package repository
+
+import (
+ "context"
+ "testing"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLegacySubscriptionAnchorCoverageUsesPlanIntersection(t *testing.T) {
+ tests := []struct {
+ name string
+ planCovers []bool
+ want bool
+ }{
+ {name: "no plan for anchor fails closed", planCovers: nil, want: false},
+ {name: "single plan covers target", planCovers: []bool{true}, want: true},
+ {name: "single plan does not cover target", planCovers: []bool{false}, want: false},
+ {name: "shared anchor inconsistent coverage", planCovers: []bool{false, true}, want: false},
+ {name: "shared anchor unanimous coverage", planCovers: []bool{true, true}, want: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx, client, anchorID, targetID := newLegacyEntitlementFixture(t)
+ for index, covers := range tt.planCovers {
+ plan := createLegacyEntitlementPlan(t, ctx, client, anchorID, index)
+ if covers {
+ bindLegacyEntitlementPlanGroup(t, ctx, client, plan.ID, targetID)
+ }
+ }
+
+ covered, err := legacySubscriptionAnchorCoversGroupUnambiguously(ctx, client, anchorID, targetID)
+ require.NoError(t, err)
+ require.Equal(t, tt.want, covered)
+ })
+ }
+}
+
+func TestLegacySubscriptionMNAnchorCoverageUsesPlanIntersection(t *testing.T) {
+ ctx, client, anchorID, targetID := newLegacyEntitlementFixture(t)
+ for index, covers := range []bool{true, false} {
+ plan, err := client.SubscriptionPlan.Create().
+ SetName("legacy-mn-plan-" + string(rune('a'+index))).
+ SetPlanType(service.PlanTypeSubscription).
+ SetPrice(10).
+ Save(ctx)
+ require.NoError(t, err)
+ bindLegacyEntitlementPlanGroup(t, ctx, client, plan.ID, anchorID)
+ if covers {
+ bindLegacyEntitlementPlanGroup(t, ctx, client, plan.ID, targetID)
+ }
+ }
+
+ covered, err := legacySubscriptionAnchorCoversGroupUnambiguously(ctx, client, anchorID, targetID)
+ require.NoError(t, err)
+ require.False(t, covered)
+}
+
+func newLegacyEntitlementFixture(t *testing.T) (context.Context, *dbent.Client, int64, int64) {
+ t.Helper()
+ ctx := context.Background()
+ client := newSecuritySecretTestClient(t)
+ anchor, err := client.Group.Create().
+ SetName("legacy-entitlement-anchor").
+ SetStatus(service.StatusActive).
+ SetSubscriptionType(service.SubscriptionTypeSubscription).
+ Save(ctx)
+ require.NoError(t, err)
+ target, err := client.Group.Create().
+ SetName("legacy-entitlement-target").
+ SetStatus(service.StatusActive).
+ SetSubscriptionType(service.SubscriptionTypeStandard).
+ Save(ctx)
+ require.NoError(t, err)
+ return ctx, client, anchor.ID, target.ID
+}
+
+func createLegacyEntitlementPlan(t *testing.T, ctx context.Context, client *dbent.Client, anchorID int64, index int) *dbent.SubscriptionPlan {
+ t.Helper()
+ plan, err := client.SubscriptionPlan.Create().
+ SetGroupID(anchorID).
+ SetName("legacy-entitlement-plan-" + string(rune('a'+index))).
+ SetPlanType(service.PlanTypeSubscription).
+ SetPrice(10).
+ Save(ctx)
+ require.NoError(t, err)
+ return plan
+}
+
+func bindLegacyEntitlementPlanGroup(t *testing.T, ctx context.Context, client *dbent.Client, planID, groupID int64) {
+ t.Helper()
+ _, err := client.SubscriptionPlanGroup.Create().
+ SetPlanID(planID).
+ SetGroupID(groupID).
+ Save(ctx)
+ require.NoError(t, err)
+}
diff --git a/backend/internal/repository/user_subscription_repo.go b/backend/internal/repository/user_subscription_repo.go
index e3f64a5f6ac..836816a6985 100644
--- a/backend/internal/repository/user_subscription_repo.go
+++ b/backend/internal/repository/user_subscription_repo.go
@@ -2,13 +2,19 @@ package repository
import (
"context"
+ "errors"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/group"
+ "github.com/Wei-Shaw/sub2api/ent/predicate"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
+ entuser "github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/ent/usersubscription"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/lib/pq"
)
type userSubscriptionRepository struct {
@@ -27,7 +33,7 @@ func (r *userSubscriptionRepository) Create(ctx context.Context, sub *service.Us
client := clientFromContext(ctx, r.client)
builder := client.UserSubscription.Create().
SetUserID(sub.UserID).
- SetGroupID(sub.GroupID).
+ SetNillableGroupID(sub.GroupID).
SetExpiresAt(sub.ExpiresAt).
SetNillableDailyWindowStart(sub.DailyWindowStart).
SetNillableWeeklyWindowStart(sub.WeeklyWindowStart).
@@ -35,7 +41,12 @@ func (r *userSubscriptionRepository) Create(ctx context.Context, sub *service.Us
SetDailyUsageUsd(sub.DailyUsageUSD).
SetWeeklyUsageUsd(sub.WeeklyUsageUSD).
SetMonthlyUsageUsd(sub.MonthlyUsageUSD).
+ SetNillableWalletBalanceUsd(sub.WalletBalanceUSD).
+ SetNillableWalletInitialUsd(sub.WalletInitialUSD).
SetNillableAssignedBy(sub.AssignedBy)
+ if len(sub.LockedRates) > 0 {
+ builder.SetLockedRates(cloneLockedRates(sub.LockedRates))
+ }
if sub.StartsAt.IsZero() {
builder.SetStartsAt(time.Now())
@@ -84,6 +95,18 @@ func (r *userSubscriptionRepository) GetByUserIDAndGroupID(ctx context.Context,
return userSubscriptionEntityToService(m), nil
}
+// LockUserForSubscriptionAssignment serializes all subscription entitlement
+// mutations for one user. The caller must already be in a transaction; the
+// service only invokes this method when an ent transaction is present.
+func (r *userSubscriptionRepository) LockUserForSubscriptionAssignment(ctx context.Context, userID int64) error {
+ client := clientFromContext(ctx, r.client)
+ _, err := client.User.Query().
+ Where(entuser.IDEQ(userID)).
+ ForUpdate().
+ OnlyID(ctx)
+ return err
+}
+
func (r *userSubscriptionRepository) GetActiveByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
client := clientFromContext(ctx, r.client)
m, err := client.UserSubscription.Query().
@@ -101,6 +124,317 @@ func (r *userSubscriptionRepository) GetActiveByUserIDAndGroupID(ctx context.Con
return userSubscriptionEntityToService(m), nil
}
+// GetActiveWalletByUserID 返回最快到期的 active 钱包订阅(先到期先消费)。
+// 多条月卡叠加场景下,计费侧优先消费最快到期的那张。
+// 使用 First() 而非 Only(),兼容多条并存。
+func (r *userSubscriptionRepository) GetActiveWalletByUserID(ctx context.Context, userID int64) (*service.UserSubscription, error) {
+ client := clientFromContext(ctx, r.client)
+ m, err := client.UserSubscription.Query().
+ Where(
+ usersubscription.UserIDEQ(userID),
+ usersubscription.GroupIDIsNil(),
+ usersubscription.WalletBalanceUsdNotNil(),
+ usersubscription.StatusEQ(service.SubscriptionStatusActive),
+ usersubscription.ExpiresAtGT(time.Now()),
+ ).
+ Order(dbent.Asc(usersubscription.FieldExpiresAt), dbent.Asc(usersubscription.FieldID)).
+ WithGroup().
+ First(ctx)
+ if err != nil {
+ return nil, translatePersistenceError(err, service.ErrSubscriptionNotFound, nil)
+ }
+ return userSubscriptionEntityToService(m), nil
+}
+
+func (r *userSubscriptionRepository) GetActiveCreditsWalletByUserID(ctx context.Context, userID int64) (*service.UserSubscription, error) {
+ client := clientFromContext(ctx, r.client)
+ creditsThreshold := service.MaxExpiresAt.Add(-24 * time.Hour)
+ m, err := client.UserSubscription.Query().
+ Where(
+ usersubscription.UserIDEQ(userID),
+ usersubscription.GroupIDIsNil(),
+ usersubscription.WalletBalanceUsdNotNil(),
+ usersubscription.StatusEQ(service.SubscriptionStatusActive),
+ usersubscription.ExpiresAtGTE(creditsThreshold),
+ ).
+ Order(dbent.Asc(usersubscription.FieldID)).
+ WithGroup().
+ First(ctx)
+ if err != nil {
+ return nil, translatePersistenceError(err, service.ErrSubscriptionNotFound, nil)
+ }
+ return userSubscriptionEntityToService(m), nil
+}
+
+// GetActiveByPlanCoveringGroup 查询用户 active 月卡订阅,其 plan 通过
+// subscription_plan_groups 覆盖目标 group。兼容两种 plan 形态:
+// 1. 老 v3 plan.group_id = 订阅主 group;
+// 2. 新 M:N plan.group_id = NULL,由 plan_groups 中 subscription 类型 group 作为订阅锚点。
+//
+// 见 docs/plans/2026-05-16-wallet-v4-group-switch-billing-fix.md §4.1。
+func (r *userSubscriptionRepository) GetActiveByPlanCoveringGroup(ctx context.Context, userID, targetGroupID int64) (*service.UserSubscription, error) {
+ client := clientFromContext(ctx, r.client)
+ if snapshotted, err := r.getActiveSnapshotGrantCoveringGroup(ctx, client, userID, targetGroupID); err != nil {
+ return nil, err
+ } else if snapshotted != nil {
+ return snapshotted, nil
+ }
+
+ primaryGroupIDs, err := r.coveringSubscriptionGroupIDs(ctx, client, targetGroupID)
+ if err != nil {
+ return nil, err
+ }
+ if len(primaryGroupIDs) == 0 {
+ return nil, service.ErrSubscriptionNotFound
+ }
+
+ models, err := client.UserSubscription.Query().
+ Where(
+ usersubscription.UserIDEQ(userID),
+ usersubscription.StatusEQ(service.SubscriptionStatusActive),
+ usersubscription.ExpiresAtGT(time.Now()),
+ usersubscription.WalletBalanceUsdIsNil(),
+ usersubscription.GroupIDIn(primaryGroupIDs...),
+ ).
+ WithGroup().
+ Order(dbent.Desc(usersubscription.FieldExpiresAt)).
+ All(ctx)
+ if err != nil {
+ return nil, translatePersistenceError(err, service.ErrSubscriptionNotFound, nil)
+ }
+ for _, model := range models {
+ hasGrant, grantErr := userSubscriptionHasAttachedSnapshotGrant(ctx, client, model.ID)
+ if grantErr != nil {
+ return nil, grantErr
+ }
+ if hasGrant {
+ continue
+ }
+ if model.GroupID == nil {
+ continue
+ }
+ covered, coverageErr := legacySubscriptionAnchorCoversGroupUnambiguously(ctx, client, *model.GroupID, targetGroupID)
+ if coverageErr != nil {
+ return nil, coverageErr
+ }
+ if !covered {
+ continue
+ }
+ return userSubscriptionEntityToService(model), nil
+ }
+ return nil, service.ErrSubscriptionNotFound
+}
+
+// legacySubscriptionAnchorCoversGroupUnambiguously grants an unsnapshotted
+// subscription only when every currently identifiable subscription plan using
+// the same anchor covers the requested group. A missing plan or divergent
+// shared-anchor coverage is ambiguous historical evidence and fails closed.
+func legacySubscriptionAnchorCoversGroupUnambiguously(ctx context.Context, client *dbent.Client, anchorGroupID, targetGroupID int64) (bool, error) {
+ rows, err := client.QueryContext(ctx, `
+ WITH anchor_plans AS (
+ SELECT plan.id
+ FROM subscription_plans plan
+ JOIN groups anchor_group
+ ON anchor_group.id = plan.group_id
+ AND anchor_group.subscription_type = $3
+ AND anchor_group.deleted_at IS NULL
+ WHERE plan.plan_type = $4
+ AND plan.group_id = $1
+
+ UNION
+
+ SELECT plan.id
+ FROM subscription_plans plan
+ JOIN subscription_plan_groups anchor
+ ON anchor.plan_id = plan.id
+ AND anchor.group_id = $1
+ JOIN groups anchor_group
+ ON anchor_group.id = anchor.group_id
+ AND anchor_group.subscription_type = $3
+ AND anchor_group.deleted_at IS NULL
+ WHERE plan.plan_type = $4
+ AND plan.group_id IS NULL
+ )
+ SELECT EXISTS (SELECT 1 FROM anchor_plans)
+ AND NOT EXISTS (
+ SELECT 1
+ FROM anchor_plans plan
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM subscription_plan_groups target
+ WHERE target.plan_id = plan.id
+ AND target.group_id = $2
+ )
+ )
+ `, anchorGroupID, targetGroupID, service.SubscriptionTypeSubscription, service.PlanTypeSubscription)
+ if err != nil {
+ return false, err
+ }
+ defer rows.Close()
+ if !rows.Next() {
+ if err := rows.Err(); err != nil {
+ return false, err
+ }
+ return false, errors.New("legacy subscription anchor coverage query returned no row")
+ }
+ var covered bool
+ if err := rows.Scan(&covered); err != nil {
+ return false, err
+ }
+ if err := rows.Err(); err != nil {
+ return false, err
+ }
+ return covered, nil
+}
+
+func (r *userSubscriptionRepository) getActiveSnapshotGrantCoveringGroup(ctx context.Context, client *dbent.Client, userID, targetGroupID int64) (*service.UserSubscription, error) {
+ var subscriptionID int64
+ rows, err := client.QueryContext(ctx, `
+ SELECT us.id
+ FROM user_subscriptions us
+ JOIN subscription_plan_fulfillment_snapshots grant_snapshot
+ ON grant_snapshot.user_subscription_id = us.id
+ WHERE us.user_id = $1
+ AND us.deleted_at IS NULL
+ AND us.status = $2
+ AND us.expires_at > NOW()
+ AND us.wallet_balance_usd IS NULL
+ AND grant_snapshot.grant_starts_at <= NOW()
+ AND grant_snapshot.grant_expires_at > NOW()
+ AND grant_snapshot.snapshot->'covered_group_ids' @> jsonb_build_array($3::bigint)
+ ORDER BY grant_snapshot.grant_expires_at DESC, grant_snapshot.payment_order_id DESC
+ LIMIT 1
+ `, userID, service.SubscriptionStatusActive, targetGroupID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ if !rows.Next() {
+ return nil, nil
+ }
+ if err := rows.Scan(&subscriptionID); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ m, err := client.UserSubscription.Query().
+ Where(usersubscription.IDEQ(subscriptionID)).
+ WithGroup().
+ Only(ctx)
+ if err != nil {
+ return nil, translatePersistenceError(err, service.ErrSubscriptionNotFound, nil)
+ }
+ return userSubscriptionEntityToService(m), nil
+}
+
+func userSubscriptionHasAttachedSnapshotGrant(ctx context.Context, client *dbent.Client, subscriptionID int64) (bool, error) {
+ var exists bool
+ rows, err := client.QueryContext(ctx, `
+ SELECT EXISTS (
+ SELECT 1
+ FROM subscription_plan_fulfillment_snapshots
+ WHERE user_subscription_id = $1
+ AND grant_starts_at IS NOT NULL
+ AND grant_expires_at IS NOT NULL
+ )
+ `, subscriptionID)
+ if err != nil {
+ return false, err
+ }
+ defer rows.Close()
+ if !rows.Next() {
+ return false, nil
+ }
+ if err := rows.Scan(&exists); err != nil {
+ return false, err
+ }
+ if err := rows.Err(); err != nil {
+ return false, err
+ }
+ return exists, nil
+}
+
+func (r *userSubscriptionRepository) coveringSubscriptionGroupIDs(ctx context.Context, client *dbent.Client, targetGroupID int64) ([]int64, error) {
+ seen := make(map[int64]struct{})
+ primaryGroupIDs := make([]int64, 0)
+
+ coveringPrimaryGroupIDs, err := client.SubscriptionPlan.Query().
+ Where(
+ subscriptionplan.GroupIDNotNil(),
+ subscriptionplan.HasPlanGroupsWith(
+ subscriptionplangroup.GroupIDEQ(targetGroupID),
+ ),
+ ).
+ Select(subscriptionplan.FieldGroupID).
+ Ints(ctx)
+ if err != nil {
+ return nil, err
+ }
+ primaryGroupIDs = appendUniqueIntIDs(primaryGroupIDs, seen, coveringPrimaryGroupIDs)
+
+ coveringPlanIDs, err := client.SubscriptionPlanGroup.Query().
+ Where(subscriptionplangroup.GroupIDEQ(targetGroupID)).
+ Select(subscriptionplangroup.FieldPlanID).
+ Ints(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if len(coveringPlanIDs) == 0 {
+ return primaryGroupIDs, nil
+ }
+
+ planIDs := intIDsToInt64(coveringPlanIDs)
+ anchorGroupIDs, err := client.SubscriptionPlanGroup.Query().
+ Where(
+ subscriptionplangroup.PlanIDIn(planIDs...),
+ subscriptionplangroup.HasGroupWith(
+ group.SubscriptionTypeEQ(service.SubscriptionTypeSubscription),
+ group.DeletedAtIsNil(),
+ ),
+ ).
+ Select(subscriptionplangroup.FieldGroupID).
+ Ints(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return appendUniqueIntIDs(primaryGroupIDs, seen, anchorGroupIDs), nil
+}
+
+func appendUniqueIntIDs(out []int64, seen map[int64]struct{}, values []int) []int64 {
+ for _, value := range values {
+ id := int64(value)
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ out = append(out, id)
+ }
+ return out
+}
+
+func intIDsToInt64(values []int) []int64 {
+ out := make([]int64, len(values))
+ for i, value := range values {
+ out[i] = int64(value)
+ }
+ return out
+}
+
+// HasAnyActiveSubscription 用户是否有任何 active 订阅(含钱包 / 月卡)。
+// middleware fallback 决策用:有订阅但当前 group 不覆盖 → 403;无订阅 → 允许
+// 走老 user.balance 兼容路径。
+func (r *userSubscriptionRepository) HasAnyActiveSubscription(ctx context.Context, userID int64) (bool, error) {
+ client := clientFromContext(ctx, r.client)
+ return client.UserSubscription.Query().
+ Where(
+ usersubscription.UserIDEQ(userID),
+ usersubscription.StatusEQ(service.SubscriptionStatusActive),
+ usersubscription.ExpiresAtGT(time.Now()),
+ ).
+ Exist(ctx)
+}
+
func (r *userSubscriptionRepository) Update(ctx context.Context, sub *service.UserSubscription) error {
if sub == nil {
return service.ErrSubscriptionNilInput
@@ -109,7 +443,7 @@ func (r *userSubscriptionRepository) Update(ctx context.Context, sub *service.Us
client := clientFromContext(ctx, r.client)
builder := client.UserSubscription.UpdateOneID(sub.ID).
SetUserID(sub.UserID).
- SetGroupID(sub.GroupID).
+ SetNillableGroupID(sub.GroupID).
SetStartsAt(sub.StartsAt).
SetExpiresAt(sub.ExpiresAt).
SetStatus(sub.Status).
@@ -119,9 +453,14 @@ func (r *userSubscriptionRepository) Update(ctx context.Context, sub *service.Us
SetDailyUsageUsd(sub.DailyUsageUSD).
SetWeeklyUsageUsd(sub.WeeklyUsageUSD).
SetMonthlyUsageUsd(sub.MonthlyUsageUSD).
+ SetNillableWalletBalanceUsd(sub.WalletBalanceUSD).
+ SetNillableWalletInitialUsd(sub.WalletInitialUSD).
SetNillableAssignedBy(sub.AssignedBy).
SetAssignedAt(sub.AssignedAt).
SetNotes(sub.Notes)
+ if len(sub.LockedRates) > 0 {
+ builder.SetLockedRates(cloneLockedRates(sub.LockedRates))
+ }
updated, err := builder.Save(ctx)
if err == nil {
@@ -135,6 +474,18 @@ func (r *userSubscriptionRepository) Delete(ctx context.Context, id int64) error
// Match GORM semantics: deleting a missing row is not an error.
client := clientFromContext(ctx, r.client)
_, err := client.UserSubscription.Delete().Where(usersubscription.IDEQ(id)).Exec(ctx)
+ return translateWalletSubscriptionDeleteError(err)
+}
+
+func translateWalletSubscriptionDeleteError(err error) error {
+ if err == nil {
+ return nil
+ }
+ var pgErr *pq.Error
+ if errors.As(err, &pgErr) && pgErr != nil && pgErr.Code == "23514" &&
+ pgErr.Constraint == "hfc_wallet_open_admission_revoke" {
+ return service.ErrSubscriptionUsageBillingInFlight.WithCause(err)
+ }
return err
}
@@ -309,6 +660,51 @@ func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int
return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil)
}
+func (r *userSubscriptionRepository) AdvanceUsageWindow(ctx context.Context, id int64, advance service.SubscriptionUsageWindowAdvance) (bool, error) {
+ client := clientFromContext(ctx, r.client)
+ update := client.UserSubscription.Update().Where(
+ usersubscription.IDEQ(id),
+ usersubscription.DeletedAtIsNil(),
+ )
+
+ switch advance.Window {
+ case service.SubscriptionUsageWindowDaily:
+ update.Where(subscriptionWindowStartPredicate(advance.ExpectedStart, usersubscription.DailyWindowStartIsNil, usersubscription.DailyWindowStartEQ)).
+ SetDailyWindowStart(advance.NewStart)
+ if advance.ResetUsage {
+ update.SetDailyUsageUsd(0)
+ }
+ case service.SubscriptionUsageWindowWeekly:
+ update.Where(subscriptionWindowStartPredicate(advance.ExpectedStart, usersubscription.WeeklyWindowStartIsNil, usersubscription.WeeklyWindowStartEQ)).
+ SetWeeklyWindowStart(advance.NewStart)
+ if advance.ResetUsage {
+ update.SetWeeklyUsageUsd(0)
+ }
+ case service.SubscriptionUsageWindowMonthly:
+ update.Where(subscriptionWindowStartPredicate(advance.ExpectedStart, usersubscription.MonthlyWindowStartIsNil, usersubscription.MonthlyWindowStartEQ)).
+ SetMonthlyWindowStart(advance.NewStart)
+ if advance.ResetUsage {
+ update.SetMonthlyUsageUsd(0)
+ }
+ default:
+ return false, service.ErrInvalidInput
+ }
+
+ affected, err := update.Save(ctx)
+ return affected == 1, err
+}
+
+func subscriptionWindowStartPredicate(
+ expected *time.Time,
+ isNil func() predicate.UserSubscription,
+ equals func(time.Time) predicate.UserSubscription,
+) predicate.UserSubscription {
+ if expected == nil {
+ return isNil()
+ }
+ return equals(*expected)
+}
+
func (r *userSubscriptionRepository) ResetDailyUsage(ctx context.Context, id int64, newWindowStart time.Time) error {
client := clientFromContext(ctx, r.client)
_, err := client.UserSubscription.UpdateOneID(id).
@@ -442,6 +838,9 @@ func userSubscriptionEntityToService(m *dbent.UserSubscription) *service.UserSub
DailyUsageUSD: m.DailyUsageUsd,
WeeklyUsageUSD: m.WeeklyUsageUsd,
MonthlyUsageUSD: m.MonthlyUsageUsd,
+ WalletBalanceUSD: m.WalletBalanceUsd,
+ WalletInitialUSD: m.WalletInitialUsd,
+ LockedRates: cloneLockedRates(m.LockedRates),
AssignedBy: m.AssignedBy,
AssignedAt: m.AssignedAt,
Notes: derefString(m.Notes),
@@ -478,3 +877,14 @@ func applyUserSubscriptionEntityToService(dst *service.UserSubscription, src *db
dst.CreatedAt = src.CreatedAt
dst.UpdatedAt = src.UpdatedAt
}
+
+func cloneLockedRates(in map[string]float64) map[string]float64 {
+ if len(in) == 0 {
+ return nil
+ }
+ out := make(map[string]float64, len(in))
+ for k, v := range in {
+ out[k] = v
+ }
+ return out
+}
diff --git a/backend/internal/repository/user_subscription_repo_integration_test.go b/backend/internal/repository/user_subscription_repo_integration_test.go
index a74860e3183..dbeb5108597 100644
--- a/backend/internal/repository/user_subscription_repo_integration_test.go
+++ b/backend/internal/repository/user_subscription_repo_integration_test.go
@@ -5,6 +5,7 @@ package repository
import (
"context"
"fmt"
+ "strconv"
"testing"
"time"
@@ -26,6 +27,15 @@ func (s *UserSubscriptionRepoSuite) SetupTest() {
tx := testEntTx(s.T())
s.client = tx.Client()
s.repo = NewUserSubscriptionRepository(s.client).(*userSubscriptionRepository)
+ _, err := tx.ExecContext(s.ctx, `
+ SET LOCAL session_replication_role = 'replica';
+ DELETE FROM user_subscriptions;
+ DELETE FROM user_allowed_groups;
+ DELETE FROM users;
+ DELETE FROM groups;
+ SET LOCAL session_replication_role = 'origin';
+ `)
+ s.Require().NoError(err, "isolate subscription repository test transaction")
}
func TestUserSubscriptionRepoSuite(t *testing.T) {
@@ -55,6 +65,7 @@ func (s *UserSubscriptionRepoSuite) mustCreateGroup(name string) *service.Group
g, err := s.client.Group.Create().
SetName(name).
SetStatus(service.StatusActive).
+ SetSubscriptionType(service.SubscriptionTypeSubscription).
Save(s.ctx)
s.Require().NoError(err, "create group")
return groupEntityToService(g)
@@ -90,7 +101,7 @@ func (s *UserSubscriptionRepoSuite) TestCreate() {
sub := &service.UserSubscription{
UserID: user.ID,
- GroupID: group.ID,
+ GroupID: repositoryInt64Ptr(group.ID),
Status: service.SubscriptionStatusActive,
ExpiresAt: time.Now().Add(24 * time.Hour),
}
@@ -204,6 +215,162 @@ func (s *UserSubscriptionRepoSuite) TestGetActiveByUserIDAndGroupID_ExpiredIgnor
s.Require().Error(err, "expected error for expired subscription")
}
+func (s *UserSubscriptionRepoSuite) TestGetActiveByPlanCoveringGroup_MNPlanUsesSubscriptionAnchor() {
+ user := s.mustCreateUser("mn-plan-cover@test.com", service.RoleUser)
+ standardOnlyUser := s.mustCreateUser("mn-plan-standard-only@test.com", service.RoleUser)
+
+ subscriptionGroup, err := s.client.Group.Create().
+ SetName("paid-lite-v3-mn-anchor").
+ SetStatus(service.StatusActive).
+ SetSubscriptionType(service.SubscriptionTypeSubscription).
+ SetRateMultiplier(1).
+ Save(s.ctx)
+ s.Require().NoError(err, "create subscription anchor group")
+
+ openAIGroup, err := s.client.Group.Create().
+ SetName("openai-default-mn-target").
+ SetPlatform(service.PlatformOpenAI).
+ SetStatus(service.StatusActive).
+ SetSubscriptionType(service.SubscriptionTypeStandard).
+ SetRateMultiplier(0.001).
+ Save(s.ctx)
+ s.Require().NoError(err, "create openai target group")
+
+ claudeGroup, err := s.client.Group.Create().
+ SetName("claude-default-mn-target").
+ SetStatus(service.StatusActive).
+ SetSubscriptionType(service.SubscriptionTypeStandard).
+ SetRateMultiplier(1).
+ Save(s.ctx)
+ s.Require().NoError(err, "create claude target group")
+
+ plan, err := s.client.SubscriptionPlan.Create().
+ SetGroupID(subscriptionGroup.ID).
+ SetName("paid-lite-v3-mn").
+ SetPrice(99).
+ SetValidityDays(30).
+ SetValidityUnit("day").
+ Save(s.ctx)
+ s.Require().NoError(err, "create M:N subscription plan")
+
+ for _, groupID := range []int64{subscriptionGroup.ID, openAIGroup.ID, claudeGroup.ID} {
+ _, err = s.client.SubscriptionPlanGroup.Create().
+ SetPlanID(plan.ID).
+ SetGroupID(groupID).
+ Save(s.ctx)
+ s.Require().NoError(err, "bind plan group")
+ }
+
+ sub := s.mustCreateSubscription(user.ID, subscriptionGroup.ID, nil)
+ got, err := s.repo.GetActiveByPlanCoveringGroup(s.ctx, user.ID, openAIGroup.ID)
+ s.Require().NoError(err, "M:N plan coverage should resolve through subscription anchor group")
+ s.Require().Equal(sub.ID, got.ID)
+
+ _, err = s.repo.GetActiveByPlanCoveringGroup(s.ctx, standardOnlyUser.ID, claudeGroup.ID)
+ s.Require().ErrorIs(err, service.ErrSubscriptionNotFound, "plan coverage requires a real subscription anchor")
+}
+
+func (s *UserSubscriptionRepoSuite) TestGetActiveByPlanCoveringGroup_SharedAnchorUsesCoverageIntersection() {
+ user := s.mustCreateUser("shared-anchor@test.com", service.RoleUser)
+ anchor := s.mustCreateGroup("g-shared-anchor")
+ target := s.mustCreateGroup("g-shared-target")
+ _, err := s.client.Group.UpdateOneID(target.ID).SetSubscriptionType(service.SubscriptionTypeStandard).Save(s.ctx)
+ s.Require().NoError(err)
+
+ createPlan := func(name string, coversTarget bool) *dbent.SubscriptionPlan {
+ plan, err := s.client.SubscriptionPlan.Create().
+ SetGroupID(anchor.ID).
+ SetPlanType(service.PlanTypeSubscription).
+ SetName(name).
+ SetPrice(10).
+ SetValidityDays(30).
+ Save(s.ctx)
+ s.Require().NoError(err)
+ _, err = s.client.SubscriptionPlanGroup.Create().SetPlanID(plan.ID).SetGroupID(anchor.ID).Save(s.ctx)
+ s.Require().NoError(err)
+ if coversTarget {
+ _, err = s.client.SubscriptionPlanGroup.Create().SetPlanID(plan.ID).SetGroupID(target.ID).Save(s.ctx)
+ s.Require().NoError(err)
+ }
+ return plan
+ }
+
+ narrow := createPlan("shared-anchor-narrow", false)
+ _ = createPlan("shared-anchor-broad", true)
+ sub := s.mustCreateSubscription(user.ID, anchor.ID, nil)
+
+ _, err = s.repo.GetActiveByPlanCoveringGroup(s.ctx, user.ID, target.ID)
+ s.Require().ErrorIs(err, service.ErrSubscriptionNotFound,
+ "one broader plan sharing the anchor must not expand an ambiguous legacy subscription")
+
+ _, err = s.client.SubscriptionPlanGroup.Create().SetPlanID(narrow.ID).SetGroupID(target.ID).Save(s.ctx)
+ s.Require().NoError(err)
+ got, err := s.repo.GetActiveByPlanCoveringGroup(s.ctx, user.ID, target.ID)
+ s.Require().NoError(err)
+ s.Require().Equal(sub.ID, got.ID,
+ "legacy compatibility is allowed only when every plan sharing the anchor covers the target")
+}
+
+func (s *UserSubscriptionRepoSuite) TestGetActiveByPlanCoveringGroup_SnapshotIsAuthoritative() {
+ user := s.mustCreateUser("snapshot-authority@test.com", service.RoleUser)
+ anchor := s.mustCreateGroup("g-snapshot-anchor")
+ allowed := s.mustCreateGroup("g-snapshot-allowed")
+ denied := s.mustCreateGroup("g-snapshot-denied")
+ for _, groupID := range []int64{allowed.ID, denied.ID} {
+ _, err := s.client.Group.UpdateOneID(groupID).SetSubscriptionType(service.SubscriptionTypeStandard).Save(s.ctx)
+ s.Require().NoError(err)
+ }
+ plan, err := s.client.SubscriptionPlan.Create().
+ SetGroupID(anchor.ID).
+ SetPlanType(service.PlanTypeSubscription).
+ SetName("snapshot-authority-plan").
+ SetPrice(10).
+ SetValidityDays(30).
+ Save(s.ctx)
+ s.Require().NoError(err)
+ for _, groupID := range []int64{anchor.ID, allowed.ID, denied.ID} {
+ _, err = s.client.SubscriptionPlanGroup.Create().SetPlanID(plan.ID).SetGroupID(groupID).Save(s.ctx)
+ s.Require().NoError(err)
+ }
+ sub := s.mustCreateSubscription(user.ID, anchor.ID, nil)
+
+ _, err = s.client.ExecContext(s.ctx, "SET LOCAL session_replication_role = 'replica'")
+ s.Require().NoError(err)
+ insertIntegrationPlanFulfillmentSnapshot(s.T(), s.ctx, s.client, -sub.ID, user.ID, integrationPlanFulfillmentSnapshot{
+ SchemaVersion: 1,
+ PlanID: plan.ID,
+ PlanType: service.PlanTypeSubscription,
+ PlanName: "snapshot authority plan",
+ PlanPrice: 10,
+ GroupID: &anchor.ID,
+ SubscriptionDays: 30,
+ CoveredGroupIDs: []int64{anchor.ID, allowed.ID},
+ LockedRates: map[string]float64{
+ strconv.FormatInt(anchor.ID, 10): 1,
+ strconv.FormatInt(allowed.ID, 10): 1,
+ },
+ CaptureSource: "integration-test",
+ })
+ _, err = s.client.ExecContext(s.ctx, `
+ UPDATE subscription_plan_fulfillment_snapshots
+ SET user_subscription_id = $1,
+ grant_starts_at = NOW() - INTERVAL '1 minute',
+ grant_expires_at = NOW() + INTERVAL '1 day',
+ attached_at = NOW()
+ WHERE payment_order_id = $2
+ `, sub.ID, -sub.ID)
+ s.Require().NoError(err)
+ _, err = s.client.ExecContext(s.ctx, "SET LOCAL session_replication_role = 'origin'")
+ s.Require().NoError(err)
+
+ got, err := s.repo.GetActiveByPlanCoveringGroup(s.ctx, user.ID, allowed.ID)
+ s.Require().NoError(err)
+ s.Require().Equal(sub.ID, got.ID)
+ _, err = s.repo.GetActiveByPlanCoveringGroup(s.ctx, user.ID, denied.ID)
+ s.Require().ErrorIs(err, service.ErrSubscriptionNotFound,
+ "a live plan mapping must not widen a subscription whose immutable snapshot excludes the target")
+}
+
// --- ListByUserID / ListActiveByUserID ---
func (s *UserSubscriptionRepoSuite) TestListByUserID() {
@@ -302,7 +469,8 @@ func (s *UserSubscriptionRepoSuite) TestList_FilterByGroupID() {
subs, _, err := s.repo.List(s.ctx, pagination.PaginationParams{Page: 1, PageSize: 10}, nil, &g1.ID, "", "", "", "")
s.Require().NoError(err)
s.Require().Len(subs, 1)
- s.Require().Equal(g1.ID, subs[0].GroupID)
+ s.Require().NotNil(subs[0].GroupID, "v3 月卡订阅 group_id 非空")
+ s.Require().Equal(g1.ID, *subs[0].GroupID)
}
func (s *UserSubscriptionRepoSuite) TestList_FilterByStatus() {
@@ -393,6 +561,87 @@ func (s *UserSubscriptionRepoSuite) TestResetDailyUsage() {
s.Require().WithinDuration(resetAt, *got.DailyWindowStart, time.Microsecond)
}
+func (s *UserSubscriptionRepoSuite) TestAdvanceUsageWindow_StaleResetCannotEraseNewUsage() {
+ oldStart := time.Now().UTC().Add(-31 * 24 * time.Hour).Truncate(time.Microsecond)
+ newStart := time.Now().UTC().Truncate(time.Microsecond)
+
+ for _, window := range []service.SubscriptionUsageWindow{
+ service.SubscriptionUsageWindowDaily,
+ service.SubscriptionUsageWindowWeekly,
+ service.SubscriptionUsageWindowMonthly,
+ } {
+ window := window
+ s.Run(string(window), func() {
+ user := s.mustCreateUser(fmt.Sprintf("window-cas-%s@test.com", window), service.RoleUser)
+ group := s.mustCreateGroup("g-window-cas-" + string(window))
+ sub := s.mustCreateSubscription(user.ID, group.ID, func(c *dbent.UserSubscriptionCreate) {
+ c.SetDailyWindowStart(oldStart).
+ SetWeeklyWindowStart(oldStart).
+ SetMonthlyWindowStart(oldStart).
+ SetDailyUsageUsd(11).
+ SetWeeklyUsageUsd(11).
+ SetMonthlyUsageUsd(11)
+ })
+
+ advanced, err := s.repo.AdvanceUsageWindow(s.ctx, sub.ID, service.SubscriptionUsageWindowAdvance{
+ Window: window,
+ ExpectedStart: &oldStart,
+ NewStart: newStart,
+ ResetUsage: true,
+ })
+ s.Require().NoError(err)
+ s.Require().True(advanced)
+ s.Require().NoError(s.repo.IncrementUsage(s.ctx, sub.ID, 5))
+
+ advanced, err = s.repo.AdvanceUsageWindow(s.ctx, sub.ID, service.SubscriptionUsageWindowAdvance{
+ Window: window,
+ ExpectedStart: &oldStart,
+ NewStart: newStart.Add(time.Minute),
+ ResetUsage: true,
+ })
+ s.Require().NoError(err)
+ s.Require().False(advanced)
+
+ got, err := s.repo.GetByID(s.ctx, sub.ID)
+ s.Require().NoError(err)
+ switch window {
+ case service.SubscriptionUsageWindowDaily:
+ s.Require().WithinDuration(newStart, *got.DailyWindowStart, time.Microsecond)
+ s.Require().InDelta(5, got.DailyUsageUSD, 1e-6)
+ case service.SubscriptionUsageWindowWeekly:
+ s.Require().WithinDuration(newStart, *got.WeeklyWindowStart, time.Microsecond)
+ s.Require().InDelta(5, got.WeeklyUsageUSD, 1e-6)
+ case service.SubscriptionUsageWindowMonthly:
+ s.Require().WithinDuration(newStart, *got.MonthlyWindowStart, time.Microsecond)
+ s.Require().InDelta(5, got.MonthlyUsageUSD, 1e-6)
+ }
+ })
+ }
+}
+
+func (s *UserSubscriptionRepoSuite) TestAdvanceUsageWindow_FirstActivationPreservesUsage() {
+ user := s.mustCreateUser("window-activation@test.com", service.RoleUser)
+ group := s.mustCreateGroup("g-window-activation")
+ sub := s.mustCreateSubscription(user.ID, group.ID, func(c *dbent.UserSubscriptionCreate) {
+ c.SetDailyUsageUsd(7)
+ })
+ start := time.Now().UTC().Truncate(time.Microsecond)
+
+ advanced, err := s.repo.AdvanceUsageWindow(s.ctx, sub.ID, service.SubscriptionUsageWindowAdvance{
+ Window: service.SubscriptionUsageWindowDaily,
+ ExpectedStart: nil,
+ NewStart: start,
+ ResetUsage: false,
+ })
+ s.Require().NoError(err)
+ s.Require().True(advanced)
+
+ got, err := s.repo.GetByID(s.ctx, sub.ID)
+ s.Require().NoError(err)
+ s.Require().WithinDuration(start, *got.DailyWindowStart, time.Microsecond)
+ s.Require().InDelta(7, got.DailyUsageUSD, 1e-6)
+}
+
func (s *UserSubscriptionRepoSuite) TestResetWeeklyUsage() {
user := s.mustCreateUser("resetw@test.com", service.RoleUser)
group := s.mustCreateGroup("g-resetw")
@@ -724,13 +973,15 @@ func (s *UserSubscriptionRepoSuite) TestTxContext_RollbackIsolation() {
groupEnt, err := tx.Client().Group.Create().
SetName("tx-group-" + suffix).
+ SetStatus(service.StatusActive).
+ SetSubscriptionType(service.SubscriptionTypeSubscription).
Save(txCtx)
s.Require().NoError(err, "create group in tx")
repo := NewUserSubscriptionRepository(baseClient)
sub := &service.UserSubscription{
UserID: userEnt.ID,
- GroupID: groupEnt.ID,
+ GroupID: repositoryInt64Ptr(groupEnt.ID),
ExpiresAt: time.Now().AddDate(0, 0, 30),
Status: service.SubscriptionStatusActive,
AssignedAt: time.Now(),
diff --git a/backend/internal/repository/user_subscription_window_security_test.go b/backend/internal/repository/user_subscription_window_security_test.go
new file mode 100644
index 00000000000..3058dc75f98
--- /dev/null
+++ b/backend/internal/repository/user_subscription_window_security_test.go
@@ -0,0 +1,147 @@
+package repository
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAdvanceUsageWindowRejectsStaleResetAfterNewUsage(t *testing.T) {
+ ctx, client, repo, subID := newUsageWindowSecurityFixture(t, 11)
+ oldStart := time.Now().UTC().Add(-31 * 24 * time.Hour).Truncate(time.Second)
+ newStart := time.Now().UTC().Truncate(time.Second)
+
+ for _, window := range []service.SubscriptionUsageWindow{
+ service.SubscriptionUsageWindowDaily,
+ service.SubscriptionUsageWindowWeekly,
+ service.SubscriptionUsageWindowMonthly,
+ } {
+ setSubscriptionWindowState(t, ctx, client, subID, window, &oldStart, 11)
+ advanced, err := repo.AdvanceUsageWindow(ctx, subID, service.SubscriptionUsageWindowAdvance{
+ Window: window,
+ ExpectedStart: &oldStart,
+ NewStart: newStart,
+ ResetUsage: true,
+ })
+ require.NoError(t, err)
+ require.True(t, advanced)
+
+ setSubscriptionWindowUsage(t, ctx, client, subID, window, 5)
+ advanced, err = repo.AdvanceUsageWindow(ctx, subID, service.SubscriptionUsageWindowAdvance{
+ Window: window,
+ ExpectedStart: &oldStart,
+ NewStart: newStart.Add(time.Minute),
+ ResetUsage: true,
+ })
+ require.NoError(t, err)
+ require.False(t, advanced)
+ requireSubscriptionWindowState(t, ctx, repo, subID, window, newStart, 5)
+ }
+}
+
+func TestAdvanceUsageWindowFirstActivationPreservesConcurrentUsage(t *testing.T) {
+ ctx, client, repo, subID := newUsageWindowSecurityFixture(t, 7)
+ start := time.Now().UTC().Truncate(time.Second)
+
+ advanced, err := repo.AdvanceUsageWindow(ctx, subID, service.SubscriptionUsageWindowAdvance{
+ Window: service.SubscriptionUsageWindowDaily,
+ ExpectedStart: nil,
+ NewStart: start,
+ ResetUsage: false,
+ })
+ require.NoError(t, err)
+ require.True(t, advanced)
+ requireSubscriptionWindowState(t, ctx, repo, subID, service.SubscriptionUsageWindowDaily, start, 7)
+
+ setSubscriptionWindowUsage(t, ctx, client, subID, service.SubscriptionUsageWindowDaily, 9)
+ advanced, err = repo.AdvanceUsageWindow(ctx, subID, service.SubscriptionUsageWindowAdvance{
+ Window: service.SubscriptionUsageWindowDaily,
+ ExpectedStart: nil,
+ NewStart: start.Add(time.Minute),
+ ResetUsage: false,
+ })
+ require.NoError(t, err)
+ require.False(t, advanced)
+ requireSubscriptionWindowState(t, ctx, repo, subID, service.SubscriptionUsageWindowDaily, start, 9)
+}
+
+func newUsageWindowSecurityFixture(t *testing.T, usage float64) (context.Context, *dbent.Client, *userSubscriptionRepository, int64) {
+ t.Helper()
+ ctx := context.Background()
+ client := newSecuritySecretTestClient(t)
+ user, err := client.User.Create().
+ SetEmail("window-security-" + time.Now().Format("150405.000000000") + "@example.test").
+ SetPasswordHash("not-a-real-password-hash").
+ Save(ctx)
+ require.NoError(t, err)
+ group, err := client.Group.Create().SetName("window-security-group-" + time.Now().Format("150405.000000000")).Save(ctx)
+ require.NoError(t, err)
+ sub, err := client.UserSubscription.Create().
+ SetUserID(user.ID).
+ SetGroupID(group.ID).
+ SetStartsAt(time.Now()).
+ SetExpiresAt(time.Now().Add(90 * 24 * time.Hour)).
+ SetDailyUsageUsd(usage).
+ SetWeeklyUsageUsd(usage).
+ SetMonthlyUsageUsd(usage).
+ Save(ctx)
+ require.NoError(t, err)
+ return ctx, client, &userSubscriptionRepository{client: client}, sub.ID
+}
+
+func setSubscriptionWindowState(t *testing.T, ctx context.Context, client *dbent.Client, id int64, window service.SubscriptionUsageWindow, start *time.Time, usage float64) {
+ t.Helper()
+ update := client.UserSubscription.UpdateOneID(id)
+ switch window {
+ case service.SubscriptionUsageWindowDaily:
+ update.SetNillableDailyWindowStart(start).SetDailyUsageUsd(usage)
+ case service.SubscriptionUsageWindowWeekly:
+ update.SetNillableWeeklyWindowStart(start).SetWeeklyUsageUsd(usage)
+ case service.SubscriptionUsageWindowMonthly:
+ update.SetNillableMonthlyWindowStart(start).SetMonthlyUsageUsd(usage)
+ }
+ _, err := update.Save(ctx)
+ require.NoError(t, err)
+}
+
+func setSubscriptionWindowUsage(t *testing.T, ctx context.Context, client *dbent.Client, id int64, window service.SubscriptionUsageWindow, usage float64) {
+ t.Helper()
+ setSubscriptionWindowState(t, ctx, client, id, window, subscriptionWindowStart(t, ctx, &userSubscriptionRepository{client: client}, id, window), usage)
+}
+
+func subscriptionWindowStart(t *testing.T, ctx context.Context, repo *userSubscriptionRepository, id int64, window service.SubscriptionUsageWindow) *time.Time {
+ t.Helper()
+ sub, err := repo.GetByID(ctx, id)
+ require.NoError(t, err)
+ switch window {
+ case service.SubscriptionUsageWindowDaily:
+ return sub.DailyWindowStart
+ case service.SubscriptionUsageWindowWeekly:
+ return sub.WeeklyWindowStart
+ default:
+ return sub.MonthlyWindowStart
+ }
+}
+
+func requireSubscriptionWindowState(t *testing.T, ctx context.Context, repo *userSubscriptionRepository, id int64, window service.SubscriptionUsageWindow, wantStart time.Time, wantUsage float64) {
+ t.Helper()
+ sub, err := repo.GetByID(ctx, id)
+ require.NoError(t, err)
+ var start *time.Time
+ var usage float64
+ switch window {
+ case service.SubscriptionUsageWindowDaily:
+ start, usage = sub.DailyWindowStart, sub.DailyUsageUSD
+ case service.SubscriptionUsageWindowWeekly:
+ start, usage = sub.WeeklyWindowStart, sub.WeeklyUsageUSD
+ case service.SubscriptionUsageWindowMonthly:
+ start, usage = sub.MonthlyWindowStart, sub.MonthlyUsageUSD
+ }
+ require.NotNil(t, start)
+ require.WithinDuration(t, wantStart, *start, time.Microsecond)
+ require.InDelta(t, wantUsage, usage, 0.000001)
+}
diff --git a/backend/internal/repository/wallet_admission_concurrency_integration_test.go b/backend/internal/repository/wallet_admission_concurrency_integration_test.go
new file mode 100644
index 00000000000..7ea26cfe034
--- /dev/null
+++ b/backend/internal/repository/wallet_admission_concurrency_integration_test.go
@@ -0,0 +1,163 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWalletAdmission_ConcurrentRequestsReserveAffordableQuotesWithoutOverbooking(t *testing.T) {
+ ctx := context.Background()
+ repo, admissionBase, walletID := newWalletAdmissionIntegrationFixture(t, ctx, 25)
+
+ const contenders = 16
+ start := make(chan struct{})
+ results := make(chan error, contenders)
+ var wg sync.WaitGroup
+ for i := 0; i < contenders; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ <-start
+ input := admissionBase
+ input.RequestID = fmt.Sprintf("wallet-concurrent-%d-%s", i, uuid.NewString())
+ admission, err := service.NewUsageBillingAdmission(input)
+ if err == nil {
+ err = repo.Admit(ctx, admission)
+ }
+ results <- err
+ }(i)
+ }
+ close(start)
+ wg.Wait()
+ close(results)
+
+ succeeded := 0
+ for err := range results {
+ switch {
+ case err == nil:
+ succeeded++
+ default:
+ t.Fatalf("unexpected admission result: %v", err)
+ }
+ }
+ require.Equal(t, contenders, succeeded)
+
+ var unresolved int
+ var reserved float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*), COALESCE(SUM(wallet_reserved_usd), 0)
+ FROM usage_billing_admissions
+ WHERE wallet_subscription_id = $1
+ AND wallet_consumed_at IS NULL
+ AND wallet_released_at IS NULL
+ `, walletID).Scan(&unresolved, &reserved))
+ require.Equal(t, contenders, unresolved)
+ require.InDelta(t, float64(contenders), reserved, 0.000001)
+}
+
+func TestWalletAdmission_FinalizedHoldBlocksPastLeaseUntilSettlement(t *testing.T) {
+ ctx := context.Background()
+ repo, admissionBase, walletID := newWalletAdmissionIntegrationFixture(t, ctx, 1)
+ first := admissionBase
+ first.RequestID = "wallet-finalized-" + uuid.NewString()
+ admission, err := service.NewUsageBillingAdmission(first)
+ require.NoError(t, err)
+ require.NoError(t, repo.Admit(ctx, admission))
+ require.NoError(t, repo.MarkDispatched(ctx, service.UsageBillingAdmissionAttemptRef{
+ RequestID: admission.RequestID(), APIKeyID: admission.APIKeyID(),
+ OwnerToken: admission.OwnerToken(), AttemptID: admission.AttemptID(),
+ }))
+
+ _, err = integrationDB.ExecContext(ctx, `
+ UPDATE usage_billing_admissions
+ SET state = 'outbox_pending', outbox_pending_at = NOW(),
+ wallet_lease_expires_at = NOW() - INTERVAL '1 hour'
+ WHERE request_id = $1 AND api_key_id = $2
+ `, admission.RequestID(), admission.APIKeyID())
+ require.NoError(t, err)
+
+ second := admissionBase
+ second.RequestID = "wallet-after-expired-lease-" + uuid.NewString()
+ secondAdmission, err := service.NewUsageBillingAdmission(second)
+ require.NoError(t, err)
+ require.ErrorIs(t, repo.Admit(ctx, secondAdmission), service.ErrWalletInsufficient)
+
+ var unresolved int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM usage_billing_admissions
+ WHERE wallet_subscription_id = $1
+ AND wallet_consumed_at IS NULL AND wallet_released_at IS NULL
+ `, walletID).Scan(&unresolved))
+ require.Equal(t, 1, unresolved)
+}
+
+func TestWalletAdmission_EnqueueBeforeDispatchFailsClosed(t *testing.T) {
+ ctx := context.Background()
+ repo, input, _ := newWalletAdmissionIntegrationFixture(t, ctx, 10)
+ input.RequestID = "wallet-undispatched-" + uuid.NewString()
+ admission, err := service.NewUsageBillingAdmission(input)
+ require.NoError(t, err)
+ require.NoError(t, repo.Admit(ctx, admission))
+
+ envelope, err := service.NewUsageBillingEnvelope(service.UsageBillingEnvelopeInput{
+ RequestID: input.RequestID, RequestPayloadHash: input.RequestPayloadHash,
+ AdmissionAttemptID: input.AttemptID, APIKeyID: input.APIKeyID,
+ AuthCacheLocator: input.AuthCacheLocator, UserID: input.UserID, AccountID: input.AccountID,
+ SubscriptionID: input.SubscriptionID, GroupID: input.GroupID,
+ EffectiveBillingGroupID: input.EffectiveBillingGroupID, AccountType: input.AccountType,
+ BillingModel: input.BillingModel, BillingType: input.BillingType,
+ PricingSource: input.PricingSource, PricingRevision: input.PricingRevision,
+ PricingHash: input.PricingHash, RateMultiplier: input.RateMultiplier, AccountRateMultiplier: 1,
+ WalletCost: 0.5,
+ })
+ require.NoError(t, err)
+
+ _, _, err = repo.Enqueue(ctx, envelope)
+ require.ErrorIs(t, err, service.ErrUsageBillingAdmissionLeaseLost)
+}
+
+func newWalletAdmissionIntegrationFixture(
+ t *testing.T,
+ ctx context.Context,
+ balance float64,
+) (*usageBillingOutboxRepository, service.UsageBillingAdmissionInput, int64) {
+ t.Helper()
+ client := testEntClient(t)
+ user := mustCreateUser(t, client, &service.User{
+ Email: "wallet-admission-" + uuid.NewString() + "@example.com",
+ })
+ group := mustGetOrCreateWalletBusinessGroup(t, client,
+ service.WalletDefaultOpenAIGroupName, service.PlatformOpenAI, false, 1)
+ wallet := mustCreateCreditsWallet(t, client, user.ID, balance)
+ apiKey := mustCreateApiKey(t, client, &service.APIKey{
+ UserID: user.ID,
+ Name: service.WalletUniversalAPIKeyName,
+ Purpose: service.APIKeyPurposeWalletUniversal,
+ Key: "sk-wallet-admission-" + uuid.NewString(),
+ })
+ account := mustCreateAccount(t, client, &service.Account{
+ Name: "wallet-admission-" + uuid.NewString(),
+ Type: service.AccountTypeOAuth,
+ })
+ mustBindAccountToGroup(t, client, account.ID, group.ID, 1)
+
+ return NewUsageBillingOutboxRepository(integrationDB), service.UsageBillingAdmissionInput{
+ APIKeyID: apiKey.ID, AuthCacheLocator: apiKey.KeyHash,
+ UserID: user.ID, AccountID: account.ID, SubscriptionID: &wallet.ID,
+ GroupID: &group.ID, EffectiveBillingGroupID: &group.ID,
+ AccountType: account.Type, BillingType: service.BillingTypeSubscription,
+ OwnerToken: strings.Repeat("a", 32), AttemptID: strings.Repeat("b", 32),
+ BillingModel: "gpt-5.6-terra", RequestPayloadHash: strings.Repeat("c", 64),
+ PricingSource: service.PricingSourceBuiltinGPT56, PricingRevision: service.GPT56PricingRevision,
+ PricingHash: strings.Repeat("d", 64), RateMultiplier: 1, WorstCaseCostUSD: 1,
+ }, wallet.ID
+}
diff --git a/backend/internal/repository/wallet_credits_e2e_integration_test.go b/backend/internal/repository/wallet_credits_e2e_integration_test.go
new file mode 100644
index 00000000000..058c339a6e3
--- /dev/null
+++ b/backend/internal/repository/wallet_credits_e2e_integration_test.go
@@ -0,0 +1,263 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+// B2.8 端到端回归 #1:链动小铺 credits SKU 走 PaymentOrder webhook 发货 →
+// 创建钱包订阅、expires_at 锁到 MaxExpiresAt(永久),单 key 模式建 1 把 universal key。
+// (5/14 反转决策后从「多 key」改回「单 key + ModelRouter」)。
+func TestWalletCreditsPlanPurchaseActivatesPermanentWallet(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("wallet-credits-buy-%s@example.com", uuid.NewString()),
+ Username: "wallet-credits-buy",
+ PasswordHash: "hash",
+ })
+
+ creditsQuota := 100.0
+ plan, err := client.SubscriptionPlan.Create().
+ SetName("Credits 100 E2E Buy").
+ SetPrice(30).
+ SetWalletQuotaUsd(creditsQuota).
+ SetValidityDays(36500).
+ SetValidityUnit("day").
+ SetPlanType(service.PlanTypeCredits).
+ Save(ctx)
+ require.NoError(t, err)
+
+ tx, err := client.Tx(ctx)
+ require.NoError(t, err)
+ defer func() { _ = tx.Rollback() }()
+ order, err := tx.Client().PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(30).
+ SetPayAmount(30).
+ SetFeeRate(0).
+ SetRechargeCode("WALLET-CREDITS-E2E-BUY-NO-CODE").
+ SetOutTradeNo("wallet-credits-buy-" + uuid.NewString()).
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("trade-credits-buy-" + uuid.NewString()).
+ SetOrderType(payment.OrderTypeSubscription).
+ SetPlanID(plan.ID).
+ SetSubscriptionDays(36500).
+ SetStatus(service.OrderStatusPaid).
+ SetPaidAt(time.Now()).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+ insertCreditsPlanFulfillmentSnapshot(t, ctx, tx.Client(), order.ID, user.ID, plan.ID, 30, 36500, creditsQuota)
+ require.NoError(t, tx.Commit())
+ require.Nil(t, order.SubscriptionGroupID)
+
+ groupRepo := NewGroupRepository(client, integrationDB)
+ userRepo := NewUserRepository(client, integrationDB)
+ apiKeyRepo := NewAPIKeyRepository(client, integrationDB, strictAPIKeyTestProtector{})
+ subRepo := NewUserSubscriptionRepository(client)
+ walletRepo := NewWalletRepository(client, integrationDB)
+ walletSvc := service.NewWalletService(walletRepo)
+ apiKeySvc := service.NewAPIKeyService(apiKeyRepo, userRepo, groupRepo, subRepo, nil, nil, &config.Config{})
+ subSvc := service.NewSubscriptionService(groupRepo, subRepo, nil, client, nil)
+ subSvc.SetWalletGroupKeyService(apiKeySvc)
+ subSvc.SetWalletTopupService(walletSvc)
+ paymentSvc := service.NewPaymentService(client, nil, nil, nil, subSvc, nil, userRepo, groupRepo, nil)
+
+ require.NoError(t, paymentSvc.ExecuteSubscriptionFulfillment(ctx, order.ID))
+
+ sub, err := subRepo.GetActiveWalletByUserID(ctx, user.ID)
+ require.NoError(t, err)
+ require.Nil(t, sub.GroupID)
+ require.NotNil(t, sub.WalletInitialUSD)
+ require.InDelta(t, creditsQuota, *sub.WalletInitialUSD, 0.000001)
+ require.InDelta(t, creditsQuota, *sub.WalletBalanceUSD, 0.000001)
+ require.True(t, sub.ExpiresAt.Equal(service.MaxExpiresAt),
+ "额度卡 expires_at 必须 == MaxExpiresAt (2099-12-31),实际 %v", sub.ExpiresAt)
+
+ // 5/14 反转决策:单 key 路径建 1 把 universal key (group_id=NULL),跨平台调度靠 model_router
+ keys, _, err := apiKeyRepo.ListByUserID(ctx, user.ID, defaultWalletE2EPagination(), service.APIKeyListFilters{Status: service.StatusAPIKeyActive})
+ require.NoError(t, err)
+ require.Len(t, keys, 1, "credits plan 应只建 1 把 universal key,不分 group")
+ require.True(t, service.IsWalletUniversalKeyName(keys[0].Name), "key 名应为 universal key 名,实际 %q", keys[0].Name)
+ require.Equal(t, service.WalletUniversalAPIKeyName, keys[0].Name)
+ require.Nil(t, keys[0].GroupID, "universal key 的 group_id 必须为 NULL")
+}
+
+// Monthly entitlements and credits wallets are separate products, rows,
+// authorization paths, and ledgers. Buying credits after monthly access must
+// never turn the monthly row into a wallet or merge their money.
+func TestMonthlySubscriptionAndCreditsWalletRemainSeparate(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("wallet-stack-%s@example.com", uuid.NewString()),
+ Username: "wallet-stack",
+ PasswordHash: "hash",
+ })
+
+ gptGroup := mustCreateGroup(t, client, &service.Group{
+ Name: "monthly-openai-" + uuid.NewString(),
+ Platform: service.PlatformOpenAI,
+ SubscriptionType: service.SubscriptionTypeSubscription,
+ RateMultiplier: 1.0,
+ })
+
+ monthlyPlan, err := client.SubscriptionPlan.Create().
+ SetName("Monthly Group E2E Separate").
+ SetPrice(299).
+ SetGroupID(gptGroup.ID).
+ SetValidityDays(30).
+ SetValidityUnit("day").
+ SetPlanType(service.PlanTypeSubscription).
+ Save(ctx)
+ require.NoError(t, err)
+ bindWalletPlanGroup(t, client, monthlyPlan.ID, gptGroup.ID)
+
+ creditsQuota := 100.0
+ creditsPlan, err := client.SubscriptionPlan.Create().
+ SetName("Credits 100 E2E Stack").
+ SetPrice(30).
+ SetWalletQuotaUsd(creditsQuota).
+ SetValidityDays(36500).
+ SetValidityUnit("day").
+ SetPlanType(service.PlanTypeCredits).
+ Save(ctx)
+ require.NoError(t, err)
+
+ groupRepo := NewGroupRepository(client, integrationDB)
+ userRepo := NewUserRepository(client, integrationDB)
+ apiKeyRepo := NewAPIKeyRepository(client, integrationDB, strictAPIKeyTestProtector{})
+ subRepo := NewUserSubscriptionRepository(client)
+ walletRepo := NewWalletRepository(client, integrationDB)
+ walletSvc := service.NewWalletService(walletRepo)
+ apiKeySvc := service.NewAPIKeyService(apiKeyRepo, userRepo, groupRepo, subRepo, nil, nil, &config.Config{})
+ subSvc := service.NewSubscriptionService(groupRepo, subRepo, nil, client, nil)
+ subSvc.SetWalletGroupKeyService(apiKeySvc)
+ subSvc.SetWalletTopupService(walletSvc)
+ paymentSvc := service.NewPaymentService(client, nil, nil, nil, subSvc, nil, userRepo, groupRepo, nil)
+
+ monthlyTx, err := client.Tx(ctx)
+ require.NoError(t, err)
+ defer func() { _ = monthlyTx.Rollback() }()
+ monthlyOrder, err := monthlyTx.Client().PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(299).
+ SetPayAmount(299).
+ SetFeeRate(0).
+ SetRechargeCode("WALLET-MONTHLY-STACK-NO-CODE").
+ SetOutTradeNo("wallet-monthly-stack-" + uuid.NewString()).
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("trade-monthly-stack-" + uuid.NewString()).
+ SetOrderType(payment.OrderTypeSubscription).
+ SetPlanID(monthlyPlan.ID).
+ SetSubscriptionGroupID(gptGroup.ID).
+ SetSubscriptionDays(30).
+ SetStatus(service.OrderStatusPaid).
+ SetPaidAt(time.Now()).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+ insertMonthlyPlanFulfillmentSnapshot(t, ctx, monthlyTx.Client(), monthlyOrder.ID, user.ID, monthlyPlan.ID, gptGroup.ID, 299, 30, gptGroup.RateMultiplier)
+ require.NoError(t, monthlyTx.Commit())
+ require.NoError(t, paymentSvc.ExecuteSubscriptionFulfillment(ctx, monthlyOrder.ID))
+
+ monthlySub, err := subRepo.GetActiveByUserIDAndGroupID(ctx, user.ID, gptGroup.ID)
+ require.NoError(t, err)
+ require.Nil(t, monthlySub.WalletBalanceUSD)
+ require.Nil(t, monthlySub.WalletInitialUSD)
+ require.False(t, monthlySub.ExpiresAt.Equal(service.MaxExpiresAt))
+ _, err = subRepo.GetActiveWalletByUserID(ctx, user.ID)
+ require.ErrorIs(t, err, service.ErrSubscriptionNotFound,
+ "monthly group access must not create a hidden wallet")
+
+ keysAfterMonthly, _, err := apiKeyRepo.ListByUserID(ctx, user.ID, defaultWalletE2EPagination(), service.APIKeyListFilters{Status: service.StatusAPIKeyActive})
+ require.NoError(t, err)
+ require.Empty(t, keysAfterMonthly, "monthly entitlement assignment must not mint a credits-wallet key")
+
+ creditsTx, err := client.Tx(ctx)
+ require.NoError(t, err)
+ defer func() { _ = creditsTx.Rollback() }()
+ creditsOrder, err := creditsTx.Client().PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(30).
+ SetPayAmount(30).
+ SetFeeRate(0).
+ SetRechargeCode("WALLET-CREDITS-STACK-NO-CODE").
+ SetOutTradeNo("wallet-credits-stack-" + uuid.NewString()).
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("trade-credits-stack-" + uuid.NewString()).
+ SetOrderType(payment.OrderTypeSubscription).
+ SetPlanID(creditsPlan.ID).
+ SetSubscriptionDays(36500).
+ SetStatus(service.OrderStatusPaid).
+ SetPaidAt(time.Now()).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+ insertCreditsPlanFulfillmentSnapshot(t, ctx, creditsTx.Client(), creditsOrder.ID, user.ID, creditsPlan.ID, 30, 36500, creditsQuota)
+ require.NoError(t, creditsTx.Commit())
+ require.NoError(t, paymentSvc.ExecuteSubscriptionFulfillment(ctx, creditsOrder.ID))
+
+ walletSub, err := subRepo.GetActiveCreditsWalletByUserID(ctx, user.ID)
+ require.NoError(t, err)
+ require.NotEqual(t, monthlySub.ID, walletSub.ID)
+ require.Nil(t, walletSub.GroupID)
+ require.InDelta(t, creditsQuota, *walletSub.WalletBalanceUSD, 0.000001)
+ require.InDelta(t, creditsQuota, *walletSub.WalletInitialUSD, 0.000001)
+ require.True(t, walletSub.ExpiresAt.Equal(service.MaxExpiresAt))
+
+ monthlyReloaded, err := subRepo.GetByID(ctx, monthlySub.ID)
+ require.NoError(t, err)
+ require.Nil(t, monthlyReloaded.WalletBalanceUSD)
+ require.Nil(t, monthlyReloaded.WalletInitialUSD)
+ var subscriptionRows int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM user_subscriptions
+ WHERE user_id = $1 AND deleted_at IS NULL
+ `, user.ID).Scan(&subscriptionRows))
+ require.Equal(t, 2, subscriptionRows)
+
+ keysAfterCredits, _, err := apiKeyRepo.ListByUserID(ctx, user.ID, defaultWalletE2EPagination(), service.APIKeyListFilters{Status: service.StatusAPIKeyActive})
+ require.NoError(t, err)
+ require.Len(t, keysAfterCredits, 1)
+ require.True(t, service.IsWalletUniversalKeyName(keysAfterCredits[0].Name))
+
+ var activationCount, topupCount int
+ var ledgerTotal float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT
+ COUNT(*) FILTER (WHERE reason = 'activation'),
+ COUNT(*) FILTER (WHERE reason = 'topup'),
+ COALESCE(SUM(delta_usd), 0)
+ FROM subscription_wallet_ledger WHERE subscription_id = $1
+ `, walletSub.ID).Scan(&activationCount, &topupCount, &ledgerTotal))
+ require.Equal(t, 1, activationCount)
+ require.Zero(t, topupCount, "a monthly row must never be reused as a credits top-up target")
+ require.InDelta(t, creditsQuota, ledgerTotal, 0.000001)
+}
diff --git a/backend/internal/repository/wallet_group_lifecycle_integration_test.go b/backend/internal/repository/wallet_group_lifecycle_integration_test.go
new file mode 100644
index 00000000000..fb4a4cc456d
--- /dev/null
+++ b/backend/internal/repository/wallet_group_lifecycle_integration_test.go
@@ -0,0 +1,213 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestSubscriptionGroupLifecycleGuardProtectsMonthlyPlans(t *testing.T) {
+ tests := []struct {
+ name string
+ mutation string
+ arguments func(groupID int64) []any
+ }{
+ {
+ name: "disable",
+ mutation: "UPDATE groups SET status = 'disabled' WHERE id = $1",
+ arguments: func(groupID int64) []any { return []any{groupID} },
+ },
+ {
+ name: "subscription type drift",
+ mutation: "UPDATE groups SET subscription_type = 'standard' WHERE id = $1",
+ arguments: func(groupID int64) []any { return []any{groupID} },
+ },
+ {
+ name: "soft delete",
+ mutation: "UPDATE groups SET deleted_at = NOW() WHERE id = $1",
+ arguments: func(groupID int64) []any { return []any{groupID} },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tx := testTx(t)
+ _, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ _ = insertMigrationPlan(t, tx, "subscription", &groupIDs[0], nil)
+
+ _, err := tx.ExecContext(context.Background(), tt.mutation, tt.arguments(groupIDs[0])...)
+ require.NoError(t, err, "lifecycle check is deferred to the transaction boundary")
+ _, err = tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ requirePostgresCode(t, err, "23514")
+ require.Contains(t, err.Error(), "referenced by a monthly plan")
+ })
+ }
+}
+
+func TestSubscriptionGroupHardDeleteIsRestrictedByPlanForeignKey(t *testing.T) {
+ tx := testTx(t)
+ _, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ _ = insertMigrationPlan(t, tx, "subscription", &groupIDs[0], nil)
+
+ _, err := tx.ExecContext(context.Background(), "DELETE FROM groups WHERE id = $1", groupIDs[0])
+ requirePostgresCodeOneOf(t, err, "23001", "23503")
+}
+
+func TestSubscriptionGroupLifecycleGuardProtectsRecoverableLegacyOrder(t *testing.T) {
+ tx := testTx(t)
+ userID, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ insertLegacyMigrationPaymentOrder(t, tx, userID, groupIDs[0], "PAID")
+
+ _, err := tx.ExecContext(context.Background(), `
+ UPDATE groups
+ SET status = 'disabled'
+ WHERE id = $1
+ `, groupIDs[0])
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ requirePostgresCode(t, err, "23514")
+ require.Contains(t, err.Error(), "legacy order remains fulfillable")
+}
+
+func TestSubscriptionGroupLifecycleGuardIgnoresUnpaidFailedAndStaleCancelledOrders(t *testing.T) {
+ tests := []struct {
+ name string
+ status string
+ makeStale bool
+ }{
+ {name: "unpaid failed", status: "FAILED"},
+ {name: "stale cancelled", status: "CANCELLED", makeStale: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tx := testTx(t)
+ userID, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ insertLegacyMigrationPaymentOrder(t, tx, userID, groupIDs[0], tt.status)
+ if tt.makeStale {
+ _, err := tx.ExecContext(context.Background(), `
+ UPDATE payment_orders
+ SET updated_at = NOW() - INTERVAL '6 minutes'
+ WHERE plan_id IS NULL AND subscription_group_id = $1
+ `, groupIDs[0])
+ require.NoError(t, err)
+ }
+
+ _, err := tx.ExecContext(context.Background(), `
+ UPDATE groups
+ SET status = 'disabled'
+ WHERE id = $1
+ `, groupIDs[0])
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ require.NoError(t, err)
+ })
+ }
+}
+
+func TestSubscriptionGroupLifecycleGuardAllowsUnreferencedGroupMutation(t *testing.T) {
+ tx := testTx(t)
+ _, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+
+ _, err := tx.ExecContext(context.Background(), `
+ UPDATE groups
+ SET status = 'disabled'
+ WHERE id = $1
+ `, groupIDs[0])
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ require.NoError(t, err)
+}
+
+func TestSubscriptionGroupLifecycleGuardDoesNotFreezeArchivedUnusedPlan(t *testing.T) {
+ tx := testTx(t)
+ _, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ planID := insertMigrationPlan(t, tx, "subscription", &groupIDs[0], nil)
+ _, err := tx.ExecContext(context.Background(), `
+ UPDATE subscription_plans
+ SET for_sale = FALSE
+ WHERE id = $1
+ `, planID)
+ require.NoError(t, err)
+
+ _, err = tx.ExecContext(context.Background(), `
+ UPDATE groups
+ SET status = 'disabled'
+ WHERE id = $1
+ `, groupIDs[0])
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ require.NoError(t, err)
+}
+
+func TestSubscriptionGroupLifecycleGuardProtectsActiveSubscription(t *testing.T) {
+ tx := testTx(t)
+ userID, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ _ = insertActiveGroupSubscription(t, tx, userID, groupIDs[0])
+
+ _, err := tx.ExecContext(context.Background(), `
+ UPDATE groups
+ SET status = 'disabled'
+ WHERE id = $1
+ `, groupIDs[0])
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ requirePostgresCode(t, err, "23514")
+ require.Contains(t, err.Error(), "active or resumable subscriptions")
+}
+
+func TestCompletedOrderCannotLeadToPaidSubscriptionCascadeDeletion(t *testing.T) {
+ tx := testTx(t)
+ ctx := context.Background()
+ userID, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ planID := insertMigrationPlan(t, tx, "subscription", &groupIDs[0], nil)
+ _, err := insertMigrationPaymentOrder(tx, userID, planID, &groupIDs[0])
+ require.NoError(t, err)
+ require.NoError(t, setMigrationPaymentOrderStatus(tx, planID, "COMPLETED"))
+ subscriptionID := insertActiveGroupSubscription(t, tx, userID, groupIDs[0])
+
+ _, err = tx.ExecContext(ctx, "DELETE FROM subscription_plans WHERE id = $1", planID)
+ require.NoError(t, err, "terminal order no longer needs the mutable product row for fulfillment")
+ require.NoError(t, execMigrationTestSQL(tx, "SAVEPOINT before_protected_group_delete"))
+ _, err = tx.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", groupIDs[0])
+ requirePostgresCodeOneOf(t, err, "23001", "23503")
+ require.NoError(t, execMigrationTestSQL(tx, "ROLLBACK TO SAVEPOINT before_protected_group_delete"))
+
+ var subscriptions int
+ require.NoError(t, tx.QueryRowContext(ctx, `
+ SELECT COUNT(*)
+ FROM user_subscriptions
+ WHERE id = $1
+ `, subscriptionID).Scan(&subscriptions))
+ require.Equal(t, 1, subscriptions, "hard group delete must never cascade away paid subscription history")
+}
+
+func insertLegacyMigrationPaymentOrder(t *testing.T, tx *sql.Tx, userID, groupID int64, status string) {
+ t.Helper()
+ _, err := tx.ExecContext(context.Background(), `
+ INSERT INTO payment_orders (
+ user_id, user_email, user_name, amount, pay_amount,
+ order_type, subscription_group_id, subscription_days,
+ status, expires_at
+ ) VALUES ($1, 'migration@example.test', 'migration test', 20, 20,
+ 'subscription', $2, 30, $3, NOW() + INTERVAL '15 minutes')
+ `, userID, groupID, status)
+ require.NoError(t, err)
+}
+
+func insertActiveGroupSubscription(t *testing.T, tx *sql.Tx, userID, groupID int64) int64 {
+ t.Helper()
+ var subscriptionID int64
+ err := tx.QueryRowContext(context.Background(), `
+ INSERT INTO user_subscriptions (
+ user_id, group_id, starts_at, expires_at, status
+ ) VALUES ($1, $2, NOW(), NOW() + INTERVAL '30 days', 'active')
+ RETURNING id
+ `, userID, groupID).Scan(&subscriptionID)
+ require.NoError(t, err)
+ return subscriptionID
+}
diff --git a/backend/internal/repository/wallet_integrity_migrations_integration_test.go b/backend/internal/repository/wallet_integrity_migrations_integration_test.go
new file mode 100644
index 00000000000..7029df0fe18
--- /dev/null
+++ b/backend/internal/repository/wallet_integrity_migrations_integration_test.go
@@ -0,0 +1,797 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+const permanentCreditsWalletThreshold = "2099-12-30 23:59:59+00"
+
+func TestWalletLedgerIntegrityMigration175BackfillsZeroOpeningBaseline(t *testing.T) {
+ tx := testTx(t)
+ ctx := context.Background()
+ createWalletMigrationTempTables(t, tx)
+
+ createdAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO user_subscriptions (
+ id, user_id, wallet_initial_usd, wallet_balance_usd,
+ status, expires_at, created_at
+ ) VALUES (234, 42, 50, 50, 'active', '2099-12-31 23:59:59+00', $1)
+ `, createdAt)
+ require.NoError(t, err)
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO subscription_wallet_ledger (
+ subscription_id, delta_usd, balance_after, reason, created_at
+ ) VALUES (234, 50, 50, 'topup', $1::timestamptz + INTERVAL '1 day')
+ `, createdAt)
+ require.NoError(t, err)
+
+ migration := readMigration(t, "175_wallet_ledger_integrity.sql")
+ _, err = tx.ExecContext(ctx, migration)
+ require.NoError(t, err)
+
+ var (
+ delta float64
+ balanceAfter float64
+ activationAt time.Time
+ )
+ err = tx.QueryRowContext(ctx, `
+ SELECT delta_usd, balance_after, created_at
+ FROM subscription_wallet_ledger
+ WHERE subscription_id = 234 AND reason = 'activation'
+ `).Scan(&delta, &balanceAfter, &activationAt)
+ require.NoError(t, err)
+ require.Zero(t, delta, "a wallet first funded entirely by topups needs a zero activation baseline")
+ require.Zero(t, balanceAfter)
+ require.True(t, activationAt.Equal(createdAt), "historical activation must keep subscription chronology")
+
+ _, err = tx.ExecContext(ctx, migration)
+ require.NoError(t, err, "migration 175 must remain idempotent after the zero baseline is recorded")
+
+ var activations int
+ require.NoError(t, tx.QueryRowContext(ctx, `
+ SELECT COUNT(*)
+ FROM subscription_wallet_ledger
+ WHERE subscription_id = 234 AND reason = 'activation'
+ `).Scan(&activations))
+ require.Equal(t, 1, activations)
+}
+
+func TestWalletLedgerIntegrityMigration175BackfillsDeletedWalletHistory(t *testing.T) {
+ tx := testTx(t)
+ ctx := context.Background()
+ createWalletMigrationTempTables(t, tx)
+
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO user_subscriptions (
+ id, user_id, wallet_initial_usd, wallet_balance_usd,
+ status, deleted_at, expires_at, created_at
+ ) VALUES (235, 42, 50, 50, 'active', NOW(), '2099-12-31 23:59:59+00', NOW())
+ `)
+ require.NoError(t, err)
+
+ _, err = tx.ExecContext(ctx, readMigration(t, "175_wallet_ledger_integrity.sql"))
+ require.NoError(t, err)
+
+ var activations int
+ require.NoError(t, tx.QueryRowContext(ctx, `
+ SELECT COUNT(*)
+ FROM subscription_wallet_ledger
+ WHERE subscription_id = 235 AND reason = 'activation'
+ `).Scan(&activations))
+ require.Equal(t, 1, activations, "soft deletion must not erase or skip financial opening history")
+}
+
+func TestWalletLedgerIntegrityMigration175RejectsUnsafeBaselines(t *testing.T) {
+ tests := []struct {
+ name string
+ initial float64
+ balance float64
+ topup float64
+ wantErrorText string
+ }{
+ {
+ name: "negative opening baseline",
+ initial: 40,
+ balance: 40,
+ topup: 50,
+ wantErrorText: "activation backfill is not safely explainable",
+ },
+ {
+ name: "unexplained cached balance drift",
+ initial: 50,
+ balance: 49,
+ topup: 0,
+ wantErrorText: "activation backfill is not safely explainable",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tx := testTx(t)
+ ctx := context.Background()
+ createWalletMigrationTempTables(t, tx)
+
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO user_subscriptions (
+ id, user_id, wallet_initial_usd, wallet_balance_usd,
+ status, expires_at, created_at
+ ) VALUES (1, 1, $1, $2, 'active', '2099-12-31 23:59:59+00', NOW())
+ `, tt.initial, tt.balance)
+ require.NoError(t, err)
+ if tt.topup != 0 {
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO subscription_wallet_ledger (
+ subscription_id, delta_usd, balance_after, reason
+ ) VALUES (1, $1, $1, 'topup')
+ `, tt.topup)
+ require.NoError(t, err)
+ }
+
+ _, err = tx.ExecContext(ctx, readMigration(t, "175_wallet_ledger_integrity.sql"))
+ require.Error(t, err)
+ require.Contains(t, err.Error(), tt.wantErrorText)
+ })
+ }
+}
+
+func TestWalletIntegrityIndexesRejectDuplicateActiveState(t *testing.T) {
+ t.Run("one activation per subscription", func(t *testing.T) {
+ tx := testTx(t)
+ ctx := context.Background()
+ userID, _ := insertWalletMigrationUserAndGroups(t, tx)
+ subscriptionID := insertWalletSubscription(t, tx, userID, "2099-12-31 23:59:59+00", "active")
+
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO subscription_wallet_ledger
+ (subscription_id, delta_usd, balance_after, reason)
+ VALUES ($1, 10, 10, 'activation')
+ `, subscriptionID)
+ require.NoError(t, err)
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO subscription_wallet_ledger
+ (subscription_id, delta_usd, balance_after, reason)
+ VALUES ($1, 0, 10, 'activation')
+ `, subscriptionID)
+ requirePostgresCode(t, err, "23505")
+ })
+
+ t.Run("one active permanent credits wallet per user", func(t *testing.T) {
+ tx := testTx(t)
+ userID, _ := insertWalletMigrationUserAndGroups(t, tx)
+ insertWalletSubscription(t, tx, userID, "2099-12-31 23:59:59+00", "active")
+
+ _, err := tx.ExecContext(context.Background(), `
+ INSERT INTO user_subscriptions (
+ user_id, starts_at, expires_at, status,
+ wallet_balance_usd, wallet_initial_usd
+ ) VALUES ($1, NOW(), '2099-12-31 23:59:59+00', 'active', 20, 20)
+ `, userID)
+ requirePostgresCode(t, err, "23505")
+ })
+}
+
+func TestWalletPermanenceBoundaryMatchesApplicationPolicy(t *testing.T) {
+ t.Run("finite wallet late in 2099 remains forbidden", func(t *testing.T) {
+ tx := testTx(t)
+ userID, _ := insertWalletMigrationUserAndGroups(t, tx)
+
+ _, err := tx.ExecContext(context.Background(), `
+ INSERT INTO user_subscriptions (
+ user_id, starts_at, expires_at, status,
+ wallet_balance_usd, wallet_initial_usd
+ ) VALUES ($1, NOW(), '2099-12-01 00:00:00+00', 'active', 10, 10)
+ `, userID)
+ requirePostgresCode(t, err, "23514")
+ })
+
+ t.Run("application threshold is accepted as permanent", func(t *testing.T) {
+ tx := testTx(t)
+ userID, _ := insertWalletMigrationUserAndGroups(t, tx)
+
+ _, err := tx.ExecContext(context.Background(), `
+ INSERT INTO user_subscriptions (
+ user_id, starts_at, expires_at, status,
+ wallet_balance_usd, wallet_initial_usd
+ ) VALUES ($1, NOW(), $2::timestamptz, 'active', 10, 10)
+ `, userID, permanentCreditsWalletThreshold)
+ require.NoError(t, err)
+ })
+
+ t.Run("group subscription cannot carry hidden wallet initial value", func(t *testing.T) {
+ tx := testTx(t)
+ userID, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+
+ _, err := tx.ExecContext(context.Background(), `
+ INSERT INTO user_subscriptions (
+ user_id, group_id, starts_at, expires_at, status,
+ wallet_initial_usd
+ ) VALUES ($1, $2, NOW(), NOW() + INTERVAL '30 days', 'active', 10)
+ `, userID, groupIDs[0])
+ requirePostgresCode(t, err, "23514")
+ })
+}
+
+func TestWalletPostpaidSettlementConstraintAllowsDebtButRejectsNonFinite(t *testing.T) {
+ t.Run("negative debt balance is persisted", func(t *testing.T) {
+ tx := testTx(t)
+ userID, _ := insertWalletMigrationUserAndGroups(t, tx)
+
+ _, err := tx.ExecContext(context.Background(), `
+ INSERT INTO user_subscriptions (
+ user_id, starts_at, expires_at, status,
+ wallet_balance_usd, wallet_initial_usd
+ ) VALUES ($1, NOW(), '2099-12-31 23:59:59+00', 'active', -5, 10)
+ `, userID)
+ require.NoError(t, err)
+ })
+
+ t.Run("NaN balance is rejected", func(t *testing.T) {
+ tx := testTx(t)
+ userID, _ := insertWalletMigrationUserAndGroups(t, tx)
+
+ _, err := tx.ExecContext(context.Background(), `
+ INSERT INTO user_subscriptions (
+ user_id, starts_at, expires_at, status,
+ wallet_balance_usd, wallet_initial_usd
+ ) VALUES ($1, NOW(), '2099-12-31 23:59:59+00', 'active', 'NaN'::numeric, 10)
+ `, userID)
+ requirePostgresCode(t, err, "23514")
+ })
+
+ for _, initial := range []string{"-1", "NaN", "Infinity", "-Infinity"} {
+ t.Run("invalid initial amount "+initial+" is rejected", func(t *testing.T) {
+ tx := testTx(t)
+ userID, _ := insertWalletMigrationUserAndGroups(t, tx)
+
+ _, err := tx.ExecContext(context.Background(), `
+ INSERT INTO user_subscriptions (
+ user_id, starts_at, expires_at, status,
+ wallet_balance_usd, wallet_initial_usd
+ ) VALUES ($1, NOW(), '2099-12-31 23:59:59+00', 'active', 10, $2::numeric)
+ `, userID, initial)
+ if initial == "Infinity" || initial == "-Infinity" {
+ requirePostgresCodeOneOf(t, err, "22003", "23514")
+ } else {
+ requirePostgresCode(t, err, "23514")
+ }
+ })
+ }
+}
+
+func TestMonthlyAndCreditsPlanDatabaseShapesAreExclusive(t *testing.T) {
+ t.Run("monthly wallet plan is rejected", func(t *testing.T) {
+ tx := testTx(t)
+ _, _ = insertWalletMigrationUserAndGroups(t, tx)
+ _, err := tx.ExecContext(context.Background(), `
+ INSERT INTO subscription_plans
+ (plan_type, group_id, wallet_quota_usd, name, price)
+ VALUES ('subscription', NULL, 20, 'invalid monthly wallet', 20)
+ `)
+ requirePostgresCode(t, err, "23514")
+ })
+
+ t.Run("credits plan cannot bind a group", func(t *testing.T) {
+ tx := testTx(t)
+ _, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ _, err := tx.ExecContext(context.Background(), `
+ INSERT INTO subscription_plans
+ (plan_type, group_id, wallet_quota_usd, name, price)
+ VALUES ('credits', $1, NULL, 'invalid grouped credits', 20)
+ `, groupIDs[0])
+ requirePostgresCode(t, err, "23514")
+ })
+
+ t.Run("valid monthly and credits plans remain accepted", func(t *testing.T) {
+ tx := testTx(t)
+ _, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ _, err := tx.ExecContext(context.Background(), `
+ INSERT INTO subscription_plans
+ (plan_type, group_id, wallet_quota_usd, name, price)
+ VALUES
+ ('subscription', $1, NULL, 'valid monthly', 20),
+ ('credits', NULL, 20, 'valid credits', 20)
+ `, groupIDs[0])
+ require.NoError(t, err)
+ })
+
+ for _, quota := range []string{"0", "-1", "NaN", "Infinity", "-Infinity"} {
+ t.Run("invalid credits quota "+quota+" is rejected", func(t *testing.T) {
+ tx := testTx(t)
+ _, _ = insertWalletMigrationUserAndGroups(t, tx)
+ _, err := tx.ExecContext(context.Background(), `
+ INSERT INTO subscription_plans
+ (plan_type, group_id, wallet_quota_usd, name, price)
+ VALUES ('credits', NULL, $1::numeric, 'invalid credits quota', 20)
+ `, quota)
+ if quota == "Infinity" || quota == "-Infinity" {
+ requirePostgresCodeOneOf(t, err, "22003", "23514")
+ } else {
+ requirePostgresCode(t, err, "23514")
+ }
+ })
+ }
+
+ t.Run("monthly plan requires active subscription group", func(t *testing.T) {
+ tx := testTx(t)
+ _, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ _, err := tx.ExecContext(context.Background(), "UPDATE groups SET status = 'disabled' WHERE id = $1", groupIDs[0])
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), `
+ INSERT INTO subscription_plans
+ (plan_type, group_id, wallet_quota_usd, name, price)
+ VALUES ('subscription', $1, NULL, 'invalid monthly group', 20)
+ `, groupIDs[0])
+ requirePostgresCode(t, err, "23514")
+ })
+}
+
+func TestMonthlyPlanMigration176BlocksHistoricalWalletWithoutRewritingIt(t *testing.T) {
+ tx := testTx(t)
+ ctx := context.Background()
+ createMonthlySeparationMigrationTempTables(t, tx)
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO subscription_plans (id, plan_type, group_id, wallet_quota_usd)
+ VALUES (77, 'subscription', NULL, 50)
+ `)
+ require.NoError(t, err)
+
+ migration := readMigration(t, "176_enforce_monthly_group_not_wallet.sql")
+ require.NotContains(t, strings.ToUpper(migration), "UPDATE SUBSCRIPTION_PLANS",
+ "migration 176 must not guess a group or destructively rewrite historical plan quota")
+ require.NoError(t, execMigrationTestSQL(tx, "SAVEPOINT before_migration_176"))
+ _, err = tx.ExecContext(ctx, migration)
+ requirePostgresCode(t, err, "23514")
+ require.Contains(t, err.Error(), "plan_ids={77}")
+ require.NoError(t, execMigrationTestSQL(tx, "ROLLBACK TO SAVEPOINT before_migration_176"))
+
+ var (
+ groupID sql.NullInt64
+ quota float64
+ )
+ require.NoError(t, tx.QueryRowContext(ctx, `
+ SELECT group_id, wallet_quota_usd
+ FROM subscription_plans
+ WHERE id = 77
+ `).Scan(&groupID, "a))
+ require.False(t, groupID.Valid)
+ require.Equal(t, 50.0, quota)
+}
+
+func TestMonthlyPlanMigration176BlocksHistoricalMixedWalletShapes(t *testing.T) {
+ tests := []struct {
+ name string
+ groupID *int64
+ balance *float64
+ initial *float64
+ }{
+ {name: "group with both wallet fields", groupID: migrationInt64Ptr(7), balance: migrationFloat64Ptr(10), initial: migrationFloat64Ptr(10)},
+ {name: "group with hidden initial only", groupID: migrationInt64Ptr(7), initial: migrationFloat64Ptr(10)},
+ {name: "wallet balance without initial", balance: migrationFloat64Ptr(10)},
+ {name: "wallet initial without balance", initial: migrationFloat64Ptr(10)},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tx := testTx(t)
+ ctx := context.Background()
+ createMonthlySeparationMigrationTempTables(t, tx)
+ if tt.groupID != nil {
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO groups (id, status, subscription_type)
+ VALUES ($1, 'active', 'subscription')
+ `, *tt.groupID)
+ require.NoError(t, err)
+ }
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO user_subscriptions (
+ id, group_id, wallet_balance_usd, wallet_initial_usd, expires_at
+ ) VALUES (88, $1, $2, $3, '2099-12-31 23:59:59+00')
+ `, tt.groupID, tt.balance, tt.initial)
+ require.NoError(t, err)
+
+ require.NoError(t, execMigrationTestSQL(tx, "SAVEPOINT before_mixed_row_migration_176"))
+ _, err = tx.ExecContext(ctx, readMigration(t, "176_enforce_monthly_group_not_wallet.sql"))
+ requirePostgresCode(t, err, "23514")
+ require.Contains(t, err.Error(), "subscription_ids={88}")
+ require.NoError(t, execMigrationTestSQL(tx, "ROLLBACK TO SAVEPOINT before_mixed_row_migration_176"))
+
+ var rows int
+ require.NoError(t, tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM user_subscriptions WHERE id = 88").Scan(&rows))
+ require.Equal(t, 1, rows, "fail-closed preflight must not rewrite the historical row")
+ })
+ }
+}
+
+func TestMonthlyPlanMigration176BlocksInvalidFulfillableOrders(t *testing.T) {
+ tests := []struct {
+ name string
+ planType string
+ planGroup *int64
+ planQuota *float64
+ orderPlan *int64
+ orderGroup *int64
+ orderStatus string
+ paidFailure bool
+ }{
+ {
+ name: "monthly order uses wrong group",
+ planType: "subscription",
+ planGroup: migrationInt64Ptr(7),
+ orderPlan: migrationInt64Ptr(11),
+ orderGroup: migrationInt64Ptr(8),
+ },
+ {
+ name: "monthly order has no group",
+ planType: "subscription",
+ planGroup: migrationInt64Ptr(7),
+ orderPlan: migrationInt64Ptr(11),
+ },
+ {
+ name: "credits order uses group path",
+ planType: "credits",
+ planQuota: migrationFloat64Ptr(50),
+ orderPlan: migrationInt64Ptr(11),
+ orderGroup: migrationInt64Ptr(7),
+ },
+ {
+ name: "order references missing plan",
+ orderPlan: migrationInt64Ptr(999),
+ },
+ {
+ name: "subscription order has neither plan nor group",
+ },
+ {
+ name: "legacy order references unavailable group",
+ orderGroup: migrationInt64Ptr(999),
+ },
+ {
+ name: "cancelled monthly order can still recover",
+ planType: "subscription",
+ planGroup: migrationInt64Ptr(7),
+ orderPlan: migrationInt64Ptr(11),
+ orderGroup: migrationInt64Ptr(8),
+ orderStatus: "CANCELLED",
+ },
+ {
+ name: "recent expired monthly order can still recover",
+ planType: "subscription",
+ planGroup: migrationInt64Ptr(7),
+ orderPlan: migrationInt64Ptr(11),
+ orderGroup: migrationInt64Ptr(8),
+ orderStatus: "EXPIRED",
+ },
+ {
+ name: "paid failed monthly order can still retry",
+ planType: "subscription",
+ planGroup: migrationInt64Ptr(7),
+ orderPlan: migrationInt64Ptr(11),
+ orderGroup: migrationInt64Ptr(8),
+ orderStatus: "FAILED",
+ paidFailure: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tx := testTx(t)
+ ctx := context.Background()
+ createMonthlySeparationMigrationTempTables(t, tx)
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO groups (id, status, subscription_type)
+ VALUES
+ (7, 'active', 'subscription'),
+ (8, 'active', 'subscription')
+ `)
+ require.NoError(t, err)
+ if tt.planType != "" {
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO subscription_plans
+ (id, plan_type, group_id, wallet_quota_usd)
+ VALUES (11, $1::varchar, $2::bigint, $3::numeric)
+ `, tt.planType, tt.planGroup, tt.planQuota)
+ require.NoError(t, err)
+ }
+ status := tt.orderStatus
+ if status == "" {
+ status = "PAID"
+ }
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO payment_orders (
+ id, order_type, plan_id, subscription_group_id, status,
+ paid_at, payment_trade_no
+ ) VALUES (
+ 101, 'subscription', $1::bigint, $2::bigint, $3::varchar,
+ CASE WHEN $4::boolean THEN NOW() ELSE NULL END,
+ CASE WHEN $4::boolean THEN 'trusted-preflight-payment' ELSE NULL END
+ )
+ `, tt.orderPlan, tt.orderGroup, status, tt.paidFailure)
+ require.NoError(t, err)
+
+ _, err = tx.ExecContext(ctx, readMigration(t, "176_enforce_monthly_group_not_wallet.sql"))
+ requirePostgresCode(t, err, "23514")
+ require.Contains(t, err.Error(), "order_ids={101}")
+ })
+ }
+}
+
+func TestMonthlyPlanMigration176AllowsValidOrTerminalHistoricalOrders(t *testing.T) {
+ tx := testTx(t)
+ ctx := context.Background()
+ createMonthlySeparationMigrationTempTables(t, tx)
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO groups (id, status, subscription_type) VALUES (7, 'active', 'subscription');
+ INSERT INTO subscription_plans (id, plan_type, group_id, wallet_quota_usd)
+ VALUES
+ (11, 'subscription', 7, NULL),
+ (12, 'credits', NULL, 50);
+ INSERT INTO payment_orders (
+ id, order_type, plan_id, subscription_group_id, status
+ ) VALUES
+ (101, 'subscription', 11, 7, 'PAID'),
+ (102, 'subscription', 12, NULL, 'RECHARGING'),
+ (103, 'subscription', 11, 999, 'COMPLETED'),
+ (104, 'subscription', NULL, 7, 'PAID'),
+ (105, 'subscription', 11, 999, 'EXPIRED'),
+ (106, 'subscription', 11, 999, 'FAILED'),
+ (107, 'subscription', 11, 999, 'CANCELLED');
+ UPDATE payment_orders
+ SET updated_at = NOW() - INTERVAL '6 minutes'
+ WHERE id IN (105, 107);
+ `)
+ require.NoError(t, err)
+
+ _, err = tx.ExecContext(ctx, readMigration(t, "176_enforce_monthly_group_not_wallet.sql"))
+ require.NoError(t, err)
+}
+
+func TestPaymentOrderPlanGroupTriggerEnforcesFulfillmentPath(t *testing.T) {
+ t.Run("monthly order requires exact plan group", func(t *testing.T) {
+ tx := testTx(t)
+ userID, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ planID := insertMigrationPlan(t, tx, "subscription", &groupIDs[0], nil)
+
+ _, err := insertMigrationPaymentOrder(tx, userID, planID, &groupIDs[1])
+ requirePostgresCode(t, err, "23514")
+ })
+
+ t.Run("monthly order accepts exact plan group", func(t *testing.T) {
+ tx := testTx(t)
+ userID, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ planID := insertMigrationPlan(t, tx, "subscription", &groupIDs[0], nil)
+
+ _, err := insertMigrationPaymentOrder(tx, userID, planID, &groupIDs[0])
+ require.NoError(t, err)
+ })
+
+ t.Run("credits order must not take a group fulfillment path", func(t *testing.T) {
+ tx := testTx(t)
+ userID, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ quota := 20.0
+ planID := insertMigrationPlan(t, tx, "credits", nil, "a)
+
+ _, err := insertMigrationPaymentOrder(tx, userID, planID, &groupIDs[0])
+ requirePostgresCode(t, err, "23514")
+ })
+
+ t.Run("credits order accepts wallet fulfillment path", func(t *testing.T) {
+ tx := testTx(t)
+ userID, _ := insertWalletMigrationUserAndGroups(t, tx)
+ quota := 20.0
+ planID := insertMigrationPlan(t, tx, "credits", nil, "a)
+
+ _, err := insertMigrationPaymentOrder(tx, userID, planID, nil)
+ require.NoError(t, err)
+ })
+
+ t.Run("dangling plan id cannot bypass the guard", func(t *testing.T) {
+ tx := testTx(t)
+ userID, _ := insertWalletMigrationUserAndGroups(t, tx)
+
+ _, err := insertMigrationPaymentOrder(tx, userID, 9_999_999_999, nil)
+ requirePostgresCode(t, err, "23514")
+ })
+}
+
+func TestPaymentOrderStatusOnlyReopenRevalidatesFulfillmentPath(t *testing.T) {
+ tx := testTx(t)
+ userID, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ planID := insertMigrationPlan(t, tx, "subscription", &groupIDs[0], nil)
+ _, err := insertMigrationPaymentOrder(tx, userID, planID, &groupIDs[0])
+ require.NoError(t, err)
+ require.NoError(t, setMigrationPaymentOrderStatus(tx, planID, "COMPLETED"))
+
+ _, err = tx.ExecContext(context.Background(), `
+ UPDATE subscription_plans
+ SET plan_type = 'credits', group_id = NULL, wallet_quota_usd = 50
+ WHERE id = $1
+ `, planID)
+ require.NoError(t, err)
+
+ // Updating only status used to bypass the path trigger. FAILED is retryable,
+ // so reopening the stale grouped order must revalidate against the new plan.
+ err = setMigrationPaymentOrderStatus(tx, planID, "FAILED")
+ requirePostgresCode(t, err, "23514")
+}
+
+func TestPlanFulfillmentShapeCannotRaceFulfillableOrders(t *testing.T) {
+ for _, status := range []string{"PENDING", "PAID", "RECHARGING", "FAILED", "CANCELLED", "EXPIRED"} {
+ t.Run("blocks monthly to credits with "+status+" order", func(t *testing.T) {
+ tx := testTx(t)
+ userID, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ planID := insertMigrationPlan(t, tx, "subscription", &groupIDs[0], nil)
+ _, err := insertMigrationPaymentOrder(tx, userID, planID, &groupIDs[0])
+ require.NoError(t, err)
+ require.NoError(t, setMigrationPaymentOrderStatus(tx, planID, status))
+
+ _, err = tx.ExecContext(context.Background(), `
+ UPDATE subscription_plans
+ SET plan_type = 'credits', group_id = NULL, wallet_quota_usd = 50
+ WHERE id = $1
+ `, planID)
+ requirePostgresCode(t, err, "23514")
+ })
+ }
+
+ t.Run("blocks credits quota change with fulfillable order", func(t *testing.T) {
+ tx := testTx(t)
+ userID, _ := insertWalletMigrationUserAndGroups(t, tx)
+ quota := 20.0
+ planID := insertMigrationPlan(t, tx, "credits", nil, "a)
+ _, err := insertMigrationPaymentOrder(tx, userID, planID, nil)
+ require.NoError(t, err)
+
+ _, err = tx.ExecContext(context.Background(), `
+ UPDATE subscription_plans
+ SET wallet_quota_usd = 50
+ WHERE id = $1
+ `, planID)
+ requirePostgresCode(t, err, "23514")
+ })
+}
+
+func TestUnpaidFailedAndStaleCancelledOrdersDoNotFreezePlanLifecycle(t *testing.T) {
+ tests := []struct {
+ name string
+ statusSQL string
+ }{
+ {
+ name: "unpaid provider creation failure",
+ statusSQL: `
+ UPDATE payment_orders
+ SET status = 'FAILED', paid_at = NULL, payment_trade_no = ''
+ WHERE plan_id = $1
+ `,
+ },
+ {
+ name: "cancelled beyond recovery grace",
+ statusSQL: `
+ UPDATE payment_orders
+ SET status = 'CANCELLED', updated_at = NOW() - INTERVAL '6 minutes'
+ WHERE plan_id = $1
+ `,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tx := testTx(t)
+ userID, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ planID := insertMigrationPlan(t, tx, "subscription", &groupIDs[0], nil)
+ _, err := insertMigrationPaymentOrder(tx, userID, planID, &groupIDs[0])
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), tt.statusSQL, planID)
+ require.NoError(t, err)
+
+ _, err = tx.ExecContext(context.Background(), `
+ UPDATE subscription_plans
+ SET plan_type = 'credits', group_id = NULL, wallet_quota_usd = 50
+ WHERE id = $1
+ `, planID)
+ require.NoError(t, err)
+ })
+ }
+}
+
+func TestPlanFulfillmentShapeCanChangeAfterOrdersAreTerminal(t *testing.T) {
+ terminalStatuses := []string{
+ "COMPLETED",
+ "REFUND_REQUESTED",
+ "REFUNDING",
+ "PARTIALLY_REFUNDED",
+ "REFUNDED",
+ "REFUND_FAILED",
+ }
+
+ t.Run("allows change after expired recovery grace", func(t *testing.T) {
+ tx := testTx(t)
+ userID, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ planID := insertMigrationPlan(t, tx, "subscription", &groupIDs[0], nil)
+ _, err := insertMigrationPaymentOrder(tx, userID, planID, &groupIDs[0])
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), `
+ UPDATE payment_orders
+ SET status = 'EXPIRED', updated_at = NOW() - INTERVAL '6 minutes'
+ WHERE plan_id = $1
+ `, planID)
+ require.NoError(t, err)
+
+ _, err = tx.ExecContext(context.Background(), `
+ UPDATE subscription_plans
+ SET plan_type = 'credits', group_id = NULL, wallet_quota_usd = 50
+ WHERE id = $1
+ `, planID)
+ require.NoError(t, err)
+ })
+ for _, status := range terminalStatuses {
+ t.Run("allows change with only "+status+" order", func(t *testing.T) {
+ tx := testTx(t)
+ userID, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ planID := insertMigrationPlan(t, tx, "subscription", &groupIDs[0], nil)
+ _, err := insertMigrationPaymentOrder(tx, userID, planID, &groupIDs[0])
+ require.NoError(t, err)
+ require.NoError(t, setMigrationPaymentOrderStatus(tx, planID, status))
+
+ _, err = tx.ExecContext(context.Background(), `
+ UPDATE subscription_plans
+ SET plan_type = 'credits', group_id = NULL, wallet_quota_usd = 50
+ WHERE id = $1
+ `, planID)
+ require.NoError(t, err)
+ })
+ }
+
+ t.Run("allows change when plan has no orders", func(t *testing.T) {
+ tx := testTx(t)
+ _, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ planID := insertMigrationPlan(t, tx, "subscription", &groupIDs[0], nil)
+
+ _, err := tx.ExecContext(context.Background(), `
+ UPDATE subscription_plans
+ SET plan_type = 'credits', group_id = NULL, wallet_quota_usd = 50
+ WHERE id = $1
+ `, planID)
+ require.NoError(t, err)
+ })
+}
+
+func TestPlanDeleteGuardProtectsRecoverableOrders(t *testing.T) {
+ for _, status := range []string{"PENDING", "PAID", "RECHARGING", "FAILED", "CANCELLED", "EXPIRED"} {
+ t.Run("blocks delete with "+status+" order", func(t *testing.T) {
+ tx := testTx(t)
+ userID, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ planID := insertMigrationPlan(t, tx, "subscription", &groupIDs[0], nil)
+ _, err := insertMigrationPaymentOrder(tx, userID, planID, &groupIDs[0])
+ require.NoError(t, err)
+ require.NoError(t, setMigrationPaymentOrderStatus(tx, planID, status))
+
+ _, err = tx.ExecContext(context.Background(), "DELETE FROM subscription_plans WHERE id = $1", planID)
+ requirePostgresCode(t, err, "23514")
+ })
+ }
+
+ t.Run("allows delete after expired recovery grace", func(t *testing.T) {
+ tx := testTx(t)
+ userID, groupIDs := insertWalletMigrationUserAndGroups(t, tx)
+ planID := insertMigrationPlan(t, tx, "subscription", &groupIDs[0], nil)
+ _, err := insertMigrationPaymentOrder(tx, userID, planID, &groupIDs[0])
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), `
+ UPDATE payment_orders
+ SET status = 'EXPIRED', updated_at = NOW() - INTERVAL '6 minutes'
+ WHERE plan_id = $1
+ `, planID)
+ require.NoError(t, err)
+
+ _, err = tx.ExecContext(context.Background(), "DELETE FROM subscription_plans WHERE id = $1", planID)
+ require.NoError(t, err)
+ })
+}
diff --git a/backend/internal/repository/wallet_ledger_deferred_integrity_integration_test.go b/backend/internal/repository/wallet_ledger_deferred_integrity_integration_test.go
new file mode 100644
index 00000000000..4051e839a2d
--- /dev/null
+++ b/backend/internal/repository/wallet_ledger_deferred_integrity_integration_test.go
@@ -0,0 +1,224 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestWalletLedgerMigration175DeferredIntegrityGuard(t *testing.T) {
+ t.Run("wallet and activation may be created in the same transaction", func(t *testing.T) {
+ tx := newWalletLedgerGuardTestTransaction(t)
+ insertTempWalletAndLedger(t, tx, 1, 10, 10, []tempLedgerAmount{
+ {delta: "10", balanceAfter: "10", reason: "activation"},
+ })
+
+ _, err := tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ require.NoError(t, err)
+ })
+
+ t.Run("wallet without activation fails at the transaction boundary", func(t *testing.T) {
+ tx := newWalletLedgerGuardTestTransaction(t)
+ insertTempWalletAndLedger(t, tx, 1, 10, 10, nil)
+
+ _, err := tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ requirePostgresCode(t, err, "23514")
+ require.Contains(t, err.Error(), "exactly one activation")
+ })
+
+ t.Run("cached balance must exactly equal the ledger sum", func(t *testing.T) {
+ tx := newWalletLedgerGuardTestTransaction(t)
+ insertTempWalletAndLedger(t, tx, 1, 10, 10, []tempLedgerAmount{
+ {delta: "9.99", balanceAfter: "9.99", reason: "activation"},
+ })
+
+ _, err := tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ requirePostgresCode(t, err, "23514")
+ require.Contains(t, err.Error(), "cached balance must equal ledger sum")
+ })
+
+ t.Run("negative post-response debt remains valid when fully ledgered", func(t *testing.T) {
+ tx := newWalletLedgerGuardTestTransaction(t)
+ insertTempWalletAndLedger(t, tx, 1, 10, -2, []tempLedgerAmount{
+ {delta: "10", balanceAfter: "10", reason: "activation"},
+ {delta: "-12", balanceAfter: "-2", reason: "usage"},
+ })
+
+ _, err := tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ require.NoError(t, err)
+ })
+
+ t.Run("a second active permanent wallet is rejected before index 178", func(t *testing.T) {
+ tx := newWalletLedgerGuardTestTransaction(t)
+ insertTempWalletAndLedger(t, tx, 1, 10, 10, []tempLedgerAmount{
+ {delta: "10", balanceAfter: "10", reason: "activation"},
+ })
+ insertTempWalletAndLedger(t, tx, 2, 20, 20, []tempLedgerAmount{
+ {delta: "20", balanceAfter: "20", reason: "activation"},
+ })
+
+ _, err := tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ requirePostgresCode(t, err, "23514")
+ require.Contains(t, err.Error(), "at most one active permanent credits wallet")
+ })
+
+ t.Run("ledger rows cannot be rewritten", func(t *testing.T) {
+ tx := newWalletLedgerGuardTestTransaction(t)
+ insertTempWalletAndLedger(t, tx, 1, 10, 10, []tempLedgerAmount{
+ {delta: "10", balanceAfter: "10", reason: "activation"},
+ })
+ _, err := tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ require.NoError(t, err)
+
+ _, err = tx.ExecContext(context.Background(), `
+ UPDATE subscription_wallet_ledger
+ SET notes = 'rewritten audit'
+ WHERE subscription_id = 1
+ `)
+ requirePostgresCode(t, err, "23514")
+ require.Contains(t, err.Error(), "append-only")
+ })
+
+ t.Run("ledger rows cannot be deleted", func(t *testing.T) {
+ tx := newWalletLedgerGuardTestTransaction(t)
+ insertTempWalletAndLedger(t, tx, 1, 10, 10, []tempLedgerAmount{
+ {delta: "10", balanceAfter: "10", reason: "activation"},
+ })
+ _, err := tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ require.NoError(t, err)
+
+ _, err = tx.ExecContext(context.Background(), `
+ DELETE FROM subscription_wallet_ledger
+ WHERE subscription_id = 1
+ `)
+ requirePostgresCode(t, err, "23514")
+ require.Contains(t, err.Error(), "append-only")
+ })
+
+ t.Run("wallet parent cannot cascade-delete its ledger", func(t *testing.T) {
+ tx := newWalletLedgerGuardTestTransaction(t)
+ insertTempWalletAndLedger(t, tx, 1, 10, 10, []tempLedgerAmount{
+ {delta: "10", balanceAfter: "10", reason: "activation"},
+ })
+ _, err := tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ require.NoError(t, err)
+
+ _, err = tx.ExecContext(context.Background(), "DELETE FROM user_subscriptions WHERE id = 1")
+ requirePostgresCodeOneOf(t, err, "23001", "23503")
+ })
+
+ t.Run("soft-deleted wallet remains protected from cached balance drift", func(t *testing.T) {
+ tx := newWalletLedgerGuardTestTransaction(t)
+ insertTempWalletAndLedger(t, tx, 1, 10, 10, []tempLedgerAmount{
+ {delta: "10", balanceAfter: "10", reason: "activation"},
+ })
+ _, err := tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL DEFERRED")
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), `
+ UPDATE user_subscriptions
+ SET deleted_at = NOW(), wallet_balance_usd = 9
+ WHERE id = 1
+ `)
+ require.NoError(t, err)
+
+ _, err = tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ requirePostgresCode(t, err, "23514")
+ require.Contains(t, err.Error(), "cached balance must equal ledger sum")
+ })
+
+ t.Run("frozen settlement may append debt to a soft-deleted wallet", func(t *testing.T) {
+ tx := newWalletLedgerGuardTestTransaction(t)
+ insertTempWalletAndLedger(t, tx, 1, 10, 10, []tempLedgerAmount{
+ {delta: "10", balanceAfter: "10", reason: "activation"},
+ })
+ _, err := tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL DEFERRED")
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), `
+ UPDATE user_subscriptions
+ SET deleted_at = NOW(), wallet_balance_usd = 8
+ WHERE id = 1
+ `)
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), `
+ INSERT INTO subscription_wallet_ledger (
+ subscription_id, delta_usd, balance_after, reason
+ ) VALUES (1, -2, 8, 'usage')
+ `)
+ require.NoError(t, err)
+
+ _, err = tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ require.NoError(t, err)
+ })
+
+ t.Run("ledger reason cannot disguise the direction of money", func(t *testing.T) {
+ tx := newWalletLedgerGuardTestTransaction(t)
+ insertTempWalletAndLedger(t, tx, 1, 10, 10, []tempLedgerAmount{
+ {delta: "10", balanceAfter: "10", reason: "activation"},
+ })
+ _, err := tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL IMMEDIATE")
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), "SET CONSTRAINTS ALL DEFERRED")
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), "UPDATE user_subscriptions SET wallet_balance_usd = 11 WHERE id = 1")
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), `
+ INSERT INTO subscription_wallet_ledger (
+ subscription_id, delta_usd, balance_after, reason
+ ) VALUES (1, 1, 11, 'usage')
+ `)
+ requirePostgresCode(t, err, "23514")
+ })
+}
+
+type tempLedgerAmount struct {
+ delta string
+ balanceAfter string
+ reason string
+}
+
+func newWalletLedgerGuardTestTransaction(t *testing.T) *sql.Tx {
+ t.Helper()
+ tx := testTx(t)
+ createWalletMigrationTempTables(t, tx)
+ _, err := tx.ExecContext(context.Background(), "INSERT INTO users (id) VALUES (42)")
+ require.NoError(t, err)
+ _, err = tx.ExecContext(context.Background(), readMigration(t, "175_wallet_ledger_integrity.sql"))
+ require.NoError(t, err)
+ return tx
+}
+
+func insertTempWalletAndLedger(
+ t *testing.T,
+ tx *sql.Tx,
+ subscriptionID int64,
+ initial float64,
+ balance float64,
+ entries []tempLedgerAmount,
+) {
+ t.Helper()
+ ctx := context.Background()
+ _, err := tx.ExecContext(ctx, `
+ INSERT INTO user_subscriptions (
+ id, user_id, wallet_initial_usd, wallet_balance_usd,
+ status, expires_at, created_at
+ ) VALUES ($1, 42, $2, $3, 'active', '2099-12-31 23:59:59+00', NOW())
+ `, subscriptionID, initial, balance)
+ require.NoError(t, err)
+
+ for _, entry := range entries {
+ _, err = tx.ExecContext(ctx, `
+ INSERT INTO subscription_wallet_ledger (
+ subscription_id, delta_usd, balance_after, reason
+ ) VALUES ($1, $2::numeric, $3::numeric, $4)
+ `, subscriptionID, entry.delta, entry.balanceAfter, entry.reason)
+ require.NoError(t, err)
+ }
+}
diff --git a/backend/internal/repository/wallet_migration_test_helpers_integration_test.go b/backend/internal/repository/wallet_migration_test_helpers_integration_test.go
new file mode 100644
index 00000000000..49a5d555bab
--- /dev/null
+++ b/backend/internal/repository/wallet_migration_test_helpers_integration_test.go
@@ -0,0 +1,220 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "testing"
+ "time"
+
+ dbmigrations "github.com/Wei-Shaw/sub2api/migrations"
+ "github.com/lib/pq"
+ "github.com/stretchr/testify/require"
+)
+
+func createWalletMigrationTempTables(t *testing.T, tx *sql.Tx) {
+ t.Helper()
+ ctx := context.Background()
+ _, err := tx.ExecContext(ctx, `
+ CREATE TEMP TABLE users (
+ id BIGINT PRIMARY KEY
+ );
+ CREATE TEMP TABLE user_subscriptions (
+ id BIGINT PRIMARY KEY,
+ user_id BIGINT NOT NULL,
+ group_id BIGINT,
+ wallet_initial_usd NUMERIC(20,10),
+ wallet_balance_usd NUMERIC(20,10),
+ status TEXT NOT NULL,
+ deleted_at TIMESTAMPTZ,
+ expires_at TIMESTAMPTZ NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL
+ );
+ CREATE TEMP TABLE subscription_wallet_ledger (
+ id BIGSERIAL PRIMARY KEY,
+ subscription_id BIGINT NOT NULL,
+ delta_usd NUMERIC(20,10) NOT NULL,
+ balance_after NUMERIC(20,10) NOT NULL,
+ reason VARCHAR(32) NOT NULL,
+ notes TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ );
+ `)
+ require.NoError(t, err)
+}
+
+func createMonthlySeparationMigrationTempTables(t *testing.T, tx *sql.Tx) {
+ t.Helper()
+ _, err := tx.ExecContext(context.Background(), `
+ CREATE TEMP TABLE groups (
+ id BIGINT PRIMARY KEY,
+ status VARCHAR(20) NOT NULL,
+ subscription_type VARCHAR(20) NOT NULL,
+ deleted_at TIMESTAMPTZ
+ );
+ CREATE TEMP TABLE subscription_plans (
+ id BIGINT PRIMARY KEY,
+ plan_type VARCHAR(16) NOT NULL,
+ group_id BIGINT,
+ wallet_quota_usd NUMERIC(20,8),
+ for_sale BOOLEAN NOT NULL DEFAULT TRUE
+ );
+ CREATE TEMP TABLE subscription_plan_groups (
+ id BIGINT PRIMARY KEY,
+ plan_id BIGINT NOT NULL,
+ group_id BIGINT NOT NULL
+ );
+ CREATE TEMP TABLE payment_orders (
+ id BIGINT PRIMARY KEY,
+ order_type VARCHAR(20) NOT NULL,
+ plan_id BIGINT,
+ subscription_group_id BIGINT,
+ status VARCHAR(30) NOT NULL DEFAULT 'PENDING',
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ paid_at TIMESTAMPTZ,
+ payment_trade_no VARCHAR(200)
+ );
+ CREATE TEMP TABLE user_subscriptions (
+ id BIGINT PRIMARY KEY,
+ group_id BIGINT,
+ wallet_balance_usd NUMERIC(20,10),
+ wallet_initial_usd NUMERIC(20,10),
+ expires_at TIMESTAMPTZ NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'active',
+ deleted_at TIMESTAMPTZ
+ );
+ `)
+ require.NoError(t, err)
+}
+
+func execMigrationTestSQL(tx *sql.Tx, statement string) error {
+ _, err := tx.ExecContext(context.Background(), statement)
+ return err
+}
+
+func readMigration(t *testing.T, name string) string {
+ t.Helper()
+ content, err := dbmigrations.FS.ReadFile(name)
+ require.NoError(t, err)
+ return string(content)
+}
+
+func insertWalletMigrationUserAndGroups(t *testing.T, tx *sql.Tx) (int64, [2]int64) {
+ t.Helper()
+ ctx := context.Background()
+ suffix := fmt.Sprintf("%d", time.Now().UnixNano())
+ var userID int64
+ err := tx.QueryRowContext(ctx, `
+ INSERT INTO users (email, password_hash)
+ VALUES ($1, 'migration-test-password-hash')
+ RETURNING id
+ `, "wallet-migration-"+suffix+"@example.test").Scan(&userID)
+ require.NoError(t, err)
+
+ var groupIDs [2]int64
+ for i := range groupIDs {
+ err = tx.QueryRowContext(ctx, `
+ INSERT INTO groups (name, subscription_type, status)
+ VALUES ($1, 'subscription', 'active')
+ RETURNING id
+ `, fmt.Sprintf("wallet-migration-group-%s-%d", suffix, i)).Scan(&groupIDs[i])
+ require.NoError(t, err)
+ }
+ return userID, groupIDs
+}
+
+func insertWalletSubscription(t *testing.T, tx *sql.Tx, userID int64, expiresAt, status string) int64 {
+ t.Helper()
+ var id int64
+ err := tx.QueryRowContext(context.Background(), `
+ INSERT INTO user_subscriptions (
+ user_id, starts_at, expires_at, status,
+ wallet_balance_usd, wallet_initial_usd
+ ) VALUES ($1, NOW(), $2::timestamptz, $3, 10, 10)
+ RETURNING id
+ `, userID, expiresAt, status).Scan(&id)
+ require.NoError(t, err)
+ return id
+}
+
+func insertMigrationPlan(t *testing.T, tx *sql.Tx, planType string, groupID *int64, walletQuota *float64) int64 {
+ t.Helper()
+ var id int64
+ err := tx.QueryRowContext(context.Background(), `
+ INSERT INTO subscription_plans
+ (plan_type, group_id, wallet_quota_usd, name, price)
+ VALUES ($1::varchar, $2::bigint, $3::numeric, $4::varchar, 20)
+ RETURNING id
+ `, planType, groupID, walletQuota, fmt.Sprintf("migration-plan-%d", time.Now().UnixNano())).Scan(&id)
+ require.NoError(t, err)
+ return id
+}
+
+func insertMigrationPaymentOrder(tx *sql.Tx, userID, planID int64, groupID *int64) (sql.Result, error) {
+ return tx.ExecContext(context.Background(), `
+ INSERT INTO payment_orders (
+ user_id, user_email, user_name, amount, pay_amount,
+ order_type, plan_id, subscription_group_id, subscription_days,
+ expires_at
+ ) VALUES ($1::bigint, 'migration@example.test', 'migration test', 20, 20,
+ 'subscription', $2::bigint, $3::bigint, 30, NOW() + INTERVAL '15 minutes')
+ `, userID, planID, groupID)
+}
+
+func insertMigrationPaymentOrderReturningID(tx *sql.Tx, userID, planID int64, groupID *int64) (int64, error) {
+ var orderID int64
+ err := tx.QueryRowContext(context.Background(), `
+ INSERT INTO payment_orders (
+ user_id, user_email, user_name, amount, pay_amount,
+ order_type, plan_id, subscription_group_id, subscription_days,
+ expires_at
+ ) VALUES ($1::bigint, 'migration@example.test', 'migration test', 20, 20,
+ 'subscription', $2::bigint, $3::bigint, 30, NOW() + INTERVAL '15 minutes')
+ RETURNING id
+ `, userID, planID, groupID).Scan(&orderID)
+ return orderID, err
+}
+
+func setMigrationPaymentOrderStatus(tx *sql.Tx, planID int64, status string) error {
+ _, err := tx.ExecContext(context.Background(), `
+ UPDATE payment_orders
+ SET
+ status = $1::varchar,
+ paid_at = CASE WHEN $1::text = 'FAILED' THEN NOW() ELSE paid_at END,
+ payment_trade_no = CASE WHEN $1::text = 'FAILED' THEN 'trusted-test-payment' ELSE payment_trade_no END
+ WHERE plan_id = $2
+ `, status, planID)
+ return err
+}
+
+func requirePostgresCode(t *testing.T, err error, code pq.ErrorCode) {
+ t.Helper()
+ require.Error(t, err)
+ var pqErr *pq.Error
+ require.True(t, errors.As(err, &pqErr), "expected PostgreSQL error, got %T: %v", err, err)
+ require.Equal(t, code, pqErr.Code, "unexpected PostgreSQL error: %s", pqErr.Message)
+}
+
+func requirePostgresCodeOneOf(t *testing.T, err error, codes ...pq.ErrorCode) {
+ t.Helper()
+ require.Error(t, err)
+ var pqErr *pq.Error
+ require.True(t, errors.As(err, &pqErr), "expected PostgreSQL error, got %T: %v", err, err)
+ for _, code := range codes {
+ if pqErr.Code == code {
+ return
+ }
+ }
+ require.Failf(t, "unexpected PostgreSQL error", "code=%s message=%s expected one of=%v", pqErr.Code, pqErr.Message, codes)
+}
+
+func migrationInt64Ptr(value int64) *int64 {
+ return &value
+}
+
+func migrationFloat64Ptr(value float64) *float64 {
+ return &value
+}
diff --git a/backend/internal/repository/wallet_mode_e2e_integration_test.go b/backend/internal/repository/wallet_mode_e2e_integration_test.go
new file mode 100644
index 00000000000..a045c2b286c
--- /dev/null
+++ b/backend/internal/repository/wallet_mode_e2e_integration_test.go
@@ -0,0 +1,183 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+func TestCreditsWalletDefaultsToOpenAIAndVIPRequiresExplicitGrant(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("wallet-mode-e2e-%s@example.com", uuid.NewString()),
+ Username: "wallet-mode-e2e",
+ PasswordHash: "hash",
+ })
+
+ gptGroup := mustGetOrCreateWalletBusinessGroup(t, client,
+ service.WalletDefaultOpenAIGroupName, service.PlatformOpenAI, false, 1)
+ vipGroup := mustGetOrCreateWalletBusinessGroup(t, client,
+ service.WalletDefaultVIPGroupName, service.PlatformAnthropic, true, 2.5)
+
+ walletQuota := 1500.0
+ plan, err := client.SubscriptionPlan.Create().
+ SetName("Credits Wallet E2E").
+ SetPrice(299).
+ SetWalletQuotaUsd(walletQuota).
+ SetValidityDays(36500).
+ SetValidityUnit("day").
+ SetPlanType(service.PlanTypeCredits).
+ Save(ctx)
+ require.NoError(t, err)
+
+ tx, err := client.Tx(ctx)
+ require.NoError(t, err)
+ defer func() { _ = tx.Rollback() }()
+ order, err := tx.Client().PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(299).
+ SetPayAmount(299).
+ SetFeeRate(0).
+ SetRechargeCode("WALLET-E2E-NO-CODE").
+ SetOutTradeNo("wallet-e2e-" + uuid.NewString()).
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("trade-wallet-e2e-" + uuid.NewString()).
+ SetOrderType(payment.OrderTypeSubscription).
+ SetPlanID(plan.ID).
+ SetSubscriptionDays(36500).
+ SetStatus(service.OrderStatusPaid).
+ SetPaidAt(time.Now()).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+ insertCreditsPlanFulfillmentSnapshot(t, ctx, tx.Client(), order.ID, user.ID, plan.ID, 299, 36500, walletQuota)
+ require.NoError(t, tx.Commit())
+ require.Nil(t, order.SubscriptionGroupID)
+
+ groupRepo := NewGroupRepository(client, integrationDB)
+ userRepo := NewUserRepository(client, integrationDB)
+ apiKeyRepo := NewAPIKeyRepository(client, integrationDB, strictAPIKeyTestProtector{})
+ subRepo := NewUserSubscriptionRepository(client)
+ walletRepo := NewWalletRepository(client, integrationDB)
+ apiKeySvc := service.NewAPIKeyService(apiKeyRepo, userRepo, groupRepo, subRepo, nil, nil, &config.Config{})
+ subSvc := service.NewSubscriptionService(groupRepo, subRepo, nil, client, nil)
+ subSvc.SetWalletGroupKeyService(apiKeySvc)
+ subSvc.SetWalletTopupService(service.NewWalletService(walletRepo))
+ paymentSvc := service.NewPaymentService(client, nil, nil, nil, subSvc, nil, userRepo, groupRepo, nil)
+
+ require.NoError(t, paymentSvc.ExecuteSubscriptionFulfillment(ctx, order.ID))
+
+ sub, err := subRepo.GetActiveWalletByUserID(ctx, user.ID)
+ require.NoError(t, err)
+ require.Nil(t, sub.GroupID)
+ require.NotNil(t, sub.WalletInitialUSD)
+ require.NotNil(t, sub.WalletBalanceUSD)
+ require.InDelta(t, walletQuota, *sub.WalletInitialUSD, 0.000001)
+ require.InDelta(t, walletQuota, *sub.WalletBalanceUSD, 0.000001)
+
+ keys, _, err := apiKeyRepo.ListByUserID(ctx, user.ID, defaultWalletE2EPagination(), service.APIKeyListFilters{Status: service.StatusAPIKeyActive})
+ require.NoError(t, err)
+ require.Len(t, keys, 1, "credits wallet must create exactly one universal key")
+
+ universalKey := keys[0]
+ require.True(t, service.IsWalletUniversalKeyName(universalKey.Name), "key 名应为 universal key 名,实际 %q", universalKey.Name)
+ require.Equal(t, service.WalletUniversalAPIKeyName, universalKey.Name)
+ require.Nil(t, universalKey.GroupID, "universal key 的 group_id 必须为 NULL(跨平台)")
+
+ routePolicy := []service.ModelRoute{
+ {Pattern: "gpt-*", GroupName: service.WalletDefaultOpenAIGroupName, ExampleModel: "gpt-5.6-high"},
+ {Pattern: "claude-*", GroupName: service.WalletDefaultVIPGroupName, ExampleModel: "claude-sonnet-4-6"},
+ }
+ routes, err := apiKeySvc.GetWalletModelRoutes(ctx, user.ID, routePolicy)
+ require.NoError(t, err)
+ require.Len(t, routes, 1, "credits users must not see Claude before an explicit vip grant")
+ require.Equal(t, gptGroup.ID, routes[0].GroupID)
+
+ billingRepo := NewUsageBillingRepository(client, integrationDB)
+ requireWalletChargeApplied(t, billingRepo, user.ID, universalKey.ID, sub.ID, "gpt-5", gptGroup.RateMultiplier)
+
+ require.NoError(t, userRepo.AddGroupToAllowedGroups(ctx, user.ID, vipGroup.ID),
+ "the explicit admin-style vip grant must persist before Claude becomes visible")
+ routes, err = apiKeySvc.GetWalletModelRoutes(ctx, user.ID, routePolicy)
+ require.NoError(t, err)
+ require.Len(t, routes, 2)
+ requireWalletChargeApplied(t, billingRepo, user.ID, universalKey.ID, sub.ID, "claude-sonnet-4-6", vipGroup.RateMultiplier)
+
+ expectedBalance := walletQuota - gptGroup.RateMultiplier - vipGroup.RateMultiplier
+ var balance float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, "SELECT wallet_balance_usd FROM user_subscriptions WHERE id = $1", sub.ID).Scan(&balance))
+ require.InDelta(t, expectedBalance, balance, 0.000001)
+
+ var activationCount, usageCount int
+ var ledgerDeltaSum, usageDeltaSum float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT
+ COUNT(*) FILTER (WHERE reason = 'activation'),
+ COUNT(*) FILTER (WHERE reason = 'usage'),
+ COALESCE(SUM(delta_usd), 0),
+ COALESCE(SUM(delta_usd) FILTER (WHERE reason = 'usage'), 0)
+ FROM subscription_wallet_ledger
+ WHERE subscription_id = $1
+ `, sub.ID).Scan(&activationCount, &usageCount, &ledgerDeltaSum, &usageDeltaSum))
+ require.Equal(t, 1, activationCount)
+ require.Equal(t, 2, usageCount)
+ require.InDelta(t, expectedBalance, ledgerDeltaSum, 0.000001)
+ require.InDelta(t, -(gptGroup.RateMultiplier + vipGroup.RateMultiplier), usageDeltaSum, 0.000001)
+
+ reloadedOrder, err := client.PaymentOrder.Get(ctx, order.ID)
+ require.NoError(t, err)
+ require.Equal(t, service.OrderStatusCompleted, reloadedOrder.Status)
+}
+
+func bindWalletPlanGroup(t *testing.T, client *dbent.Client, planID, groupID int64) {
+ t.Helper()
+ _, err := client.SubscriptionPlanGroup.Create().
+ SetPlanID(planID).
+ SetGroupID(groupID).
+ Save(context.Background())
+ require.NoError(t, err)
+}
+
+func defaultWalletE2EPagination() pagination.PaginationParams {
+ return pagination.PaginationParams{
+ Page: 1,
+ PageSize: 100,
+ SortBy: "created_at",
+ SortOrder: "desc",
+ }
+}
+
+func requireWalletChargeApplied(t *testing.T, repo service.UsageBillingRepository, userID, apiKeyID, subscriptionID int64, model string, walletCost float64) {
+ t.Helper()
+ result, err := repo.Apply(context.Background(), &service.UsageBillingCommand{
+ RequestID: "wallet-e2e-" + model + "-" + uuid.NewString(),
+ APIKeyID: apiKeyID,
+ UserID: userID,
+ SubscriptionID: &subscriptionID,
+ Model: model,
+ WalletCost: walletCost,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.True(t, result.Applied)
+ require.False(t, result.WalletInsufficient)
+ require.NotNil(t, result.NewWalletBalance)
+}
diff --git a/backend/internal/repository/wallet_payment_atomicity_integration_test.go b/backend/internal/repository/wallet_payment_atomicity_integration_test.go
new file mode 100644
index 00000000000..628c049c6eb
--- /dev/null
+++ b/backend/internal/repository/wallet_payment_atomicity_integration_test.go
@@ -0,0 +1,404 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+func TestWalletFulfillmentKeyCreateFailureRollsBackAndRetryCreditsOnce(t *testing.T) {
+ ctx := context.Background()
+ fixture := newWalletPaymentAtomicityFixture(t, ctx, "key-create")
+
+ failingAPIKeyRepo := &failOnceAPIKeyRepository{
+ APIKeyRepository: fixture.apiKeyRepo,
+ err: errors.New("injected wallet key create failure"),
+ }
+ apiKeySvc := service.NewAPIKeyService(
+ failingAPIKeyRepo,
+ fixture.userRepo,
+ fixture.groupRepo,
+ fixture.subRepo,
+ nil,
+ nil,
+ &config.Config{},
+ )
+ paymentSvc := fixture.paymentService(apiKeySvc)
+
+ err := paymentSvc.ExecuteSubscriptionFulfillment(ctx, fixture.orderID)
+ require.ErrorContains(t, err, "injected wallet key create failure")
+ assertWalletPaymentNotDelivered(t, ctx, fixture)
+ requirePaymentOrderStatus(t, ctx, fixture.orderID, service.OrderStatusFailed)
+
+ require.NoError(t, paymentSvc.ExecuteSubscriptionFulfillment(ctx, fixture.orderID))
+ assertWalletPaymentDeliveredExactlyOnce(t, ctx, fixture)
+ require.Equal(t, 2, failingAPIKeyRepo.createAttempts(), "retry should make one fresh key-create attempt")
+}
+
+func TestWalletFulfillmentOrderCompletionFailureRollsBackAndRetryCreditsOnce(t *testing.T) {
+ ctx := context.Background()
+ fixture := newWalletPaymentAtomicityFixture(t, ctx, "order-complete")
+
+ apiKeySvc := service.NewAPIKeyService(
+ fixture.apiKeyRepo,
+ fixture.userRepo,
+ fixture.groupRepo,
+ fixture.subRepo,
+ nil,
+ nil,
+ &config.Config{},
+ )
+ removeFailure := installPaymentCompletionFailure(t, ctx, fixture.orderID)
+ paymentSvc := fixture.paymentService(apiKeySvc)
+
+ err := paymentSvc.ExecuteSubscriptionFulfillment(ctx, fixture.orderID)
+ require.ErrorContains(t, err, "injected payment completion failure")
+ assertWalletPaymentNotDelivered(t, ctx, fixture)
+ requirePaymentOrderStatus(t, ctx, fixture.orderID, service.OrderStatusFailed)
+
+ removeFailure()
+ require.NoError(t, paymentSvc.ExecuteSubscriptionFulfillment(ctx, fixture.orderID))
+ assertWalletPaymentDeliveredExactlyOnce(t, ctx, fixture)
+}
+
+func TestWalletTopupKeyEnsureFailureRollsBackAndRetryCreditsOnce(t *testing.T) {
+ ctx := context.Background()
+ fixture := newWalletPaymentAtomicityFixture(t, ctx, "topup-key-ensure")
+ apiKeySvc := fixture.seedWallet(t, ctx)
+
+ fixture.orderID = createWalletPaymentAtomicityOrder(t, ctx, fixture.client, fixture.user, fixture.planID, "topup-key-ensure-retry")
+ failingKeySvc := &failOnceWalletKeyService{
+ WalletGroupKeyService: apiKeySvc,
+ err: errors.New("injected wallet key ensure failure"),
+ }
+ paymentSvc := fixture.paymentService(failingKeySvc)
+
+ err := paymentSvc.ExecuteSubscriptionFulfillment(ctx, fixture.orderID)
+ require.ErrorContains(t, err, "injected wallet key ensure failure")
+ assertWalletPaymentState(t, ctx, fixture, fixture.quotaUSD, 1, 0)
+ require.Equal(t, 0, paymentAuditCount(t, ctx, fixture.orderID, "SUBSCRIPTION_SUCCESS"))
+ requirePaymentOrderStatus(t, ctx, fixture.orderID, service.OrderStatusFailed)
+
+ require.NoError(t, paymentSvc.ExecuteSubscriptionFulfillment(ctx, fixture.orderID))
+ assertWalletPaymentState(t, ctx, fixture, fixture.quotaUSD*2, 2, 1)
+ require.Equal(t, 1, userAPIKeyCount(t, ctx, fixture.userID), "topup retry must reuse the universal key")
+ require.Equal(t, 1, paymentAuditCount(t, ctx, fixture.orderID, "SUBSCRIPTION_SUCCESS"))
+ requirePaymentOrderStatus(t, ctx, fixture.orderID, service.OrderStatusCompleted)
+}
+
+func TestWalletTopupOrderCompletionFailureRollsBackAndRetryCreditsOnce(t *testing.T) {
+ ctx := context.Background()
+ fixture := newWalletPaymentAtomicityFixture(t, ctx, "topup-order-complete")
+ apiKeySvc := fixture.seedWallet(t, ctx)
+
+ fixture.orderID = createWalletPaymentAtomicityOrder(t, ctx, fixture.client, fixture.user, fixture.planID, "topup-order-complete-retry")
+ removeFailure := installPaymentCompletionFailure(t, ctx, fixture.orderID)
+ paymentSvc := fixture.paymentService(apiKeySvc)
+
+ err := paymentSvc.ExecuteSubscriptionFulfillment(ctx, fixture.orderID)
+ require.ErrorContains(t, err, "injected payment completion failure")
+ assertWalletPaymentState(t, ctx, fixture, fixture.quotaUSD, 1, 0)
+ require.Equal(t, 0, paymentAuditCount(t, ctx, fixture.orderID, "SUBSCRIPTION_SUCCESS"))
+ requirePaymentOrderStatus(t, ctx, fixture.orderID, service.OrderStatusFailed)
+
+ removeFailure()
+ require.NoError(t, paymentSvc.ExecuteSubscriptionFulfillment(ctx, fixture.orderID))
+ assertWalletPaymentState(t, ctx, fixture, fixture.quotaUSD*2, 2, 1)
+ require.Equal(t, 1, userAPIKeyCount(t, ctx, fixture.userID), "topup retry must reuse the universal key")
+ require.Equal(t, 1, paymentAuditCount(t, ctx, fixture.orderID, "SUBSCRIPTION_SUCCESS"))
+ requirePaymentOrderStatus(t, ctx, fixture.orderID, service.OrderStatusCompleted)
+}
+
+type walletPaymentAtomicityFixture struct {
+ client *dbent.Client
+ user *service.User
+ userID int64
+ planID int64
+ orderID int64
+ quotaUSD float64
+ userRepo service.UserRepository
+ groupRepo service.GroupRepository
+ subRepo service.UserSubscriptionRepository
+ apiKeyRepo service.APIKeyRepository
+ walletSvc *service.WalletService
+}
+
+func newWalletPaymentAtomicityFixture(t *testing.T, ctx context.Context, label string) *walletPaymentAtomicityFixture {
+ t.Helper()
+ client := testEntClient(t)
+ suffix := uuid.NewString()
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("wallet-atomicity-%s-%s@example.com", label, suffix),
+ Username: "wallet-atomicity-" + label,
+ PasswordHash: "hash",
+ })
+
+ const quotaUSD = 100.0
+ plan, err := client.SubscriptionPlan.Create().
+ SetName("Wallet atomicity " + label + " " + suffix).
+ SetPrice(30).
+ SetWalletQuotaUsd(quotaUSD).
+ SetValidityDays(36500).
+ SetValidityUnit("day").
+ SetPlanType(service.PlanTypeCredits).
+ Save(ctx)
+ require.NoError(t, err)
+
+ orderID := createWalletPaymentAtomicityOrder(t, ctx, client, user, plan.ID, label)
+
+ userRepo := NewUserRepository(client, integrationDB)
+ groupRepo := NewGroupRepository(client, integrationDB)
+ subRepo := NewUserSubscriptionRepository(client)
+ apiKeyRepo := NewAPIKeyRepository(client, integrationDB, strictAPIKeyTestProtector{})
+ walletRepo := NewWalletRepository(client, integrationDB)
+
+ return &walletPaymentAtomicityFixture{
+ client: client,
+ user: user,
+ userID: user.ID,
+ planID: plan.ID,
+ orderID: orderID,
+ quotaUSD: quotaUSD,
+ userRepo: userRepo,
+ groupRepo: groupRepo,
+ subRepo: subRepo,
+ apiKeyRepo: apiKeyRepo,
+ walletSvc: service.NewWalletService(walletRepo),
+ }
+}
+
+func createWalletPaymentAtomicityOrder(t *testing.T, ctx context.Context, client *dbent.Client, user *service.User, planID int64, label string) int64 {
+ t.Helper()
+ tx, err := client.Tx(ctx)
+ require.NoError(t, err)
+ defer func() { _ = tx.Rollback() }()
+ txClient := tx.Client()
+ orderUUID := uuid.NewString()
+ order, err := txClient.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(30).
+ SetPayAmount(30).
+ SetFeeRate(0).
+ SetRechargeCode("WAT-" + orderUUID).
+ SetOutTradeNo("wat-" + orderUUID).
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("tr-wat-" + orderUUID).
+ SetOrderType(payment.OrderTypeSubscription).
+ SetPlanID(planID).
+ SetSubscriptionDays(36500).
+ SetStatus(service.OrderStatusPaid).
+ SetPaidAt(time.Now()).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+ insertCreditsPlanFulfillmentSnapshot(t, ctx, txClient, order.ID, user.ID, planID, 30, 36500, 100)
+ require.NoError(t, tx.Commit())
+ return order.ID
+}
+
+func (f *walletPaymentAtomicityFixture) paymentService(keySvc service.WalletGroupKeyService) *service.PaymentService {
+ subSvc := service.NewSubscriptionService(f.groupRepo, f.subRepo, nil, f.client, nil)
+ subSvc.SetWalletGroupKeyService(keySvc)
+ subSvc.SetWalletTopupService(f.walletSvc)
+ return service.NewPaymentService(f.client, nil, nil, nil, subSvc, nil, f.userRepo, f.groupRepo, nil)
+}
+
+func (f *walletPaymentAtomicityFixture) seedWallet(t *testing.T, ctx context.Context) *service.APIKeyService {
+ t.Helper()
+ apiKeySvc := service.NewAPIKeyService(
+ f.apiKeyRepo,
+ f.userRepo,
+ f.groupRepo,
+ f.subRepo,
+ nil,
+ nil,
+ &config.Config{},
+ )
+ require.NoError(t, f.paymentService(apiKeySvc).ExecuteSubscriptionFulfillment(ctx, f.orderID))
+ assertWalletPaymentDeliveredExactlyOnce(t, ctx, f)
+ return apiKeySvc
+}
+
+type failOnceAPIKeyRepository struct {
+ service.APIKeyRepository
+
+ mu sync.Mutex
+ attempts int
+ err error
+}
+
+func (r *failOnceAPIKeyRepository) GetByUserIDAndPurpose(ctx context.Context, userID int64, purpose string) (*service.APIKey, error) {
+ repo, ok := r.APIKeyRepository.(service.APIKeyPurposeRepository)
+ if !ok {
+ return nil, fmt.Errorf("wrapped api key repository does not support purpose lookup")
+ }
+ return repo.GetByUserIDAndPurpose(ctx, userID, purpose)
+}
+
+func (r *failOnceAPIKeyRepository) Create(ctx context.Context, key *service.APIKey) error {
+ r.mu.Lock()
+ r.attempts++
+ attempt := r.attempts
+ r.mu.Unlock()
+ if attempt == 1 {
+ return r.err
+ }
+ return r.APIKeyRepository.Create(ctx, key)
+}
+
+func (r *failOnceAPIKeyRepository) createAttempts() int {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ return r.attempts
+}
+
+type failOnceWalletKeyService struct {
+ service.WalletGroupKeyService
+
+ mu sync.Mutex
+ attempts int
+ err error
+}
+
+func (s *failOnceWalletKeyService) EnsureWalletUniversalKey(ctx context.Context, userID int64) (*service.APIKey, bool, error) {
+ s.mu.Lock()
+ s.attempts++
+ attempt := s.attempts
+ s.mu.Unlock()
+ if attempt == 1 {
+ return nil, false, s.err
+ }
+ return s.WalletGroupKeyService.EnsureWalletUniversalKey(ctx, userID)
+}
+
+func assertWalletPaymentNotDelivered(t *testing.T, ctx context.Context, fixture *walletPaymentAtomicityFixture) {
+ t.Helper()
+ require.Equal(t, 0, walletSubscriptionCount(t, ctx, fixture.userID))
+ require.Equal(t, 0, walletLedgerCount(t, ctx, fixture.userID))
+ require.Equal(t, 0, userAPIKeyCount(t, ctx, fixture.userID))
+ require.Equal(t, 0, paymentAuditCount(t, ctx, fixture.orderID, "SUBSCRIPTION_SUCCESS"))
+}
+
+func assertWalletPaymentDeliveredExactlyOnce(t *testing.T, ctx context.Context, fixture *walletPaymentAtomicityFixture) {
+ t.Helper()
+ requirePaymentOrderStatus(t, ctx, fixture.orderID, service.OrderStatusCompleted)
+ require.Equal(t, 1, walletSubscriptionCount(t, ctx, fixture.userID))
+ require.Equal(t, 1, userAPIKeyCount(t, ctx, fixture.userID))
+ require.Equal(t, 1, paymentAuditCount(t, ctx, fixture.orderID, "SUBSCRIPTION_SUCCESS"))
+
+ var subscriptionID int64
+ var initialUSD, balanceUSD float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT id, wallet_initial_usd, wallet_balance_usd
+ FROM user_subscriptions
+ WHERE user_id = $1 AND wallet_balance_usd IS NOT NULL AND deleted_at IS NULL
+ `, fixture.userID).Scan(&subscriptionID, &initialUSD, &balanceUSD))
+ require.InDelta(t, fixture.quotaUSD, initialUSD, 0.000001)
+ require.InDelta(t, fixture.quotaUSD, balanceUSD, 0.000001)
+
+ var activationCount int
+ var activationSum float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*), COALESCE(SUM(delta_usd), 0)
+ FROM subscription_wallet_ledger
+ WHERE subscription_id = $1 AND reason = 'activation'
+ `, subscriptionID).Scan(&activationCount, &activationSum))
+ require.Equal(t, 1, activationCount)
+ require.InDelta(t, fixture.quotaUSD, activationSum, 0.000001)
+ require.Equal(t, 1, walletLedgerCount(t, ctx, fixture.userID), "retry must not leave duplicate wallet ledger rows")
+}
+
+func assertWalletPaymentState(t *testing.T, ctx context.Context, fixture *walletPaymentAtomicityFixture, wantBalance float64, wantLedgerCount, wantTopupCount int) {
+ t.Helper()
+ require.Equal(t, 1, walletSubscriptionCount(t, ctx, fixture.userID))
+
+ var subscriptionID int64
+ var initialUSD, balanceUSD float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT id, wallet_initial_usd, wallet_balance_usd
+ FROM user_subscriptions
+ WHERE user_id = $1 AND wallet_balance_usd IS NOT NULL AND deleted_at IS NULL
+ `, fixture.userID).Scan(&subscriptionID, &initialUSD, &balanceUSD))
+ require.InDelta(t, wantBalance, initialUSD, 0.000001)
+ require.InDelta(t, wantBalance, balanceUSD, 0.000001)
+
+ var ledgerCount, topupCount int
+ var ledgerSum float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*),
+ COUNT(*) FILTER (WHERE reason = 'topup'),
+ COALESCE(SUM(delta_usd), 0)
+ FROM subscription_wallet_ledger
+ WHERE subscription_id = $1
+ `, subscriptionID).Scan(&ledgerCount, &topupCount, &ledgerSum))
+ require.Equal(t, wantLedgerCount, ledgerCount)
+ require.Equal(t, wantTopupCount, topupCount)
+ require.InDelta(t, wantBalance, ledgerSum, 0.000001)
+}
+
+func walletSubscriptionCount(t *testing.T, ctx context.Context, userID int64) int {
+ t.Helper()
+ var count int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*)
+ FROM user_subscriptions
+ WHERE user_id = $1 AND wallet_balance_usd IS NOT NULL AND deleted_at IS NULL
+ `, userID).Scan(&count))
+ return count
+}
+
+func walletLedgerCount(t *testing.T, ctx context.Context, userID int64) int {
+ t.Helper()
+ var count int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*)
+ FROM subscription_wallet_ledger l
+ JOIN user_subscriptions us ON us.id = l.subscription_id
+ WHERE us.user_id = $1
+ `, userID).Scan(&count))
+ return count
+}
+
+func userAPIKeyCount(t *testing.T, ctx context.Context, userID int64) int {
+ t.Helper()
+ var count int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM api_keys WHERE user_id = $1 AND deleted_at IS NULL
+ `, userID).Scan(&count))
+ return count
+}
+
+func paymentAuditCount(t *testing.T, ctx context.Context, orderID int64, action string) int {
+ t.Helper()
+ var count int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*) FROM payment_audit_logs WHERE order_id = $1 AND action = $2
+ `, fmt.Sprintf("%d", orderID), action).Scan(&count))
+ return count
+}
+
+func requirePaymentOrderStatus(t *testing.T, ctx context.Context, orderID int64, want string) {
+ t.Helper()
+ var got string
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT status FROM payment_orders WHERE id = $1
+ `, orderID).Scan(&got))
+ require.Equal(t, want, got)
+}
diff --git a/backend/internal/repository/wallet_plan_migration_concurrency_integration_test.go b/backend/internal/repository/wallet_plan_migration_concurrency_integration_test.go
new file mode 100644
index 00000000000..5e328af9cd6
--- /dev/null
+++ b/backend/internal/repository/wallet_plan_migration_concurrency_integration_test.go
@@ -0,0 +1,298 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestPlanShapeUpdateSerializesWithConcurrentOrderCreation(t *testing.T) {
+ ctx := context.Background()
+ userID, groupID, planID := insertCommittedPlanRaceFixture(t)
+
+ orderTx, err := integrationDB.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = orderTx.Rollback() })
+ orderID, err := insertMigrationPaymentOrderReturningID(orderTx, userID, planID, &groupID)
+ require.NoError(t, err)
+ insertPlanSnapshotTestRow(t, orderTx, orderID, userID, planID, groupID)
+
+ planTx, err := integrationDB.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = planTx.Rollback() })
+ var planBackendPID int
+ require.NoError(t, planTx.QueryRowContext(ctx, "SELECT pg_backend_pid()").Scan(&planBackendPID))
+
+ updateResult := make(chan error, 1)
+ go func() {
+ _, updateErr := planTx.ExecContext(ctx, `
+ UPDATE subscription_plans
+ SET plan_type = 'credits', group_id = NULL, wallet_quota_usd = 50
+ WHERE id = $1
+ `, planID)
+ updateResult <- updateErr
+ }()
+
+ requireBackendWaitingOnLock(t, ctx, planBackendPID,
+ "plan update must wait for the order transaction's plan lock")
+ require.NoError(t, orderTx.Commit())
+ requireNoErrorResult(t, updateResult, "plan update remained blocked after snapshotted order committed")
+}
+
+func TestConcurrentOrderCreationRevalidatesAfterPlanShapeCommit(t *testing.T) {
+ ctx := context.Background()
+ userID, groupID, planID := insertCommittedPlanRaceFixture(t)
+
+ planTx, err := integrationDB.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = planTx.Rollback() })
+ _, err = planTx.ExecContext(ctx, `
+ UPDATE subscription_plans
+ SET plan_type = 'credits', group_id = NULL, wallet_quota_usd = 50
+ WHERE id = $1
+ `, planID)
+ require.NoError(t, err)
+
+ orderTx, err := integrationDB.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = orderTx.Rollback() })
+ var orderBackendPID int
+ require.NoError(t, orderTx.QueryRowContext(ctx, "SELECT pg_backend_pid()").Scan(&orderBackendPID))
+
+ insertResult := make(chan error, 1)
+ go func() {
+ _, insertErr := insertMigrationPaymentOrder(orderTx, userID, planID, &groupID)
+ insertResult <- insertErr
+ }()
+
+ requireBackendWaitingOnLock(t, ctx, orderBackendPID,
+ "order creation must wait for the in-flight plan shape update")
+ require.NoError(t, planTx.Commit())
+ requirePostgresErrorResult(t, insertResult, "order creation remained blocked after plan shape committed")
+}
+
+func TestPlanDeleteWaitsForConcurrentOrderCreationThenFails(t *testing.T) {
+ ctx := context.Background()
+ userID, groupID, planID := insertCommittedPlanRaceFixture(t)
+
+ orderTx, err := integrationDB.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = orderTx.Rollback() })
+ orderID, err := insertMigrationPaymentOrderReturningID(orderTx, userID, planID, &groupID)
+ require.NoError(t, err)
+ insertPlanSnapshotTestRow(t, orderTx, orderID, userID, planID, groupID)
+
+ deleteTx, err := integrationDB.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = deleteTx.Rollback() })
+ var deleteBackendPID int
+ require.NoError(t, deleteTx.QueryRowContext(ctx, "SELECT pg_backend_pid()").Scan(&deleteBackendPID))
+
+ deleteResult := make(chan error, 1)
+ go func() {
+ _, deleteErr := deleteTx.ExecContext(ctx, "DELETE FROM subscription_plans WHERE id = $1", planID)
+ deleteResult <- deleteErr
+ }()
+
+ requireBackendWaitingOnLock(t, ctx, deleteBackendPID,
+ "plan delete must wait for the concurrent order's plan lock")
+ require.NoError(t, orderTx.Commit())
+ requireNoErrorResult(t, deleteResult, "plan delete remained blocked after snapshotted order committed")
+}
+
+func TestConcurrentOrderCreationRejectsPlanDeletedBeforeCommit(t *testing.T) {
+ ctx := context.Background()
+ userID, groupID, planID := insertCommittedPlanRaceFixture(t)
+
+ deleteTx, err := integrationDB.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = deleteTx.Rollback() })
+ _, err = deleteTx.ExecContext(ctx, "DELETE FROM subscription_plans WHERE id = $1", planID)
+ require.NoError(t, err)
+
+ orderTx, err := integrationDB.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = orderTx.Rollback() })
+ var orderBackendPID int
+ require.NoError(t, orderTx.QueryRowContext(ctx, "SELECT pg_backend_pid()").Scan(&orderBackendPID))
+
+ insertResult := make(chan error, 1)
+ go func() {
+ _, insertErr := insertMigrationPaymentOrder(orderTx, userID, planID, &groupID)
+ insertResult <- insertErr
+ }()
+
+ requireBackendWaitingOnLock(t, ctx, orderBackendPID,
+ "order creation must wait for the in-flight plan delete")
+ require.NoError(t, deleteTx.Commit())
+ requirePostgresErrorResult(t, insertResult, "order creation remained blocked after plan delete committed")
+}
+
+func TestSubscriptionGroupDisableWaitsForConcurrentPlanCreationThenFails(t *testing.T) {
+ ctx := context.Background()
+ _, groupID := insertCommittedGroupRaceFixture(t)
+
+ planTx, err := integrationDB.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = planTx.Rollback() })
+ var planID int64
+ require.NoError(t, planTx.QueryRowContext(ctx, `
+ INSERT INTO subscription_plans
+ (plan_type, group_id, wallet_quota_usd, name, price)
+ VALUES ('subscription', $1, NULL, $2, 20)
+ RETURNING id
+ `, groupID, fmt.Sprintf("wallet-group-race-plan-%d", time.Now().UnixNano())).Scan(&planID))
+ t.Cleanup(func() {
+ _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM subscription_plans WHERE id = $1", planID)
+ })
+
+ groupTx, err := integrationDB.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = groupTx.Rollback() })
+ var groupBackendPID int
+ require.NoError(t, groupTx.QueryRowContext(ctx, "SELECT pg_backend_pid()").Scan(&groupBackendPID))
+
+ updateResult := make(chan error, 1)
+ go func() {
+ _, updateErr := groupTx.ExecContext(ctx, "UPDATE groups SET status = 'disabled' WHERE id = $1", groupID)
+ updateResult <- updateErr
+ }()
+
+ requireBackendWaitingOnLock(t, ctx, groupBackendPID,
+ "group mutation must wait for the concurrent plan's group lock")
+ require.NoError(t, planTx.Commit())
+ select {
+ case updateErr := <-updateResult:
+ require.NoError(t, updateErr, "reverse lifecycle check is deferred until commit")
+ case <-time.After(5 * time.Second):
+ t.Fatal("group mutation remained blocked after concurrent plan committed")
+ }
+ requirePostgresCode(t, groupTx.Commit(), "23514")
+}
+
+func TestConcurrentPlanCreationRevalidatesAfterGroupDisableCommit(t *testing.T) {
+ ctx := context.Background()
+ _, groupID := insertCommittedGroupRaceFixture(t)
+
+ groupTx, err := integrationDB.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = groupTx.Rollback() })
+ _, err = groupTx.ExecContext(ctx, "UPDATE groups SET status = 'disabled' WHERE id = $1", groupID)
+ require.NoError(t, err)
+
+ planTx, err := integrationDB.BeginTx(ctx, nil)
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = planTx.Rollback() })
+ var planBackendPID int
+ require.NoError(t, planTx.QueryRowContext(ctx, "SELECT pg_backend_pid()").Scan(&planBackendPID))
+
+ insertResult := make(chan error, 1)
+ go func() {
+ _, insertErr := planTx.ExecContext(ctx, `
+ INSERT INTO subscription_plans
+ (plan_type, group_id, wallet_quota_usd, name, price)
+ VALUES ('subscription', $1, NULL, $2, 20)
+ `, groupID, fmt.Sprintf("wallet-disabled-group-plan-%d", time.Now().UnixNano()))
+ insertResult <- insertErr
+ }()
+
+ requireBackendWaitingOnLock(t, ctx, planBackendPID,
+ "plan creation must wait for the in-flight group disable")
+ require.NoError(t, groupTx.Commit())
+ requirePostgresErrorResult(t, insertResult, "plan creation remained blocked after group disable committed")
+}
+
+func requireBackendWaitingOnLock(t *testing.T, ctx context.Context, backendPID int, message string) {
+ t.Helper()
+ require.Eventually(t, func() bool {
+ var waiting bool
+ err := integrationDB.QueryRowContext(ctx, `
+ SELECT COALESCE(wait_event_type = 'Lock', FALSE)
+ FROM pg_stat_activity
+ WHERE pid = $1
+ `, backendPID).Scan(&waiting)
+ return err == nil && waiting
+ }, 3*time.Second, 20*time.Millisecond, message)
+}
+
+func requirePostgresErrorResult(t *testing.T, result <-chan error, timeoutMessage string) {
+ t.Helper()
+ select {
+ case err := <-result:
+ requirePostgresCode(t, err, "23514")
+ case <-time.After(5 * time.Second):
+ t.Fatal(timeoutMessage)
+ }
+}
+
+func requireNoErrorResult(t *testing.T, result <-chan error, timeoutMessage string) {
+ t.Helper()
+ select {
+ case err := <-result:
+ require.NoError(t, err)
+ case <-time.After(5 * time.Second):
+ t.Fatal(timeoutMessage)
+ }
+}
+
+func insertCommittedPlanRaceFixture(t *testing.T) (userID, groupID, planID int64) {
+ t.Helper()
+ ctx := context.Background()
+ suffix := fmt.Sprintf("%d", time.Now().UnixNano())
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ INSERT INTO users (email, password_hash)
+ VALUES ($1, 'migration-race-test-password-hash')
+ RETURNING id
+ `, "wallet-migration-race-"+suffix+"@example.test").Scan(&userID))
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ INSERT INTO groups (name, subscription_type, status)
+ VALUES ($1, 'subscription', 'active')
+ RETURNING id
+ `, "wallet-migration-race-group-"+suffix).Scan(&groupID))
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ INSERT INTO subscription_plans
+ (plan_type, group_id, wallet_quota_usd, name, price)
+ VALUES ('subscription', $1, NULL, $2, 20)
+ RETURNING id
+ `, groupID, "wallet-migration-race-plan-"+suffix).Scan(&planID))
+
+ t.Cleanup(func() {
+ _, _ = integrationDB.ExecContext(context.Background(), `
+ DELETE FROM subscription_plan_fulfillment_snapshots
+ WHERE payment_order_id IN (SELECT id FROM payment_orders WHERE plan_id = $1)
+ `, planID)
+ _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM payment_orders WHERE plan_id = $1", planID)
+ _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM subscription_plan_groups WHERE plan_id = $1", planID)
+ _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM subscription_plans WHERE id = $1", planID)
+ _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM groups WHERE id = $1", groupID)
+ _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM users WHERE id = $1", userID)
+ })
+ return userID, groupID, planID
+}
+
+func insertCommittedGroupRaceFixture(t *testing.T) (userID, groupID int64) {
+ t.Helper()
+ ctx := context.Background()
+ suffix := fmt.Sprintf("%d", time.Now().UnixNano())
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ INSERT INTO users (email, password_hash)
+ VALUES ($1, 'migration-group-race-test-password-hash')
+ RETURNING id
+ `, "wallet-group-race-"+suffix+"@example.test").Scan(&userID))
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ INSERT INTO groups (name, subscription_type, status)
+ VALUES ($1, 'subscription', 'active')
+ RETURNING id
+ `, "wallet-group-race-"+suffix).Scan(&groupID))
+
+ t.Cleanup(func() {
+ _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM groups WHERE id = $1", groupID)
+ _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM users WHERE id = $1", userID)
+ })
+ return userID, groupID
+}
diff --git a/backend/internal/repository/wallet_repo.go b/backend/internal/repository/wallet_repo.go
new file mode 100644
index 00000000000..24450a2469a
--- /dev/null
+++ b/backend/internal/repository/wallet_repo.go
@@ -0,0 +1,528 @@
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "math"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+// walletRepository 实现 service.WalletRepository。
+//
+// 设计要点:
+// - 直接走 *sql.DB,避开 ent 的 transaction 抽象,原因是:
+// 1) ent 不直接支持 FOR UPDATE row-level lock 的 fluent API
+// 2) 钱包扣款是热路径,少一层抽象 = 少一份分配开销
+// - DECIMAL(20,10) 精度匹配 user_subscriptions.wallet_balance_usd 列定义;
+// 用 float64 即可承载(业务最大值远小于 2^53)。
+// - 调用方应保证 wallet_balance_usd 列已是 NOT NULL(CHECK 约束保证:
+// 只有钱包模式订阅会进 wallet_repo,wallet_balance_usd 必非 NULL)。
+type walletRepository struct {
+ db *sql.DB
+}
+
+func NewWalletRepository(_ *dbent.Client, sqlDB *sql.DB) service.WalletRepository {
+ return &walletRepository{db: sqlDB}
+}
+
+func (r *walletRepository) withWalletMutation(ctx context.Context, fn func(sqlQueryExecutor) (service.WalletLedgerEntry, error)) (service.WalletLedgerEntry, error) {
+ if existingTx := dbent.TxFromContext(ctx); existingTx != nil {
+ exec := sqlExecutorFromEntClient(existingTx.Client())
+ if exec == nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("wallet transaction executor is unavailable")
+ }
+ return fn(exec)
+ }
+
+ tx, err := r.db.BeginTx(ctx, nil)
+ if err != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("begin tx: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ entry, err := fn(tx)
+ if err != nil {
+ return service.WalletLedgerEntry{}, err
+ }
+ if err := tx.Commit(); err != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("commit: %w", err)
+ }
+ return entry, nil
+}
+
+func (r *walletRepository) Deduct(ctx context.Context, cmd service.WalletDeductCommand) (service.WalletLedgerEntry, error) {
+ if cmd.SubscriptionID <= 0 {
+ return service.WalletLedgerEntry{}, service.ErrWalletNotFound
+ }
+ if cmd.CostUSD <= 0 {
+ return service.WalletLedgerEntry{}, service.ErrWalletNegativeDelta
+ }
+
+ tx, err := r.db.BeginTx(ctx, nil)
+ if err != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("begin tx: %w", err)
+ }
+ defer func() {
+ if tx != nil {
+ _ = tx.Rollback()
+ }
+ }()
+
+ // FOR UPDATE 锁住订阅行,串行化同 user 多并发请求的扣款。
+ // 同时 NOWAIT 不能用:v3 老订阅的 daily_usage_usd UPDATE 也走同一行,
+ // 简单的 FOR UPDATE 排队即可。
+ var balance sql.NullFloat64
+ err = tx.QueryRowContext(ctx, `
+ SELECT wallet_balance_usd
+ FROM user_subscriptions
+ WHERE id = $1 AND deleted_at IS NULL
+ FOR UPDATE
+ `, cmd.SubscriptionID).Scan(&balance)
+ if errors.Is(err, sql.ErrNoRows) {
+ return service.WalletLedgerEntry{}, service.ErrWalletNotFound
+ }
+ if err != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("lock subscription row: %w", err)
+ }
+ if !balance.Valid {
+ // 不是钱包模式订阅
+ return service.WalletLedgerEntry{}, service.ErrWalletNotFound
+ }
+
+ if cmd.UsageLogID != nil {
+ existing, found, err := findUsageLedger(ctx, tx, *cmd.UsageLogID)
+ if err != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("query usage ledger: %w", err)
+ }
+ if found {
+ if existing.SubscriptionID != cmd.SubscriptionID {
+ return service.WalletLedgerEntry{}, fmt.Errorf("usage log is already settled for another subscription")
+ }
+ if err := tx.Commit(); err != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("commit replay lookup: %w", err)
+ }
+ tx = nil
+ return existing, nil
+ }
+ }
+
+ if balance.Float64 < cmd.CostUSD && !cmd.PostpaidSettlement {
+ return service.WalletLedgerEntry{}, service.ErrWalletInsufficient
+ }
+
+ newBalance := balance.Float64 - cmd.CostUSD
+ if _, err := tx.ExecContext(ctx, `
+ UPDATE user_subscriptions
+ SET wallet_balance_usd = $1, updated_at = NOW()
+ WHERE id = $2
+ `, newBalance, cmd.SubscriptionID); err != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("update balance: %w", err)
+ }
+
+ entry, err := insertLedger(ctx, tx, ledgerInsert{
+ subscriptionID: cmd.SubscriptionID,
+ deltaUSD: -cmd.CostUSD,
+ balanceAfter: newBalance,
+ reason: service.WalletLedgerReasonUsage,
+ usageLogID: cmd.UsageLogID,
+ })
+ if err != nil {
+ if cmd.UsageLogID != nil && isUniqueConstraintViolation(err) {
+ if rollbackErr := tx.Rollback(); rollbackErr != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("rollback duplicate usage settlement: %w", rollbackErr)
+ }
+ tx = nil
+ existing, found, lookupErr := findUsageLedger(ctx, r.db, *cmd.UsageLogID)
+ if lookupErr != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("query winning usage ledger: %w", lookupErr)
+ }
+ if found {
+ if existing.SubscriptionID != cmd.SubscriptionID {
+ return service.WalletLedgerEntry{}, fmt.Errorf("usage log is already settled for another subscription")
+ }
+ return existing, nil
+ }
+ }
+ return service.WalletLedgerEntry{}, err
+ }
+
+ if err := tx.Commit(); err != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("commit: %w", err)
+ }
+ tx = nil
+ return entry, nil
+}
+
+func (r *walletRepository) Adjust(ctx context.Context, cmd service.WalletAdjustCommand) (service.WalletLedgerEntry, error) {
+ if cmd.SubscriptionID <= 0 {
+ return service.WalletLedgerEntry{}, service.ErrWalletNotFound
+ }
+ if cmd.DeltaUSD == 0 {
+ return service.WalletLedgerEntry{}, service.ErrWalletNegativeDelta
+ }
+
+ tx, err := r.db.BeginTx(ctx, nil)
+ if err != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("begin tx: %w", err)
+ }
+ defer func() {
+ if tx != nil {
+ _ = tx.Rollback()
+ }
+ }()
+
+ var balance sql.NullFloat64
+ err = tx.QueryRowContext(ctx, `
+ SELECT wallet_balance_usd
+ FROM user_subscriptions
+ WHERE id = $1 AND deleted_at IS NULL
+ FOR UPDATE
+ `, cmd.SubscriptionID).Scan(&balance)
+ if errors.Is(err, sql.ErrNoRows) {
+ return service.WalletLedgerEntry{}, service.ErrWalletNotFound
+ }
+ if err != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("lock subscription row: %w", err)
+ }
+ if !balance.Valid {
+ return service.WalletLedgerEntry{}, service.ErrWalletNotFound
+ }
+
+ newBalance := balance.Float64 + cmd.DeltaUSD
+ // 管理员负向扣减不能把正常钱包扣成欠费;但已经因 post-response
+ // settlement 进入负数的钱包,必须允许退款/补偿等正向金额逐步还债。
+ if cmd.DeltaUSD < 0 && newBalance < 0 {
+ return service.WalletLedgerEntry{}, service.ErrWalletInsufficient
+ }
+
+ if _, err := tx.ExecContext(ctx, `
+ UPDATE user_subscriptions
+ SET wallet_balance_usd = $1, updated_at = NOW()
+ WHERE id = $2
+ `, newBalance, cmd.SubscriptionID); err != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("update balance: %w", err)
+ }
+
+ entry, err := insertLedger(ctx, tx, ledgerInsert{
+ subscriptionID: cmd.SubscriptionID,
+ deltaUSD: cmd.DeltaUSD,
+ balanceAfter: newBalance,
+ reason: cmd.Reason,
+ operatorID: cmd.OperatorID,
+ notes: cmd.Notes,
+ })
+ if err != nil {
+ return service.WalletLedgerEntry{}, err
+ }
+
+ if err := tx.Commit(); err != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("commit: %w", err)
+ }
+ tx = nil
+ return entry, nil
+}
+
+func (r *walletRepository) RecordActivation(ctx context.Context, cmd service.WalletActivationCommand) (service.WalletLedgerEntry, error) {
+ if cmd.SubscriptionID <= 0 {
+ return service.WalletLedgerEntry{}, service.ErrWalletNotFound
+ }
+ if cmd.InitialUSD <= 0 {
+ return service.WalletLedgerEntry{}, service.ErrWalletNegativeDelta
+ }
+
+ return r.withWalletMutation(ctx, func(exec sqlQueryExecutor) (service.WalletLedgerEntry, error) {
+ var balance, initial sql.NullFloat64
+ err := scanSingleRow(ctx, exec, `
+ SELECT wallet_balance_usd, wallet_initial_usd
+ FROM user_subscriptions
+ WHERE id = $1 AND deleted_at IS NULL
+ FOR UPDATE
+ `, []any{cmd.SubscriptionID}, &balance, &initial)
+ if errors.Is(err, sql.ErrNoRows) {
+ return service.WalletLedgerEntry{}, service.ErrWalletNotFound
+ }
+ if err != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("lock subscription row: %w", err)
+ }
+ if !balance.Valid || !initial.Valid {
+ return service.WalletLedgerEntry{}, service.ErrWalletNotFound
+ }
+ if math.Abs(initial.Float64-cmd.InitialUSD) > 0.0000001 || math.Abs(balance.Float64-cmd.InitialUSD) > 0.01 {
+ return service.WalletLedgerEntry{}, fmt.Errorf("wallet activation amount does not match opening balance")
+ }
+
+ var existing service.WalletLedgerEntry
+ var usageLogID, operatorID sql.NullInt64
+ var notes sql.NullString
+ err = scanSingleRow(ctx, exec, `
+ SELECT id, subscription_id, delta_usd, balance_after, reason,
+ usage_log_id, operator_id, notes
+ FROM subscription_wallet_ledger
+ WHERE subscription_id = $1 AND reason = 'activation'
+ ORDER BY id
+ LIMIT 1
+ `, []any{cmd.SubscriptionID}, &existing.ID, &existing.SubscriptionID, &existing.DeltaUSD,
+ &existing.BalanceAfter, &existing.Reason, &usageLogID, &operatorID, ¬es)
+ if err == nil {
+ if math.Abs(existing.DeltaUSD-cmd.InitialUSD) > 0.0000001 {
+ return service.WalletLedgerEntry{}, fmt.Errorf("wallet activation ledger conflicts with opening balance")
+ }
+ if operatorID.Valid {
+ v := operatorID.Int64
+ existing.OperatorID = &v
+ }
+ if notes.Valid {
+ existing.Notes = notes.String
+ }
+ return existing, nil
+ }
+ if !errors.Is(err, sql.ErrNoRows) {
+ return service.WalletLedgerEntry{}, fmt.Errorf("query activation ledger: %w", err)
+ }
+
+ return insertLedger(ctx, exec, ledgerInsert{
+ subscriptionID: cmd.SubscriptionID,
+ deltaUSD: cmd.InitialUSD,
+ balanceAfter: balance.Float64,
+ reason: service.WalletLedgerReasonActivation,
+ paymentOrderID: cmd.PaymentOrderID,
+ operatorID: cmd.OperatorID,
+ notes: cmd.Notes,
+ })
+ })
+}
+
+// Topup 额度卡叠加 (B2.4):同时 +balance 和 +initial,写 reason='topup' 流水。
+// DeltaUSD 必须 > 0;非钱包模式订阅(wallet_balance_usd IS NULL)返 ErrWalletNotFound。
+func (r *walletRepository) Topup(ctx context.Context, cmd service.WalletTopupCommand) (service.WalletLedgerEntry, error) {
+ if cmd.SubscriptionID <= 0 {
+ return service.WalletLedgerEntry{}, service.ErrWalletNotFound
+ }
+ if cmd.DeltaUSD <= 0 {
+ return service.WalletLedgerEntry{}, service.ErrWalletNegativeDelta
+ }
+
+ return r.withWalletMutation(ctx, func(exec sqlQueryExecutor) (service.WalletLedgerEntry, error) {
+ var balance, initial sql.NullFloat64
+ err := scanSingleRow(ctx, exec, `
+ SELECT wallet_balance_usd, wallet_initial_usd
+ FROM user_subscriptions
+ WHERE id = $1 AND deleted_at IS NULL
+ FOR UPDATE
+ `, []any{cmd.SubscriptionID}, &balance, &initial)
+ if errors.Is(err, sql.ErrNoRows) {
+ return service.WalletLedgerEntry{}, service.ErrWalletNotFound
+ }
+ if err != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("lock subscription row: %w", err)
+ }
+ if !balance.Valid || !initial.Valid {
+ return service.WalletLedgerEntry{}, service.ErrWalletNotFound
+ }
+
+ newBalance := balance.Float64 + cmd.DeltaUSD
+ newInitial := initial.Float64 + cmd.DeltaUSD
+ if _, err := exec.ExecContext(ctx, `
+ UPDATE user_subscriptions
+ SET wallet_balance_usd = $1, wallet_initial_usd = $2, updated_at = NOW()
+ WHERE id = $3
+ `, newBalance, newInitial, cmd.SubscriptionID); err != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("update balance+initial: %w", err)
+ }
+
+ return insertLedger(ctx, exec, ledgerInsert{
+ subscriptionID: cmd.SubscriptionID,
+ deltaUSD: cmd.DeltaUSD,
+ balanceAfter: newBalance,
+ reason: service.WalletLedgerReasonTopup,
+ paymentOrderID: cmd.PaymentOrderID,
+ operatorID: cmd.OperatorID,
+ notes: cmd.Notes,
+ })
+ })
+}
+
+// ReconcileBalances 把所有钱包模式订阅的 cached wallet_balance_usd 与
+// subscription_wallet_ledger.SUM(delta_usd) 对比,返回漂移超过 tolerance 的
+// 条目。漂移 0 / -0 视为相等。
+//
+// 设计:
+// - 所有 ledger 行的 delta 总和 = 当前 wallet_balance_usd(activation 加 +bal,
+// usage 减 -cost,refund 再加 +bal …);任何单调插入都不会改变这个不变量。
+// - 用 LEFT JOIN COALESCE(SUM, 0) 兼容刚开通还没出账的订阅。
+// - DECIMAL(20,10) 在 sql 端聚合时自动转 float64,精度足够。
+func (r *walletRepository) ReconcileBalances(ctx context.Context, tolerance float64) ([]service.WalletReconcileDrift, error) {
+ if tolerance < 0 {
+ tolerance = 0.01
+ }
+ rows, err := r.db.QueryContext(ctx, `
+ SELECT us.id, us.wallet_balance_usd, COALESCE(SUM(l.delta_usd), 0)
+ FROM user_subscriptions us
+ LEFT JOIN subscription_wallet_ledger l ON l.subscription_id = us.id
+ WHERE us.wallet_balance_usd IS NOT NULL
+ AND us.deleted_at IS NULL
+ GROUP BY us.id, us.wallet_balance_usd
+ `)
+ if err != nil {
+ return nil, fmt.Errorf("reconcile query: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+
+ var out []service.WalletReconcileDrift
+ for rows.Next() {
+ var (
+ id int64
+ cached float64
+ ledgerSum float64
+ )
+ if err := rows.Scan(&id, &cached, &ledgerSum); err != nil {
+ return nil, fmt.Errorf("reconcile scan: %w", err)
+ }
+ drift := cached - ledgerSum
+ if drift < 0 {
+ drift = -drift
+ }
+ if drift > tolerance {
+ out = append(out, service.WalletReconcileDrift{
+ SubscriptionID: id,
+ Cached: cached,
+ LedgerSum: ledgerSum,
+ Drift: drift,
+ })
+ }
+ }
+ return out, rows.Err()
+}
+
+func (r *walletRepository) ListLedger(ctx context.Context, subscriptionID int64, limit int) ([]service.WalletLedgerEntry, error) {
+ if subscriptionID <= 0 {
+ return nil, service.ErrWalletNotFound
+ }
+ if limit <= 0 {
+ limit = 50
+ }
+ rows, err := r.db.QueryContext(ctx, `
+ SELECT id, subscription_id, delta_usd, balance_after, reason,
+ payment_order_id, usage_log_id, operator_id, COALESCE(notes, '')
+ FROM subscription_wallet_ledger
+ WHERE subscription_id = $1
+ ORDER BY created_at DESC, id DESC
+ LIMIT $2
+ `, subscriptionID, limit)
+ if err != nil {
+ return nil, fmt.Errorf("query ledger: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+
+ out := make([]service.WalletLedgerEntry, 0, limit)
+ for rows.Next() {
+ var e service.WalletLedgerEntry
+ var paymentOrderID, usageLogID, operatorID sql.NullInt64
+ if err := rows.Scan(&e.ID, &e.SubscriptionID, &e.DeltaUSD, &e.BalanceAfter, &e.Reason,
+ &paymentOrderID, &usageLogID, &operatorID, &e.Notes); err != nil {
+ return nil, fmt.Errorf("scan ledger: %w", err)
+ }
+ if paymentOrderID.Valid {
+ v := paymentOrderID.Int64
+ e.PaymentOrderID = &v
+ }
+ if usageLogID.Valid {
+ v := usageLogID.Int64
+ e.UsageLogID = &v
+ }
+ if operatorID.Valid {
+ v := operatorID.Int64
+ e.OperatorID = &v
+ }
+ out = append(out, e)
+ }
+ return out, rows.Err()
+}
+
+type ledgerInsert struct {
+ subscriptionID int64
+ deltaUSD float64
+ balanceAfter float64
+ reason string
+ paymentOrderID *int64
+ usageLogID *int64
+ operatorID *int64
+ notes string
+}
+
+func findUsageLedger(ctx context.Context, exec sqlQueryExecutor, usageLogID int64) (service.WalletLedgerEntry, bool, error) {
+ var entry service.WalletLedgerEntry
+ var paymentOrderID, storedUsageLogID, operatorID sql.NullInt64
+ err := scanSingleRow(ctx, exec, `
+ SELECT id, subscription_id, delta_usd, balance_after, reason,
+ payment_order_id, usage_log_id, operator_id, COALESCE(notes, '')
+ FROM subscription_wallet_ledger
+ WHERE usage_log_id = $1 AND reason = 'usage'
+ ORDER BY id
+ LIMIT 1
+ `, []any{usageLogID}, &entry.ID, &entry.SubscriptionID, &entry.DeltaUSD, &entry.BalanceAfter,
+ &entry.Reason, &paymentOrderID, &storedUsageLogID, &operatorID, &entry.Notes)
+ if errors.Is(err, sql.ErrNoRows) {
+ return service.WalletLedgerEntry{}, false, nil
+ }
+ if err != nil {
+ return service.WalletLedgerEntry{}, false, err
+ }
+ if paymentOrderID.Valid {
+ value := paymentOrderID.Int64
+ entry.PaymentOrderID = &value
+ }
+ if storedUsageLogID.Valid {
+ value := storedUsageLogID.Int64
+ entry.UsageLogID = &value
+ }
+ if operatorID.Valid {
+ value := operatorID.Int64
+ entry.OperatorID = &value
+ }
+ return entry, true, nil
+}
+
+func insertLedger(ctx context.Context, exec sqlQueryExecutor, in ledgerInsert) (service.WalletLedgerEntry, error) {
+ var paymentOrder, usageLog, operator sql.NullInt64
+ if in.paymentOrderID != nil {
+ paymentOrder = sql.NullInt64{Int64: *in.paymentOrderID, Valid: true}
+ }
+ if in.usageLogID != nil {
+ usageLog = sql.NullInt64{Int64: *in.usageLogID, Valid: true}
+ }
+ if in.operatorID != nil {
+ operator = sql.NullInt64{Int64: *in.operatorID, Valid: true}
+ }
+ var notes sql.NullString
+ if in.notes != "" {
+ notes = sql.NullString{String: in.notes, Valid: true}
+ }
+
+ var id int64
+ err := scanSingleRow(ctx, exec, `
+ INSERT INTO subscription_wallet_ledger
+ (subscription_id, delta_usd, balance_after, reason, payment_order_id, usage_log_id, operator_id, notes)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+ RETURNING id
+ `, []any{in.subscriptionID, in.deltaUSD, in.balanceAfter, in.reason, paymentOrder, usageLog, operator, notes}, &id)
+ if err != nil {
+ return service.WalletLedgerEntry{}, fmt.Errorf("insert ledger: %w", err)
+ }
+
+ return service.WalletLedgerEntry{
+ ID: id,
+ SubscriptionID: in.subscriptionID,
+ DeltaUSD: in.deltaUSD,
+ BalanceAfter: in.balanceAfter,
+ Reason: in.reason,
+ PaymentOrderID: in.paymentOrderID,
+ UsageLogID: in.usageLogID,
+ OperatorID: in.operatorID,
+ Notes: in.notes,
+ }, nil
+}
diff --git a/backend/internal/repository/wallet_repo_test.go b/backend/internal/repository/wallet_repo_test.go
new file mode 100644
index 00000000000..4ac79ed2097
--- /dev/null
+++ b/backend/internal/repository/wallet_repo_test.go
@@ -0,0 +1,465 @@
+package repository
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "testing"
+
+ sqlmock "github.com/DATA-DOG/go-sqlmock"
+ "github.com/lib/pq"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+func newWalletRepoWithMock(t *testing.T) (*walletRepository, sqlmock.Sqlmock, *sql.DB) {
+ t.Helper()
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ return &walletRepository{db: db}, mock, db
+}
+
+func TestWalletRepo_Deduct_HappyPath(t *testing.T) {
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ usageLogID := int64(7777)
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_balance_usd FROM user_subscriptions").
+ WithArgs(int64(42)).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd"}).AddRow(100.0))
+ mock.ExpectQuery("SELECT id, subscription_id, delta_usd").
+ WithArgs(usageLogID).
+ WillReturnRows(sqlmock.NewRows([]string{
+ "id", "subscription_id", "delta_usd", "balance_after", "reason",
+ "payment_order_id", "usage_log_id", "operator_id", "notes",
+ }))
+ mock.ExpectExec("UPDATE user_subscriptions").
+ WithArgs(75.5, int64(42)).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectQuery("INSERT INTO subscription_wallet_ledger").
+ WithArgs(int64(42), -24.5, 75.5, "usage", sql.NullInt64{}, sql.NullInt64{Int64: usageLogID, Valid: true}, sql.NullInt64{}, sql.NullString{}).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(99)))
+ mock.ExpectCommit()
+
+ entry, err := repo.Deduct(context.Background(), service.WalletDeductCommand{
+ SubscriptionID: 42,
+ CostUSD: 24.5,
+ UsageLogID: &usageLogID,
+ })
+ require.NoError(t, err)
+ require.Equal(t, int64(99), entry.ID)
+ require.Equal(t, 75.5, entry.BalanceAfter)
+ require.Equal(t, -24.5, entry.DeltaUSD)
+ require.Equal(t, "usage", entry.Reason)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestWalletRepo_Deduct_ReplayReturnsOriginalLedgerWithoutSecondDebit(t *testing.T) {
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ usageLogID := int64(7777)
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_balance_usd FROM user_subscriptions").
+ WithArgs(int64(42)).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd"}).AddRow(75.5))
+ mock.ExpectQuery("SELECT id, subscription_id, delta_usd").
+ WithArgs(usageLogID).
+ WillReturnRows(sqlmock.NewRows([]string{
+ "id", "subscription_id", "delta_usd", "balance_after", "reason",
+ "payment_order_id", "usage_log_id", "operator_id", "notes",
+ }).AddRow(int64(99), int64(42), -24.5, 75.5, "usage", nil, usageLogID, nil, ""))
+ mock.ExpectCommit()
+
+ entry, err := repo.Deduct(context.Background(), service.WalletDeductCommand{
+ SubscriptionID: 42,
+ CostUSD: 99, // Retries keep the first committed amount authoritative.
+ UsageLogID: &usageLogID,
+ })
+ require.NoError(t, err)
+ require.Equal(t, int64(99), entry.ID)
+ require.Equal(t, int64(42), entry.SubscriptionID)
+ require.InDelta(t, -24.5, entry.DeltaUSD, 0.000001)
+ require.InDelta(t, 75.5, entry.BalanceAfter, 0.000001)
+ require.Equal(t, &usageLogID, entry.UsageLogID)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestWalletRepo_Deduct_RejectsUsageReplayAcrossSubscriptions(t *testing.T) {
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ usageLogID := int64(7777)
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_balance_usd FROM user_subscriptions").
+ WithArgs(int64(42)).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd"}).AddRow(75.5))
+ mock.ExpectQuery("SELECT id, subscription_id, delta_usd").
+ WithArgs(usageLogID).
+ WillReturnRows(sqlmock.NewRows([]string{
+ "id", "subscription_id", "delta_usd", "balance_after", "reason",
+ "payment_order_id", "usage_log_id", "operator_id", "notes",
+ }).AddRow(int64(99), int64(43), -24.5, 75.5, "usage", nil, usageLogID, nil, ""))
+ mock.ExpectRollback()
+
+ _, err := repo.Deduct(context.Background(), service.WalletDeductCommand{
+ SubscriptionID: 42,
+ CostUSD: 24.5,
+ UsageLogID: &usageLogID,
+ })
+ require.ErrorContains(t, err, "another subscription")
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestWalletRepo_Deduct_UniqueRaceRollsBackDebitAndReturnsWinner(t *testing.T) {
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ usageLogID := int64(7777)
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_balance_usd FROM user_subscriptions").
+ WithArgs(int64(42)).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd"}).AddRow(100.0))
+ mock.ExpectQuery("SELECT id, subscription_id, delta_usd").
+ WithArgs(usageLogID).
+ WillReturnRows(sqlmock.NewRows([]string{
+ "id", "subscription_id", "delta_usd", "balance_after", "reason",
+ "payment_order_id", "usage_log_id", "operator_id", "notes",
+ }))
+ mock.ExpectExec("UPDATE user_subscriptions").
+ WithArgs(75.5, int64(42)).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectQuery("INSERT INTO subscription_wallet_ledger").
+ WithArgs(int64(42), -24.5, 75.5, "usage", sql.NullInt64{}, sql.NullInt64{Int64: usageLogID, Valid: true}, sql.NullInt64{}, sql.NullString{}).
+ WillReturnError(&pq.Error{Code: "23505"})
+ mock.ExpectRollback()
+ mock.ExpectQuery("SELECT id, subscription_id, delta_usd").
+ WithArgs(usageLogID).
+ WillReturnRows(sqlmock.NewRows([]string{
+ "id", "subscription_id", "delta_usd", "balance_after", "reason",
+ "payment_order_id", "usage_log_id", "operator_id", "notes",
+ }).AddRow(int64(101), int64(42), -24.5, 75.5, "usage", nil, usageLogID, nil, ""))
+
+ entry, err := repo.Deduct(context.Background(), service.WalletDeductCommand{
+ SubscriptionID: 42,
+ CostUSD: 24.5,
+ UsageLogID: &usageLogID,
+ })
+ require.NoError(t, err)
+ require.Equal(t, int64(101), entry.ID)
+ require.InDelta(t, 75.5, entry.BalanceAfter, 0.000001)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestWalletRepo_Deduct_InsufficientBalance(t *testing.T) {
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_balance_usd FROM user_subscriptions").
+ WithArgs(int64(42)).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd"}).AddRow(10.0))
+ mock.ExpectRollback()
+
+ _, err := repo.Deduct(context.Background(), service.WalletDeductCommand{
+ SubscriptionID: 42,
+ CostUSD: 50.0,
+ })
+ require.ErrorIs(t, err, service.ErrWalletInsufficient)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestWalletRepo_Deduct_PostpaidSettlementPersistsFullDebt(t *testing.T) {
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_balance_usd FROM user_subscriptions").
+ WithArgs(int64(42)).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd"}).AddRow(10.0))
+ mock.ExpectExec("UPDATE user_subscriptions").
+ WithArgs(-40.0, int64(42)).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectQuery("INSERT INTO subscription_wallet_ledger").
+ WithArgs(int64(42), -50.0, -40.0, "usage", sql.NullInt64{}, sql.NullInt64{}, sql.NullInt64{}, sql.NullString{}).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(100)))
+ mock.ExpectCommit()
+
+ entry, err := repo.Deduct(context.Background(), service.WalletDeductCommand{
+ SubscriptionID: 42,
+ CostUSD: 50,
+ PostpaidSettlement: true,
+ })
+ require.NoError(t, err)
+ require.InDelta(t, -40, entry.BalanceAfter, 0.000001)
+ require.InDelta(t, -50, entry.DeltaUSD, 0.000001)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestWalletRepo_Deduct_SubscriptionNotFound(t *testing.T) {
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_balance_usd FROM user_subscriptions").
+ WithArgs(int64(42)).
+ WillReturnError(sql.ErrNoRows)
+ mock.ExpectRollback()
+
+ _, err := repo.Deduct(context.Background(), service.WalletDeductCommand{
+ SubscriptionID: 42,
+ CostUSD: 1.0,
+ })
+ require.ErrorIs(t, err, service.ErrWalletNotFound)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestWalletRepo_Deduct_NotWalletMode(t *testing.T) {
+ // 钱包列为 NULL = 不是钱包模式订阅 → ErrWalletNotFound
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_balance_usd FROM user_subscriptions").
+ WithArgs(int64(42)).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd"}).AddRow(nil))
+ mock.ExpectRollback()
+
+ _, err := repo.Deduct(context.Background(), service.WalletDeductCommand{
+ SubscriptionID: 42,
+ CostUSD: 1.0,
+ })
+ require.ErrorIs(t, err, service.ErrWalletNotFound)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestWalletRepo_Deduct_RejectsZeroCost(t *testing.T) {
+ repo, _, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ _, err := repo.Deduct(context.Background(), service.WalletDeductCommand{
+ SubscriptionID: 42,
+ CostUSD: 0,
+ })
+ require.ErrorIs(t, err, service.ErrWalletNegativeDelta)
+}
+
+func TestWalletRepo_Adjust_RefundIncreasesBalance(t *testing.T) {
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ operatorID := int64(5)
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_balance_usd FROM user_subscriptions").
+ WithArgs(int64(42)).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd"}).AddRow(50.0))
+ mock.ExpectExec("UPDATE user_subscriptions").
+ WithArgs(70.0, int64(42)).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectQuery("INSERT INTO subscription_wallet_ledger").
+ WithArgs(int64(42), 20.0, 70.0, "refund", sql.NullInt64{}, sql.NullInt64{}, sql.NullInt64{Int64: operatorID, Valid: true}, sql.NullString{String: "credited back", Valid: true}).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(101)))
+ mock.ExpectCommit()
+
+ entry, err := repo.Adjust(context.Background(), service.WalletAdjustCommand{
+ SubscriptionID: 42,
+ DeltaUSD: 20.0,
+ Reason: service.WalletLedgerReasonRefund,
+ OperatorID: &operatorID,
+ Notes: "credited back",
+ })
+ require.NoError(t, err)
+ require.Equal(t, 70.0, entry.BalanceAfter)
+ require.Equal(t, 20.0, entry.DeltaUSD)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestWalletRepo_Adjust_NegativeDeltaCannotGoBelowZero(t *testing.T) {
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_balance_usd FROM user_subscriptions").
+ WithArgs(int64(42)).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd"}).AddRow(5.0))
+ mock.ExpectRollback()
+
+ _, err := repo.Adjust(context.Background(), service.WalletAdjustCommand{
+ SubscriptionID: 42,
+ DeltaUSD: -10.0,
+ Reason: service.WalletLedgerReasonAdjustment,
+ })
+ require.ErrorIs(t, err, service.ErrWalletInsufficient)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestWalletRepo_Adjust_PositiveCreditCanReduceExistingWalletDebt(t *testing.T) {
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_balance_usd FROM user_subscriptions").
+ WithArgs(int64(42)).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd"}).AddRow(-2.0))
+ mock.ExpectExec("UPDATE user_subscriptions").
+ WithArgs(-1.0, int64(42)).
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ mock.ExpectQuery("INSERT INTO subscription_wallet_ledger").
+ WithArgs(int64(42), 1.0, -1.0, "refund", sql.NullInt64{}, sql.NullInt64{}, sql.NullInt64{}, sql.NullString{}).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(102)))
+ mock.ExpectCommit()
+
+ entry, err := repo.Adjust(context.Background(), service.WalletAdjustCommand{
+ SubscriptionID: 42,
+ DeltaUSD: 1.0,
+ Reason: service.WalletLedgerReasonRefund,
+ })
+ require.NoError(t, err)
+ require.InDelta(t, -1.0, entry.BalanceAfter, 0.000001)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestWalletRepo_Adjust_BeginTxError(t *testing.T) {
+ // 触发 BeginTx 失败的回退路径:模拟 connection 错误。
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectBegin().WillReturnError(errors.New("conn dead"))
+ _, err := repo.Adjust(context.Background(), service.WalletAdjustCommand{
+ SubscriptionID: 42,
+ DeltaUSD: 1.0,
+ Reason: service.WalletLedgerReasonAdjustment,
+ })
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "begin tx")
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestWalletRepo_RecordActivation_AppendsLedgerWithoutAddingBalanceAgain(t *testing.T) {
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ operatorID := int64(9)
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_balance_usd, wallet_initial_usd").
+ WithArgs(int64(42)).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd", "wallet_initial_usd"}).AddRow(100.0, 100.0))
+ mock.ExpectQuery("SELECT id, subscription_id, delta_usd").
+ WithArgs(int64(42)).
+ WillReturnRows(sqlmock.NewRows([]string{"id", "subscription_id", "delta_usd", "balance_after", "reason", "usage_log_id", "operator_id", "notes"}))
+ mock.ExpectQuery("INSERT INTO subscription_wallet_ledger").
+ WithArgs(int64(42), 100.0, 100.0, "activation", sql.NullInt64{}, sql.NullInt64{}, sql.NullInt64{Int64: operatorID, Valid: true}, sql.NullString{String: "initial", Valid: true}).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(102)))
+ mock.ExpectCommit()
+
+ entry, err := repo.RecordActivation(context.Background(), service.WalletActivationCommand{
+ SubscriptionID: 42,
+ InitialUSD: 100,
+ OperatorID: &operatorID,
+ Notes: "initial",
+ })
+ require.NoError(t, err)
+ require.Equal(t, int64(102), entry.ID)
+ require.Equal(t, 100.0, entry.DeltaUSD)
+ require.Equal(t, 100.0, entry.BalanceAfter)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestWalletRepo_RecordActivation_IsIdempotent(t *testing.T) {
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectBegin()
+ mock.ExpectQuery("SELECT wallet_balance_usd, wallet_initial_usd").
+ WithArgs(int64(42)).
+ WillReturnRows(sqlmock.NewRows([]string{"wallet_balance_usd", "wallet_initial_usd"}).AddRow(100.0, 100.0))
+ mock.ExpectQuery("SELECT id, subscription_id, delta_usd").
+ WithArgs(int64(42)).
+ WillReturnRows(sqlmock.NewRows([]string{"id", "subscription_id", "delta_usd", "balance_after", "reason", "usage_log_id", "operator_id", "notes"}).
+ AddRow(int64(102), int64(42), 100.0, 100.0, "activation", nil, nil, "initial"))
+ mock.ExpectCommit()
+
+ entry, err := repo.RecordActivation(context.Background(), service.WalletActivationCommand{SubscriptionID: 42, InitialUSD: 100})
+ require.NoError(t, err)
+ require.Equal(t, int64(102), entry.ID)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+// TestWalletRepo_ReconcileBalances_DriftDetection 验证 cached vs ledger SUM
+// 漂移检测:相等 / 容忍内 / 漂移超容忍三种情况都要正确分类。
+func TestWalletRepo_ReconcileBalances_DriftDetection(t *testing.T) {
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ // 三条订阅:
+ // 1) cached=100, sum=100 → 0 漂移,不上报
+ // 2) cached=50.005, sum=50 → 0.005 < tolerance 0.01,不上报
+ // 3) cached=42, sum=40 → 2.0 > tolerance,上报
+ mock.ExpectQuery("SELECT us.id, us.wallet_balance_usd").
+ WillReturnRows(sqlmock.NewRows([]string{"id", "wallet_balance_usd", "ledger_sum"}).
+ AddRow(int64(1), 100.0, 100.0).
+ AddRow(int64(2), 50.005, 50.0).
+ AddRow(int64(3), 42.0, 40.0))
+
+ drifts, err := repo.ReconcileBalances(context.Background(), 0.01)
+ require.NoError(t, err)
+ require.Len(t, drifts, 1, "only sub#3 should be flagged")
+ require.Equal(t, int64(3), drifts[0].SubscriptionID)
+ require.InDelta(t, 2.0, drifts[0].Drift, 0.0001)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+// TestWalletRepo_ReconcileBalances_DefaultTolerance tolerance ≤ 0 时应回退
+// 默认值 0.01;负 tolerance 不应误把所有订阅都标为漂移。
+func TestWalletRepo_ReconcileBalances_DefaultTolerance(t *testing.T) {
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectQuery("SELECT us.id, us.wallet_balance_usd").
+ WillReturnRows(sqlmock.NewRows([]string{"id", "wallet_balance_usd", "ledger_sum"}).
+ AddRow(int64(1), 100.0, 100.0))
+
+ drifts, err := repo.ReconcileBalances(context.Background(), -1)
+ require.NoError(t, err)
+ require.Empty(t, drifts)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+// TestWalletRepo_ReconcileBalances_QueryError SQL 错误时返回错误,不静默丢弃。
+func TestWalletRepo_ReconcileBalances_QueryError(t *testing.T) {
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectQuery("SELECT us.id, us.wallet_balance_usd").
+ WillReturnError(errors.New("db down"))
+
+ _, err := repo.ReconcileBalances(context.Background(), 0.01)
+ require.Error(t, err)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestWalletRepo_ListLedger_OrderedAndScanned(t *testing.T) {
+ repo, mock, db := newWalletRepoWithMock(t)
+ defer func() { _ = db.Close() }()
+
+ mock.ExpectQuery("SELECT id, subscription_id, delta_usd").
+ WithArgs(int64(42), 50).
+ WillReturnRows(sqlmock.NewRows([]string{
+ "id", "subscription_id", "delta_usd", "balance_after", "reason",
+ "payment_order_id", "usage_log_id", "operator_id", "notes",
+ }).
+ AddRow(int64(2), int64(42), -1.5, 8.5, "usage", nil, int64(7), nil, "").
+ AddRow(int64(1), int64(42), 10.0, 10.0, "activation", nil, nil, nil, ""))
+
+ out, err := repo.ListLedger(context.Background(), 42, 50)
+ require.NoError(t, err)
+ require.Len(t, out, 2)
+ require.Equal(t, "usage", out[0].Reason)
+ require.NotNil(t, out[0].UsageLogID)
+ require.Equal(t, int64(7), *out[0].UsageLogID)
+ require.Equal(t, "activation", out[1].Reason)
+ require.Nil(t, out[1].UsageLogID)
+ require.NoError(t, mock.ExpectationsWereMet())
+}
diff --git a/backend/internal/repository/wallet_revoke_open_admission_test.go b/backend/internal/repository/wallet_revoke_open_admission_test.go
new file mode 100644
index 00000000000..b975f294ef3
--- /dev/null
+++ b/backend/internal/repository/wallet_revoke_open_admission_test.go
@@ -0,0 +1,27 @@
+package repository
+
+import (
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/lib/pq"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTranslateWalletSubscriptionDeleteErrorMapsOpenAdmissionGuard(t *testing.T) {
+ err := fmt.Errorf("wrapped: %w", &pq.Error{
+ Code: "23514",
+ Constraint: "hfc_wallet_open_admission_revoke",
+ })
+
+ translated := translateWalletSubscriptionDeleteError(err)
+ require.ErrorIs(t, translated, service.ErrSubscriptionUsageBillingInFlight)
+ require.ErrorIs(t, translated, err)
+}
+
+func TestTranslateWalletSubscriptionDeleteErrorPreservesUnrelatedFailure(t *testing.T) {
+ sentinel := errors.New("database unavailable")
+ require.ErrorIs(t, translateWalletSubscriptionDeleteError(sentinel), sentinel)
+}
diff --git a/backend/internal/repository/wallet_unknown_delivery_release_integration_test.go b/backend/internal/repository/wallet_unknown_delivery_release_integration_test.go
new file mode 100644
index 00000000000..7c9c4fd5b90
--- /dev/null
+++ b/backend/internal/repository/wallet_unknown_delivery_release_integration_test.go
@@ -0,0 +1,210 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/google/uuid"
+ "github.com/lib/pq"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWalletUnknownDeliveryReleaseGuard(t *testing.T) {
+ t.Run("ever-dispatched hold remains frozen", func(t *testing.T) {
+ ctx := context.Background()
+ repo, input, _ := newWalletAdmissionIntegrationFixture(t, ctx, 10)
+ input.RequestID = "wallet-unknown-delivery-" + uuid.NewString()
+ admission, err := service.NewUsageBillingAdmission(input)
+ require.NoError(t, err)
+ require.NoError(t, repo.Admit(ctx, admission))
+ ref := service.UsageBillingAdmissionAttemptRef{
+ RequestID: admission.RequestID(), APIKeyID: admission.APIKeyID(),
+ OwnerToken: admission.OwnerToken(), AttemptID: admission.AttemptID(),
+ }
+ require.NoError(t, repo.MarkDispatched(ctx, ref))
+ require.NoError(t, repo.MarkOrphaned(ctx, ref))
+ require.NoError(t, repo.MarkAttemptFailed(ctx, ref), "current failed state must not erase dispatch history")
+ _, err = integrationDB.ExecContext(ctx, `
+ UPDATE usage_billing_admission_attempts
+ SET dispatched_at = NULL
+ WHERE request_id = $1 AND api_key_id = $2 AND attempt_id = $3
+ `, admission.RequestID(), admission.APIKeyID(), admission.AttemptID())
+ require.NoError(t, err, "simulate incomplete historical attempt dispatch metadata")
+ _, err = integrationDB.ExecContext(ctx, `
+ UPDATE usage_billing_admissions
+ SET state = 'reconcile', reconcile_started_at = NOW(), updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2
+ `, admission.RequestID(), admission.APIKeyID())
+ require.NoError(t, err)
+
+ _, err = integrationDB.ExecContext(ctx, `
+ UPDATE usage_billing_admissions
+ SET state = 'abandoned', abandoned_at = NOW(), wallet_released_at = NOW(), updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2
+ `, admission.RequestID(), admission.APIKeyID())
+ requirePostgresConstraint(t, err, "hfc_wallet_unknown_delivery_release")
+
+ var state string
+ var released bool
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT state, wallet_released_at IS NOT NULL
+ FROM usage_billing_admissions
+ WHERE request_id = $1 AND api_key_id = $2
+ `, admission.RequestID(), admission.APIKeyID()).Scan(&state, &released))
+ require.Equal(t, service.UsageBillingAdmissionStateReconcile, state)
+ require.False(t, released)
+ })
+
+ t.Run("never-dispatched hold remains releasable", func(t *testing.T) {
+ ctx := context.Background()
+ repo, input, _ := newWalletAdmissionIntegrationFixture(t, ctx, 10)
+ input.RequestID = "wallet-never-dispatched-" + uuid.NewString()
+ admission, err := service.NewUsageBillingAdmission(input)
+ require.NoError(t, err)
+ require.NoError(t, repo.Admit(ctx, admission))
+ ref := service.UsageBillingAdmissionAttemptRef{
+ RequestID: admission.RequestID(), APIKeyID: admission.APIKeyID(),
+ OwnerToken: admission.OwnerToken(), AttemptID: admission.AttemptID(),
+ }
+ require.NoError(t, repo.MarkOrphaned(ctx, ref))
+ require.NoError(t, repo.MarkAttemptFailed(ctx, ref))
+ _, err = integrationDB.ExecContext(ctx, `
+ UPDATE usage_billing_admissions
+ SET state = 'reconcile', reconcile_started_at = NOW(), updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2
+ `, admission.RequestID(), admission.APIKeyID())
+ require.NoError(t, err)
+ _, err = integrationDB.ExecContext(ctx, `
+ UPDATE usage_billing_admissions
+ SET state = 'abandoned', abandoned_at = NOW(), wallet_released_at = NOW(), updated_at = NOW()
+ WHERE request_id = $1 AND api_key_id = $2
+ `, admission.RequestID(), admission.APIKeyID())
+ require.NoError(t, err)
+ })
+
+ t.Run("known rejection still releases from dispatched state", func(t *testing.T) {
+ ctx := context.Background()
+ repo, input, _ := newWalletAdmissionIntegrationFixture(t, ctx, 10)
+ input.RequestID = "wallet-known-rejection-" + uuid.NewString()
+ admission, err := service.NewUsageBillingAdmission(input)
+ require.NoError(t, err)
+ require.NoError(t, repo.Admit(ctx, admission))
+ ref := service.UsageBillingAdmissionAttemptRef{
+ RequestID: admission.RequestID(), APIKeyID: admission.APIKeyID(),
+ OwnerToken: admission.OwnerToken(), AttemptID: admission.AttemptID(),
+ }
+ require.NoError(t, repo.MarkDispatched(ctx, ref))
+ require.NoError(t, repo.MarkAttemptFailed(ctx, ref))
+ require.NoError(t, repo.Abandon(ctx, ref))
+
+ var state string
+ var released bool
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT state, wallet_released_at IS NOT NULL
+ FROM usage_billing_admissions
+ WHERE request_id = $1 AND api_key_id = $2
+ `, admission.RequestID(), admission.APIKeyID()).Scan(&state, &released))
+ require.Equal(t, service.UsageBillingAdmissionStateAbandoned, state)
+ require.True(t, released)
+ })
+
+ t.Run("stale known rejection remains automatically releasable", func(t *testing.T) {
+ ctx := context.Background()
+ repo, input, _ := newWalletAdmissionIntegrationFixture(t, ctx, 10)
+ input.RequestID = "wallet-stale-known-rejection-" + uuid.NewString()
+ admission, err := service.NewUsageBillingAdmission(input)
+ require.NoError(t, err)
+ require.NoError(t, repo.Admit(ctx, admission))
+ ref := service.UsageBillingAdmissionAttemptRef{
+ RequestID: admission.RequestID(), APIKeyID: admission.APIKeyID(),
+ OwnerToken: admission.OwnerToken(), AttemptID: admission.AttemptID(),
+ }
+ require.NoError(t, repo.MarkDispatched(ctx, ref))
+ require.NoError(t, repo.MarkAttemptFailed(ctx, ref))
+ _, err = integrationDB.ExecContext(ctx, `
+ UPDATE usage_billing_admissions
+ SET updated_at = NOW() - INTERVAL '1 hour'
+ WHERE request_id = $1 AND api_key_id = $2
+ `, admission.RequestID(), admission.APIKeyID())
+ require.NoError(t, err)
+
+ result, err := repo.ReconcileStaleAdmissions(ctx, time.Millisecond, time.Millisecond, 10)
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, result.AbandonedFailedDispatched, 1)
+
+ var state string
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT state FROM usage_billing_admissions
+ WHERE request_id = $1 AND api_key_id = $2
+ `, admission.RequestID(), admission.APIKeyID()).Scan(&state))
+ require.Equal(t, service.UsageBillingAdmissionStateAbandoned, state)
+ })
+
+ t.Run("node B cannot release node A local spool", func(t *testing.T) {
+ ctx := context.Background()
+ dbRepo, input, _ := newWalletAdmissionIntegrationFixture(t, ctx, 10)
+ input.RequestID = "wallet-cross-node-spool-" + uuid.NewString()
+ admission, err := service.NewUsageBillingAdmission(input)
+ require.NoError(t, err)
+ require.NoError(t, dbRepo.Admit(ctx, admission))
+ ref := service.UsageBillingAdmissionAttemptRef{
+ RequestID: admission.RequestID(), APIKeyID: admission.APIKeyID(),
+ OwnerToken: admission.OwnerToken(), AttemptID: admission.AttemptID(),
+ }
+ require.NoError(t, dbRepo.MarkDispatched(ctx, ref))
+ require.NoError(t, dbRepo.MarkOrphaned(ctx, ref))
+
+ envelope, err := service.NewUsageBillingEnvelope(service.UsageBillingEnvelopeInput{
+ RequestID: input.RequestID, RequestPayloadHash: input.RequestPayloadHash,
+ AdmissionAttemptID: input.AttemptID, APIKeyID: input.APIKeyID,
+ AuthCacheLocator: input.AuthCacheLocator, UserID: input.UserID, AccountID: input.AccountID,
+ SubscriptionID: input.SubscriptionID, GroupID: input.GroupID,
+ EffectiveBillingGroupID: input.EffectiveBillingGroupID, AccountType: input.AccountType,
+ BillingModel: input.BillingModel, BillingType: input.BillingType,
+ PricingSource: input.PricingSource, PricingRevision: input.PricingRevision,
+ PricingHash: input.PricingHash, RateMultiplier: input.RateMultiplier, AccountRateMultiplier: 1,
+ WalletCost: 0.5, ActualCost: 0.5, TotalCost: 0.5,
+ APIKeyQuotaCost: 0.5, APIKeyRateLimitCost: 0.5, AccountQuotaCost: 0.5,
+ })
+ require.NoError(t, err)
+ nodeAInner := &durableUsageOutboxInnerStub{
+ enqueueErr: service.MarkUsageBillingOutboxAdmissionRetryable(errors.New("postgres unavailable")),
+ }
+ nodeA, err := newDurableUsageBillingOutboxRepository(
+ nodeAInner, nodeAInner, t.TempDir(), durableUsageBillingOutboxOptions{},
+ )
+ require.NoError(t, err)
+ _, inserted, err := nodeA.Enqueue(ctx, envelope)
+ require.NoError(t, err)
+ require.True(t, inserted)
+ stored, found, err := nodeA.spool.GetByIdentity(input.RequestID, input.APIKeyID)
+ require.NoError(t, err)
+ require.True(t, found)
+ require.Equal(t, envelope.RequestFingerprint(), stored.RequestFingerprint())
+
+ nodeB, err := newDurableUsageBillingOutboxRepository(
+ dbRepo, dbRepo, t.TempDir(), durableUsageBillingOutboxOptions{},
+ )
+ require.NoError(t, err)
+ err = nodeB.ResolveUsageBillingReconciliation(ctx, service.UsageBillingReconciliationResolveInput{
+ RequestID: input.RequestID, APIKeyID: input.APIKeyID, OperatorID: 9,
+ Action: service.UsageBillingReconciliationActionReleaseUndelivered,
+ EvidenceRef: "incident:HFC-cross-node",
+ })
+ require.ErrorIs(t, err, service.ErrUsageBillingReconciliationConflict)
+ })
+}
+
+func requirePostgresConstraint(t *testing.T, err error, constraint string) {
+ t.Helper()
+ require.Error(t, err)
+ var pqErr *pq.Error
+ require.True(t, errors.As(err, &pqErr), "expected PostgreSQL error, got %T: %v", err, err)
+ require.Equal(t, pq.ErrorCode("23514"), pqErr.Code)
+ require.Equal(t, constraint, pqErr.Constraint)
+}
diff --git a/backend/internal/repository/wallet_usage_replay_integration_test.go b/backend/internal/repository/wallet_usage_replay_integration_test.go
new file mode 100644
index 00000000000..3698e2dd4f4
--- /dev/null
+++ b/backend/internal/repository/wallet_usage_replay_integration_test.go
@@ -0,0 +1,96 @@
+//go:build integration
+
+package repository
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+)
+
+func TestWalletDeductConcurrentUsageReplayDebitsExactlyOnce(t *testing.T) {
+ ctx := context.Background()
+ client := testEntClient(t)
+ user := mustCreateUser(t, client, &service.User{
+ Email: fmt.Sprintf("wallet-usage-replay-%s@example.com", uuid.NewString()),
+ PasswordHash: "hash",
+ })
+ apiKey := mustCreateApiKey(t, client, &service.APIKey{
+ UserID: user.ID,
+ Key: "sk-wallet-usage-replay-" + uuid.NewString(),
+ Name: "wallet replay integration",
+ })
+ account := mustCreateAccount(t, client, &service.Account{
+ Name: "wallet-usage-replay-" + uuid.NewString(),
+ Type: service.AccountTypeAPIKey,
+ })
+ usageLog, err := client.UsageLog.Create().
+ SetUserID(user.ID).
+ SetAPIKeyID(apiKey.ID).
+ SetAccountID(account.ID).
+ SetRequestID(uuid.NewString()).
+ SetModel("wallet-replay-test").
+ Save(ctx)
+ require.NoError(t, err)
+ wallet := mustCreateCreditsWallet(t, client, user.ID, 100)
+ repo := NewWalletRepository(client, integrationDB)
+
+ entries := make([]service.WalletLedgerEntry, 2)
+ errs := make([]error, 2)
+ var wg sync.WaitGroup
+ for index := range entries {
+ wg.Add(1)
+ go func(index int) {
+ defer wg.Done()
+ entries[index], errs[index] = repo.Deduct(ctx, service.WalletDeductCommand{
+ SubscriptionID: wallet.ID,
+ CostUSD: 10,
+ UsageLogID: &usageLog.ID,
+ })
+ }(index)
+ }
+ wg.Wait()
+ for _, err := range errs {
+ require.NoError(t, err)
+ }
+ require.Equal(t, entries[0].ID, entries[1].ID)
+ require.NotZero(t, entries[0].ID)
+
+ replay, err := repo.Deduct(ctx, service.WalletDeductCommand{
+ SubscriptionID: wallet.ID,
+ CostUSD: 50,
+ UsageLogID: &usageLog.ID,
+ })
+ require.NoError(t, err)
+ require.Equal(t, entries[0].ID, replay.ID)
+ require.InDelta(t, -10, replay.DeltaUSD, 0.000001)
+
+ var balance float64
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT wallet_balance_usd
+ FROM user_subscriptions
+ WHERE id = $1
+ `, wallet.ID).Scan(&balance))
+ require.InDelta(t, 90, balance, 0.000001)
+
+ var ledgerCount int
+ require.NoError(t, integrationDB.QueryRowContext(ctx, `
+ SELECT COUNT(*)
+ FROM subscription_wallet_ledger
+ WHERE usage_log_id = $1 AND reason = 'usage'
+ `, usageLog.ID).Scan(&ledgerCount))
+ require.Equal(t, 1, ledgerCount)
+
+ _, err = integrationDB.ExecContext(ctx, `
+ INSERT INTO subscription_wallet_ledger
+ (subscription_id, delta_usd, balance_after, reason, usage_log_id)
+ VALUES ($1, -10, 80, 'usage', $2)
+ `, wallet.ID, usageLog.ID)
+ requirePostgresCode(t, err, "23505")
+}
diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go
index f07bbb33018..b70cd1e7a89 100644
--- a/backend/internal/repository/wire.go
+++ b/backend/internal/repository/wire.go
@@ -48,7 +48,7 @@ func ProvideSessionLimitCache(rdb *redis.Client, cfg *config.Config) service.Ses
}
// ProvideSchedulerCache 创建调度快照缓存,并注入快照分块参数。
-func ProvideSchedulerCache(rdb *redis.Client, cfg *config.Config) service.SchedulerCache {
+func ProvideSchedulerCache(rdb *redis.Client, cfg *config.Config, encryptor service.SecretEncryptor) (service.SchedulerCache, error) {
mgetChunkSize := defaultSchedulerSnapshotMGetChunkSize
writeChunkSize := defaultSchedulerSnapshotWriteChunkSize
if cfg != nil {
@@ -59,7 +59,7 @@ func ProvideSchedulerCache(rdb *redis.Client, cfg *config.Config) service.Schedu
writeChunkSize = cfg.Gateway.Scheduling.SnapshotWriteChunkSize
}
}
- return newSchedulerCacheWithChunkSizes(rdb, mgetChunkSize, writeChunkSize)
+ return newSchedulerCacheWithChunkSizes(rdb, encryptor, mgetChunkSize, writeChunkSize)
}
// ProviderSet is the Wire provider set for all repositories
@@ -77,12 +77,18 @@ var ProviderSet = wire.NewSet(
NewAnnouncementReadRepository,
NewUsageLogRepository,
NewUsageBillingRepository,
+ NewDurableUsageBillingOutboxRepository,
+ wire.Bind(new(service.UsageBillingOutboxRepository), new(*durableUsageBillingOutboxRepository)),
+ wire.Bind(new(service.UsageBillingBindingValidator), new(*durableUsageBillingOutboxRepository)),
+ wire.Bind(new(service.UsageBillingAdmissionRepository), new(*durableUsageBillingOutboxRepository)),
+ wire.Bind(new(service.UsageBillingReconciliationRepository), new(*durableUsageBillingOutboxRepository)),
NewIdempotencyRepository,
NewUsageCleanupRepository,
NewDashboardAggregationRepository,
NewSettingRepository,
NewOpsRepository,
NewUserSubscriptionRepository,
+ NewWalletRepository,
NewUserAttributeDefinitionRepository,
NewUserAttributeValueRepository,
NewUserGroupRateRepository,
@@ -91,6 +97,8 @@ var ProviderSet = wire.NewSet(
NewChannelRepository,
NewChannelMonitorRepository,
NewChannelMonitorRequestTemplateRepository,
+ NewContentModerationRepository,
+ NewHFCAbuseRiskRepository,
NewAffiliateRepository,
// Cache implementations
@@ -119,9 +127,11 @@ var ProviderSet = wire.NewSet(
NewRefreshTokenCache,
NewErrorPassthroughCache,
NewTLSFingerprintProfileCache,
+ NewContentModerationHashCache,
// Encryptors
NewAESEncryptor,
+ NewAPIKeyProtector,
// Backup infrastructure
NewPgDumper,
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index 607b93dcef0..d4ddd3fce78 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -187,42 +187,27 @@ func TestAPIContracts(t *testing.T) {
}`,
},
{
- name: "POST /api/v1/keys",
+ name: "POST /api/v1/keys",
+ setup: func(t *testing.T, deps *contractDeps) {
+ t.Helper()
+ deps.groupRepo.SetActive([]service.Group{{
+ ID: 10,
+ Name: "Group One",
+ Status: service.StatusActive,
+ SubscriptionType: service.SubscriptionTypeStandard,
+ }})
+ },
method: http.MethodPost,
path: "/api/v1/keys",
- body: `{"name":"Key One","custom_key":"sk_custom_1234567890"}`,
+ body: `{"name":"Key One","group_id":10,"custom_key":"sk_custom_1234567890","verification":"correct-password"}`,
headers: map[string]string{
"Content-Type": "application/json",
},
- wantStatus: http.StatusOK,
+ wantStatus: http.StatusBadRequest,
wantJSON: `{
- "code": 0,
- "message": "success",
- "data": {
- "id": 100,
- "user_id": 1,
- "key": "sk_custom_1234567890",
- "name": "Key One",
- "group_id": null,
- "status": "active",
- "ip_whitelist": null,
- "ip_blacklist": null,
- "last_used_at": null,
- "quota": 0,
- "quota_used": 0,
- "rate_limit_5h": 0,
- "rate_limit_1d": 0,
- "rate_limit_7d": 0,
- "usage_5h": 0,
- "usage_1d": 0,
- "usage_7d": 0,
- "window_5h_start": null,
- "window_1d_start": null,
- "window_7d_start": null,
- "expires_at": null,
- "created_at": "2025-01-02T03:04:05Z",
- "updated_at": "2025-01-02T03:04:05Z"
- }
+ "code": 400,
+ "message": "custom API keys are disabled; use a generated key",
+ "reason": "API_KEY_CUSTOM_KEY_DISABLED"
}`,
},
{
@@ -234,6 +219,8 @@ func TestAPIContracts(t *testing.T) {
UserID: 1,
Key: "sk_custom_1234567890",
Name: "Key One",
+ Purpose: service.APIKeyPurposeStandard,
+ GroupID: ptr(int64(10)),
Status: service.StatusActive,
CreatedAt: deps.now,
UpdatedAt: deps.now,
@@ -250,9 +237,10 @@ func TestAPIContracts(t *testing.T) {
{
"id": 100,
"user_id": 1,
- "key": "sk_custom_1234567890",
+ "key": "sk_custo••••",
"name": "Key One",
- "group_id": null,
+ "purpose": "standard",
+ "group_id": 10,
"status": "active",
"ip_whitelist": null,
"ip_blacklist": null,
@@ -328,6 +316,9 @@ func TestAPIContracts(t *testing.T) {
"image_price_1k": null,
"image_price_2k": null,
"image_price_4k": null,
+ "allow_image_generation": false,
+ "image_rate_independent": false,
+ "image_rate_multiplier": 0,
"claude_code_only": false,
"allow_messages_dispatch": false,
"fallback_group_id": null,
@@ -350,7 +341,7 @@ func TestAPIContracts(t *testing.T) {
{
ID: 501,
UserID: 1,
- GroupID: 10,
+ GroupID: ptr(int64(10)),
StartsAt: deps.now,
ExpiresAt: time.Date(2099, 1, 2, 3, 4, 5, 0, time.UTC), // 使用未来日期避免 normalizeSubscriptionStatus 标记为过期
Status: service.SubscriptionStatusActive,
@@ -514,7 +505,7 @@ func TestAPIContracts(t *testing.T) {
})
},
method: http.MethodGet,
- path: "/api/v1/usage?page=1&page_size=10",
+ path: "/api/v1/usage?page=1&page_size=10&start_date=2025-01-01&end_date=2025-01-03",
wantStatus: http.StatusOK,
wantJSON: `{
"code": 0,
@@ -643,12 +634,21 @@ func TestAPIContracts(t *testing.T) {
"registration_email_suffix_whitelist": [],
"promo_code_enabled": true,
"password_reset_enabled": false,
- "frontend_url": "",
- "totp_enabled": false,
- "totp_encryption_key_configured": false,
- "smtp_host": "smtp.example.com",
- "smtp_port": 587,
- "smtp_username": "user",
+ "frontend_url": "",
+ "totp_enabled": false,
+ "totp_encryption_key_configured": false,
+ "login_agreement_enabled": false,
+ "login_agreement_mode": "modal",
+ "login_agreement_updated_at": "2026-03-31",
+ "login_agreement_documents": [
+ {"id": "terms", "title": "服务条款", "content_md": ""},
+ {"id": "usage-policy", "title": "使用政策", "content_md": ""},
+ {"id": "supported-regions", "title": "支持的国家和地区", "content_md": ""},
+ {"id": "service-specific-terms", "title": "服务特定条款", "content_md": ""}
+ ],
+ "smtp_host": "smtp.example.com",
+ "smtp_port": 587,
+ "smtp_username": "user",
"smtp_password_configured": true,
"smtp_from_email": "no-reply@example.com",
"smtp_from_name": "Sub2API",
@@ -682,6 +682,16 @@ func TestAPIContracts(t *testing.T) {
"oidc_connect_userinfo_email_path": "",
"oidc_connect_userinfo_id_path": "",
"oidc_connect_userinfo_username_path": "",
+ "github_oauth_enabled": false,
+ "github_oauth_client_id": "",
+ "github_oauth_client_secret_configured": false,
+ "github_oauth_redirect_url": "",
+ "github_oauth_frontend_redirect_url": "/auth/oauth/callback",
+ "google_oauth_enabled": false,
+ "google_oauth_client_id": "",
+ "google_oauth_client_secret_configured": false,
+ "google_oauth_redirect_url": "",
+ "google_oauth_frontend_redirect_url": "/auth/oauth/callback",
"ops_monitoring_enabled": false,
"ops_realtime_monitoring_enabled": true,
"ops_query_mode_default": "auto",
@@ -697,6 +707,16 @@ func TestAPIContracts(t *testing.T) {
"auth_source_default_email_subscriptions": [],
"auth_source_default_email_grant_on_signup": false,
"auth_source_default_email_grant_on_first_bind": false,
+ "auth_source_default_github_balance": 0,
+ "auth_source_default_github_concurrency": 5,
+ "auth_source_default_github_subscriptions": [],
+ "auth_source_default_github_grant_on_signup": false,
+ "auth_source_default_github_grant_on_first_bind": false,
+ "auth_source_default_google_balance": 0,
+ "auth_source_default_google_concurrency": 5,
+ "auth_source_default_google_subscriptions": [],
+ "auth_source_default_google_grant_on_signup": false,
+ "auth_source_default_google_grant_on_first_bind": false,
"auth_source_default_linuxdo_balance": 0,
"auth_source_default_linuxdo_concurrency": 5,
"auth_source_default_linuxdo_subscriptions": [],
@@ -789,6 +809,7 @@ func TestAPIContracts(t *testing.T) {
"channel_monitor_enabled": true,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
+ "risk_control_enabled": false,
"affiliate_enabled": false,
"wechat_connect_enabled": false,
"wechat_connect_app_id": "",
@@ -856,12 +877,21 @@ func TestAPIContracts(t *testing.T) {
"promo_code_enabled": true,
"password_reset_enabled": false,
"frontend_url": "",
- "invitation_code_enabled": false,
- "totp_enabled": false,
- "totp_encryption_key_configured": false,
- "smtp_host": "",
- "smtp_port": 587,
- "smtp_username": "",
+ "invitation_code_enabled": false,
+ "totp_enabled": false,
+ "totp_encryption_key_configured": false,
+ "login_agreement_enabled": false,
+ "login_agreement_mode": "modal",
+ "login_agreement_updated_at": "2026-03-31",
+ "login_agreement_documents": [
+ {"id": "terms", "title": "服务条款", "content_md": ""},
+ {"id": "usage-policy", "title": "使用政策", "content_md": ""},
+ {"id": "supported-regions", "title": "支持的国家和地区", "content_md": ""},
+ {"id": "service-specific-terms", "title": "服务特定条款", "content_md": ""}
+ ],
+ "smtp_host": "",
+ "smtp_port": 587,
+ "smtp_username": "",
"smtp_password_configured": false,
"smtp_from_email": "",
"smtp_from_name": "",
@@ -895,9 +925,19 @@ func TestAPIContracts(t *testing.T) {
"oidc_connect_userinfo_email_path": "",
"oidc_connect_userinfo_id_path": "",
"oidc_connect_userinfo_username_path": "",
+ "github_oauth_enabled": false,
+ "github_oauth_client_id": "",
+ "github_oauth_client_secret_configured": false,
+ "github_oauth_redirect_url": "",
+ "github_oauth_frontend_redirect_url": "/auth/oauth/callback",
+ "google_oauth_enabled": false,
+ "google_oauth_client_id": "",
+ "google_oauth_client_secret_configured": false,
+ "google_oauth_redirect_url": "",
+ "google_oauth_frontend_redirect_url": "/auth/oauth/callback",
"site_name": "Sub2API",
"site_logo": "",
- "site_subtitle": "Subscription to API Conversion Platform",
+ "site_subtitle": "按需充值的 AI API 中转服务",
"api_base_url": "",
"contact_info": "",
"doc_url": "",
@@ -980,6 +1020,7 @@ func TestAPIContracts(t *testing.T) {
"channel_monitor_enabled": true,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
+ "risk_control_enabled": false,
"affiliate_enabled": false,
"wechat_connect_enabled": true,
"wechat_connect_app_id": "wx-open-config",
@@ -1002,6 +1043,16 @@ func TestAPIContracts(t *testing.T) {
"auth_source_default_email_subscriptions": [],
"auth_source_default_email_grant_on_signup": false,
"auth_source_default_email_grant_on_first_bind": false,
+ "auth_source_default_github_balance": 0,
+ "auth_source_default_github_concurrency": 5,
+ "auth_source_default_github_subscriptions": [],
+ "auth_source_default_github_grant_on_signup": false,
+ "auth_source_default_github_grant_on_first_bind": false,
+ "auth_source_default_google_balance": 0,
+ "auth_source_default_google_concurrency": 5,
+ "auth_source_default_google_subscriptions": [],
+ "auth_source_default_google_grant_on_signup": false,
+ "auth_source_default_google_grant_on_first_bind": false,
"auth_source_default_linuxdo_balance": 0,
"auth_source_default_linuxdo_concurrency": 5,
"auth_source_default_linuxdo_subscriptions": [],
@@ -1095,6 +1146,7 @@ func newContractDeps(t *testing.T) *contractDeps {
},
},
}
+ require.NoError(t, userRepo.users[1].SetPassword("correct-password"))
apiKeyRepo := newStubApiKeyRepo(now)
apiKeyCache := stubApiKeyCache{}
@@ -1120,7 +1172,7 @@ func newContractDeps(t *testing.T) *contractDeps {
subscriptionService := service.NewSubscriptionService(groupRepo, userSubRepo, nil, nil, cfg)
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
- redeemService := service.NewRedeemService(redeemRepo, userRepo, subscriptionService, nil, nil, nil, nil)
+ redeemService := service.NewRedeemService(redeemRepo, userRepo, subscriptionService, nil, nil, nil, nil, nil)
redeemHandler := handler.NewRedeemHandler(redeemService)
settingRepo := newStubSettingRepo()
@@ -1291,6 +1343,9 @@ func (r *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i
return errors.New("not implemented")
}
+func (r *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
+func (r *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
+
func (r *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
return false, errors.New("not implemented")
}
@@ -1353,6 +1408,14 @@ func (stubApiKeyCache) DeleteCreateAttemptCount(ctx context.Context, userID int6
return nil
}
+func (stubApiKeyCache) IncrementAPIKeyStepUpAttempt(context.Context, string, int64) (int, error) {
+ return 1, nil
+}
+
+func (stubApiKeyCache) DeleteAPIKeyStepUpAttempts(context.Context, string, int64) error {
+ return nil
+}
+
func (stubApiKeyCache) IncrementDailyUsage(ctx context.Context, apiKey string) error {
return nil
}
@@ -1393,12 +1456,18 @@ func (stubGroupRepo) Create(ctx context.Context, group *service.Group) error {
return errors.New("not implemented")
}
-func (stubGroupRepo) GetByID(ctx context.Context, id int64) (*service.Group, error) {
+func (r *stubGroupRepo) GetByID(ctx context.Context, id int64) (*service.Group, error) {
+ for i := range r.active {
+ if r.active[i].ID == id {
+ group := r.active[i]
+ return &group, nil
+ }
+ }
return nil, service.ErrGroupNotFound
}
-func (stubGroupRepo) GetByIDLite(ctx context.Context, id int64) (*service.Group, error) {
- return nil, service.ErrGroupNotFound
+func (r *stubGroupRepo) GetByIDLite(ctx context.Context, id int64) (*service.Group, error) {
+ return r.GetByID(ctx, id)
}
func (stubGroupRepo) Update(ctx context.Context, group *service.Group) error {
@@ -1722,6 +1791,14 @@ func (stubRedeemCodeRepo) Delete(ctx context.Context, id int64) error {
return errors.New("not implemented")
}
+func (stubRedeemCodeRepo) DeleteIfUnused(ctx context.Context, id int64) (bool, error) {
+ return false, errors.New("not implemented")
+}
+
+func (stubRedeemCodeRepo) ExpireIfUnused(ctx context.Context, id int64) (bool, error) {
+ return false, errors.New("not implemented")
+}
+
func (stubRedeemCodeRepo) Use(ctx context.Context, id, userID int64) error {
return errors.New("not implemented")
}
@@ -1784,6 +1861,18 @@ func (stubUserSubscriptionRepo) GetByUserIDAndGroupID(ctx context.Context, userI
func (stubUserSubscriptionRepo) GetActiveByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
return nil, errors.New("not implemented")
}
+func (stubUserSubscriptionRepo) GetActiveWalletByUserID(ctx context.Context, userID int64) (*service.UserSubscription, error) {
+ return nil, service.ErrSubscriptionNotFound
+}
+func (s stubUserSubscriptionRepo) GetActiveCreditsWalletByUserID(ctx context.Context, userID int64) (*service.UserSubscription, error) {
+ return s.GetActiveWalletByUserID(ctx, userID)
+}
+func (stubUserSubscriptionRepo) GetActiveByPlanCoveringGroup(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
+ return nil, service.ErrSubscriptionNotFound
+}
+func (stubUserSubscriptionRepo) HasAnyActiveSubscription(ctx context.Context, userID int64) (bool, error) {
+ return false, nil
+}
func (stubUserSubscriptionRepo) Update(ctx context.Context, sub *service.UserSubscription) error {
return errors.New("not implemented")
}
@@ -1832,6 +1921,9 @@ func (stubUserSubscriptionRepo) ResetWeeklyUsage(ctx context.Context, id int64,
func (stubUserSubscriptionRepo) ResetMonthlyUsage(ctx context.Context, id int64, newWindowStart time.Time) error {
return errors.New("not implemented")
}
+func (stubUserSubscriptionRepo) AdvanceUsageWindow(ctx context.Context, id int64, advance service.SubscriptionUsageWindowAdvance) (bool, error) {
+ return false, errors.New("not implemented")
+}
func (stubUserSubscriptionRepo) IncrementUsage(ctx context.Context, id int64, costUSD float64) error {
return errors.New("not implemented")
}
@@ -2045,11 +2137,11 @@ func (r *stubApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int
return 0, errors.New("not implemented")
}
-func (r *stubApiKeyRepo) ListKeysByUserID(ctx context.Context, userID int64) ([]string, error) {
+func (r *stubApiKeyRepo) ListAuthCacheLocatorsByUserID(ctx context.Context, userID int64) ([]string, error) {
return nil, errors.New("not implemented")
}
-func (r *stubApiKeyRepo) ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error) {
+func (r *stubApiKeyRepo) ListAuthCacheLocatorsByGroupID(ctx context.Context, groupID int64) ([]string, error) {
return nil, errors.New("not implemented")
}
diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go
index 023e40bb216..7803b604c43 100644
--- a/backend/internal/server/http.go
+++ b/backend/internal/server/http.go
@@ -114,7 +114,7 @@ func ProvideHTTPServer(cfg *config.Config, router *gin.Engine) *http.Server {
// 根据配置决定是否启用 H2C
if cfg.Server.H2C.Enabled {
h2cConfig := cfg.Server.H2C
- httpHandler = h2c.NewHandler(router, &http2.Server{
+ httpHandler = h2c.NewHandler(httpHandler, &http2.Server{
MaxConcurrentStreams: h2cConfig.MaxConcurrentStreams,
IdleTimeout: time.Duration(h2cConfig.IdleTimeout) * time.Second,
MaxReadFrameSize: uint32(h2cConfig.MaxReadFrameSize),
diff --git a/backend/internal/server/http_body_limit_test.go b/backend/internal/server/http_body_limit_test.go
new file mode 100644
index 00000000000..3d141947686
--- /dev/null
+++ b/backend/internal/server/http_body_limit_test.go
@@ -0,0 +1,48 @@
+package server
+
+import (
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestProvideHTTPServerKeepsGlobalBodyLimitWhenH2CEnabled(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ router.POST("/upload", func(c *gin.Context) {
+ _, err := io.ReadAll(c.Request.Body)
+ var maxBytesError *http.MaxBytesError
+ if errors.As(err, &maxBytesError) {
+ c.Status(http.StatusRequestEntityTooLarge)
+ return
+ }
+ require.NoError(t, err)
+ c.Status(http.StatusNoContent)
+ })
+
+ server := ProvideHTTPServer(&config.Config{
+ Server: config.ServerConfig{
+ MaxRequestBodySize: 8,
+ H2C: config.H2CConfig{
+ Enabled: true,
+ MaxConcurrentStreams: 10,
+ IdleTimeout: 10,
+ MaxReadFrameSize: 1 << 14,
+ MaxUploadBufferPerConnection: 1 << 16,
+ MaxUploadBufferPerStream: 1 << 15,
+ },
+ },
+ }, router)
+
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodPost, "/upload", strings.NewReader("123456789"))
+ server.Handler.ServeHTTP(recorder, request)
+ require.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code)
+}
diff --git a/backend/internal/server/middleware/admin_auth.go b/backend/internal/server/middleware/admin_auth.go
index 6f294ff0d65..58e712c6198 100644
--- a/backend/internal/server/middleware/admin_auth.go
+++ b/backend/internal/server/middleware/admin_auth.go
@@ -30,13 +30,12 @@ func adminAuth(
settingService *service.SettingService,
) gin.HandlerFunc {
return func(c *gin.Context) {
- // WebSocket upgrade requests cannot set Authorization headers in browsers.
- // For admin WebSocket endpoints (e.g. Ops realtime), allow passing the JWT via
- // Sec-WebSocket-Protocol (subprotocol list) using a prefixed token item:
- // Sec-WebSocket-Protocol: sub2api-admin, jwt.
+ // Browser WebSocket APIs cannot set Authorization headers. Accept only a
+ // short-lived, purpose-bound ticket here; ordinary admin JWTs are explicitly
+ // not valid WebSocket subprotocol credentials.
if isWebSocketUpgradeRequest(c) {
- if token := extractJWTFromWebSocketSubprotocol(c); token != "" {
- if !validateJWTForAdmin(c, token, authService, userService) {
+ if ticket := extractAdminWSTicketFromSubprotocol(c); ticket != "" {
+ if !validateAdminWSTicketForAdmin(c, ticket, authService, userService) {
return
}
c.Next()
@@ -92,7 +91,7 @@ func isWebSocketUpgradeRequest(c *gin.Context) bool {
return strings.Contains(connection, "upgrade")
}
-func extractJWTFromWebSocketSubprotocol(c *gin.Context) string {
+func extractAdminWSTicketFromSubprotocol(c *gin.Context) string {
if c == nil {
return ""
}
@@ -101,12 +100,12 @@ func extractJWTFromWebSocketSubprotocol(c *gin.Context) string {
return ""
}
- // The header is a comma-separated list of tokens. We reserve the prefix "jwt."
- // for carrying the admin JWT.
+ // Only the domain-specific ticket prefix is accepted. Legacy
+ // jwt. values are deliberately ignored.
for _, part := range strings.Split(raw, ",") {
p := strings.TrimSpace(part)
- if strings.HasPrefix(p, "jwt.") {
- token := strings.TrimSpace(strings.TrimPrefix(p, "jwt."))
+ if strings.HasPrefix(p, "ticket.") {
+ token := strings.TrimSpace(strings.TrimPrefix(p, "ticket."))
if token != "" {
return token
}
@@ -115,6 +114,46 @@ func extractJWTFromWebSocketSubprotocol(c *gin.Context) string {
return ""
}
+func validateAdminWSTicketForAdmin(
+ c *gin.Context,
+ ticket string,
+ authService *service.AuthService,
+ userService *service.UserService,
+) bool {
+ claims, err := authService.ValidateAdminOpsWSTicket(ticket)
+ if err != nil {
+ if errors.Is(err, service.ErrTokenExpired) {
+ AbortWithError(c, 401, "TICKET_EXPIRED", "WebSocket ticket has expired")
+ return false
+ }
+ AbortWithError(c, 401, "INVALID_TICKET", "Invalid WebSocket ticket")
+ return false
+ }
+
+ user, err := userService.GetByID(c.Request.Context(), claims.UserID)
+ if err != nil {
+ AbortWithError(c, 401, "USER_NOT_FOUND", "User not found")
+ return false
+ }
+ if !user.IsActive() {
+ AbortWithError(c, 401, "USER_INACTIVE", "User account is not active")
+ return false
+ }
+ if claims.TokenVersion != user.TokenVersion {
+ AbortWithError(c, 401, "TOKEN_REVOKED", "Token has been revoked (password changed)")
+ return false
+ }
+ if !user.IsAdmin() {
+ AbortWithError(c, 403, "FORBIDDEN", "Admin access required")
+ return false
+ }
+
+ c.Set(string(ContextKeyUser), AuthSubject{UserID: user.ID, Concurrency: user.Concurrency})
+ c.Set(string(ContextKeyUserRole), user.Role)
+ c.Set("auth_method", "admin_ws_ticket")
+ return true
+}
+
// validateAdminAPIKey 验证管理员 API Key
func validateAdminAPIKey(
c *gin.Context,
diff --git a/backend/internal/server/middleware/admin_auth_test.go b/backend/internal/server/middleware/admin_auth_test.go
index dde92dfdc1a..4be04a58421 100644
--- a/backend/internal/server/middleware/admin_auth_test.go
+++ b/backend/internal/server/middleware/admin_auth_test.go
@@ -23,12 +23,13 @@ func TestAdminAuthJWTValidatesTokenVersion(t *testing.T) {
authService := service.NewAuthService(nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil)
admin := &service.User{
- ID: 1,
- Email: "admin@example.com",
- Role: service.RoleAdmin,
- Status: service.StatusActive,
- TokenVersion: 2,
- Concurrency: 1,
+ ID: 1,
+ Email: "admin@example.com",
+ Role: service.RoleAdmin,
+ Status: service.StatusActive,
+ TokenVersion: 2,
+ TokenVersionResolved: true,
+ Concurrency: 1,
}
userRepo := &stubUserRepo{
@@ -50,10 +51,11 @@ func TestAdminAuthJWTValidatesTokenVersion(t *testing.T) {
t.Run("token_version_mismatch_rejected", func(t *testing.T) {
token, err := authService.GenerateToken(&service.User{
- ID: admin.ID,
- Email: admin.Email,
- Role: admin.Role,
- TokenVersion: admin.TokenVersion - 1,
+ ID: admin.ID,
+ Email: admin.Email,
+ Role: admin.Role,
+ TokenVersion: admin.TokenVersion - 1,
+ TokenVersionResolved: true,
})
require.NoError(t, err)
@@ -68,10 +70,11 @@ func TestAdminAuthJWTValidatesTokenVersion(t *testing.T) {
t.Run("token_version_match_allows", func(t *testing.T) {
token, err := authService.GenerateToken(&service.User{
- ID: admin.ID,
- Email: admin.Email,
- Role: admin.Role,
- TokenVersion: admin.TokenVersion,
+ ID: admin.ID,
+ Email: admin.Email,
+ Role: admin.Role,
+ TokenVersion: admin.TokenVersion,
+ TokenVersionResolved: true,
})
require.NoError(t, err)
@@ -83,13 +86,8 @@ func TestAdminAuthJWTValidatesTokenVersion(t *testing.T) {
require.Equal(t, http.StatusOK, w.Code)
})
- t.Run("websocket_token_version_mismatch_rejected", func(t *testing.T) {
- token, err := authService.GenerateToken(&service.User{
- ID: admin.ID,
- Email: admin.Email,
- Role: admin.Role,
- TokenVersion: admin.TokenVersion - 1,
- })
+ t.Run("websocket_legacy_access_token_subprotocol_rejected", func(t *testing.T) {
+ token, err := authService.GenerateToken(admin)
require.NoError(t, err)
w := httptest.NewRecorder()
@@ -100,15 +98,17 @@ func TestAdminAuthJWTValidatesTokenVersion(t *testing.T) {
router.ServeHTTP(w, req)
require.Equal(t, http.StatusUnauthorized, w.Code)
- require.Contains(t, w.Body.String(), "TOKEN_REVOKED")
+ require.Contains(t, w.Body.String(), "UNAUTHORIZED")
})
- t.Run("websocket_token_version_match_allows", func(t *testing.T) {
- token, err := authService.GenerateToken(&service.User{
- ID: admin.ID,
- Email: admin.Email,
- Role: admin.Role,
- TokenVersion: admin.TokenVersion,
+ t.Run("websocket_ticket_token_version_mismatch_rejected", func(t *testing.T) {
+ ticket, _, err := authService.GenerateAdminOpsWSTicket(&service.User{
+ ID: admin.ID,
+ Email: admin.Email,
+ Role: admin.Role,
+ Status: admin.Status,
+ TokenVersion: admin.TokenVersion - 1,
+ TokenVersionResolved: true,
})
require.NoError(t, err)
@@ -116,7 +116,22 @@ func TestAdminAuthJWTValidatesTokenVersion(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/t", nil)
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Connection", "Upgrade")
- req.Header.Set("Sec-WebSocket-Protocol", "sub2api-admin, jwt."+token)
+ req.Header.Set("Sec-WebSocket-Protocol", "sub2api-admin, ticket."+ticket)
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusUnauthorized, w.Code)
+ require.Contains(t, w.Body.String(), "TOKEN_REVOKED")
+ })
+
+ t.Run("websocket_ticket_token_version_match_allows", func(t *testing.T) {
+ ticket, _, err := authService.GenerateAdminOpsWSTicket(admin)
+ require.NoError(t, err)
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/t", nil)
+ req.Header.Set("Upgrade", "websocket")
+ req.Header.Set("Connection", "Upgrade")
+ req.Header.Set("Sec-WebSocket-Protocol", "sub2api-admin, ticket."+ticket)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
@@ -198,6 +213,9 @@ func (s *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i
panic("unexpected UpdateConcurrency call")
}
+func (s *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
+func (s *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
+
func (s *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
panic("unexpected ExistsByEmail call")
}
diff --git a/backend/internal/server/middleware/api_key_auth.go b/backend/internal/server/middleware/api_key_auth.go
index 972c1eafa5e..1f2091657ac 100644
--- a/backend/internal/server/middleware/api_key_auth.go
+++ b/backend/internal/server/middleware/api_key_auth.go
@@ -1,8 +1,12 @@
package middleware
import (
+ "bytes"
"context"
+ "encoding/json"
"errors"
+ "io"
+ "net/http"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config"
@@ -15,7 +19,22 @@ import (
// NewAPIKeyAuthMiddleware 创建 API Key 认证中间件
func NewAPIKeyAuthMiddleware(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) APIKeyAuthMiddleware {
- return APIKeyAuthMiddleware(apiKeyAuthWithSubscription(apiKeyService, subscriptionService, cfg))
+ return NewAPIKeyAuthMiddlewareWithRouter(apiKeyService, subscriptionService, nil, nil, cfg)
+}
+
+type apiKeyAuthGroupGetter interface {
+ GetByID(ctx context.Context, id int64) (*service.Group, error)
+}
+
+// NewAPIKeyAuthMiddlewareWithRouter 创建支持钱包通用 Key 动态分组路由的认证中间件。
+func NewAPIKeyAuthMiddlewareWithRouter(
+ apiKeyService *service.APIKeyService,
+ subscriptionService *service.SubscriptionService,
+ modelRouter service.ModelRouter,
+ groupGetter apiKeyAuthGroupGetter,
+ cfg *config.Config,
+) APIKeyAuthMiddleware {
+ return APIKeyAuthMiddleware(apiKeyAuthWithSubscription(apiKeyService, subscriptionService, modelRouter, groupGetter, cfg))
}
// apiKeyAuthWithSubscription API Key认证中间件(支持订阅验证)
@@ -25,7 +44,13 @@ func NewAPIKeyAuthMiddleware(apiKeyService *service.APIKeyService, subscriptionS
// - 计费执行(Billing Enforcement):过期/配额/订阅/余额检查 —— skipBilling 时整块跳过
//
// /v1/usage 端点只需鉴权,不需要计费执行(允许过期/配额耗尽的 Key 查询自身用量)。
-func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) gin.HandlerFunc {
+func apiKeyAuthWithSubscription(
+ apiKeyService *service.APIKeyService,
+ subscriptionService *service.SubscriptionService,
+ modelRouter service.ModelRouter,
+ groupGetter apiKeyAuthGroupGetter,
+ cfg *config.Config,
+) gin.HandlerFunc {
return func(c *gin.Context) {
// ── 1. 提取 API Key ──────────────────────────────────────────
@@ -109,9 +134,40 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
return
}
+ // Group liveness and exclusive/reserved authorization are authentication
+ // boundaries, not billing conveniences. They must run even in simple mode
+ // so a revoked VIP key cannot regain access by changing run_mode.
+ if apiKey.GroupID != nil && (apiKey.Group == nil || apiKey.Group.ID != *apiKey.GroupID || !apiKey.Group.Hydrated || apiKey.Group.Status != service.StatusActive) {
+ AbortWithError(c, 403, "GROUP_NOT_ALLOWED", "该分组已停用或授权已撤销,请联系客服")
+ return
+ }
+ if apiKey.GroupID != nil && apiKey.Group != nil && !apiKey.Group.IsSubscriptionType() {
+ groupName := strings.TrimSpace(apiKey.Group.Name)
+ if (groupName == service.WalletDefaultOpenAIGroupName || groupName == service.WalletDefaultVIPGroupName) &&
+ !service.CanUseWalletGroup(apiKey.User, apiKey.Group) {
+ AbortWithError(c, 403, "GROUP_NOT_ALLOWED", "钱包保留分组配置或授权不符合要求,请联系客服")
+ return
+ }
+ if apiKey.Group.IsExclusive && !apiKey.User.CanBindGroup(apiKey.Group.ID, true) {
+ AbortWithError(c, 403, "GROUP_NOT_ALLOWED", "该专属分组授权已撤销,请联系客服")
+ return
+ }
+ }
+
// ── 4. SimpleMode → early return ─────────────────────────────
if cfg.RunMode == config.RunModeSimple {
+ if apiKey.Group != nil {
+ groupName := strings.TrimSpace(apiKey.Group.Name)
+ if groupName == service.WalletDefaultOpenAIGroupName || groupName == service.WalletDefaultVIPGroupName {
+ AbortWithError(c, 403, "SIMPLE_MODE_RESERVED_GROUP_DISABLED", "simple 模式禁止使用钱包与 VIP 保留分组")
+ return
+ }
+ }
+ if apiKey.IsWalletUniversal() {
+ AbortWithError(c, 403, "SIMPLE_MODE_RESERVED_GROUP_DISABLED", "simple 模式禁止使用额度钱包 Key")
+ return
+ }
c.Set(string(ContextKeyAPIKey), apiKey)
c.Set(string(ContextKeyUser), AuthSubject{
UserID: apiKey.User.ID,
@@ -130,22 +186,199 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
skipBilling := c.Request.URL.Path == "/v1/usage"
var subscription *service.UserSubscription
+ var walletSub *service.UserSubscription
+ var routedGroup *service.Group
+
+ // Probe the wallet, but do not select it yet. A fixed monthly key must
+ // charge its monthly subscription first; the wallet is only a fallback
+ // for the approved credits groups below.
+ if subscriptionService != nil {
+ foundWalletSub, walletErr := subscriptionService.GetActiveCreditsWalletSubscription(
+ c.Request.Context(),
+ apiKey.User.ID,
+ )
+ switch {
+ case walletErr == nil && foundWalletSub != nil:
+ walletSub = foundWalletSub
+ case walletErr != nil && !errors.Is(walletErr, service.ErrSubscriptionNotFound):
+ AbortWithError(c, 503, "BILLING_SERVICE_UNAVAILABLE", "订阅与钱包服务暂时不可用,请稍后重试")
+ return
+ default:
+ legacyWallet, legacyErr := subscriptionService.GetActiveWalletSubscription(c.Request.Context(), apiKey.User.ID)
+ if legacyErr == nil && legacyWallet != nil {
+ walletSub = legacyWallet
+ } else if legacyErr != nil && !errors.Is(legacyErr, service.ErrSubscriptionNotFound) {
+ AbortWithError(c, 503, "BILLING_SERVICE_UNAVAILABLE", "订阅与钱包服务暂时不可用,请稍后重试")
+ return
+ }
+ }
+ }
+
+ if (apiKey.GroupID == nil || apiKey.IsWalletUniversal()) && !apiKey.HasValidWalletUniversalShape() {
+ AbortWithError(c, 403, "WALLET_KEY_INVALID", "该 Key 不是系统生成的钱包通用 Key,请使用钱包页面提供的 Key")
+ return
+ }
+
+ // A NULL-group key is the wallet universal key, not an unscoped balance
+ // key. Without an active permanent credits wallet it must fail closed;
+ // otherwise an old universal key can fall through to user.balance with no
+ // effective group and bypass the wallet routing policy.
+ if apiKey.GroupID == nil && (walletSub == nil || !walletSub.IsUniversalWalletMode()) {
+ AbortWithError(c, 403, "WALLET_NOT_ACTIVE", "额度钱包未开通或已失效,请联系客服")
+ return
+ }
+
+ // group_id=NULL is reserved for permanent credits wallets. Route by the
+ // requested model and then enforce the HFC group policy: GPT only through
+ // openai-default; Claude/Fable only through an explicitly granted vip.
+ if walletSub != nil && walletSub.IsUniversalWalletMode() && apiKey.HasValidWalletUniversalShape() {
+ modelName := "gpt-wallet-default"
+ if !usesWalletDefaultOpenAIRoute(c) {
+ var extractErr error
+ modelName, extractErr = extractModelFromRequest(c)
+ if extractErr != nil {
+ AbortWithError(c, 400, "model_unsupported", "该模型未启用,请联系客服")
+ return
+ }
+ }
+ if modelRouter == nil || groupGetter == nil {
+ AbortWithError(c, 500, "INTERNAL_ERROR", "Model router is not configured")
+ return
+ }
+ groupID, routeErr := modelRouter.ResolveGroupID(c.Request.Context(), apiKey.User.ID, modelName)
+ if routeErr != nil {
+ if errors.Is(routeErr, service.ErrModelUnsupported) {
+ AbortWithError(c, 400, "model_unsupported", "该模型未启用,请联系客服")
+ return
+ }
+ AbortWithError(c, 500, "INTERNAL_ERROR", "Failed to route model")
+ return
+ }
+ group, groupErr := groupGetter.GetByID(c.Request.Context(), groupID)
+ if groupErr != nil || !service.IsGroupContextValid(group) {
+ AbortWithError(c, 400, "model_unsupported", "该模型未启用,请联系客服")
+ return
+ }
+ if !service.CanUseWalletGroup(apiKey.User, group) {
+ AbortWithError(c, 403, "GROUP_NOT_ALLOWED", "该模型分组未授权;Claude/VIP 请先联系管理员开通")
+ return
+ }
+ if isWalletModelsEndpoint(c) {
+ visibility, visibilityErr := buildWalletModelVisibility(
+ c.Request.Context(),
+ apiKey,
+ group,
+ modelRouter,
+ groupGetter,
+ )
+ if visibilityErr == nil {
+ setWalletModelVisibility(c, visibility)
+ } else if shouldFailClosedWalletModelVisibility(visibilityErr) {
+ AbortWithError(c, 500, "INTERNAL_ERROR", "Failed to resolve wallet model visibility")
+ return
+ }
+ }
+ routedGroup = group
+ effectiveGroupID := group.ID
+ apiKeyCopy := *apiKey
+ apiKeyCopy.GroupID = &effectiveGroupID
+ apiKeyCopy.Group = group
+ apiKey = &apiKeyCopy
+ subscription = walletSub
+ }
+
isSubscriptionType := apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
- if isSubscriptionType && subscriptionService != nil {
+ // Fixed-group keys use monthly entitlement first. This keeps monthly
+ // quota and credits balance as two independent ledgers.
+ //
+ // 月卡查找两步走(2026-05-16 方案 C,见 docs/plans/2026-05-16-wallet-v4-group-switch-billing-fix.md):
+ // 1. exact match by (user_id, key.group_id):用户没切 group / 切到订阅主 group
+ // 2. plan_groups 间接覆盖:用户在 admin 把 key 切到 plan 关联的 standard group
+ // (如月卡 paid-trial-v3-30d 关联 claude-Max pool / openai-default 等)
+ if subscription == nil && apiKey.Group != nil && subscriptionService != nil {
sub, subErr := subscriptionService.GetActiveSubscription(
c.Request.Context(),
apiKey.User.ID,
apiKey.Group.ID,
)
- if subErr != nil {
- if !skipBilling {
- AbortWithError(c, 403, "SUBSCRIPTION_NOT_FOUND", "No active subscription found for this group")
+ if subErr == nil && sub != nil {
+ subscription = sub
+ } else if subErr != nil && !errors.Is(subErr, service.ErrSubscriptionNotFound) {
+ AbortWithError(c, 503, "BILLING_SERVICE_UNAVAILABLE", "订阅与钱包服务暂时不可用,请稍后重试")
+ return
+ } else {
+ covering, coverErr := subscriptionService.GetActiveSubscriptionCoveringGroup(
+ c.Request.Context(),
+ apiKey.User.ID,
+ apiKey.Group.ID,
+ )
+ if coverErr == nil && covering != nil {
+ subscription = covering
+ } else if coverErr != nil && !errors.Is(coverErr, service.ErrSubscriptionNotFound) {
+ AbortWithError(c, 503, "BILLING_SERVICE_UNAVAILABLE", "订阅与钱包服务暂时不可用,请稍后重试")
return
}
- // skipBilling: 订阅不存在也放行,handler 会返回可用的数据
- } else {
- subscription = sub
+ }
+ }
+
+ // A fixed standard-group key may use the wallet only for the approved
+ // credits groups. Finite legacy wallets are retained solely for an
+ // explicitly granted vip; openai-default requires a permanent credits
+ // wallet.
+ if subscription == nil && walletSub != nil && apiKey.GroupID != nil && apiKey.Group != nil && !apiKey.Group.IsSubscriptionType() {
+ allowed := service.CanUseWalletGroup(apiKey.User, apiKey.Group)
+ if !allowed {
+ AbortWithError(c, 403, "GROUP_NOT_ALLOWED", "该分组未授权或不属于额度钱包")
+ return
+ }
+ if strings.TrimSpace(apiKey.Group.Name) == service.WalletDefaultVIPGroupName || walletSub.IsUniversalWalletMode() {
+ if modelRouter == nil {
+ AbortWithError(c, 500, "INTERNAL_ERROR", "Model router is not configured")
+ return
+ }
+ modelName, extractErr := extractModelFromRequest(c)
+ if extractErr != nil {
+ AbortWithError(c, 400, "model_unsupported", "该模型未启用,请联系客服")
+ return
+ }
+ expectedGroupID, routeErr := modelRouter.ResolveGroupID(c.Request.Context(), apiKey.User.ID, modelName)
+ if routeErr != nil {
+ if errors.Is(routeErr, service.ErrModelUnsupported) {
+ AbortWithError(c, 400, "model_unsupported", "该模型未启用,请联系客服")
+ return
+ }
+ AbortWithError(c, 500, "INTERNAL_ERROR", "Failed to route model")
+ return
+ }
+ if expectedGroupID != apiKey.Group.ID && !isFixedOpenAIMessagesDispatchRequest(c, apiKey.Group, modelName) {
+ AbortWithError(c, 403, "GROUP_NOT_ALLOWED", "当前 Key 分组与请求模型不匹配,请使用正确的钱包 Key")
+ return
+ }
+ subscription = walletSub
+ }
+ }
+
+ // 都没匹配 → 决定 403 还是走老 balance 兼容路径。
+ // - 订阅类型 group:必须有 exact / 覆盖订阅,否则 SUBSCRIPTION_NOT_FOUND(保留原行为)
+ // - standard 类型 group:
+ // - 用户有任何 active 订阅但当前 group 不覆盖 → GROUP_NOT_IN_SUBSCRIPTION
+ // (2026-05-16 修复:原静默扣 user.balance 是 bug,月卡用户应被保护)
+ // - 用户没任何订阅 → 落到 §6 余额检查(保留纯余额用户兼容路径)
+ if subscription == nil && !skipBilling && subscriptionService != nil {
+ if isSubscriptionType {
+ AbortWithError(c, 403, "SUBSCRIPTION_NOT_FOUND", "No active subscription found for this group")
+ return
+ }
+ hasAny, hasAnyErr := subscriptionService.UserHasAnyActiveSubscription(c.Request.Context(), apiKey.User.ID)
+ if hasAnyErr != nil {
+ AbortWithError(c, 503, "BILLING_SERVICE_UNAVAILABLE", "订阅与钱包服务暂时不可用,请稍后重试")
+ return
+ }
+ if hasAny {
+ AbortWithError(c, 403, "GROUP_NOT_IN_SUBSCRIPTION",
+ "该分组不在你的额度使用范围内,请到后台选择可用分组,或联系管理员或客服")
+ return
}
}
@@ -174,24 +407,45 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
// 订阅模式:验证订阅限额
if subscription != nil {
- needsMaintenance, validateErr := subscriptionService.ValidateAndCheckLimits(subscription, apiKey.Group)
- if validateErr != nil {
- code := "SUBSCRIPTION_INVALID"
- status := 403
- if errors.Is(validateErr, service.ErrDailyLimitExceeded) ||
- errors.Is(validateErr, service.ErrWeeklyLimitExceeded) ||
- errors.Is(validateErr, service.ErrMonthlyLimitExceeded) {
- code = "USAGE_LIMIT_EXCEEDED"
- status = 429
+ // 钱包模式 (v4) 跳过 group 维度 daily/weekly/monthly 限额检查 +
+ // 窗口维护:钱包是用户级共享额度,不绑 group 限额。余额检查由
+ // BillingCacheService.checkWalletEligibility 处理(→ 402)。
+ // IsExpired 检查仍要做,避免过期钱包订阅继续扣款。
+ if subscription.IsWalletMode() {
+ if subscription.IsExpired() {
+ AbortWithError(c, 403, "SUBSCRIPTION_EXPIRED", "Wallet subscription has expired")
+ return
+ }
+ } else {
+ _, effectiveGroup := service.EffectiveBillingContext(apiKey.Group, subscription)
+ needsMaintenance, validateErr := subscriptionService.ValidateAndCheckLimits(subscription, effectiveGroup)
+ if validateErr != nil {
+ code := "SUBSCRIPTION_INVALID"
+ status := 403
+ if isSubscriptionUsageLimitError(validateErr) {
+ code = "USAGE_LIMIT_EXCEEDED"
+ status = 429
+ }
+ AbortWithError(c, status, code, validateErr.Error())
+ return
}
- AbortWithError(c, status, code, validateErr.Error())
- return
- }
- // 窗口维护异步化(不阻塞请求)
- if needsMaintenance {
- maintenanceCopy := *subscription
- subscriptionService.DoWindowMaintenance(&maintenanceCopy)
+ // 边界窗口必须在请求继续前完成权威重读与 CAS 维护,
+ // 防止延迟的重复 reset 清除已经结算的新窗口用量。
+ if subscription != nil && needsMaintenance {
+ maintained, maintenanceErr := subscriptionService.DoWindowMaintenance(c.Request.Context(), subscription.ID)
+ if maintenanceErr != nil {
+ AbortWithError(c, 503, "SUBSCRIPTION_MAINTENANCE_FAILED", "Subscription window maintenance is temporarily unavailable")
+ return
+ }
+ subscription = maintained
+ _, maintainedGroup := service.EffectiveBillingContext(apiKey.Group, subscription)
+ stillNeedsMaintenance, validateErr := subscriptionService.ValidateAndCheckLimits(subscription, maintainedGroup)
+ if validateErr != nil || stillNeedsMaintenance {
+ AbortWithError(c, 503, "SUBSCRIPTION_MAINTENANCE_FAILED", "Subscription window maintenance did not converge")
+ return
+ }
+ }
}
} else {
// 非订阅模式 或 订阅模式但 subscriptionService 未注入:回退到余额检查
@@ -213,13 +467,164 @@ func apiKeyAuthWithSubscription(apiKeyService *service.APIKeyService, subscripti
Concurrency: apiKey.User.Concurrency,
})
c.Set(string(ContextKeyUserRole), apiKey.User.Role)
- setGroupContext(c, apiKey.Group)
+ if routedGroup != nil {
+ setGroupContext(c, routedGroup)
+ } else {
+ setGroupContext(c, apiKey.Group)
+ }
_ = apiKeyService.TouchLastUsed(c.Request.Context(), apiKey.ID)
c.Next()
}
}
+func usesWalletDefaultOpenAIRoute(c *gin.Context) bool {
+ if c == nil || c.Request == nil || c.Request.URL == nil {
+ return false
+ }
+ path := c.Request.URL.Path
+ if path == "/v1/models" || path == "/v1/usage" {
+ return true
+ }
+ return c.Request.Method == "GET" && (path == "/v1/responses" || path == "/responses" || strings.HasSuffix(path, "/responses"))
+}
+
+// isFixedOpenAIMessagesDispatchRequest preserves the intentional HFC
+// OpenAI-backed Claude Code route without weakening fixed-group isolation.
+// A Claude-shaped model may stay on openai-default only when the request will
+// enter the dedicated /v1/messages dispatcher and that group can map the
+// requested Claude family to an OpenAI model. All other cross-group requests
+// continue to fail closed.
+func isFixedOpenAIMessagesDispatchRequest(c *gin.Context, group *service.Group, modelName string) bool {
+ if c == nil || c.Request == nil || c.Request.URL == nil || group == nil {
+ return false
+ }
+ if c.Request.Method != http.MethodPost || c.Request.URL.Path != "/v1/messages" {
+ return false
+ }
+ if strings.TrimSpace(group.Name) != service.WalletDefaultOpenAIGroupName ||
+ group.Platform != service.PlatformOpenAI || !group.AllowMessagesDispatch {
+ return false
+ }
+ return strings.TrimSpace(group.ResolveMessagesDispatchModel(modelName)) != ""
+}
+
+func isWalletModelsEndpoint(c *gin.Context) bool {
+ return c != nil && c.Request != nil && c.Request.URL != nil &&
+ c.Request.Method == "GET" && c.Request.URL.Path == "/v1/models"
+}
+
+func buildWalletModelVisibility(
+ ctx context.Context,
+ apiKey *service.APIKey,
+ defaultGroup *service.Group,
+ modelRouter service.ModelRouter,
+ groupGetter apiKeyAuthGroupGetter,
+) (WalletModelVisibility, error) {
+ if apiKey == nil || apiKey.User == nil || !apiKey.HasValidWalletUniversalShape() || defaultGroup == nil {
+ return WalletModelVisibility{}, errors.New("invalid universal wallet model visibility input")
+ }
+ routeProvider, ok := modelRouter.(service.ModelRouteProvider)
+ if !ok {
+ return WalletModelVisibility{}, errors.New("model router does not expose routes")
+ }
+ routes := routeProvider.Routes()
+ if len(routes) == 0 {
+ return WalletModelVisibility{}, errors.New("wallet model routes are empty")
+ }
+ if defaultGroup.Name != service.WalletDefaultOpenAIGroupName || !service.CanUseWalletGroup(apiKey.User, defaultGroup) {
+ return WalletModelVisibility{}, errors.New("default wallet group violates policy")
+ }
+
+ visibility := WalletModelVisibility{
+ APIKeyID: apiKey.ID,
+ Groups: []service.Group{*defaultGroup},
+ Routes: append([]service.ModelRoute(nil), routes...),
+ }
+ seenGroupIDs := map[int64]struct{}{defaultGroup.ID: {}}
+
+ // openai-default is already the effective metadata route above. Additional
+ // visibility is limited to the exact vip route and only when the user has an
+ // explicit grant for the resolved group ID.
+ probeModel, found := walletRouteProbeModel(routes, service.WalletDefaultVIPGroupName)
+ if !found {
+ return visibility, nil
+ }
+ vipGroupID, err := modelRouter.ResolveGroupID(ctx, apiKey.User.ID, probeModel)
+ if err != nil {
+ return visibility, nil
+ }
+ if !apiKey.User.CanBindGroup(vipGroupID, true) {
+ return visibility, nil
+ }
+ vipGroup, err := groupGetter.GetByID(ctx, vipGroupID)
+ if err != nil {
+ return WalletModelVisibility{}, err
+ }
+ if vipGroup.Name != service.WalletDefaultVIPGroupName || !service.CanUseWalletGroup(apiKey.User, vipGroup) {
+ return WalletModelVisibility{}, errors.New("vip wallet group violates policy")
+ }
+ if _, duplicate := seenGroupIDs[vipGroup.ID]; !duplicate {
+ visibility.Groups = append(visibility.Groups, *vipGroup)
+ }
+ return visibility, nil
+}
+
+func shouldFailClosedWalletModelVisibility(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := err.Error()
+ return !strings.Contains(msg, "model router does not expose routes") &&
+ !strings.Contains(msg, "wallet model routes are empty")
+}
+
+func walletRouteProbeModel(routes []service.ModelRoute, groupName string) (string, bool) {
+ for _, route := range routes {
+ if strings.TrimSpace(route.GroupName) != groupName {
+ continue
+ }
+ pattern := strings.TrimSpace(route.Pattern)
+ probe := strings.TrimSpace(route.ExampleModel)
+ if probe == "" {
+ if strings.HasSuffix(pattern, "*") {
+ probe = strings.TrimSuffix(pattern, "*") + "wallet-model-list-probe"
+ } else {
+ probe = pattern
+ }
+ }
+ routedGroupName, ok := service.WalletModelRouteGroupName(routes, probe)
+ if ok && routedGroupName == groupName {
+ return probe, true
+ }
+ }
+ return "", false
+}
+
+func extractModelFromRequest(c *gin.Context) (string, error) {
+ if c.Request == nil || c.Request.Body == nil {
+ return "", service.ErrModelUnsupported
+ }
+
+ bodyBytes, err := io.ReadAll(c.Request.Body)
+ c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
+ if err != nil {
+ return "", err
+ }
+
+ var payload struct {
+ Model string `json:"model"`
+ }
+ if err := json.Unmarshal(bodyBytes, &payload); err != nil {
+ return "", err
+ }
+ modelName := strings.TrimSpace(payload.Model)
+ if modelName == "" {
+ return "", service.ErrModelUnsupported
+ }
+ return modelName, nil
+}
+
// GetAPIKeyFromContext 从上下文中获取API key
func GetAPIKeyFromContext(c *gin.Context) (*service.APIKey, bool) {
value, exists := c.Get(string(ContextKeyAPIKey))
@@ -240,6 +645,12 @@ func GetSubscriptionFromContext(c *gin.Context) (*service.UserSubscription, bool
return subscription, ok
}
+func isSubscriptionUsageLimitError(err error) bool {
+ return errors.Is(err, service.ErrDailyLimitExceeded) ||
+ errors.Is(err, service.ErrWeeklyLimitExceeded) ||
+ errors.Is(err, service.ErrMonthlyLimitExceeded)
+}
+
func setGroupContext(c *gin.Context, group *service.Group) {
if !service.IsGroupContextValid(group) {
return
diff --git a/backend/internal/server/middleware/api_key_auth_google.go b/backend/internal/server/middleware/api_key_auth_google.go
index 84d93edc568..ee68bc502fa 100644
--- a/backend/internal/server/middleware/api_key_auth_google.go
+++ b/backend/internal/server/middleware/api_key_auth_google.go
@@ -22,8 +22,8 @@ func APIKeyAuthGoogle(apiKeyService *service.APIKeyService, cfg *config.Config)
// It is intended for Gemini native endpoints (/v1beta) to match Gemini SDK expectations.
func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
- if v := strings.TrimSpace(c.Query("api_key")); v != "" {
- abortWithGoogleError(c, 400, "Query parameter api_key is deprecated. Use Authorization header or key instead.")
+ if strings.TrimSpace(c.Query("api_key")) != "" || strings.TrimSpace(c.Query("key")) != "" {
+ abortWithGoogleError(c, 400, "API keys in query parameters are not supported. Use an authentication header instead.")
return
}
apiKeyString := extractAPIKeyForGoogle(c)
@@ -54,6 +54,35 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
abortWithGoogleError(c, 401, "User account is not active")
return
}
+ if apiKey.IsExpired() {
+ abortWithGoogleError(c, 403, "API key has expired")
+ return
+ }
+ if apiKey.IsQuotaExhausted() {
+ abortWithGoogleError(c, 429, "API key quota is exhausted")
+ return
+ }
+ // Native Gemini routing has no wallet model-to-group policy equivalent.
+ // Never reinterpret a NULL-group or wallet-purpose key as an unscoped
+ // balance key, even in simple mode.
+ if apiKey.GroupID == nil || apiKey.IsWalletUniversal() {
+ abortWithGoogleError(c, 403, "Ungrouped and wallet universal API keys are not supported on Gemini native endpoints")
+ return
+ }
+ if apiKey.Group == nil || apiKey.Group.ID != *apiKey.GroupID || !apiKey.Group.Hydrated || apiKey.Group.Status != service.StatusActive {
+ abortWithGoogleError(c, 403, "API key group is inactive or authorization was revoked")
+ return
+ }
+ groupName := strings.TrimSpace(apiKey.Group.Name)
+ if groupName == service.WalletDefaultOpenAIGroupName || groupName == service.WalletDefaultVIPGroupName {
+ if !service.CanUseWalletGroup(apiKey.User, apiKey.Group) {
+ abortWithGoogleError(c, 403, "API key group authorization was revoked")
+ return
+ }
+ } else if apiKey.Group.IsExclusive && !apiKey.User.CanBindGroup(apiKey.Group.ID, true) {
+ abortWithGoogleError(c, 403, "API key group authorization was revoked")
+ return
+ }
// 简易模式:跳过余额和订阅检查
if cfg.RunMode == config.RunModeSimple {
@@ -84,20 +113,30 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
needsMaintenance, err := subscriptionService.ValidateAndCheckLimits(subscription, apiKey.Group)
if err != nil {
status := 403
- if errors.Is(err, service.ErrDailyLimitExceeded) ||
- errors.Is(err, service.ErrWeeklyLimitExceeded) ||
- errors.Is(err, service.ErrMonthlyLimitExceeded) {
+ if isSubscriptionUsageLimitError(err) {
status = 429
}
abortWithGoogleError(c, status, err.Error())
return
}
- c.Set(string(ContextKeySubscription), subscription)
+ if subscription != nil {
+ c.Set(string(ContextKeySubscription), subscription)
+ }
- if needsMaintenance {
- maintenanceCopy := *subscription
- subscriptionService.DoWindowMaintenance(&maintenanceCopy)
+ if subscription != nil && needsMaintenance {
+ maintained, maintenanceErr := subscriptionService.DoWindowMaintenance(c.Request.Context(), subscription.ID)
+ if maintenanceErr != nil {
+ abortWithGoogleError(c, 503, "Subscription window maintenance is temporarily unavailable")
+ return
+ }
+ subscription = maintained
+ stillNeedsMaintenance, validateErr := subscriptionService.ValidateAndCheckLimits(subscription, apiKey.Group)
+ if validateErr != nil || stillNeedsMaintenance {
+ abortWithGoogleError(c, 503, "Subscription window maintenance did not converge")
+ return
+ }
+ c.Set(string(ContextKeySubscription), subscription)
}
} else {
if apiKey.User.Balance <= 0 {
@@ -119,7 +158,7 @@ func APIKeyAuthWithSubscriptionGoogle(apiKeyService *service.APIKeyService, subs
}
// extractAPIKeyForGoogle extracts API key for Google/Gemini endpoints.
-// Priority: x-goog-api-key > Authorization: Bearer > x-api-key > query key
+// Priority: x-goog-api-key > Authorization: Bearer > x-api-key
// This allows OpenClaw and other clients using Bearer auth to work with Gemini endpoints.
func extractAPIKeyForGoogle(c *gin.Context) string {
// 1) preferred: Gemini native header
@@ -143,20 +182,9 @@ func extractAPIKeyForGoogle(c *gin.Context) string {
return k
}
- // 4) query parameter key (for specific paths)
- if allowGoogleQueryKey(c.Request.URL.Path) {
- if v := strings.TrimSpace(c.Query("key")); v != "" {
- return v
- }
- }
-
return ""
}
-func allowGoogleQueryKey(path string) bool {
- return strings.HasPrefix(path, "/v1beta") || strings.HasPrefix(path, "/antigravity/v1beta")
-}
-
func abortWithGoogleError(c *gin.Context, status int, message string) {
c.JSON(status, gin.H{
"error": gin.H{
diff --git a/backend/internal/server/middleware/api_key_auth_google_test.go b/backend/internal/server/middleware/api_key_auth_google_test.go
index f8e50fcdef3..c36bf50f68b 100644
--- a/backend/internal/server/middleware/api_key_auth_google_test.go
+++ b/backend/internal/server/middleware/api_key_auth_google_test.go
@@ -24,12 +24,17 @@ type fakeAPIKeyRepo struct {
}
type fakeGoogleSubscriptionRepo struct {
- getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error)
- updateStatus func(ctx context.Context, subscriptionID int64, status string) error
- activateWindow func(ctx context.Context, id int64, start time.Time) error
- resetDaily func(ctx context.Context, id int64, start time.Time) error
- resetWeekly func(ctx context.Context, id int64, start time.Time) error
- resetMonthly func(ctx context.Context, id int64, start time.Time) error
+ getByID func(ctx context.Context, id int64) (*service.UserSubscription, error)
+ getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error)
+ getActiveWallet func(ctx context.Context, userID int64) (*service.UserSubscription, error)
+ getCovering func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error)
+ hasAny func(ctx context.Context, userID int64) (bool, error)
+ updateStatus func(ctx context.Context, subscriptionID int64, status string) error
+ activateWindow func(ctx context.Context, id int64, start time.Time) error
+ resetDaily func(ctx context.Context, id int64, start time.Time) error
+ resetWeekly func(ctx context.Context, id int64, start time.Time) error
+ resetMonthly func(ctx context.Context, id int64, start time.Time) error
+ advanceWindow func(ctx context.Context, id int64, advance service.SubscriptionUsageWindowAdvance) (bool, error)
}
func (f fakeAPIKeyRepo) Create(ctx context.Context, key *service.APIKey) error {
@@ -80,10 +85,10 @@ func (f fakeAPIKeyRepo) ClearGroupIDByGroupID(ctx context.Context, groupID int64
func (f fakeAPIKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int64, error) {
return 0, errors.New("not implemented")
}
-func (f fakeAPIKeyRepo) ListKeysByUserID(ctx context.Context, userID int64) ([]string, error) {
+func (f fakeAPIKeyRepo) ListAuthCacheLocatorsByUserID(ctx context.Context, userID int64) ([]string, error) {
return nil, errors.New("not implemented")
}
-func (f fakeAPIKeyRepo) ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error) {
+func (f fakeAPIKeyRepo) ListAuthCacheLocatorsByGroupID(ctx context.Context, groupID int64) ([]string, error) {
return nil, errors.New("not implemented")
}
func (f fakeAPIKeyRepo) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) (float64, error) {
@@ -112,6 +117,9 @@ func (f fakeGoogleSubscriptionRepo) Create(ctx context.Context, sub *service.Use
return errors.New("not implemented")
}
func (f fakeGoogleSubscriptionRepo) GetByID(ctx context.Context, id int64) (*service.UserSubscription, error) {
+ if f.getByID != nil {
+ return f.getByID(ctx, id)
+ }
return nil, errors.New("not implemented")
}
func (f fakeGoogleSubscriptionRepo) GetByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
@@ -123,6 +131,34 @@ func (f fakeGoogleSubscriptionRepo) GetActiveByUserIDAndGroupID(ctx context.Cont
}
return nil, errors.New("not implemented")
}
+func (f fakeGoogleSubscriptionRepo) GetActiveWalletByUserID(ctx context.Context, userID int64) (*service.UserSubscription, error) {
+ if f.getActiveWallet != nil {
+ return f.getActiveWallet(ctx, userID)
+ }
+ return nil, service.ErrSubscriptionNotFound
+}
+func (f fakeGoogleSubscriptionRepo) GetActiveCreditsWalletByUserID(ctx context.Context, userID int64) (*service.UserSubscription, error) {
+ sub, err := f.GetActiveWalletByUserID(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ if sub == nil || !sub.IsUniversalWalletMode() {
+ return nil, service.ErrSubscriptionNotFound
+ }
+ return sub, nil
+}
+func (f fakeGoogleSubscriptionRepo) GetActiveByPlanCoveringGroup(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
+ if f.getCovering != nil {
+ return f.getCovering(ctx, userID, groupID)
+ }
+ return nil, service.ErrSubscriptionNotFound
+}
+func (f fakeGoogleSubscriptionRepo) HasAnyActiveSubscription(ctx context.Context, userID int64) (bool, error) {
+ if f.hasAny != nil {
+ return f.hasAny(ctx, userID)
+ }
+ return false, nil
+}
func (f fakeGoogleSubscriptionRepo) Update(ctx context.Context, sub *service.UserSubscription) error {
return errors.New("not implemented")
}
@@ -180,6 +216,12 @@ func (f fakeGoogleSubscriptionRepo) ResetMonthlyUsage(ctx context.Context, id in
}
return errors.New("not implemented")
}
+func (f fakeGoogleSubscriptionRepo) AdvanceUsageWindow(ctx context.Context, id int64, advance service.SubscriptionUsageWindowAdvance) (bool, error) {
+ if f.advanceWindow != nil {
+ return f.advanceWindow(ctx, id, advance)
+ }
+ return false, errors.New("not implemented")
+}
func (f fakeGoogleSubscriptionRepo) IncrementUsage(ctx context.Context, id int64, costUSD float64) error {
return errors.New("not implemented")
}
@@ -251,7 +293,7 @@ func TestApiKeyAuthWithSubscriptionGoogle_QueryApiKeyRejected(t *testing.T) {
var resp googleErrorResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, http.StatusBadRequest, resp.Error.Code)
- require.Equal(t, "Query parameter api_key is deprecated. Use Authorization header or key instead.", resp.Error.Message)
+ require.Equal(t, "API keys in query parameters are not supported. Use an authentication header instead.", resp.Error.Message)
require.Equal(t, "INVALID_ARGUMENT", resp.Error.Status)
}
@@ -320,21 +362,55 @@ func TestApiKeyAuthWithSubscriptionGoogleSetsGroupContext(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
}
-func TestApiKeyAuthWithSubscriptionGoogle_QueryKeyAllowedOnV1Beta(t *testing.T) {
+func TestAPIKeyAuthWithSubscriptionGoogleRejectsUngroupedAndWalletPurposeKeys(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ group := &service.Group{ID: 88, Name: "gemini-fixed", Platform: service.PlatformGemini, Status: service.StatusActive, Hydrated: true}
+
+ for _, tt := range []struct {
+ name string
+ purpose string
+ groupID *int64
+ group *service.Group
+ }{
+ {name: "ordinary ungrouped key", purpose: service.APIKeyPurposeStandard},
+ {name: "wallet universal key", purpose: service.APIKeyPurposeWalletUniversal},
+ {name: "malformed grouped wallet key", purpose: service.APIKeyPurposeWalletUniversal, groupID: &group.ID, group: group},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ apiKey := &service.APIKey{
+ ID: 700, UserID: 71, Key: "google-wallet-rejected", Name: service.WalletUniversalAPIKeyName,
+ Purpose: tt.purpose, GroupID: tt.groupID, Group: tt.group, Status: service.StatusActive,
+ User: &service.User{ID: 71, Status: service.StatusActive, Balance: 100},
+ }
+ apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{getByKey: func(context.Context, string) (*service.APIKey, error) {
+ clone := *apiKey
+ return &clone, nil
+ }})
+
+ for _, runMode := range []string{config.RunModeSimple, config.RunModeStandard} {
+ router := gin.New()
+ router.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{RunMode: runMode}))
+ router.GET("/v1beta/test", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
+ req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
+ req.Header.Set("x-goog-api-key", apiKey.Key)
+ rec := httptest.NewRecorder()
+
+ router.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusForbidden, rec.Code, "run mode %s", runMode)
+ require.Contains(t, rec.Body.String(), "not supported on Gemini native endpoints")
+ }
+ })
+ }
+}
+
+func TestApiKeyAuthWithSubscriptionGoogle_QueryKeyRejectedOnV1Beta(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
- return &service.APIKey{
- ID: 1,
- Key: key,
- Status: service.StatusActive,
- User: &service.User{
- ID: 123,
- Status: service.StatusActive,
- },
- }, nil
+ return nil, errors.New("query key must be rejected before repository lookup")
},
})
cfg := &config.Config{RunMode: config.RunModeSimple}
@@ -345,7 +421,10 @@ func TestApiKeyAuthWithSubscriptionGoogle_QueryKeyAllowedOnV1Beta(t *testing.T)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
- require.Equal(t, http.StatusOK, rec.Code)
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+ var resp googleErrorResponse
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
+ require.Equal(t, "API keys in query parameters are not supported. Use an authentication header instead.", resp.Error.Message)
}
func TestApiKeyAuthWithSubscriptionGoogle_InvalidKey(t *testing.T) {
@@ -431,16 +510,92 @@ func TestApiKeyAuthWithSubscriptionGoogle_DisabledKey(t *testing.T) {
require.Equal(t, "UNAUTHENTICATED", resp.Error.Status)
}
+func TestAPIKeyAuthWithSubscriptionGoogleEnforcesKeyAndGroupSecurityBoundaries(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ expiredAt := time.Now().Add(-time.Minute)
+
+ tests := []struct {
+ name string
+ mutateKey func(*service.APIKey)
+ mutateUser func(*service.User)
+ mutateGroup func(*service.Group)
+ wantStatus int
+ }{
+ {
+ name: "expired active key",
+ mutateKey: func(key *service.APIKey) { key.ExpiresAt = &expiredAt },
+ wantStatus: http.StatusForbidden,
+ },
+ {
+ name: "quota exhausted active key",
+ mutateKey: func(key *service.APIKey) { key.Quota, key.QuotaUsed = 1, 1 },
+ wantStatus: http.StatusTooManyRequests,
+ },
+ {
+ name: "inactive group",
+ mutateGroup: func(group *service.Group) { group.Status = service.StatusDisabled },
+ wantStatus: http.StatusForbidden,
+ },
+ {
+ name: "missing hydrated group",
+ mutateKey: func(key *service.APIKey) { key.Group = nil },
+ wantStatus: http.StatusForbidden,
+ },
+ {
+ name: "exclusive group authorization revoked",
+ mutateGroup: func(group *service.Group) { group.IsExclusive = true },
+ wantStatus: http.StatusForbidden,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ for _, runMode := range []string{config.RunModeSimple, config.RunModeStandard} {
+ group := &service.Group{ID: 401, Name: "gemini-secure", Platform: service.PlatformGemini, Status: service.StatusActive, Hydrated: true}
+ user := &service.User{ID: 402, Status: service.StatusActive, Balance: 10}
+ key := &service.APIKey{ID: 403, UserID: user.ID, Key: "gemini-secure-key", Status: service.StatusActive, User: user, GroupID: &group.ID, Group: group}
+ if tt.mutateKey != nil {
+ tt.mutateKey(key)
+ }
+ if tt.mutateUser != nil {
+ tt.mutateUser(user)
+ }
+ if tt.mutateGroup != nil {
+ tt.mutateGroup(group)
+ }
+
+ apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{getByKey: func(context.Context, string) (*service.APIKey, error) {
+ clone := *key
+ return &clone, nil
+ }})
+ router := gin.New()
+ router.Use(APIKeyAuthWithSubscriptionGoogle(apiKeyService, nil, &config.Config{RunMode: runMode}))
+ router.GET("/v1beta/test", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
+ req := httptest.NewRequest(http.MethodGet, "/v1beta/test", nil)
+ req.Header.Set("x-goog-api-key", key.Key)
+ rec := httptest.NewRecorder()
+
+ router.ServeHTTP(rec, req)
+
+ require.Equal(t, tt.wantStatus, rec.Code, "run mode %s", runMode)
+ }
+ })
+ }
+}
+
func TestApiKeyAuthWithSubscriptionGoogle_InsufficientBalance(t *testing.T) {
gin.SetMode(gin.TestMode)
+ group := &service.Group{ID: 32, Name: "gemini-balance", Platform: service.PlatformGemini, Status: service.StatusActive, Hydrated: true}
r := gin.New()
apiKeyService := newTestAPIKeyService(fakeAPIKeyRepo{
getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
return &service.APIKey{
- ID: 1,
- Key: key,
- Status: service.StatusActive,
+ ID: 1,
+ Key: key,
+ GroupID: &group.ID,
+ Group: group,
+ Status: service.StatusActive,
User: &service.User{
ID: 123,
Status: service.StatusActive,
@@ -475,12 +630,15 @@ func TestApiKeyAuthWithSubscriptionGoogle_TouchesLastUsedOnSuccess(t *testing.T)
Balance: 10,
Concurrency: 3,
}
+ group := &service.Group{ID: 33, Name: "gemini-touch", Platform: service.PlatformGemini, Status: service.StatusActive, Hydrated: true}
apiKey := &service.APIKey{
- ID: 201,
- UserID: user.ID,
- Key: "google-touch-ok",
- Status: service.StatusActive,
- User: user,
+ ID: 201,
+ UserID: user.ID,
+ Key: "google-touch-ok",
+ GroupID: &group.ID,
+ Group: group,
+ Status: service.StatusActive,
+ User: user,
}
var touchedID int64
@@ -524,12 +682,15 @@ func TestApiKeyAuthWithSubscriptionGoogle_TouchFailureDoesNotBlock(t *testing.T)
Balance: 10,
Concurrency: 3,
}
+ group := &service.Group{ID: 34, Name: "gemini-touch-failure", Platform: service.PlatformGemini, Status: service.StatusActive, Hydrated: true}
apiKey := &service.APIKey{
- ID: 202,
- UserID: user.ID,
- Key: "google-touch-fail",
- Status: service.StatusActive,
- User: user,
+ ID: 202,
+ UserID: user.ID,
+ Key: "google-touch-fail",
+ GroupID: &group.ID,
+ Group: group,
+ Status: service.StatusActive,
+ User: user,
}
touchCalls := 0
@@ -570,12 +731,15 @@ func TestApiKeyAuthWithSubscriptionGoogle_TouchesLastUsedInStandardMode(t *testi
Balance: 10,
Concurrency: 3,
}
+ group := &service.Group{ID: 35, Name: "gemini-touch-standard", Platform: service.PlatformGemini, Status: service.StatusActive, Hydrated: true}
apiKey := &service.APIKey{
- ID: 203,
- UserID: user.ID,
- Key: "google-touch-standard",
- Status: service.StatusActive,
- User: user,
+ ID: 203,
+ UserID: user.ID,
+ Key: "google-touch-standard",
+ GroupID: &group.ID,
+ Group: group,
+ Status: service.StatusActive,
+ User: user,
}
touchCalls := 0
@@ -647,10 +811,11 @@ func TestApiKeyAuthWithSubscriptionGoogle_SubscriptionLimitExceededReturns429(t
})
now := time.Now()
+ groupID := group.ID
sub := &service.UserSubscription{
ID: 601,
UserID: user.ID,
- GroupID: group.ID,
+ GroupID: &groupID,
Status: service.SubscriptionStatusActive,
ExpiresAt: now.Add(24 * time.Hour),
DailyWindowStart: &now,
diff --git a/backend/internal/server/middleware/api_key_auth_group_switch_test.go b/backend/internal/server/middleware/api_key_auth_group_switch_test.go
new file mode 100644
index 00000000000..4b3e43f5c17
--- /dev/null
+++ b/backend/internal/server/middleware/api_key_auth_group_switch_test.go
@@ -0,0 +1,303 @@
+//go:build unit
+
+package middleware
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+// TestAPIKeyAuth_GroupSwitchCoverage 验证 2026-05-16 方案 C:
+// 用户在 admin 把 api_key 切到非订阅主 group 时,middleware 通过
+// subscription_plan_groups 间接 lookup,找到覆盖该 group 的月卡订阅,
+// 走月卡 quota 而不是静默扣 user.balance。
+//
+// 见 docs/plans/2026-05-16-wallet-v4-group-switch-billing-fix.md。
+func TestAPIKeyAuth_GroupSwitchCoverage(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ // 用户买了月卡 paid-trial-v3-30d,订阅 sub.group_id = 13 (paid-trial-v3, 'subscription' 类型);
+ // 月卡 plan_groups 关联 [claude-Max pool=2, openai-default=3, gemini-default=4, cc-antigravity=5],
+ // 都是 'standard' 类型。
+ //
+ // 用户把 api_key.group_id 切到 3 (openai-default standard 类型)。
+ standardGroup := &service.Group{
+ ID: 3,
+ Name: "openai-default",
+ Platform: service.PlatformOpenAI,
+ Status: service.StatusActive,
+ Hydrated: true,
+ SubscriptionType: service.SubscriptionTypeStandard,
+ }
+ user := &service.User{
+ ID: 42,
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ Balance: 0, // 主余额 0;中间件不能静默扣 balance
+ Concurrency: 3,
+ }
+ apiKey := &service.APIKey{
+ ID: 500,
+ UserID: user.ID,
+ Key: "switched-key",
+ Purpose: service.APIKeyPurposeStandard,
+ Status: service.StatusActive,
+ User: user,
+ Group: standardGroup,
+ }
+ apiKey.GroupID = &standardGroup.ID
+
+ apiKeyRepo := &stubApiKeyRepo{
+ getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
+ if key != apiKey.Key {
+ return nil, service.ErrAPIKeyNotFound
+ }
+ clone := *apiKey
+ return &clone, nil
+ },
+ }
+
+ makeRequest := func(repo *stubUserSubscriptionRepo) *httptest.ResponseRecorder {
+ cfg := &config.Config{RunMode: config.RunModeStandard}
+ cfg.SubscriptionMaintenance.WorkerCount = 1
+ cfg.SubscriptionMaintenance.QueueSize = 1
+
+ apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
+ subscriptionService := service.NewSubscriptionService(nil, repo, nil, nil, cfg)
+ t.Cleanup(subscriptionService.Stop)
+
+ router := gin.New()
+ router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, cfg)))
+ router.GET("/t", func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ })
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/t", nil)
+ req.Header.Set("x-api-key", apiKey.Key)
+ router.ServeHTTP(w, req)
+ return w
+ }
+
+ t.Run("link_inside_uses_monthly_quota", func(t *testing.T) {
+ // 月卡覆盖 openai-default → middleware 走月卡 quota,不扣主余额
+ windowStart := time.Now()
+ monthlySub := &service.UserSubscription{
+ ID: 700,
+ UserID: user.ID,
+ GroupID: int64Ptr(13), // 订阅主 group = paid-trial-v3
+ Status: service.SubscriptionStatusActive,
+ ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
+ DailyWindowStart: &windowStart,
+ WeeklyWindowStart: &windowStart,
+ MonthlyWindowStart: &windowStart,
+ // 注意:没有 wallet_balance_usd → 月卡模式
+ }
+
+ coverCalled := false
+ repo := &stubUserSubscriptionRepo{
+ getActive: func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
+ // exact (user_id, group_id=3) 没匹配(订阅是 group 13)
+ return nil, service.ErrSubscriptionNotFound
+ },
+ getActiveByPlanCover: func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
+ coverCalled = true
+ require.Equal(t, user.ID, userID)
+ require.Equal(t, standardGroup.ID, groupID)
+ clone := *monthlySub
+ return &clone, nil
+ },
+ }
+
+ w := makeRequest(repo)
+ require.Equal(t, http.StatusOK, w.Code,
+ "月卡 plan_groups 覆盖切换的 group → 应放行走月卡 quota")
+ require.True(t, coverCalled, "应调用 GetActiveByPlanCoveringGroup 做间接 lookup")
+ })
+
+ t.Run("link_outside_rejects_with_403", func(t *testing.T) {
+ // 用户有月卡,但 plan_groups 不包含 openai-default → 拒绝,不能静默扣主余额
+ repo := &stubUserSubscriptionRepo{
+ getActive: func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
+ return nil, service.ErrSubscriptionNotFound
+ },
+ getActiveByPlanCover: func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
+ return nil, service.ErrSubscriptionNotFound
+ },
+ hasAnyActive: func(ctx context.Context, userID int64) (bool, error) {
+ // 用户有月卡但不覆盖
+ return true, nil
+ },
+ }
+
+ w := makeRequest(repo)
+ require.Equal(t, http.StatusForbidden, w.Code,
+ "用户有订阅但当前 group 不在 plan_groups 链内 → 403,不再 fallback balance")
+ require.Contains(t, w.Body.String(), "GROUP_NOT_IN_SUBSCRIPTION",
+ "错误码应为 GROUP_NOT_IN_SUBSCRIPTION")
+ require.Contains(t, w.Body.String(), "联系管理员或客服")
+ require.NotContains(t, w.Body.String(), "微信")
+ require.NotContains(t, w.Body.String(), "aa402837")
+ })
+
+ t.Run("balance_only_user_still_works", func(t *testing.T) {
+ // 无任何订阅 + balance > 0 → 兼容老 balance 用户,放行
+ balUser := &service.User{
+ ID: 43,
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ Balance: 50, // 老充值余额
+ Concurrency: 3,
+ }
+ balKey := &service.APIKey{
+ ID: 501,
+ UserID: balUser.ID,
+ Key: "balance-user-key",
+ Purpose: service.APIKeyPurposeStandard,
+ Status: service.StatusActive,
+ User: balUser,
+ Group: standardGroup,
+ }
+ balKey.GroupID = &standardGroup.ID
+
+ balApiKeyRepo := &stubApiKeyRepo{
+ getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
+ if key != balKey.Key {
+ return nil, service.ErrAPIKeyNotFound
+ }
+ clone := *balKey
+ return &clone, nil
+ },
+ }
+
+ cfg := &config.Config{RunMode: config.RunModeStandard}
+ cfg.SubscriptionMaintenance.WorkerCount = 1
+ cfg.SubscriptionMaintenance.QueueSize = 1
+ apiKeyService := service.NewAPIKeyService(balApiKeyRepo, nil, nil, nil, nil, nil, cfg)
+ repo := &stubUserSubscriptionRepo{
+ getActive: func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
+ return nil, service.ErrSubscriptionNotFound
+ },
+ getActiveByPlanCover: func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
+ return nil, service.ErrSubscriptionNotFound
+ },
+ hasAnyActive: func(ctx context.Context, userID int64) (bool, error) {
+ return false, nil // 纯余额用户
+ },
+ }
+ subscriptionService := service.NewSubscriptionService(nil, repo, nil, nil, cfg)
+ t.Cleanup(subscriptionService.Stop)
+
+ router := gin.New()
+ router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, cfg)))
+ router.GET("/t", func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ })
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/t", nil)
+ req.Header.Set("x-api-key", balKey.Key)
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusOK, w.Code,
+ "纯余额用户(无任何订阅) + balance > 0 → 应放行(兼容老逻辑)")
+ })
+
+ t.Run("no_subscription_no_balance_rejects", func(t *testing.T) {
+ // 既无订阅也无余额 → INSUFFICIENT_BALANCE
+ repo := &stubUserSubscriptionRepo{
+ getActive: func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
+ return nil, service.ErrSubscriptionNotFound
+ },
+ getActiveByPlanCover: func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
+ return nil, service.ErrSubscriptionNotFound
+ },
+ hasAnyActive: func(ctx context.Context, userID int64) (bool, error) {
+ return false, nil
+ },
+ }
+ w := makeRequest(repo)
+ require.Equal(t, http.StatusForbidden, w.Code)
+ require.Contains(t, w.Body.String(), "INSUFFICIENT_BALANCE")
+ })
+}
+
+func TestAPIKeyAuth_RejectsRestrictedGroupBeforePlanCoverage(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ vip := &service.Group{
+ ID: 22,
+ Name: service.WalletDefaultVIPGroupName,
+ Platform: service.PlatformAnthropic,
+ Status: service.StatusActive,
+ Hydrated: true,
+ SubscriptionType: service.SubscriptionTypeStandard,
+ IsExclusive: true,
+ }
+ user := &service.User{
+ ID: 52,
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ Concurrency: 3,
+ }
+ apiKey := &service.APIKey{
+ ID: 502,
+ UserID: user.ID,
+ Key: "vip-plan-coverage-without-grant",
+ Purpose: service.APIKeyPurposeStandard,
+ Status: service.StatusActive,
+ User: user,
+ GroupID: &vip.ID,
+ Group: vip,
+ }
+ apiKeyService := service.NewAPIKeyService(&stubApiKeyRepo{
+ getByKey: func(context.Context, string) (*service.APIKey, error) {
+ clone := *apiKey
+ return &clone, nil
+ },
+ }, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeStandard})
+
+ coverageCalled := false
+ repo := &stubUserSubscriptionRepo{
+ getActive: func(context.Context, int64, int64) (*service.UserSubscription, error) {
+ return nil, service.ErrSubscriptionNotFound
+ },
+ getActiveByPlanCover: func(context.Context, int64, int64) (*service.UserSubscription, error) {
+ coverageCalled = true
+ return &service.UserSubscription{
+ ID: 701,
+ UserID: user.ID,
+ Status: service.SubscriptionStatusActive,
+ ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
+ }, nil
+ },
+ }
+ cfg := &config.Config{RunMode: config.RunModeStandard}
+ cfg.SubscriptionMaintenance.WorkerCount = 1
+ cfg.SubscriptionMaintenance.QueueSize = 1
+ subscriptionService := service.NewSubscriptionService(nil, repo, nil, nil, cfg)
+ t.Cleanup(subscriptionService.Stop)
+
+ router := gin.New()
+ router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, cfg)))
+ router.GET("/t", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/t", nil)
+ req.Header.Set("x-api-key", apiKey.Key)
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusForbidden, w.Code)
+ require.Contains(t, w.Body.String(), "GROUP_NOT_ALLOWED")
+ require.False(t, coverageCalled, "restricted-group authorization must run before plan coverage lookup")
+}
+
+func int64Ptr(v int64) *int64 {
+ return &v
+}
diff --git a/backend/internal/server/middleware/api_key_auth_test.go b/backend/internal/server/middleware/api_key_auth_test.go
index 4a4ab0f9817..8ece3bd3a6b 100644
--- a/backend/internal/server/middleware/api_key_auth_test.go
+++ b/backend/internal/server/middleware/api_key_auth_test.go
@@ -57,7 +57,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) {
},
}
- t.Run("standard_mode_needs_maintenance_does_not_block_request", func(t *testing.T) {
+ t.Run("standard_mode_waits_for_authoritative_window_maintenance", func(t *testing.T) {
cfg := &config.Config{RunMode: config.RunModeStandard}
cfg.SubscriptionMaintenance.WorkerCount = 1
cfg.SubscriptionMaintenance.QueueSize = 1
@@ -65,46 +65,72 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) {
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
past := time.Now().Add(-48 * time.Hour)
+ recent := time.Now()
+ groupID := group.ID
sub := &service.UserSubscription{
- ID: 55,
- UserID: user.ID,
- GroupID: group.ID,
- Status: service.SubscriptionStatusActive,
- ExpiresAt: time.Now().Add(24 * time.Hour),
- DailyWindowStart: &past,
- DailyUsageUSD: 0,
+ ID: 55,
+ UserID: user.ID,
+ GroupID: &groupID,
+ Status: service.SubscriptionStatusActive,
+ ExpiresAt: time.Now().Add(24 * time.Hour),
+ DailyWindowStart: &past,
+ WeeklyWindowStart: &recent,
+ MonthlyWindowStart: &recent,
+ DailyUsageUSD: 0,
}
- maintenanceCalled := make(chan struct{}, 1)
+ maintenanceStarted := make(chan struct{}, 1)
+ releaseMaintenance := make(chan struct{})
subscriptionRepo := &stubUserSubscriptionRepo{
getActive: func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
clone := *sub
return &clone, nil
},
- updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil },
- activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil },
- resetDaily: func(ctx context.Context, id int64, start time.Time) error {
- maintenanceCalled <- struct{}{}
- return nil
+ getByID: func(ctx context.Context, id int64) (*service.UserSubscription, error) {
+ clone := *sub
+ return &clone, nil
+ },
+ advanceWindow: func(ctx context.Context, id int64, advance service.SubscriptionUsageWindowAdvance) (bool, error) {
+ require.Equal(t, service.SubscriptionUsageWindowDaily, advance.Window)
+ require.NotNil(t, advance.ExpectedStart)
+ require.True(t, advance.ExpectedStart.Equal(past))
+ maintenanceStarted <- struct{}{}
+ <-releaseMaintenance
+ start := advance.NewStart
+ sub.DailyWindowStart = &start
+ sub.DailyUsageUSD = 0
+ return true, nil
},
- resetWeekly: func(ctx context.Context, id int64, start time.Time) error { return nil },
- resetMonthly: func(ctx context.Context, id int64, start time.Time) error { return nil },
}
subscriptionService := service.NewSubscriptionService(nil, subscriptionRepo, nil, nil, cfg)
t.Cleanup(subscriptionService.Stop)
router := newAuthTestRouter(apiKeyService, subscriptionService, cfg)
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/t", nil)
- req.Header.Set("x-api-key", apiKey.Key)
- router.ServeHTTP(w, req)
+ responseDone := make(chan *httptest.ResponseRecorder, 1)
+ go func() {
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/t", nil)
+ req.Header.Set("x-api-key", apiKey.Key)
+ router.ServeHTTP(w, req)
+ responseDone <- w
+ }()
- require.Equal(t, http.StatusOK, w.Code)
select {
- case <-maintenanceCalled:
- // ok
+ case <-maintenanceStarted:
case <-time.After(time.Second):
- t.Fatalf("expected maintenance to be scheduled")
+ t.Fatalf("expected maintenance to start")
+ }
+ select {
+ case <-responseDone:
+ t.Fatalf("request must not proceed before window maintenance completes")
+ case <-time.After(25 * time.Millisecond):
+ }
+ close(releaseMaintenance)
+ select {
+ case w := <-responseDone:
+ require.Equal(t, http.StatusOK, w.Code)
+ case <-time.After(time.Second):
+ t.Fatalf("request did not resume after window maintenance")
}
})
@@ -136,15 +162,16 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) {
require.Equal(t, http.StatusOK, w.Code)
})
- t.Run("standard_mode_enforces_quota_check", func(t *testing.T) {
+ t.Run("standard_mode_subscription_limit_never_falls_back_to_balance", func(t *testing.T) {
cfg := &config.Config{RunMode: config.RunModeStandard}
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
now := time.Now()
+ groupID := group.ID
sub := &service.UserSubscription{
ID: 55,
UserID: user.ID,
- GroupID: group.ID,
+ GroupID: &groupID,
Status: service.SubscriptionStatusActive,
ExpiresAt: now.Add(24 * time.Hour),
DailyWindowStart: &now,
@@ -152,7 +179,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) {
}
subscriptionRepo := &stubUserSubscriptionRepo{
getActive: func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
- if userID != sub.UserID || groupID != sub.GroupID {
+ if sub.GroupID == nil || userID != sub.UserID || groupID != *sub.GroupID {
return nil, service.ErrSubscriptionNotFound
}
clone := *sub
@@ -165,18 +192,133 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) {
resetMonthly: func(ctx context.Context, id int64, start time.Time) error { return nil },
}
subscriptionService := service.NewSubscriptionService(nil, subscriptionRepo, nil, nil, cfg)
- router := newAuthTestRouter(apiKeyService, subscriptionService, cfg)
+
+ router := gin.New()
+ router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, cfg)))
+ handlerCalled := false
+ router.GET("/t", func(c *gin.Context) {
+ handlerCalled = true
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ })
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/t", nil)
req.Header.Set("x-api-key", apiKey.Key)
router.ServeHTTP(w, req)
+ require.Equal(t, http.StatusTooManyRequests, w.Code)
+ require.Contains(t, w.Body.String(), "USAGE_LIMIT_EXCEEDED")
+ require.False(t, handlerCalled, "an over-limit monthly subscription must not silently fall through to another ledger")
+ })
+
+ t.Run("standard_mode_enforces_quota_check_without_balance", func(t *testing.T) {
+ cfg := &config.Config{RunMode: config.RunModeStandard}
+ zeroBalanceUser := *user
+ zeroBalanceUser.Balance = 0
+ zeroBalanceAPIKey := *apiKey
+ zeroBalanceAPIKey.User = &zeroBalanceUser
+ zeroBalanceAPIKeyRepo := &stubApiKeyRepo{
+ getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
+ if key != zeroBalanceAPIKey.Key {
+ return nil, service.ErrAPIKeyNotFound
+ }
+ clone := zeroBalanceAPIKey
+ return &clone, nil
+ },
+ }
+ apiKeyService := service.NewAPIKeyService(zeroBalanceAPIKeyRepo, nil, nil, nil, nil, nil, cfg)
+
+ now := time.Now()
+ groupID := group.ID
+ sub := &service.UserSubscription{
+ ID: 55,
+ UserID: user.ID,
+ GroupID: &groupID,
+ Status: service.SubscriptionStatusActive,
+ ExpiresAt: now.Add(24 * time.Hour),
+ DailyWindowStart: &now,
+ DailyUsageUSD: 10,
+ }
+ subscriptionRepo := &stubUserSubscriptionRepo{
+ getActive: func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
+ if sub.GroupID == nil || userID != sub.UserID || groupID != *sub.GroupID {
+ return nil, service.ErrSubscriptionNotFound
+ }
+ clone := *sub
+ return &clone, nil
+ },
+ updateStatus: func(ctx context.Context, subscriptionID int64, status string) error { return nil },
+ activateWindow: func(ctx context.Context, id int64, start time.Time) error { return nil },
+ resetDaily: func(ctx context.Context, id int64, start time.Time) error { return nil },
+ resetWeekly: func(ctx context.Context, id int64, start time.Time) error { return nil },
+ resetMonthly: func(ctx context.Context, id int64, start time.Time) error { return nil },
+ }
+ subscriptionService := service.NewSubscriptionService(nil, subscriptionRepo, nil, nil, cfg)
+ router := newAuthTestRouter(apiKeyService, subscriptionService, cfg)
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/t", nil)
+ req.Header.Set("x-api-key", zeroBalanceAPIKey.Key)
+ router.ServeHTTP(w, req)
+
require.Equal(t, http.StatusTooManyRequests, w.Code)
require.Contains(t, w.Body.String(), "USAGE_LIMIT_EXCEEDED")
})
}
+func TestSimpleModeRejectsReservedWalletBusinessGroups(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ tests := []struct {
+ name string
+ group *service.Group
+ user *service.User
+ wantCode string
+ }{
+ {
+ name: "openai default cannot bypass billing",
+ group: &service.Group{
+ ID: 31, Name: service.WalletDefaultOpenAIGroupName, Platform: service.PlatformOpenAI,
+ Status: service.StatusActive, Hydrated: true, SubscriptionType: service.SubscriptionTypeStandard,
+ },
+ user: &service.User{ID: 7, Status: service.StatusActive, Concurrency: 1},
+ wantCode: "SIMPLE_MODE_RESERVED_GROUP_DISABLED",
+ },
+ {
+ name: "revoked vip cannot bypass authorization",
+ group: &service.Group{
+ ID: 22, Name: service.WalletDefaultVIPGroupName, Platform: service.PlatformAnthropic,
+ Status: service.StatusActive, Hydrated: true, IsExclusive: true,
+ SubscriptionType: service.SubscriptionTypeStandard,
+ },
+ user: &service.User{ID: 8, Status: service.StatusActive, Concurrency: 1},
+ wantCode: "GROUP_NOT_ALLOWED",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ apiKey := &service.APIKey{
+ ID: 101, UserID: tt.user.ID, Key: "simple-reserved-key", Status: service.StatusActive,
+ User: tt.user, GroupID: &tt.group.ID, Group: tt.group,
+ }
+ repo := &stubApiKeyRepo{getByKey: func(context.Context, string) (*service.APIKey, error) {
+ clone := *apiKey
+ return &clone, nil
+ }}
+ cfg := &config.Config{RunMode: config.RunModeSimple}
+ router := newAuthTestRouter(service.NewAPIKeyService(repo, nil, nil, nil, nil, nil, cfg), nil, cfg)
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/t", nil)
+ req.Header.Set("x-api-key", apiKey.Key)
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusForbidden, w.Code)
+ require.Contains(t, w.Body.String(), tt.wantCode)
+ })
+ }
+}
+
func TestAPIKeyAuthSetsGroupContext(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -456,12 +598,23 @@ func TestAPIKeyAuthTouchesLastUsedInStandardMode(t *testing.T) {
Balance: 10,
Concurrency: 3,
}
+ group := &service.Group{
+ ID: 12,
+ Name: "general-openai",
+ Platform: service.PlatformOpenAI,
+ Status: service.StatusActive,
+ Hydrated: true,
+ SubscriptionType: service.SubscriptionTypeStandard,
+ }
apiKey := &service.APIKey{
- ID: 102,
- UserID: user.ID,
- Key: "touch-standard",
- Status: service.StatusActive,
- User: user,
+ ID: 102,
+ UserID: user.ID,
+ Key: "touch-standard",
+ Purpose: service.APIKeyPurposeStandard,
+ GroupID: &group.ID,
+ Status: service.StatusActive,
+ User: user,
+ Group: group,
}
touchCalls := 0
@@ -492,6 +645,155 @@ func TestAPIKeyAuthTouchesLastUsedInStandardMode(t *testing.T) {
require.Equal(t, 1, touchCalls)
}
+// TestAPIKeyAuthLoadsWalletSubscription 验证 A7:钱包订阅 lookup 独立于
+// api_key.group_id —— 即使 group 是 standard 类型(无 subscription 限额),
+// 中间件也要先查钱包,命中后跳过 (user, group) 老路径,并且跳过 group 维度
+// 的 daily/weekly/monthly 限额检查(钱包是用户级共享额度)。
+func TestAPIKeyAuthLoadsWalletSubscription(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ dailyLimit := 1.0
+ // standard group + daily limit:如果中间件错误地走 ValidateAndCheckLimits,
+ // 会因为 DailyUsageUSD 撞 limit 而 429;钱包模式必须跳过该检查。
+ group := &service.Group{
+ ID: 42,
+ Name: service.WalletDefaultOpenAIGroupName,
+ Platform: service.PlatformOpenAI,
+ Status: service.StatusActive,
+ Hydrated: true,
+ SubscriptionType: service.SubscriptionTypeStandard,
+ DailyLimitUSD: &dailyLimit,
+ }
+ user := &service.User{
+ ID: 7,
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ Balance: 0, // 主余额=0,确认走的是钱包而非 INSUFFICIENT_BALANCE 回退
+ Concurrency: 3,
+ }
+ apiKey := &service.APIKey{
+ ID: 100,
+ UserID: user.ID,
+ Key: "wallet-key",
+ Name: service.WalletUniversalAPIKeyName,
+ Purpose: service.APIKeyPurposeWalletUniversal,
+ Status: service.StatusActive,
+ User: user,
+ }
+
+ apiKeyRepo := &stubApiKeyRepo{
+ getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
+ if key != apiKey.Key {
+ return nil, service.ErrAPIKeyNotFound
+ }
+ clone := *apiKey
+ return &clone, nil
+ },
+ }
+
+ t.Run("active_wallet_skips_group_limits_and_balance_fallback", func(t *testing.T) {
+ cfg := &config.Config{RunMode: config.RunModeStandard}
+ cfg.SubscriptionMaintenance.WorkerCount = 1
+ cfg.SubscriptionMaintenance.QueueSize = 1
+
+ apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
+
+ bal := 100.0
+ walletSub := &service.UserSubscription{
+ ID: 201,
+ UserID: user.ID,
+ GroupID: nil, // 钱包订阅 group_id=NULL
+ Status: service.SubscriptionStatusActive,
+ ExpiresAt: service.MaxExpiresAt,
+ WalletBalanceUSD: &bal,
+ DailyUsageUSD: 999, // 故意超 daily limit;钱包模式必须忽略
+ }
+
+ getActiveCalls := 0
+ subscriptionRepo := &stubUserSubscriptionRepo{
+ getActiveWallet: func(ctx context.Context, userID int64) (*service.UserSubscription, error) {
+ if userID != user.ID {
+ return nil, service.ErrSubscriptionNotFound
+ }
+ clone := *walletSub
+ return &clone, nil
+ },
+ getActive: func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
+ getActiveCalls++
+ return nil, service.ErrSubscriptionNotFound
+ },
+ }
+ subscriptionService := service.NewSubscriptionService(nil, subscriptionRepo, nil, nil, cfg)
+ t.Cleanup(subscriptionService.Stop)
+
+ router := gin.New()
+ router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddlewareWithRouter(
+ apiKeyService,
+ subscriptionService,
+ &walletRouteModelRouterStub{groupID: group.ID},
+ &walletRouteGroupGetterStub{group: group},
+ cfg,
+ )))
+ var loadedSub *service.UserSubscription
+ router.GET("/v1/models", func(c *gin.Context) {
+ if sub, ok := GetSubscriptionFromContext(c); ok {
+ loadedSub = sub
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ })
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
+ req.Header.Set("x-api-key", apiKey.Key)
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusOK, w.Code, "wallet 命中应放行,主余额=0 不该兜底成 INSUFFICIENT_BALANCE")
+ require.NotNil(t, loadedSub, "钱包订阅应被注入 context")
+ require.True(t, loadedSub.IsWalletMode(), "context 中订阅应为 wallet mode")
+ require.Equal(t, int64(201), loadedSub.ID)
+ require.Zero(t, getActiveCalls, "钱包命中后不应再走 (user, group) lookup")
+ })
+
+ t.Run("expired_wallet_returns_403", func(t *testing.T) {
+ cfg := &config.Config{RunMode: config.RunModeStandard}
+ cfg.SubscriptionMaintenance.WorkerCount = 1
+ cfg.SubscriptionMaintenance.QueueSize = 1
+
+ apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
+
+ bal := 100.0
+ expiredWallet := &service.UserSubscription{
+ ID: 202,
+ UserID: user.ID,
+ GroupID: nil,
+ Status: service.SubscriptionStatusActive,
+ ExpiresAt: time.Now().Add(-1 * time.Hour),
+ WalletBalanceUSD: &bal,
+ }
+ subscriptionRepo := &stubUserSubscriptionRepo{
+ getActiveWallet: func(ctx context.Context, userID int64) (*service.UserSubscription, error) {
+ if !expiredWallet.ExpiresAt.After(time.Now()) {
+ return nil, service.ErrSubscriptionNotFound
+ }
+ clone := *expiredWallet
+ return &clone, nil
+ },
+ }
+ subscriptionService := service.NewSubscriptionService(nil, subscriptionRepo, nil, nil, cfg)
+ t.Cleanup(subscriptionService.Stop)
+
+ router := newAuthTestRouter(apiKeyService, subscriptionService, cfg)
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/t", nil)
+ req.Header.Set("x-api-key", apiKey.Key)
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusForbidden, w.Code)
+ require.Contains(t, w.Body.String(), "WALLET_NOT_ACTIVE")
+ })
+}
+
func newAuthTestRouter(apiKeyService *service.APIKeyService, subscriptionService *service.SubscriptionService, cfg *config.Config) *gin.Engine {
router := gin.New()
router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, cfg)))
@@ -573,11 +875,11 @@ func (r *stubApiKeyRepo) CountByGroupID(ctx context.Context, groupID int64) (int
return 0, errors.New("not implemented")
}
-func (r *stubApiKeyRepo) ListKeysByUserID(ctx context.Context, userID int64) ([]string, error) {
+func (r *stubApiKeyRepo) ListAuthCacheLocatorsByUserID(ctx context.Context, userID int64) ([]string, error) {
return nil, errors.New("not implemented")
}
-func (r *stubApiKeyRepo) ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error) {
+func (r *stubApiKeyRepo) ListAuthCacheLocatorsByGroupID(ctx context.Context, groupID int64) ([]string, error) {
return nil, errors.New("not implemented")
}
@@ -603,12 +905,17 @@ func (r *stubApiKeyRepo) GetRateLimitData(ctx context.Context, id int64) (*servi
}
type stubUserSubscriptionRepo struct {
- getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error)
- updateStatus func(ctx context.Context, subscriptionID int64, status string) error
- activateWindow func(ctx context.Context, id int64, start time.Time) error
- resetDaily func(ctx context.Context, id int64, start time.Time) error
- resetWeekly func(ctx context.Context, id int64, start time.Time) error
- resetMonthly func(ctx context.Context, id int64, start time.Time) error
+ getByID func(ctx context.Context, id int64) (*service.UserSubscription, error)
+ getActive func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error)
+ getActiveWallet func(ctx context.Context, userID int64) (*service.UserSubscription, error)
+ getActiveByPlanCover func(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error)
+ hasAnyActive func(ctx context.Context, userID int64) (bool, error)
+ updateStatus func(ctx context.Context, subscriptionID int64, status string) error
+ activateWindow func(ctx context.Context, id int64, start time.Time) error
+ resetDaily func(ctx context.Context, id int64, start time.Time) error
+ resetWeekly func(ctx context.Context, id int64, start time.Time) error
+ resetMonthly func(ctx context.Context, id int64, start time.Time) error
+ advanceWindow func(ctx context.Context, id int64, advance service.SubscriptionUsageWindowAdvance) (bool, error)
}
func (r *stubUserSubscriptionRepo) Create(ctx context.Context, sub *service.UserSubscription) error {
@@ -616,6 +923,9 @@ func (r *stubUserSubscriptionRepo) Create(ctx context.Context, sub *service.User
}
func (r *stubUserSubscriptionRepo) GetByID(ctx context.Context, id int64) (*service.UserSubscription, error) {
+ if r.getByID != nil {
+ return r.getByID(ctx, id)
+ }
return nil, errors.New("not implemented")
}
@@ -630,6 +940,31 @@ func (r *stubUserSubscriptionRepo) GetActiveByUserIDAndGroupID(ctx context.Conte
return nil, errors.New("not implemented")
}
+func (r *stubUserSubscriptionRepo) GetActiveWalletByUserID(ctx context.Context, userID int64) (*service.UserSubscription, error) {
+ if r.getActiveWallet != nil {
+ return r.getActiveWallet(ctx, userID)
+ }
+ // 默认返回 NotFound:老测试默认不走钱包路径,行为与现有 (user, group) lookup 一致。
+ return nil, service.ErrSubscriptionNotFound
+}
+func (r *stubUserSubscriptionRepo) GetActiveCreditsWalletByUserID(ctx context.Context, userID int64) (*service.UserSubscription, error) {
+ return r.GetActiveWalletByUserID(ctx, userID)
+}
+
+func (r *stubUserSubscriptionRepo) GetActiveByPlanCoveringGroup(ctx context.Context, userID, groupID int64) (*service.UserSubscription, error) {
+ if r.getActiveByPlanCover != nil {
+ return r.getActiveByPlanCover(ctx, userID, groupID)
+ }
+ return nil, service.ErrSubscriptionNotFound
+}
+
+func (r *stubUserSubscriptionRepo) HasAnyActiveSubscription(ctx context.Context, userID int64) (bool, error) {
+ if r.hasAnyActive != nil {
+ return r.hasAnyActive(ctx, userID)
+ }
+ return false, nil
+}
+
func (r *stubUserSubscriptionRepo) Update(ctx context.Context, sub *service.UserSubscription) error {
return errors.New("not implemented")
}
@@ -700,6 +1035,12 @@ func (r *stubUserSubscriptionRepo) ResetMonthlyUsage(ctx context.Context, id int
}
return errors.New("not implemented")
}
+func (r *stubUserSubscriptionRepo) AdvanceUsageWindow(ctx context.Context, id int64, advance service.SubscriptionUsageWindowAdvance) (bool, error) {
+ if r.advanceWindow != nil {
+ return r.advanceWindow(ctx, id, advance)
+ }
+ return false, errors.New("not implemented")
+}
func (r *stubUserSubscriptionRepo) IncrementUsage(ctx context.Context, id int64, costUSD float64) error {
return errors.New("not implemented")
diff --git a/backend/internal/server/middleware/api_key_auth_wallet_models_visibility_test.go b/backend/internal/server/middleware/api_key_auth_wallet_models_visibility_test.go
new file mode 100644
index 00000000000..34c8cdfaeaa
--- /dev/null
+++ b/backend/internal/server/middleware/api_key_auth_wallet_models_visibility_test.go
@@ -0,0 +1,197 @@
+package middleware
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+type walletModelsGroupRepoStub struct {
+ groups []service.Group
+}
+
+func (s *walletModelsGroupRepoStub) ListActive(context.Context) ([]service.Group, error) {
+ return append([]service.Group(nil), s.groups...), nil
+}
+
+type walletModelsGroupGetterStub struct {
+ groups map[int64]*service.Group
+ requested []int64
+}
+
+func (s *walletModelsGroupGetterStub) GetByID(_ context.Context, id int64) (*service.Group, error) {
+ s.requested = append(s.requested, id)
+ group, ok := s.groups[id]
+ if !ok {
+ return nil, service.ErrGroupNotFound
+ }
+ clone := *group
+ return &clone, nil
+}
+
+func TestAPIKeyAuthWalletUniversalModelsVisibilityUsesOnlyAuthorizedRouteGroups(t *testing.T) {
+ openAI := walletModelsTestGroup(3, service.WalletDefaultOpenAIGroupName, service.PlatformOpenAI, false)
+ vip := walletModelsTestGroup(22, service.WalletDefaultVIPGroupName, service.PlatformAnthropic, true)
+ routes := []service.ModelRoute{
+ {Pattern: "claude-sonnet-*", GroupName: service.WalletDefaultVIPGroupName, ExampleModel: "claude-sonnet-4-6"},
+ {Pattern: "claude-opus-*", GroupName: service.WalletDefaultVIPGroupName, ExampleModel: "claude-opus-4-7"},
+ {Pattern: "gpt-*", GroupName: service.WalletDefaultOpenAIGroupName, ExampleModel: "gpt-5.6-high"},
+ {Pattern: "o1-*", GroupName: service.WalletDefaultOpenAIGroupName, ExampleModel: "o1-preview"},
+ }
+
+ for _, tt := range []struct {
+ name string
+ allowedGroups []int64
+ wantGroups []string
+ }{
+ {
+ name: "ungranted user sees only openai default",
+ wantGroups: []string{service.WalletDefaultOpenAIGroupName},
+ },
+ {
+ name: "explicit vip grant adds vip once",
+ allowedGroups: []int64{vip.ID},
+ wantGroups: []string{service.WalletDefaultOpenAIGroupName, service.WalletDefaultVIPGroupName},
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ user := &service.User{
+ ID: 900,
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ Concurrency: 2,
+ AllowedGroups: append([]int64(nil), tt.allowedGroups...),
+ }
+ apiKey := &service.APIKey{
+ ID: 901, UserID: user.ID, Key: "wallet-models-key",
+ Name: service.WalletUniversalAPIKeyName, Purpose: service.APIKeyPurposeWalletUniversal, Status: service.StatusActive, User: user,
+ }
+ modelRouter := service.NewModelRouterServiceWithRoutes(
+ &walletModelsGroupRepoStub{groups: []service.Group{openAI, vip}},
+ routes,
+ )
+ groupGetter := &walletModelsGroupGetterStub{groups: map[int64]*service.Group{
+ openAI.ID: &openAI,
+ vip.ID: &vip,
+ }}
+
+ router := walletModelsAuthTestRouter(t, apiKey, modelRouter, groupGetter, func(c *gin.Context) {
+ visibility, ok := GetWalletModelVisibilityFromContext(c)
+ require.True(t, ok)
+ require.Equal(t, apiKey.ID, visibility.APIKeyID)
+ actualNames := make([]string, 0, len(visibility.Groups))
+ for _, group := range visibility.Groups {
+ actualNames = append(actualNames, group.Name)
+ }
+ require.Equal(t, tt.wantGroups, actualNames)
+
+ routedKey, routed := GetAPIKeyFromContext(c)
+ require.True(t, routed)
+ require.NotNil(t, routedKey.GroupID)
+ require.Equal(t, openAI.ID, *routedKey.GroupID)
+ })
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
+ req.Header.Set("x-api-key", apiKey.Key)
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusOK, w.Code, w.Body.String())
+ })
+ }
+}
+
+func TestAPIKeyAuthWalletUniversalUsageKeepsSingleDefaultRoute(t *testing.T) {
+ openAI := walletModelsTestGroup(3, service.WalletDefaultOpenAIGroupName, service.PlatformOpenAI, false)
+ vip := walletModelsTestGroup(22, service.WalletDefaultVIPGroupName, service.PlatformAnthropic, true)
+ user := &service.User{
+ ID: 910, Role: service.RoleUser, Status: service.StatusActive,
+ Concurrency: 2, AllowedGroups: []int64{vip.ID},
+ }
+ apiKey := &service.APIKey{
+ ID: 911, UserID: user.ID, Key: "wallet-usage-key",
+ Name: service.WalletUniversalAPIKeyName, Purpose: service.APIKeyPurposeWalletUniversal, Status: service.StatusActive, User: user,
+ }
+ modelRouter := service.NewModelRouterServiceWithRoutes(
+ &walletModelsGroupRepoStub{groups: []service.Group{openAI, vip}},
+ service.DefaultModelRoutes(),
+ )
+ groupGetter := &walletModelsGroupGetterStub{groups: map[int64]*service.Group{
+ openAI.ID: &openAI,
+ vip.ID: &vip,
+ }}
+
+ router := walletModelsAuthTestRouter(t, apiKey, modelRouter, groupGetter, func(c *gin.Context) {
+ _, hasVisibility := GetWalletModelVisibilityFromContext(c)
+ require.False(t, hasVisibility, "/v1/usage must not receive multi-group model visibility")
+ routedKey, ok := GetAPIKeyFromContext(c)
+ require.True(t, ok)
+ require.NotNil(t, routedKey.GroupID)
+ require.Equal(t, openAI.ID, *routedKey.GroupID)
+ })
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/v1/usage", nil)
+ req.Header.Set("x-api-key", apiKey.Key)
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusOK, w.Code, w.Body.String())
+ require.Equal(t, []int64{openAI.ID}, groupGetter.requested, "usage metadata must not resolve or expose vip")
+}
+
+func walletModelsAuthTestRouter(
+ t *testing.T,
+ apiKey *service.APIKey,
+ modelRouter service.ModelRouter,
+ groupGetter apiKeyAuthGroupGetter,
+ handler func(*gin.Context),
+) *gin.Engine {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ cfg := &config.Config{RunMode: config.RunModeStandard}
+ apiKeyService := service.NewAPIKeyService(fakeAPIKeyRepo{getByKey: func(context.Context, string) (*service.APIKey, error) {
+ clone := *apiKey
+ return &clone, nil
+ }}, nil, nil, nil, nil, nil, cfg)
+ walletBalance := 100.0
+ subscriptionService := service.NewSubscriptionService(nil, fakeGoogleSubscriptionRepo{
+ getActiveWallet: func(context.Context, int64) (*service.UserSubscription, error) {
+ return &service.UserSubscription{
+ ID: 920, UserID: apiKey.UserID, Status: service.SubscriptionStatusActive,
+ ExpiresAt: service.MaxExpiresAt, WalletBalanceUSD: &walletBalance,
+ }, nil
+ },
+ }, nil, nil, cfg)
+ t.Cleanup(subscriptionService.Stop)
+
+ router := gin.New()
+ router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddlewareWithRouter(
+ apiKeyService,
+ subscriptionService,
+ modelRouter,
+ groupGetter,
+ cfg,
+ )))
+ router.GET("/v1/models", func(c *gin.Context) {
+ handler(c)
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ })
+ router.GET("/v1/usage", func(c *gin.Context) {
+ handler(c)
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ })
+ return router
+}
+
+func walletModelsTestGroup(id int64, name, platform string, exclusive bool) service.Group {
+ return service.Group{
+ ID: id, Name: name, Platform: platform, IsExclusive: exclusive,
+ Status: service.StatusActive, Hydrated: true, SubscriptionType: service.SubscriptionTypeStandard,
+ }
+}
diff --git a/backend/internal/server/middleware/api_key_auth_wallet_route_test.go b/backend/internal/server/middleware/api_key_auth_wallet_route_test.go
new file mode 100644
index 00000000000..392468bf946
--- /dev/null
+++ b/backend/internal/server/middleware/api_key_auth_wallet_route_test.go
@@ -0,0 +1,726 @@
+package middleware
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+type walletRouteModelRouterStub struct {
+ groupID int64
+ err error
+ model string
+ userID int64
+}
+
+func (s *walletRouteModelRouterStub) ResolveGroupID(ctx context.Context, userID int64, modelName string) (int64, error) {
+ s.userID = userID
+ s.model = modelName
+ if s.err != nil {
+ return 0, s.err
+ }
+ return s.groupID, nil
+}
+
+type walletRouteGroupGetterStub struct {
+ group *service.Group
+ err error
+}
+
+func (s *walletRouteGroupGetterStub) GetByID(ctx context.Context, id int64) (*service.Group, error) {
+ if s.err != nil {
+ return nil, s.err
+ }
+ if s.group == nil || s.group.ID != id {
+ return nil, service.ErrGroupNotFound
+ }
+ clone := *s.group
+ return &clone, nil
+}
+
+func TestAPIKeyAuthWalletUniversalKeyRoutesByModelAndRestoresBody(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ user := &service.User{
+ ID: 7,
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ Balance: 0,
+ Concurrency: 3,
+ }
+ apiKey := &service.APIKey{
+ ID: 100,
+ UserID: user.ID,
+ Key: "wallet-any-key",
+ Name: service.WalletUniversalAPIKeyName,
+ Purpose: service.APIKeyPurposeWalletUniversal,
+ Status: service.StatusActive,
+ User: user,
+ }
+ apiKeyRepo := fakeAPIKeyRepo{getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
+ if key != apiKey.Key {
+ return nil, service.ErrAPIKeyNotFound
+ }
+ clone := *apiKey
+ return &clone, nil
+ }}
+ cfg := &config.Config{RunMode: config.RunModeStandard}
+ apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
+
+ balance := 100.0
+ walletSub := &service.UserSubscription{
+ ID: 201,
+ UserID: user.ID,
+ GroupID: nil,
+ Status: service.SubscriptionStatusActive,
+ ExpiresAt: service.MaxExpiresAt,
+ WalletBalanceUSD: &balance,
+ }
+ subscriptionService := service.NewSubscriptionService(nil, fakeGoogleSubscriptionRepo{
+ getActiveWallet: func(ctx context.Context, userID int64) (*service.UserSubscription, error) {
+ if userID != user.ID {
+ return nil, service.ErrSubscriptionNotFound
+ }
+ clone := *walletSub
+ return &clone, nil
+ },
+ }, nil, nil, cfg)
+ t.Cleanup(subscriptionService.Stop)
+
+ targetGroup := &service.Group{
+ ID: 3,
+ Name: "openai-default",
+ Status: service.StatusActive,
+ Platform: service.PlatformOpenAI,
+ Hydrated: true,
+ SubscriptionType: service.SubscriptionTypeStandard,
+ }
+ modelRouter := &walletRouteModelRouterStub{groupID: targetGroup.ID}
+ groupGetter := &walletRouteGroupGetterStub{group: targetGroup}
+
+ router := gin.New()
+ router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddlewareWithRouter(apiKeyService, subscriptionService, modelRouter, groupGetter, cfg)))
+ body := `{"model":"gpt-5.6-high","max_tokens":50,"messages":[{"role":"user","content":"hi"}]}`
+ router.POST("/t", func(c *gin.Context) {
+ routedAPIKey, ok := GetAPIKeyFromContext(c)
+ require.True(t, ok)
+ require.NotNil(t, routedAPIKey.GroupID)
+ require.Equal(t, targetGroup.ID, *routedAPIKey.GroupID)
+
+ groupFromCtx, ok := c.Request.Context().Value(ctxkey.Group).(*service.Group)
+ require.True(t, ok)
+ require.Equal(t, targetGroup.ID, groupFromCtx.ID)
+
+ restored, err := io.ReadAll(c.Request.Body)
+ require.NoError(t, err)
+ require.JSONEq(t, body, string(restored))
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ })
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/t", strings.NewReader(body))
+ req.Header.Set("x-api-key", apiKey.Key)
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusOK, w.Code)
+ require.Equal(t, user.ID, modelRouter.userID)
+ require.Equal(t, "gpt-5.6-high", modelRouter.model)
+}
+
+func TestAPIKeyAuthWalletUniversalKeyRejectsUnsupportedModel(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ user := &service.User{ID: 7, Role: service.RoleUser, Status: service.StatusActive, Balance: 0, Concurrency: 3}
+ apiKey := &service.APIKey{ID: 100, UserID: user.ID, Key: "wallet-any-key", Name: service.WalletUniversalAPIKeyName, Purpose: service.APIKeyPurposeWalletUniversal, Status: service.StatusActive, User: user}
+ apiKeyService := service.NewAPIKeyService(fakeAPIKeyRepo{getByKey: func(ctx context.Context, key string) (*service.APIKey, error) {
+ clone := *apiKey
+ return &clone, nil
+ }}, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeStandard})
+
+ balance := 100.0
+ subscriptionService := service.NewSubscriptionService(nil, fakeGoogleSubscriptionRepo{
+ getActiveWallet: func(ctx context.Context, userID int64) (*service.UserSubscription, error) {
+ return &service.UserSubscription{
+ ID: 201,
+ UserID: user.ID,
+ Status: service.SubscriptionStatusActive,
+ ExpiresAt: service.MaxExpiresAt,
+ WalletBalanceUSD: &balance,
+ }, nil
+ },
+ }, nil, nil, &config.Config{RunMode: config.RunModeStandard})
+ t.Cleanup(subscriptionService.Stop)
+
+ router := gin.New()
+ router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddlewareWithRouter(
+ apiKeyService,
+ subscriptionService,
+ &walletRouteModelRouterStub{err: service.ErrModelUnsupported},
+ &walletRouteGroupGetterStub{},
+ &config.Config{RunMode: config.RunModeStandard},
+ )))
+ router.POST("/t", func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ })
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/t", strings.NewReader(`{"model":"unknown-model-xyz"}`))
+ req.Header.Set("x-api-key", apiKey.Key)
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusBadRequest, w.Code)
+ require.Contains(t, w.Body.String(), "model_unsupported")
+}
+
+func TestAPIKeyAuthWalletUniversalKeyUsesOpenAIDefaultForMetadataAndResponsesWebSocket(t *testing.T) {
+ user := &service.User{ID: 71, Role: service.RoleUser, Status: service.StatusActive, Concurrency: 3}
+ apiKey := &service.APIKey{ID: 101, UserID: user.ID, Key: "wallet-metadata-key", Name: service.WalletUniversalAPIKeyName, Purpose: service.APIKeyPurposeWalletUniversal, Status: service.StatusActive, User: user}
+ apiKeyService := service.NewAPIKeyService(fakeAPIKeyRepo{getByKey: func(context.Context, string) (*service.APIKey, error) {
+ clone := *apiKey
+ return &clone, nil
+ }}, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeStandard})
+ balance := 100.0
+ wallet := &service.UserSubscription{ID: 202, UserID: user.ID, Status: service.SubscriptionStatusActive, ExpiresAt: service.MaxExpiresAt, WalletBalanceUSD: &balance}
+ subscriptionService := service.NewSubscriptionService(nil, fakeGoogleSubscriptionRepo{getActiveWallet: func(context.Context, int64) (*service.UserSubscription, error) {
+ clone := *wallet
+ return &clone, nil
+ }}, nil, nil, &config.Config{RunMode: config.RunModeStandard})
+ t.Cleanup(subscriptionService.Stop)
+ openAI := &service.Group{ID: 3, Name: service.WalletDefaultOpenAIGroupName, Platform: service.PlatformOpenAI, Status: service.StatusActive, Hydrated: true, SubscriptionType: service.SubscriptionTypeStandard}
+
+ for _, path := range []string{"/v1/models", "/v1/usage", "/v1/responses"} {
+ t.Run(path, func(t *testing.T) {
+ router := gin.New()
+ router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddlewareWithRouter(
+ apiKeyService, subscriptionService, &walletRouteModelRouterStub{groupID: openAI.ID}, &walletRouteGroupGetterStub{group: openAI}, &config.Config{RunMode: config.RunModeStandard},
+ )))
+ router.GET(path, func(c *gin.Context) {
+ routedKey, ok := GetAPIKeyFromContext(c)
+ require.True(t, ok)
+ require.NotNil(t, routedKey.GroupID)
+ require.Equal(t, openAI.ID, *routedKey.GroupID)
+ sub, ok := GetSubscriptionFromContext(c)
+ require.True(t, ok)
+ require.Equal(t, wallet.ID, sub.ID)
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ })
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ req.Header.Set("x-api-key", apiKey.Key)
+ router.ServeHTTP(w, req)
+ require.Equal(t, http.StatusOK, w.Code)
+ })
+ }
+}
+
+func TestAPIKeyAuthUsageRejectsArbitraryNullGroupKey(t *testing.T) {
+ user := &service.User{ID: 72, Role: service.RoleUser, Status: service.StatusActive, Balance: 100, Concurrency: 3}
+ apiKey := &service.APIKey{ID: 102, UserID: user.ID, Key: "manual-null-usage-key", Name: "manual", Status: service.StatusActive, User: user}
+ cfg := &config.Config{RunMode: config.RunModeStandard}
+ apiKeyService := service.NewAPIKeyService(fakeAPIKeyRepo{getByKey: func(context.Context, string) (*service.APIKey, error) {
+ clone := *apiKey
+ return &clone, nil
+ }}, nil, nil, nil, nil, nil, cfg)
+ subscriptionService := service.NewSubscriptionService(nil, fakeGoogleSubscriptionRepo{}, nil, nil, cfg)
+ t.Cleanup(subscriptionService.Stop)
+ router := gin.New()
+ router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddlewareWithRouter(apiKeyService, subscriptionService, nil, nil, cfg)))
+ router.GET("/v1/usage", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/v1/usage", nil)
+ req.Header.Set("x-api-key", apiKey.Key)
+ router.ServeHTTP(w, req)
+ require.Equal(t, http.StatusForbidden, w.Code)
+ require.Contains(t, w.Body.String(), "WALLET_KEY_INVALID")
+}
+
+func TestAPIKeyAuthWalletUniversalKeyRequiresExplicitVIPGrant(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ allowedGroups []int64
+ wantStatus int
+ }{
+ {name: "missing grant is rejected", wantStatus: http.StatusForbidden},
+ {name: "explicit grant is accepted", allowedGroups: []int64{22}, wantStatus: http.StatusOK},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ user := &service.User{
+ ID: 77,
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ Concurrency: 3,
+ AllowedGroups: append([]int64(nil), tt.allowedGroups...),
+ }
+ apiKey := &service.APIKey{ID: 700, UserID: user.ID, Key: "wallet-vip-key", Name: service.WalletUniversalAPIKeyName, Purpose: service.APIKeyPurposeWalletUniversal, Status: service.StatusActive, User: user}
+ vip := &service.Group{
+ ID: 22,
+ Name: "vip",
+ Status: service.StatusActive,
+ Platform: service.PlatformAnthropic,
+ Hydrated: true,
+ IsExclusive: true,
+ SubscriptionType: service.SubscriptionTypeStandard,
+ }
+
+ w := performWalletRouteRequest(t, apiKey, &service.UserSubscription{
+ ID: 701,
+ UserID: user.ID,
+ Status: service.SubscriptionStatusActive,
+ ExpiresAt: service.MaxExpiresAt,
+ WalletBalanceUSD: float64Ptr(50),
+ }, &walletRouteModelRouterStub{groupID: vip.ID}, &walletRouteGroupGetterStub{group: vip}, `{"model":"claude-sonnet-4-6"}`, nil)
+
+ require.Equal(t, tt.wantStatus, w.Code)
+ if tt.wantStatus == http.StatusForbidden {
+ require.Contains(t, w.Body.String(), "GROUP_NOT_ALLOWED")
+ }
+ })
+ }
+}
+
+func TestAPIKeyAuthWalletUniversalKeyRejectsOtherPublicGroups(t *testing.T) {
+ user := &service.User{ID: 78, Role: service.RoleUser, Status: service.StatusActive, Concurrency: 3}
+ apiKey := &service.APIKey{ID: 710, UserID: user.ID, Key: "wallet-public-key", Name: service.WalletUniversalAPIKeyName, Purpose: service.APIKeyPurposeWalletUniversal, Status: service.StatusActive, User: user}
+ otherPublic := &service.Group{
+ ID: 30,
+ Name: "some-public-group",
+ Status: service.StatusActive,
+ Platform: service.PlatformOpenAI,
+ Hydrated: true,
+ SubscriptionType: service.SubscriptionTypeStandard,
+ }
+
+ w := performWalletRouteRequest(t, apiKey, &service.UserSubscription{
+ ID: 711,
+ UserID: user.ID,
+ Status: service.SubscriptionStatusActive,
+ ExpiresAt: service.MaxExpiresAt,
+ WalletBalanceUSD: float64Ptr(50),
+ }, &walletRouteModelRouterStub{groupID: otherPublic.ID}, &walletRouteGroupGetterStub{group: otherPublic}, `{"model":"gpt-5.6-high"}`, nil)
+
+ require.Equal(t, http.StatusForbidden, w.Code)
+ require.Contains(t, w.Body.String(), "GROUP_NOT_ALLOWED")
+}
+
+func TestAPIKeyAuthWalletUniversalKeyRejectsNonVIPExclusiveGroupEvenWhenGranted(t *testing.T) {
+ user := &service.User{ID: 781, Role: service.RoleUser, Status: service.StatusActive, Concurrency: 3, AllowedGroups: []int64{29}}
+ apiKey := &service.APIKey{ID: 718, UserID: user.ID, Key: "wallet-wrong-vip-key", Name: service.WalletUniversalAPIKeyName, Purpose: service.APIKeyPurposeWalletUniversal, Status: service.StatusActive, User: user}
+ wrongVIP := &service.Group{
+ ID: 29,
+ Name: "Claude VIP",
+ Status: service.StatusActive,
+ Platform: service.PlatformAnthropic,
+ Hydrated: true,
+ IsExclusive: true,
+ SubscriptionType: service.SubscriptionTypeStandard,
+ }
+
+ w := performWalletRouteRequest(t, apiKey, &service.UserSubscription{
+ ID: 719,
+ UserID: user.ID,
+ Status: service.SubscriptionStatusActive,
+ ExpiresAt: service.MaxExpiresAt,
+ WalletBalanceUSD: float64Ptr(50),
+ }, &walletRouteModelRouterStub{groupID: wrongVIP.ID}, &walletRouteGroupGetterStub{group: wrongVIP}, `{"model":"claude-sonnet-4-6"}`, nil)
+
+ require.Equal(t, http.StatusForbidden, w.Code)
+ require.Contains(t, w.Body.String(), "GROUP_NOT_ALLOWED")
+}
+
+func TestAPIKeyAuthWalletUniversalKeyRejectsMissingPermanentCreditsWallet(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ wallet *service.UserSubscription
+ }{
+ {name: "no wallet"},
+ {
+ name: "finite legacy wallet",
+ wallet: &service.UserSubscription{
+ ID: 717,
+ Status: service.SubscriptionStatusActive,
+ ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
+ WalletBalanceUSD: float64Ptr(50),
+ },
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ user := &service.User{ID: 782, Role: service.RoleUser, Status: service.StatusActive, Balance: 100, Concurrency: 3}
+ apiKey := &service.APIKey{ID: 719, UserID: user.ID, Key: "stale-wallet-universal-key", Name: service.WalletUniversalAPIKeyName, Purpose: service.APIKeyPurposeWalletUniversal, Status: service.StatusActive, User: user}
+
+ w := performWalletRouteRequest(t, apiKey, tt.wallet, nil, nil, `{"model":"gpt-5.6-high"}`, nil)
+
+ require.Equal(t, http.StatusForbidden, w.Code)
+ require.Contains(t, w.Body.String(), "WALLET_NOT_ACTIVE")
+ })
+ }
+}
+
+func TestAPIKeyAuthWalletUniversalKeyRejectsArbitraryNullGroupKey(t *testing.T) {
+ user := &service.User{ID: 783, Role: service.RoleUser, Status: service.StatusActive, Balance: 100, Concurrency: 3}
+ apiKey := &service.APIKey{ID: 720, UserID: user.ID, Key: "manual-null-group-key", Name: "manual unscoped key", Status: service.StatusActive, User: user}
+
+ w := performWalletRouteRequest(t, apiKey, &service.UserSubscription{
+ ID: 718,
+ UserID: user.ID,
+ Status: service.SubscriptionStatusActive,
+ ExpiresAt: service.MaxExpiresAt,
+ WalletBalanceUSD: float64Ptr(50),
+ }, &walletRouteModelRouterStub{groupID: 3}, &walletRouteGroupGetterStub{group: &service.Group{
+ ID: 3, Name: service.WalletDefaultOpenAIGroupName, Platform: service.PlatformOpenAI,
+ Status: service.StatusActive, Hydrated: true, SubscriptionType: service.SubscriptionTypeStandard,
+ }}, `{"model":"gpt-5.6-high"}`, nil)
+
+ require.Equal(t, http.StatusForbidden, w.Code)
+ require.Contains(t, w.Body.String(), "WALLET_KEY_INVALID")
+}
+
+func TestAPIKeyAuthWalletUniversalKeyRejectsReservedNameWithoutPurpose(t *testing.T) {
+ user := &service.User{ID: 784, Role: service.RoleUser, Status: service.StatusActive, Balance: 100, Concurrency: 3}
+ apiKey := &service.APIKey{
+ ID: 721,
+ UserID: user.ID,
+ Key: "forged-wallet-name-key",
+ Name: service.WalletUniversalAPIKeyName,
+ Purpose: service.APIKeyPurposeStandard,
+ Status: service.StatusActive,
+ User: user,
+ }
+
+ w := performWalletRouteRequest(t, apiKey, &service.UserSubscription{
+ ID: 719,
+ UserID: user.ID,
+ Status: service.SubscriptionStatusActive,
+ ExpiresAt: service.MaxExpiresAt,
+ WalletBalanceUSD: float64Ptr(50),
+ }, &walletRouteModelRouterStub{groupID: 3}, &walletRouteGroupGetterStub{group: &service.Group{
+ ID: 3, Name: service.WalletDefaultOpenAIGroupName, Platform: service.PlatformOpenAI,
+ Status: service.StatusActive, Hydrated: true, SubscriptionType: service.SubscriptionTypeStandard,
+ }}, `{"model":"gpt-5.6-high"}`, nil)
+
+ require.Equal(t, http.StatusForbidden, w.Code)
+ require.Contains(t, w.Body.String(), "WALLET_KEY_INVALID")
+}
+
+func TestAPIKeyAuthWalletFixedGroupRejectsRevokedVIPAndDisabledGroup(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ group *service.Group
+ }{
+ {
+ name: "revoked vip grant",
+ group: &service.Group{ID: 22, Name: "vip", Status: service.StatusActive, Platform: service.PlatformAnthropic, Hydrated: true, IsExclusive: true, SubscriptionType: service.SubscriptionTypeStandard},
+ },
+ {
+ name: "disabled default group",
+ group: &service.Group{ID: 3, Name: "openai-default", Status: service.StatusDisabled, Platform: service.PlatformOpenAI, Hydrated: true, SubscriptionType: service.SubscriptionTypeStandard},
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ user := &service.User{ID: 79, Role: service.RoleUser, Status: service.StatusActive, Concurrency: 3}
+ groupID := tt.group.ID
+ apiKey := &service.APIKey{ID: 720, UserID: user.ID, Key: "wallet-fixed-key", Status: service.StatusActive, User: user, GroupID: &groupID, Group: tt.group}
+ w := performWalletRouteRequest(t, apiKey, &service.UserSubscription{
+ ID: 721,
+ UserID: user.ID,
+ Status: service.SubscriptionStatusActive,
+ ExpiresAt: service.MaxExpiresAt,
+ WalletBalanceUSD: float64Ptr(50),
+ }, nil, nil, `{"model":"claude-sonnet-4-6"}`, nil)
+
+ require.Equal(t, http.StatusForbidden, w.Code)
+ require.Contains(t, w.Body.String(), "GROUP_NOT_ALLOWED")
+ })
+ }
+}
+
+func TestAPIKeyAuthFixedExclusiveGroupRejectsRevokedGrantWithoutWallet(t *testing.T) {
+ vip := &service.Group{
+ ID: 22, Name: service.WalletDefaultVIPGroupName, Status: service.StatusActive,
+ Platform: service.PlatformAnthropic, Hydrated: true, IsExclusive: true,
+ SubscriptionType: service.SubscriptionTypeStandard,
+ }
+ user := &service.User{ID: 791, Role: service.RoleUser, Status: service.StatusActive, Balance: 100, Concurrency: 3}
+ groupID := vip.ID
+ apiKey := &service.APIKey{ID: 740, UserID: user.ID, Key: "revoked-vip-with-balance", Status: service.StatusActive, User: user, GroupID: &groupID, Group: vip}
+
+ w := performWalletRouteRequest(t, apiKey, nil, nil, nil, `{"model":"claude-sonnet-4-6"}`, nil)
+
+ require.Equal(t, http.StatusForbidden, w.Code)
+ require.Contains(t, w.Body.String(), "GROUP_NOT_ALLOWED")
+}
+
+func TestAPIKeyAuthFixedReservedWalletGroupsRejectPolicyDriftWithoutWallet(t *testing.T) {
+ tests := []struct {
+ name string
+ group *service.Group
+ allowedGroups []int64
+ }{
+ {
+ name: "vip made public",
+ group: &service.Group{ID: 22, Name: service.WalletDefaultVIPGroupName, Status: service.StatusActive,
+ Platform: service.PlatformAnthropic, Hydrated: true, IsExclusive: false, SubscriptionType: service.SubscriptionTypeStandard},
+ },
+ {
+ name: "vip moved to wrong platform",
+ group: &service.Group{ID: 22, Name: service.WalletDefaultVIPGroupName, Status: service.StatusActive,
+ Platform: service.PlatformOpenAI, Hydrated: true, IsExclusive: true, SubscriptionType: service.SubscriptionTypeStandard},
+ allowedGroups: []int64{22},
+ },
+ {
+ name: "openai default moved to wrong platform",
+ group: &service.Group{ID: 3, Name: service.WalletDefaultOpenAIGroupName, Status: service.StatusActive,
+ Platform: service.PlatformAnthropic, Hydrated: true, SubscriptionType: service.SubscriptionTypeStandard},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ user := &service.User{ID: 794, Role: service.RoleUser, Status: service.StatusActive, Balance: 100, Concurrency: 3, AllowedGroups: tt.allowedGroups}
+ groupID := tt.group.ID
+ apiKey := &service.APIKey{ID: 744, UserID: user.ID, Key: "reserved-group-drift-key", Status: service.StatusActive, User: user, GroupID: &groupID, Group: tt.group}
+ w := performWalletRouteRequest(t, apiKey, nil, nil, nil, `{"model":"gpt-5.6-high"}`, nil)
+ require.Equal(t, http.StatusForbidden, w.Code)
+ require.Contains(t, w.Body.String(), "GROUP_NOT_ALLOWED")
+ })
+ }
+}
+
+func TestAPIKeyAuthFixedWalletGroupRequiresModelToMatchGroup(t *testing.T) {
+ openAI := &service.Group{
+ ID: 3, Name: service.WalletDefaultOpenAIGroupName, Status: service.StatusActive,
+ Platform: service.PlatformOpenAI, Hydrated: true, SubscriptionType: service.SubscriptionTypeStandard,
+ }
+ vip := &service.Group{
+ ID: 22, Name: service.WalletDefaultVIPGroupName, Status: service.StatusActive,
+ Platform: service.PlatformAnthropic, Hydrated: true, IsExclusive: true,
+ SubscriptionType: service.SubscriptionTypeStandard,
+ }
+ wallet := &service.UserSubscription{
+ ID: 741, Status: service.SubscriptionStatusActive, ExpiresAt: service.MaxExpiresAt,
+ WalletBalanceUSD: float64Ptr(50),
+ }
+
+ tests := []struct {
+ name string
+ group *service.Group
+ allowedGroups []int64
+ model string
+ routedGroupID int64
+ path string
+ allowMessagesDispatch bool
+ wantStatus int
+ }{
+ {name: "gpt on openai default", group: openAI, model: "gpt-5.6-high", routedGroupID: openAI.ID, path: "/v1/responses", wantStatus: http.StatusOK},
+ {name: "claude on openai default without messages dispatch is rejected", group: openAI, model: "claude-sonnet-4-6", routedGroupID: vip.ID, path: "/v1/messages", wantStatus: http.StatusForbidden},
+ {name: "claude on openai default messages dispatch is accepted", group: openAI, model: "claude-opus-4-7", routedGroupID: vip.ID, path: "/v1/messages", allowMessagesDispatch: true, wantStatus: http.StatusOK},
+ {name: "claude on openai default non messages endpoint is rejected", group: openAI, model: "claude-opus-4-7", routedGroupID: vip.ID, path: "/v1/chat/completions", allowMessagesDispatch: true, wantStatus: http.StatusForbidden},
+ {name: "unmapped claude on openai default messages dispatch is rejected", group: openAI, model: "claude-future", routedGroupID: vip.ID, path: "/v1/messages", allowMessagesDispatch: true, wantStatus: http.StatusForbidden},
+ {name: "claude on granted vip", group: vip, allowedGroups: []int64{vip.ID}, model: "claude-sonnet-4-6", routedGroupID: vip.ID, path: "/v1/messages", wantStatus: http.StatusOK},
+ {name: "gpt on granted vip is rejected", group: vip, allowedGroups: []int64{vip.ID}, model: "gpt-5.6-high", routedGroupID: openAI.ID, path: "/v1/responses", wantStatus: http.StatusForbidden},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ user := &service.User{ID: 792, Role: service.RoleUser, Status: service.StatusActive, Balance: 0, Concurrency: 3, AllowedGroups: tt.allowedGroups}
+ group := *tt.group
+ group.AllowMessagesDispatch = tt.allowMessagesDispatch
+ groupID := group.ID
+ apiKey := &service.APIKey{ID: 742, UserID: user.ID, Key: "fixed-wallet-model-key", Status: service.StatusActive, User: user, GroupID: &groupID, Group: &group}
+ wallet.UserID = user.ID
+
+ w := performWalletRouteRequestAtPath(t, apiKey, wallet, &walletRouteModelRouterStub{groupID: tt.routedGroupID}, nil, tt.path, `{"model":"`+tt.model+`"}`, nil)
+
+ require.Equal(t, tt.wantStatus, w.Code)
+ if tt.wantStatus == http.StatusForbidden {
+ require.Contains(t, w.Body.String(), "GROUP_NOT_ALLOWED")
+ }
+ })
+ }
+}
+
+func TestAPIKeyAuthFixedMonthlyGroupPrefersMonthlySubscriptionOverWallet(t *testing.T) {
+ group := &service.Group{
+ ID: 14,
+ Name: "paid-standard-v3",
+ Status: service.StatusActive,
+ Platform: service.PlatformOpenAI,
+ Hydrated: true,
+ SubscriptionType: service.SubscriptionTypeSubscription,
+ }
+ user := &service.User{ID: 80, Role: service.RoleUser, Status: service.StatusActive, Concurrency: 3}
+ groupID := group.ID
+ apiKey := &service.APIKey{ID: 730, UserID: user.ID, Key: "monthly-key", Status: service.StatusActive, User: user, GroupID: &groupID, Group: group}
+ wallet := &service.UserSubscription{ID: 731, UserID: user.ID, Status: service.SubscriptionStatusActive, ExpiresAt: service.MaxExpiresAt, WalletBalanceUSD: float64Ptr(50)}
+ monthly := &service.UserSubscription{ID: 732, UserID: user.ID, GroupID: &groupID, Group: group, Status: service.SubscriptionStatusActive, ExpiresAt: time.Now().Add(30 * 24 * time.Hour)}
+
+ w := performWalletRouteRequest(t, apiKey, wallet, nil, nil, `{"model":"gpt-5.6-high"}`, func(c *gin.Context) {
+ sub, ok := GetSubscriptionFromContext(c)
+ require.True(t, ok)
+ require.Equal(t, monthly.ID, sub.ID, "fixed monthly key must charge the monthly subscription before the credits wallet")
+ })
+
+ require.Equal(t, http.StatusOK, w.Code)
+}
+
+func TestAPIKeyAuthFailsClosedOnSubscriptionRepositoryErrors(t *testing.T) {
+ sentinel := errors.New("injected subscription database failure")
+ notFoundWallet := func(context.Context, int64) (*service.UserSubscription, error) {
+ return nil, service.ErrSubscriptionNotFound
+ }
+ notFoundGroup := func(context.Context, int64, int64) (*service.UserSubscription, error) {
+ return nil, service.ErrSubscriptionNotFound
+ }
+
+ tests := []struct {
+ name string
+ repo fakeGoogleSubscriptionRepo
+ }{
+ {
+ name: "credits wallet lookup",
+ repo: fakeGoogleSubscriptionRepo{
+ getActiveWallet: func(context.Context, int64) (*service.UserSubscription, error) { return nil, sentinel },
+ getActive: notFoundGroup,
+ },
+ },
+ {
+ name: "exact subscription lookup",
+ repo: fakeGoogleSubscriptionRepo{getActiveWallet: notFoundWallet, getActive: func(context.Context, int64, int64) (*service.UserSubscription, error) {
+ return nil, sentinel
+ }},
+ },
+ {
+ name: "covering subscription lookup",
+ repo: fakeGoogleSubscriptionRepo{getActiveWallet: notFoundWallet, getActive: notFoundGroup, getCovering: func(context.Context, int64, int64) (*service.UserSubscription, error) {
+ return nil, sentinel
+ }},
+ },
+ {
+ name: "any subscription lookup",
+ repo: fakeGoogleSubscriptionRepo{getActiveWallet: notFoundWallet, getActive: notFoundGroup, hasAny: func(context.Context, int64) (bool, error) {
+ return false, sentinel
+ }},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ group := &service.Group{
+ ID: 3, Name: service.WalletDefaultOpenAIGroupName, Platform: service.PlatformOpenAI,
+ Status: service.StatusActive, Hydrated: true, SubscriptionType: service.SubscriptionTypeStandard,
+ }
+ user := &service.User{ID: 793, Role: service.RoleUser, Status: service.StatusActive, Balance: 100, Concurrency: 3}
+ groupID := group.ID
+ apiKey := &service.APIKey{ID: 743, UserID: user.ID, Key: "subscription-error-key", Status: service.StatusActive, User: user, GroupID: &groupID, Group: group}
+
+ w := performWalletRouteRequestWithSubscriptionRepo(t, apiKey, tt.repo, `{"model":"gpt-5.6-high"}`)
+
+ require.Equal(t, http.StatusServiceUnavailable, w.Code)
+ require.Contains(t, w.Body.String(), "BILLING_SERVICE_UNAVAILABLE")
+ })
+ }
+}
+
+func performWalletRouteRequestWithSubscriptionRepo(t *testing.T, apiKey *service.APIKey, repo fakeGoogleSubscriptionRepo, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ cfg := &config.Config{RunMode: config.RunModeStandard}
+ apiKeyService := service.NewAPIKeyService(fakeAPIKeyRepo{getByKey: func(context.Context, string) (*service.APIKey, error) {
+ clone := *apiKey
+ return &clone, nil
+ }}, nil, nil, nil, nil, nil, cfg)
+ subscriptionService := service.NewSubscriptionService(nil, repo, nil, nil, cfg)
+ t.Cleanup(subscriptionService.Stop)
+ router := gin.New()
+ router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddlewareWithRouter(apiKeyService, subscriptionService, nil, nil, cfg)))
+ router.POST("/t", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/t", strings.NewReader(body))
+ req.Header.Set("x-api-key", apiKey.Key)
+ router.ServeHTTP(w, req)
+ return w
+}
+
+func performWalletRouteRequest(
+ t *testing.T,
+ apiKey *service.APIKey,
+ wallet *service.UserSubscription,
+ modelRouter service.ModelRouter,
+ groupGetter apiKeyAuthGroupGetter,
+ body string,
+ handler func(*gin.Context),
+) *httptest.ResponseRecorder {
+ return performWalletRouteRequestAtPath(t, apiKey, wallet, modelRouter, groupGetter, "/t", body, handler)
+}
+
+func performWalletRouteRequestAtPath(
+ t *testing.T,
+ apiKey *service.APIKey,
+ wallet *service.UserSubscription,
+ modelRouter service.ModelRouter,
+ groupGetter apiKeyAuthGroupGetter,
+ path string,
+ body string,
+ handler func(*gin.Context),
+) *httptest.ResponseRecorder {
+ t.Helper()
+ gin.SetMode(gin.TestMode)
+ cfg := &config.Config{RunMode: config.RunModeStandard}
+ apiKeyService := service.NewAPIKeyService(fakeAPIKeyRepo{getByKey: func(context.Context, string) (*service.APIKey, error) {
+ clone := *apiKey
+ return &clone, nil
+ }}, nil, nil, nil, nil, nil, cfg)
+ subscriptionService := service.NewSubscriptionService(nil, fakeGoogleSubscriptionRepo{
+ getActiveWallet: func(context.Context, int64) (*service.UserSubscription, error) {
+ if wallet == nil {
+ return nil, service.ErrSubscriptionNotFound
+ }
+ clone := *wallet
+ return &clone, nil
+ },
+ getActive: func(_ context.Context, userID, groupID int64) (*service.UserSubscription, error) {
+ if apiKey.Group == nil || apiKey.GroupID == nil || groupID != *apiKey.GroupID || apiKey.Group.SubscriptionType != service.SubscriptionTypeSubscription {
+ return nil, service.ErrSubscriptionNotFound
+ }
+ windowStart := time.Now()
+ return &service.UserSubscription{
+ ID: 732,
+ UserID: userID,
+ GroupID: &groupID,
+ Group: apiKey.Group,
+ Status: service.SubscriptionStatusActive,
+ ExpiresAt: time.Now().Add(30 * 24 * time.Hour),
+ DailyWindowStart: &windowStart,
+ WeeklyWindowStart: &windowStart,
+ MonthlyWindowStart: &windowStart,
+ }, nil
+ },
+ }, nil, nil, cfg)
+ t.Cleanup(subscriptionService.Stop)
+
+ router := gin.New()
+ router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddlewareWithRouter(apiKeyService, subscriptionService, modelRouter, groupGetter, cfg)))
+ router.POST(path, func(c *gin.Context) {
+ if handler != nil {
+ handler(c)
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ })
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
+ req.Header.Set("x-api-key", apiKey.Key)
+ router.ServeHTTP(w, req)
+ return w
+}
+
+func float64Ptr(v float64) *float64 { return &v }
diff --git a/backend/internal/server/middleware/backend_mode_guard.go b/backend/internal/server/middleware/backend_mode_guard.go
index ae53037e67e..157f06b0983 100644
--- a/backend/internal/server/middleware/backend_mode_guard.go
+++ b/backend/internal/server/middleware/backend_mode_guard.go
@@ -40,6 +40,8 @@ func backendModeAllowsAuthPath(path string) bool {
"/auth/oauth/wechat/callback",
"/auth/oauth/wechat/payment/callback",
"/auth/oauth/oidc/callback",
+ "/auth/oauth/github/callback",
+ "/auth/oauth/google/callback",
"/auth/oauth/linuxdo/complete-registration",
"/auth/oauth/wechat/complete-registration",
"/auth/oauth/oidc/complete-registration",
diff --git a/backend/internal/server/middleware/backend_mode_guard_test.go b/backend/internal/server/middleware/backend_mode_guard_test.go
index bd77677b74d..de9c9ec9dbd 100644
--- a/backend/internal/server/middleware/backend_mode_guard_test.go
+++ b/backend/internal/server/middleware/backend_mode_guard_test.go
@@ -246,6 +246,30 @@ func TestBackendModeAuthGuard(t *testing.T) {
path: "/api/v1/auth/oauth/oidc/callback",
wantStatus: http.StatusOK,
},
+ {
+ name: "enabled_blocks_github_oauth_start",
+ enabled: "true",
+ path: "/api/v1/auth/oauth/github/start",
+ wantStatus: http.StatusForbidden,
+ },
+ {
+ name: "enabled_allows_github_oauth_callback",
+ enabled: "true",
+ path: "/api/v1/auth/oauth/github/callback",
+ wantStatus: http.StatusOK,
+ },
+ {
+ name: "enabled_blocks_google_oauth_start",
+ enabled: "true",
+ path: "/api/v1/auth/oauth/google/start",
+ wantStatus: http.StatusForbidden,
+ },
+ {
+ name: "enabled_allows_google_oauth_callback",
+ enabled: "true",
+ path: "/api/v1/auth/oauth/google/callback",
+ wantStatus: http.StatusOK,
+ },
{
name: "enabled_allows_oauth_pending_exchange",
enabled: "true",
diff --git a/backend/internal/server/middleware/cors.go b/backend/internal/server/middleware/cors.go
index 03d5d025de8..6f1a7c219db 100644
--- a/backend/internal/server/middleware/cors.go
+++ b/backend/internal/server/middleware/cors.go
@@ -52,7 +52,7 @@ func CORS(cfg config.CORSConfig) gin.HandlerFunc {
}
allowHeaders := []string{
"Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization",
- "accept", "origin", "Cache-Control", "X-Requested-With", "X-API-Key",
+ "accept", "origin", "Cache-Control", "X-Requested-With", "X-API-Key", "X-Sub2API-Browser-Session",
}
// OpenAI Node SDK 会发送 x-stainless-* 请求头,需在 CORS 中显式放行。
openAIProperties := []string{
diff --git a/backend/internal/server/middleware/middleware.go b/backend/internal/server/middleware/middleware.go
index 27985cf8bd4..c89ef79618f 100644
--- a/backend/internal/server/middleware/middleware.go
+++ b/backend/internal/server/middleware/middleware.go
@@ -24,8 +24,48 @@ const (
ContextKeySubscription ContextKey = "subscription"
// ContextKeyForcePlatform 强制平台(用于 /antigravity 路由)
ContextKeyForcePlatform ContextKey = "force_platform"
+ // ContextKeyWalletModelVisibility is set only for an authenticated, exact
+ // system universal-wallet key on GET /v1/models.
+ ContextKeyWalletModelVisibility ContextKey = "wallet_model_visibility"
)
+// WalletModelVisibility is the authenticated multi-group model-discovery
+// boundary for one universal wallet key. Request routing and billing continue
+// to use the single effective group stored on the API key context.
+type WalletModelVisibility struct {
+ APIKeyID int64
+ Groups []service.Group
+ Routes []service.ModelRoute
+}
+
+func setWalletModelVisibility(c *gin.Context, visibility WalletModelVisibility) {
+ if c == nil || visibility.APIKeyID <= 0 || len(visibility.Groups) == 0 || len(visibility.Routes) == 0 {
+ return
+ }
+ visibility.Groups = append([]service.Group(nil), visibility.Groups...)
+ visibility.Routes = append([]service.ModelRoute(nil), visibility.Routes...)
+ c.Set(string(ContextKeyWalletModelVisibility), visibility)
+}
+
+// GetWalletModelVisibilityFromContext returns a defensive copy of the
+// universal-wallet model-list visibility assembled by API-key auth.
+func GetWalletModelVisibilityFromContext(c *gin.Context) (WalletModelVisibility, bool) {
+ if c == nil {
+ return WalletModelVisibility{}, false
+ }
+ value, exists := c.Get(string(ContextKeyWalletModelVisibility))
+ if !exists {
+ return WalletModelVisibility{}, false
+ }
+ visibility, ok := value.(WalletModelVisibility)
+ if !ok || visibility.APIKeyID <= 0 || len(visibility.Groups) == 0 || len(visibility.Routes) == 0 {
+ return WalletModelVisibility{}, false
+ }
+ visibility.Groups = append([]service.Group(nil), visibility.Groups...)
+ visibility.Routes = append([]service.ModelRoute(nil), visibility.Routes...)
+ return visibility, true
+}
+
// ForcePlatform 返回设置强制平台的中间件
// 同时设置 request.Context(供 Service 使用)和 gin.Context(供 Handler 快速检查)
func ForcePlatform(platform string) gin.HandlerFunc {
@@ -110,6 +150,11 @@ func RequireGroupAssignment(settingService *service.SettingService, writeError G
c.Next()
return
}
+ if apiKey.IsWalletUniversal() {
+ writeError(c, http.StatusForbidden, "Wallet universal API key routing was not resolved for this endpoint.")
+ c.Abort()
+ return
+ }
// 未分组 Key — 检查系统设置
if settingService.IsUngroupedKeySchedulingAllowed(c.Request.Context()) {
c.Next()
diff --git a/backend/internal/server/middleware/middleware_group_assignment_wallet_test.go b/backend/internal/server/middleware/middleware_group_assignment_wallet_test.go
new file mode 100644
index 00000000000..a0a2c6fbc67
--- /dev/null
+++ b/backend/internal/server/middleware/middleware_group_assignment_wallet_test.go
@@ -0,0 +1,54 @@
+package middleware
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRequireGroupAssignmentFailsClosedForUnroutedWalletPurpose(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Set(string(ContextKeyAPIKey), &service.APIKey{
+ ID: 1,
+ Purpose: service.APIKeyPurposeWalletUniversal,
+ Name: service.WalletUniversalAPIKeyName,
+ })
+ c.Next()
+ })
+ router.Use(RequireGroupAssignment(nil, GoogleErrorWriter))
+ router.GET("/v1beta/test", func(c *gin.Context) { c.Status(http.StatusNoContent) })
+
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1beta/test", nil))
+
+ require.Equal(t, http.StatusForbidden, recorder.Code)
+ require.Contains(t, recorder.Body.String(), "routing was not resolved")
+}
+
+func TestRequireGroupAssignmentAllowsAlreadyRoutedWalletPurpose(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ groupID := int64(3)
+ router := gin.New()
+ router.Use(func(c *gin.Context) {
+ c.Set(string(ContextKeyAPIKey), &service.APIKey{
+ ID: 2,
+ Purpose: service.APIKeyPurposeWalletUniversal,
+ Name: service.WalletUniversalAPIKeyName,
+ GroupID: &groupID,
+ })
+ c.Next()
+ })
+ router.Use(RequireGroupAssignment(nil, GoogleErrorWriter))
+ router.GET("/v1beta/test", func(c *gin.Context) { c.Status(http.StatusNoContent) })
+
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1beta/test", nil))
+
+ require.Equal(t, http.StatusNoContent, recorder.Code)
+}
diff --git a/backend/internal/server/middleware/security_headers.go b/backend/internal/server/middleware/security_headers.go
index 398c0351dc0..d3756247381 100644
--- a/backend/internal/server/middleware/security_headers.go
+++ b/backend/internal/server/middleware/security_headers.go
@@ -96,6 +96,8 @@ func isAPIRoutePath(c *gin.Context) bool {
return strings.HasPrefix(path, "/v1/") ||
strings.HasPrefix(path, "/v1beta/") ||
strings.HasPrefix(path, "/antigravity/") ||
+ strings.HasPrefix(path, "/kiro/") ||
+ strings.HasPrefix(path, "/cursor/") ||
strings.HasPrefix(path, "/responses") ||
strings.HasPrefix(path, "/images")
}
diff --git a/backend/internal/server/middleware/security_headers_test.go b/backend/internal/server/middleware/security_headers_test.go
index 031385d062e..d393af5183e 100644
--- a/backend/internal/server/middleware/security_headers_test.go
+++ b/backend/internal/server/middleware/security_headers_test.go
@@ -151,6 +151,42 @@ func TestSecurityHeaders(t *testing.T) {
assert.Empty(t, GetNonceFromContext(c))
})
+ t.Run("kiro_api_route_skips_csp_nonce_generation", func(t *testing.T) {
+ cfg := config.CSPConfig{
+ Enabled: true,
+ Policy: "default-src 'self'; script-src 'self' __CSP_NONCE__",
+ }
+ middleware := SecurityHeaders(cfg, nil)
+
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodPost, "/kiro/v1/messages", nil)
+
+ middleware(c)
+
+ assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
+ assert.Empty(t, w.Header().Get("Content-Security-Policy"))
+ assert.Empty(t, GetNonceFromContext(c))
+ })
+
+ t.Run("cursor_api_route_skips_csp_nonce_generation", func(t *testing.T) {
+ cfg := config.CSPConfig{
+ Enabled: true,
+ Policy: "default-src 'self'; script-src 'self' __CSP_NONCE__",
+ }
+ middleware := SecurityHeaders(cfg, nil)
+
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = httptest.NewRequest(http.MethodPost, "/cursor/v1/messages", nil)
+
+ middleware(c)
+
+ assert.Equal(t, "nosniff", w.Header().Get("X-Content-Type-Options"))
+ assert.Empty(t, w.Header().Get("Content-Security-Policy"))
+ assert.Empty(t, GetNonceFromContext(c))
+ })
+
t.Run("csp_enabled_with_nonce_placeholder", func(t *testing.T) {
cfg := config.CSPConfig{
Enabled: true,
diff --git a/backend/internal/server/middleware/wire.go b/backend/internal/server/middleware/wire.go
index dc01b74366d..680e840be8c 100644
--- a/backend/internal/server/middleware/wire.go
+++ b/backend/internal/server/middleware/wire.go
@@ -1,6 +1,8 @@
package middleware
import (
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/google/wire"
)
@@ -14,9 +16,19 @@ type AdminAuthMiddleware gin.HandlerFunc
// APIKeyAuthMiddleware API Key 认证中间件类型
type APIKeyAuthMiddleware gin.HandlerFunc
+func ProvideAPIKeyAuthMiddleware(
+ apiKeyService *service.APIKeyService,
+ subscriptionService *service.SubscriptionService,
+ modelRouter service.ModelRouter,
+ groupRepo service.GroupRepository,
+ cfg *config.Config,
+) APIKeyAuthMiddleware {
+ return NewAPIKeyAuthMiddlewareWithRouter(apiKeyService, subscriptionService, modelRouter, groupRepo, cfg)
+}
+
// ProviderSet 中间件层的依赖注入
var ProviderSet = wire.NewSet(
NewJWTAuthMiddleware,
NewAdminAuthMiddleware,
- NewAPIKeyAuthMiddleware,
+ ProvideAPIKeyAuthMiddleware,
)
diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go
index a507b6f831e..4f3ca674960 100644
--- a/backend/internal/server/router.go
+++ b/backend/internal/server/router.go
@@ -108,8 +108,11 @@ func registerRoutes(
// 注册各模块路由
routes.RegisterAuthRoutes(v1, h, jwtAuth, redisClient, settingService)
- routes.RegisterUserRoutes(v1, h, jwtAuth, settingService)
- routes.RegisterAdminRoutes(v1, h, adminAuth)
+ routes.RegisterUserRoutes(v1, h, jwtAuth, settingService, redisClient)
+ routes.RegisterAdminRoutes(v1, h, adminAuth, redisClient)
+ routes.RegisterInternalChatRoutes(r)
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg)
- routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, settingService)
+ routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, settingService, redisClient)
+
+ handler.RegisterPageRoutes(v1, cfg.Pricing.DataDir, gin.HandlerFunc(jwtAuth), gin.HandlerFunc(adminAuth), settingService)
}
diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go
index 1c786f50267..041904aea5f 100644
--- a/backend/internal/server/routes/admin.go
+++ b/backend/internal/server/routes/admin.go
@@ -2,10 +2,14 @@
package routes
import (
+ "time"
+
"github.com/Wei-Shaw/sub2api/internal/handler"
+ appmiddleware "github.com/Wei-Shaw/sub2api/internal/middleware"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/gin-gonic/gin"
+ "github.com/redis/go-redis/v9"
)
// RegisterAdminRoutes 注册管理员路由
@@ -13,6 +17,7 @@ func RegisterAdminRoutes(
v1 *gin.RouterGroup,
h *handler.Handlers,
adminAuth middleware.AdminAuthMiddleware,
+ redisClient *redis.Client,
) {
admin := v1.Group("/admin")
admin.Use(gin.HandlerFunc(adminAuth))
@@ -69,7 +74,7 @@ func RegisterAdminRoutes(
registerSubscriptionRoutes(admin, h)
// 使用记录管理
- registerUsageRoutes(admin, h)
+ registerUsageRoutes(admin, h, redisClient)
// 用户属性管理
registerUserAttributeRoutes(admin, h)
@@ -92,11 +97,31 @@ func RegisterAdminRoutes(
// 渠道监控
registerChannelMonitorRoutes(admin, h)
+ // 风控中心
+ registerContentModerationRoutes(admin, h)
+
// 邀请返利(专属用户管理)
registerAffiliateRoutes(admin, h)
}
}
+func registerContentModerationRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
+ risk := admin.Group("/risk-control")
+ {
+ risk.GET("/config", h.Admin.ContentModeration.GetConfig)
+ risk.PUT("/config", h.Admin.ContentModeration.UpdateConfig)
+ risk.POST("/api-keys/test", h.Admin.ContentModeration.TestAPIKeys)
+ risk.GET("/status", h.Admin.ContentModeration.GetStatus)
+ risk.GET("/logs", h.Admin.ContentModeration.ListLogs)
+ risk.GET("/abuse/events", h.Admin.ContentModeration.ListAbuseRiskEvents)
+ risk.GET("/abuse/events/:id", h.Admin.ContentModeration.GetAbuseRiskEvent)
+ risk.POST("/abuse/events/:id/actions", h.Admin.ContentModeration.ApplyAbuseRiskAction)
+ risk.POST("/users/:user_id/unban", h.Admin.ContentModeration.UnbanUser)
+ risk.DELETE("/hashes", h.Admin.ContentModeration.DeleteFlaggedHash)
+ risk.DELETE("/hashes/all", h.Admin.ContentModeration.ClearFlaggedHashes)
+ }
+}
+
func registerAdminAPIKeyRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
apiKeys := admin.Group("/api-keys")
{
@@ -151,6 +176,7 @@ func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
// WebSocket realtime (QPS/TPS)
ws := ops.Group("/ws")
{
+ ws.POST("/ticket", h.Auth.IssueAdminOpsWSTicket)
ws.GET("/qps", h.Admin.Ops.QPSWSHandler)
}
@@ -228,6 +254,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
users.GET("/:id/balance-history", h.Admin.User.GetBalanceHistory)
users.POST("/:id/replace-group", h.Admin.User.ReplaceGroup)
users.GET("/:id/rpm-status", h.Admin.User.GetUserRPMStatus)
+ users.POST("/batch-concurrency", h.Admin.User.BatchUpdateConcurrency)
// User attribute values
users.GET("/:id/attributes", h.Admin.UserAttribute.GetUserAttributes)
@@ -263,6 +290,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
accounts.GET("", h.Admin.Account.List)
accounts.GET("/:id", h.Admin.Account.GetByID)
accounts.POST("", h.Admin.Account.Create)
+ accounts.POST("/cursor-sidecar", h.Admin.Account.ProvisionCursorAccount)
accounts.POST("/check-mixed-channel", h.Admin.Account.CheckMixedChannel)
accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS)
accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS)
@@ -398,6 +426,7 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
adminSettings := admin.Group("/settings")
{
adminSettings.GET("", h.Admin.Setting.GetSettings)
+ adminSettings.GET("/admin-ui", h.Admin.Setting.GetAdminUISettings)
adminSettings.PUT("", h.Admin.Setting.UpdateSettings)
adminSettings.POST("/test-smtp", h.Admin.Setting.TestSMTPConnection)
adminSettings.POST("/send-test-email", h.Admin.Setting.SendTestEmail)
@@ -408,6 +437,9 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
// 529过载冷却配置
adminSettings.GET("/overload-cooldown", h.Admin.Setting.GetOverloadCooldownSettings)
adminSettings.PUT("/overload-cooldown", h.Admin.Setting.UpdateOverloadCooldownSettings)
+ // 429默认回避配置
+ adminSettings.GET("/rate-limit-429-cooldown", h.Admin.Setting.GetRateLimit429CooldownSettings)
+ adminSettings.PUT("/rate-limit-429-cooldown", h.Admin.Setting.UpdateRateLimit429CooldownSettings)
// 流超时处理配置
adminSettings.GET("/stream-timeout", h.Admin.Setting.GetStreamTimeoutSettings)
adminSettings.PUT("/stream-timeout", h.Admin.Setting.UpdateStreamTimeoutSettings)
@@ -503,16 +535,27 @@ func registerSubscriptionRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
admin.GET("/users/:id/subscriptions", h.Admin.Subscription.ListByUser)
}
-func registerUsageRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
+func registerUsageRoutes(admin *gin.RouterGroup, h *handler.Handlers, redisClient *redis.Client) {
usage := admin.Group("/usage")
+ usageQueryLimit := appmiddleware.NewRateLimiter(redisClient).LimitWithOptions(
+ "admin-usage-query",
+ adminUsageQueryRequestsPerMinute,
+ time.Minute,
+ appmiddleware.RateLimitOptions{
+ FailureMode: appmiddleware.RateLimitFailClose,
+ KeyFunc: authenticatedUsageRateLimitKey,
+ },
+ )
{
- usage.GET("", h.Admin.Usage.List)
- usage.GET("/stats", h.Admin.Usage.Stats)
- usage.GET("/search-users", h.Admin.Usage.SearchUsers)
- usage.GET("/search-api-keys", h.Admin.Usage.SearchAPIKeys)
- usage.GET("/cleanup-tasks", h.Admin.Usage.ListCleanupTasks)
+ usage.GET("", usageQueryLimit, h.Admin.Usage.List)
+ usage.GET("/stats", usageQueryLimit, h.Admin.Usage.Stats)
+ usage.GET("/search-users", usageQueryLimit, h.Admin.Usage.SearchUsers)
+ usage.GET("/search-api-keys", usageQueryLimit, h.Admin.Usage.SearchAPIKeys)
+ usage.GET("/cleanup-tasks", usageQueryLimit, h.Admin.Usage.ListCleanupTasks)
usage.POST("/cleanup-tasks", h.Admin.Usage.CreateCleanupTask)
usage.POST("/cleanup-tasks/:id/cancel", h.Admin.Usage.CancelCleanupTask)
+ usage.GET("/reconciliation", usageQueryLimit, h.Admin.Usage.ListBillingReconciliationCases)
+ usage.POST("/reconciliation/resolve", h.Admin.Usage.ResolveBillingReconciliation)
}
}
@@ -602,11 +645,16 @@ func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
affiliates := admin.Group("/affiliates")
{
+ affiliates.GET("/invites", h.Admin.Affiliate.ListInviteRecords)
+ affiliates.GET("/rebates", h.Admin.Affiliate.ListRebateRecords)
+ affiliates.GET("/transfers", h.Admin.Affiliate.ListTransferRecords)
+
users := affiliates.Group("/users")
{
users.GET("", h.Admin.Affiliate.ListUsers)
users.GET("/lookup", h.Admin.Affiliate.LookupUsers)
users.POST("/batch-rate", h.Admin.Affiliate.BatchSetRate)
+ users.GET("/:user_id/overview", h.Admin.Affiliate.GetUserOverview)
users.PUT("/:user_id", h.Admin.Affiliate.UpdateUserSettings)
users.DELETE("/:user_id", h.Admin.Affiliate.ClearUserSettings)
}
diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go
index 642a2103e32..04ea78c7a83 100644
--- a/backend/internal/server/routes/auth.go
+++ b/backend/internal/server/routes/auth.go
@@ -63,6 +63,22 @@ func RegisterAuthRoutes(
FailureMode: middleware.RateLimitFailClose,
}), h.Auth.ResetPassword)
auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart)
+ auth.GET("/oauth/github/start", h.Auth.GitHubOAuthStart)
+ auth.GET("/oauth/github/callback", h.Auth.GitHubOAuthCallback)
+ auth.POST("/oauth/github/complete-registration",
+ rateLimiter.LimitWithOptions("oauth-github-complete", 10, time.Minute, middleware.RateLimitOptions{
+ FailureMode: middleware.RateLimitFailClose,
+ }),
+ h.Auth.CompleteGitHubOAuthRegistration,
+ )
+ auth.GET("/oauth/google/start", h.Auth.GoogleOAuthStart)
+ auth.GET("/oauth/google/callback", h.Auth.GoogleOAuthCallback)
+ auth.POST("/oauth/google/complete-registration",
+ rateLimiter.LimitWithOptions("oauth-google-complete", 10, time.Minute, middleware.RateLimitOptions{
+ FailureMode: middleware.RateLimitFailClose,
+ }),
+ h.Auth.CompleteGoogleOAuthRegistration,
+ )
auth.GET("/oauth/linuxdo/bind/start", func(c *gin.Context) {
query := c.Request.URL.Query()
query.Set("intent", "bind_current_user")
@@ -172,6 +188,7 @@ func RegisterAuthRoutes(
settings := v1.Group("/settings")
{
settings.GET("/public", h.Setting.GetPublicSettings)
+ settings.GET("/site-logo", h.Setting.GetPublicSiteLogo)
}
// 需要认证的当前用户信息
@@ -184,4 +201,20 @@ func RegisterAuthRoutes(
authenticated.POST("/auth/revoke-all-sessions", h.Auth.RevokeAllSessions)
authenticated.POST("/auth/oauth/bind-token", h.Auth.PrepareOAuthBindAccessTokenCookie)
}
+
+ // Chat 子域一次性登录桥接。创建短码需要用户登录;兑换短码由 chat 服务端调用,不依赖 admin 子域 localStorage。
+ chatBridge := v1.Group("/chat/bridge")
+ {
+ chatBridge.POST("/exchange", rateLimiter.LimitWithOptions("chat-bridge-exchange", 60, time.Minute, middleware.RateLimitOptions{
+ FailureMode: middleware.RateLimitFailClose,
+ }), h.Auth.ExchangeChatBridgeCode)
+ }
+
+ authenticatedChatBridge := v1.Group("/chat/bridge")
+ authenticatedChatBridge.Use(gin.HandlerFunc(jwtAuth))
+ {
+ authenticatedChatBridge.POST("/code", rateLimiter.LimitWithOptions("chat-bridge-code", 30, time.Minute, middleware.RateLimitOptions{
+ FailureMode: middleware.RateLimitFailClose,
+ }), h.Auth.CreateChatBridgeCode)
+ }
}
diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go
index 9541cda1abf..21c5ef230fa 100644
--- a/backend/internal/server/routes/gateway.go
+++ b/backend/internal/server/routes/gateway.go
@@ -2,6 +2,8 @@ package routes
import (
"net/http"
+ "os"
+ "strings"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
@@ -31,6 +33,86 @@ func RegisterGatewayRoutes(
requireGroupAnthropic := middleware.RequireGroupAssignment(settingService, middleware.AnthropicErrorWriter)
requireGroupGoogle := middleware.RequireGroupAssignment(settingService, middleware.GoogleErrorWriter)
+ anthropicMessagesHandler := func(c *gin.Context) {
+ if rejectKiroAutoRoute(c, cfg) || rejectCursorAutoRoute(c, cfg) {
+ return
+ }
+ if getGroupPlatform(c) == service.PlatformOpenAI {
+ h.OpenAIGateway.Messages(c)
+ return
+ }
+ h.Gateway.Messages(c)
+ }
+ anthropicCountTokensHandler := func(c *gin.Context) {
+ if rejectKiroAutoRoute(c, cfg) || rejectCursorAutoRoute(c, cfg) {
+ return
+ }
+ if getGroupPlatform(c) == service.PlatformOpenAI {
+ c.JSON(http.StatusNotFound, gin.H{
+ "type": "error",
+ "error": gin.H{
+ "type": "not_found_error",
+ "message": "Token counting is not supported for this platform",
+ },
+ })
+ return
+ }
+ h.Gateway.CountTokens(c)
+ }
+ responsesHandler := func(c *gin.Context) {
+ if rejectKiroAutoRoute(c, cfg) || rejectCursorAutoRoute(c, cfg) {
+ return
+ }
+ if getGroupPlatform(c) == service.PlatformOpenAI {
+ h.OpenAIGateway.Responses(c)
+ return
+ }
+ h.Gateway.Responses(c)
+ }
+ responsesWebSocketHandler := func(c *gin.Context) {
+ if rejectKiroAutoRoute(c, cfg) || rejectCursorAutoRoute(c, cfg) {
+ return
+ }
+ h.OpenAIGateway.ResponsesWebSocket(c)
+ }
+ chatCompletionsHandler := func(c *gin.Context) {
+ if rejectKiroAutoRoute(c, cfg) || rejectCursorAutoRoute(c, cfg) {
+ return
+ }
+ if getGroupPlatform(c) == service.PlatformOpenAI {
+ h.OpenAIGateway.ChatCompletions(c)
+ return
+ }
+ h.Gateway.ChatCompletions(c)
+ }
+ imagesHandler := func(c *gin.Context) {
+ if rejectKiroAutoRoute(c, cfg) || rejectCursorAutoRoute(c, cfg) {
+ return
+ }
+ if getGroupPlatform(c) != service.PlatformOpenAI {
+ c.JSON(http.StatusNotFound, gin.H{
+ "error": gin.H{
+ "type": "not_found_error",
+ "message": "Images API is not supported for this platform",
+ },
+ })
+ return
+ }
+ h.OpenAIGateway.Images(c)
+ }
+ modelsHandler := func(c *gin.Context) {
+ if rejectKiroAutoRoute(c, cfg) || rejectCursorAutoRoute(c, cfg) {
+ return
+ }
+ h.Gateway.Models(c)
+ }
+ usageHandler := func(c *gin.Context) {
+ if rejectKiroAutoRoute(c, cfg) || rejectCursorAutoRoute(c, cfg) {
+ return
+ }
+ h.Gateway.Usage(c)
+ }
+
// API网关(Claude API兼容)
gateway := r.Group("/v1")
gateway.Use(bodyLimit)
@@ -41,77 +123,18 @@ func RegisterGatewayRoutes(
gateway.Use(requireGroupAnthropic)
{
// /v1/messages: auto-route based on group platform
- gateway.POST("/messages", func(c *gin.Context) {
- if getGroupPlatform(c) == service.PlatformOpenAI {
- h.OpenAIGateway.Messages(c)
- return
- }
- h.Gateway.Messages(c)
- })
- // /v1/messages/count_tokens: OpenAI groups get 404
- gateway.POST("/messages/count_tokens", func(c *gin.Context) {
- if getGroupPlatform(c) == service.PlatformOpenAI {
- c.JSON(http.StatusNotFound, gin.H{
- "type": "error",
- "error": gin.H{
- "type": "not_found_error",
- "message": "Token counting is not supported for this platform",
- },
- })
- return
- }
- h.Gateway.CountTokens(c)
- })
- gateway.GET("/models", h.Gateway.Models)
- gateway.GET("/usage", h.Gateway.Usage)
+ gateway.POST("/messages", anthropicMessagesHandler)
+ gateway.POST("/messages/count_tokens", anthropicCountTokensHandler)
+ gateway.GET("/models", modelsHandler)
+ gateway.GET("/usage", usageHandler)
// OpenAI Responses API: auto-route based on group platform
- gateway.POST("/responses", func(c *gin.Context) {
- if getGroupPlatform(c) == service.PlatformOpenAI {
- h.OpenAIGateway.Responses(c)
- return
- }
- h.Gateway.Responses(c)
- })
- gateway.POST("/responses/*subpath", func(c *gin.Context) {
- if getGroupPlatform(c) == service.PlatformOpenAI {
- h.OpenAIGateway.Responses(c)
- return
- }
- h.Gateway.Responses(c)
- })
- gateway.GET("/responses", h.OpenAIGateway.ResponsesWebSocket)
+ gateway.POST("/responses", responsesHandler)
+ gateway.POST("/responses/*subpath", responsesHandler)
+ gateway.GET("/responses", responsesWebSocketHandler)
// OpenAI Chat Completions API: auto-route based on group platform
- gateway.POST("/chat/completions", func(c *gin.Context) {
- if getGroupPlatform(c) == service.PlatformOpenAI {
- h.OpenAIGateway.ChatCompletions(c)
- return
- }
- h.Gateway.ChatCompletions(c)
- })
- gateway.POST("/images/generations", func(c *gin.Context) {
- if getGroupPlatform(c) != service.PlatformOpenAI {
- c.JSON(http.StatusNotFound, gin.H{
- "error": gin.H{
- "type": "not_found_error",
- "message": "Images API is not supported for this platform",
- },
- })
- return
- }
- h.OpenAIGateway.Images(c)
- })
- gateway.POST("/images/edits", func(c *gin.Context) {
- if getGroupPlatform(c) != service.PlatformOpenAI {
- c.JSON(http.StatusNotFound, gin.H{
- "error": gin.H{
- "type": "not_found_error",
- "message": "Images API is not supported for this platform",
- },
- })
- return
- }
- h.OpenAIGateway.Images(c)
- })
+ gateway.POST("/chat/completions", chatCompletionsHandler)
+ gateway.POST("/images/generations", imagesHandler)
+ gateway.POST("/images/edits", imagesHandler)
}
// Gemini 原生 API 兼容层(Gemini SDK/CLI 直连)
@@ -129,56 +152,20 @@ func RegisterGatewayRoutes(
gemini.POST("/models/*modelAction", h.Gateway.GeminiV1BetaModels)
}
- // OpenAI Responses API(不带v1前缀的别名)— auto-route based on group platform
- responsesHandler := func(c *gin.Context) {
- if getGroupPlatform(c) == service.PlatformOpenAI {
- h.OpenAIGateway.Responses(c)
- return
- }
- h.Gateway.Responses(c)
- }
r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, responsesHandler)
r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, responsesHandler)
- r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.ResponsesWebSocket)
+ r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, responsesWebSocketHandler)
codexDirect := r.Group("/backend-api/codex")
codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic)
{
codexDirect.POST("/responses", responsesHandler)
codexDirect.POST("/responses/*subpath", responsesHandler)
- codexDirect.GET("/responses", h.OpenAIGateway.ResponsesWebSocket)
+ codexDirect.GET("/responses", responsesWebSocketHandler)
}
// OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform
- r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) {
- if getGroupPlatform(c) == service.PlatformOpenAI {
- h.OpenAIGateway.ChatCompletions(c)
- return
- }
- h.Gateway.ChatCompletions(c)
- })
- r.POST("/images/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) {
- if getGroupPlatform(c) != service.PlatformOpenAI {
- c.JSON(http.StatusNotFound, gin.H{
- "error": gin.H{
- "type": "not_found_error",
- "message": "Images API is not supported for this platform",
- },
- })
- return
- }
- h.OpenAIGateway.Images(c)
- })
- r.POST("/images/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) {
- if getGroupPlatform(c) != service.PlatformOpenAI {
- c.JSON(http.StatusNotFound, gin.H{
- "error": gin.H{
- "type": "not_found_error",
- "message": "Images API is not supported for this platform",
- },
- })
- return
- }
- h.OpenAIGateway.Images(c)
- })
+ r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, chatCompletionsHandler)
+ r.POST("/images/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, imagesHandler)
+ r.POST("/images/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, imagesHandler)
// Antigravity 模型列表
r.GET("/antigravity/models", gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.Gateway.AntigravityModels)
@@ -199,6 +186,38 @@ func RegisterGatewayRoutes(
antigravityV1.GET("/usage", h.Gateway.Usage)
}
+ kiroV1 := r.Group("/kiro/v1")
+ kiroV1.Use(bodyLimit)
+ kiroV1.Use(clientRequestID)
+ kiroV1.Use(opsErrorLogger)
+ kiroV1.Use(endpointNorm)
+ kiroV1.Use(gin.HandlerFunc(apiKeyAuth))
+ kiroV1.Use(requireGroupAnthropic)
+ kiroV1.Use(requireKiroGroup())
+ {
+ kiroV1.GET("/models", kiroBridgeConfiguredHandler(cfg, h.Gateway.KiroModels))
+ kiroV1.POST("/messages", kiroBridgeConfiguredHandler(cfg, h.Gateway.KiroMessages))
+ kiroV1.POST("/messages/count_tokens", kiroBridgeConfiguredHandler(cfg, h.Gateway.KiroCountTokens))
+ kiroV1.POST("/responses", kiroBridgeConfiguredHandler(cfg, h.Gateway.KiroResponses))
+ kiroV1.POST("/chat/completions", kiroBridgeConfiguredHandler(cfg, h.Gateway.KiroChatCompletions))
+ }
+
+ cursorV1 := r.Group("/cursor/v1")
+ cursorV1.Use(bodyLimit)
+ cursorV1.Use(clientRequestID)
+ cursorV1.Use(opsErrorLogger)
+ cursorV1.Use(endpointNorm)
+ cursorV1.Use(gin.HandlerFunc(apiKeyAuth))
+ cursorV1.Use(requireGroupAnthropic)
+ cursorV1.Use(requireCursorGroup())
+ {
+ cursorV1.GET("/models", cursorBridgeConfiguredHandler(cfg, h.Gateway.CursorModels))
+ cursorV1.POST("/messages", cursorBridgeConfiguredHandler(cfg, h.Gateway.CursorMessages))
+ cursorV1.POST("/messages/count_tokens", cursorBridgeConfiguredHandler(cfg, h.Gateway.CursorCountTokens))
+ cursorV1.POST("/responses", cursorBridgeConfiguredHandler(cfg, h.Gateway.CursorResponses))
+ cursorV1.POST("/chat/completions", cursorBridgeConfiguredHandler(cfg, h.Gateway.CursorChatCompletions))
+ }
+
antigravityV1Beta := r.Group("/antigravity/v1beta")
antigravityV1Beta.Use(bodyLimit)
antigravityV1Beta.Use(clientRequestID)
@@ -223,3 +242,168 @@ func getGroupPlatform(c *gin.Context) string {
}
return apiKey.Group.Platform
}
+
+func isCursorRouteEnabled(cfg *config.Config) bool {
+ if cfg != nil && cfg.Cursor.Enabled && cfg.Cursor.RouteEnabled {
+ return true
+ }
+ return envBool("CURSOR_ENABLED") && envBool("CURSOR_ROUTE_ENABLED")
+}
+
+func rejectKiroAutoRoute(c *gin.Context, cfg *config.Config) bool {
+ if getGroupPlatform(c) != service.PlatformKiro {
+ return false
+ }
+ if kiroAutoRouteOnV1(cfg) {
+ writeKiroRouteError(
+ c,
+ http.StatusNotImplemented,
+ "api_error",
+ "Kiro /v1 auto routing is not enabled; use the dedicated /kiro/v1 endpoint",
+ )
+ return true
+ }
+ writeKiroRouteError(
+ c,
+ http.StatusNotFound,
+ "not_found_error",
+ "Kiro routing is disabled on the shared /v1 surface",
+ )
+ return true
+}
+
+func rejectCursorAutoRoute(c *gin.Context, cfg *config.Config) bool {
+ if getGroupPlatform(c) != service.PlatformCursor {
+ return false
+ }
+ if cursorAutoRouteOnV1(cfg) {
+ writeCursorRouteError(
+ c,
+ http.StatusNotImplemented,
+ "api_error",
+ "Cursor /v1 auto routing is not enabled; use the dedicated /cursor/v1 endpoint",
+ )
+ return true
+ }
+ writeCursorRouteError(
+ c,
+ http.StatusNotFound,
+ "not_found_error",
+ "Cursor routing is disabled on the shared /v1 surface",
+ )
+ return true
+}
+
+func kiroAutoRouteOnV1(cfg *config.Config) bool {
+ if cfg != nil && cfg.Kiro.Enabled && cfg.Kiro.AutoRouteOnV1 {
+ return true
+ }
+ return envBool("KIRO_ENABLED") && envBool("KIRO_AUTO_ROUTE_ON_V1")
+}
+
+func cursorAutoRouteOnV1(cfg *config.Config) bool {
+ if cfg != nil && cfg.Cursor.Enabled && cfg.Cursor.AutoRouteOnV1 {
+ return true
+ }
+ return envBool("CURSOR_ENABLED") && envBool("CURSOR_AUTO_ROUTE_ON_V1")
+}
+
+func envBool(key string) bool {
+ switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
+ case "1", "true", "yes", "on":
+ return true
+ default:
+ return false
+ }
+}
+
+func requireKiroGroup() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if getGroupPlatform(c) == service.PlatformKiro {
+ c.Next()
+ return
+ }
+ writeKiroRouteError(
+ c,
+ http.StatusForbidden,
+ "authentication_error",
+ "Kiro endpoint requires an API key assigned to a kiro group",
+ )
+ c.Abort()
+ }
+}
+
+func kiroBridgeConfiguredHandler(cfg *config.Config, next gin.HandlerFunc) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if (cfg == nil || strings.TrimSpace(cfg.Kiro.SidecarURL) == "") && strings.TrimSpace(os.Getenv("KIRO_SIDECAR_URL")) == "" {
+ writeKiroRouteError(
+ c,
+ http.StatusServiceUnavailable,
+ "api_error",
+ "Kiro sidecar is not configured",
+ )
+ return
+ }
+ next(c)
+ }
+}
+
+func requireCursorGroup() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if getGroupPlatform(c) == service.PlatformCursor {
+ c.Next()
+ return
+ }
+ writeCursorRouteError(
+ c,
+ http.StatusForbidden,
+ "authentication_error",
+ "Cursor endpoint requires an API key assigned to a cursor group",
+ )
+ c.Abort()
+ }
+}
+
+func cursorBridgeConfiguredHandler(cfg *config.Config, next gin.HandlerFunc) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if !isCursorRouteEnabled(cfg) {
+ writeCursorRouteError(
+ c,
+ http.StatusNotFound,
+ "not_found_error",
+ "Cursor route is disabled",
+ )
+ return
+ }
+ if (cfg == nil || strings.TrimSpace(cfg.Cursor.SidecarURL) == "") && strings.TrimSpace(os.Getenv("CURSOR_SIDECAR_URL")) == "" {
+ writeCursorRouteError(
+ c,
+ http.StatusServiceUnavailable,
+ "api_error",
+ "Cursor sidecar is not configured",
+ )
+ return
+ }
+ next(c)
+ }
+}
+
+func writeKiroRouteError(c *gin.Context, status int, errType string, message string) {
+ c.JSON(status, gin.H{
+ "type": "error",
+ "error": gin.H{
+ "type": errType,
+ "message": message,
+ },
+ })
+}
+
+func writeCursorRouteError(c *gin.Context, status int, errType string, message string) {
+ c.JSON(status, gin.H{
+ "type": "error",
+ "error": gin.H{
+ "type": errType,
+ "message": message,
+ },
+ })
+}
diff --git a/backend/internal/server/routes/gateway_test.go b/backend/internal/server/routes/gateway_test.go
index 19ef568600c..db0dc1812af 100644
--- a/backend/internal/server/routes/gateway_test.go
+++ b/backend/internal/server/routes/gateway_test.go
@@ -15,8 +15,15 @@ import (
)
func newGatewayRoutesTestRouter() *gin.Engine {
+ return newGatewayRoutesTestRouterWith(&config.Config{Gateway: config.GatewayConfig{MaxBodySize: 1 << 20}}, service.PlatformOpenAI)
+}
+
+func newGatewayRoutesTestRouterWith(cfg *config.Config, platform string) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
+ if cfg == nil {
+ cfg = &config.Config{Gateway: config.GatewayConfig{MaxBodySize: 1 << 20}}
+ }
RegisterGatewayRoutes(
router,
@@ -28,7 +35,7 @@ func newGatewayRoutesTestRouter() *gin.Engine {
groupID := int64(1)
c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{
GroupID: &groupID,
- Group: &service.Group{Platform: service.PlatformOpenAI},
+ Group: &service.Group{ID: groupID, Platform: platform, AllowMessagesDispatch: true},
})
c.Next()
}),
@@ -36,12 +43,22 @@ func newGatewayRoutesTestRouter() *gin.Engine {
nil,
nil,
nil,
- &config.Config{},
+ cfg,
)
return router
}
+func requireRouteRegistered(t *testing.T, router *gin.Engine, method, path string) {
+ t.Helper()
+ for _, route := range router.Routes() {
+ if route.Method == method && route.Path == path {
+ return
+ }
+ }
+ t.Fatalf("route %s %s is not registered", method, path)
+}
+
func TestGatewayRoutesOpenAIResponsesCompactPathIsRegistered(t *testing.T) {
router := newGatewayRoutesTestRouter()
@@ -77,3 +94,197 @@ func TestGatewayRoutesOpenAIImagesPathsAreRegistered(t *testing.T) {
require.NotEqual(t, http.StatusNotFound, w.Code, "path=%s should hit OpenAI images handler", path)
}
}
+func TestGatewayRoutesKiroDedicatedRoutesRequireKiroGroupByDefault(t *testing.T) {
+ router := newGatewayRoutesTestRouter()
+
+ requireRouteRegistered(t, router, http.MethodGet, "/kiro/v1/models")
+ requireRouteRegistered(t, router, http.MethodPost, "/kiro/v1/messages")
+ requireRouteRegistered(t, router, http.MethodPost, "/kiro/v1/messages/count_tokens")
+
+ req := httptest.NewRequest(http.MethodGet, "/kiro/v1/models", nil)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusForbidden, w.Code)
+ require.Contains(t, w.Body.String(), "kiro group")
+}
+
+func TestGatewayRoutesKiroDedicatedRoutesRequireKiroGroup(t *testing.T) {
+ cfg := &config.Config{
+ Gateway: config.GatewayConfig{MaxBodySize: 1 << 20},
+ Kiro: config.KiroConfig{
+ Enabled: true,
+ RouteEnabled: true,
+ MaxConcurrency: 1,
+ RequestTimeoutSeconds: 90,
+ },
+ }
+ router := newGatewayRoutesTestRouterWith(cfg, service.PlatformOpenAI)
+
+ requireRouteRegistered(t, router, http.MethodGet, "/kiro/v1/models")
+ requireRouteRegistered(t, router, http.MethodPost, "/kiro/v1/messages/count_tokens")
+
+ req := httptest.NewRequest(http.MethodGet, "/kiro/v1/models", nil)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusForbidden, w.Code)
+ require.Contains(t, w.Body.String(), "kiro group")
+}
+
+func TestGatewayRoutesKiroDedicatedRoutesReportMissingSidecar(t *testing.T) {
+ cfg := &config.Config{
+ Gateway: config.GatewayConfig{MaxBodySize: 1 << 20},
+ Kiro: config.KiroConfig{
+ Enabled: true,
+ RouteEnabled: true,
+ MaxConcurrency: 1,
+ RequestTimeoutSeconds: 90,
+ },
+ }
+ router := newGatewayRoutesTestRouterWith(cfg, service.PlatformKiro)
+
+ req := httptest.NewRequest(http.MethodGet, "/kiro/v1/models", nil)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusServiceUnavailable, w.Code)
+ require.Contains(t, w.Body.String(), "Kiro sidecar is not configured")
+}
+
+func TestGatewayRoutesKiroGroupDoesNotFallThroughSharedV1(t *testing.T) {
+ router := newGatewayRoutesTestRouterWith(
+ &config.Config{Gateway: config.GatewayConfig{MaxBodySize: 1 << 20}},
+ service.PlatformKiro,
+ )
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"kiro"}`))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusNotFound, w.Code)
+ require.Contains(t, w.Body.String(), "shared /v1")
+}
+
+func TestGatewayRoutesKiroGroupDoesNotFallThroughSharedResponsesWebSocket(t *testing.T) {
+ router := newGatewayRoutesTestRouterWith(
+ &config.Config{Gateway: config.GatewayConfig{MaxBodySize: 1 << 20}},
+ service.PlatformKiro,
+ )
+
+ req := httptest.NewRequest(http.MethodGet, "/responses", nil)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusNotFound, w.Code)
+ require.Contains(t, w.Body.String(), "shared /v1")
+}
+
+func TestGatewayRoutesCursorDedicatedRoutesRequireCursorGroupByDefault(t *testing.T) {
+ clearCursorRouteEnv(t)
+ router := newGatewayRoutesTestRouter()
+
+ requireRouteRegistered(t, router, http.MethodGet, "/cursor/v1/models")
+ requireRouteRegistered(t, router, http.MethodPost, "/cursor/v1/messages")
+ requireRouteRegistered(t, router, http.MethodPost, "/cursor/v1/messages/count_tokens")
+ requireRouteRegistered(t, router, http.MethodPost, "/cursor/v1/responses")
+ requireRouteRegistered(t, router, http.MethodPost, "/cursor/v1/chat/completions")
+
+ req := httptest.NewRequest(http.MethodGet, "/cursor/v1/models", nil)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusForbidden, w.Code)
+ require.Contains(t, w.Body.String(), "cursor group")
+}
+
+func TestGatewayRoutesCursorDedicatedRoutesReportMissingSidecar(t *testing.T) {
+ clearCursorRouteEnv(t)
+ cfg := &config.Config{
+ Gateway: config.GatewayConfig{MaxBodySize: 1 << 20},
+ Cursor: config.CursorConfig{
+ Enabled: true,
+ RouteEnabled: true,
+ MaxConcurrency: 1,
+ RequestTimeoutSeconds: 90,
+ },
+ }
+ router := newGatewayRoutesTestRouterWith(cfg, service.PlatformCursor)
+
+ req := httptest.NewRequest(http.MethodGet, "/cursor/v1/models", nil)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusServiceUnavailable, w.Code)
+ require.Contains(t, w.Body.String(), "Cursor sidecar is not configured")
+}
+
+func TestGatewayRoutesCursorDedicatedRoutesStayDisabledWithoutRouteFlag(t *testing.T) {
+ clearCursorRouteEnv(t)
+ cfg := &config.Config{
+ Gateway: config.GatewayConfig{MaxBodySize: 1 << 20},
+ Cursor: config.CursorConfig{
+ SidecarURL: "http://127.0.0.1:8788",
+ MaxConcurrency: 1,
+ RequestTimeoutSeconds: 90,
+ },
+ }
+ router := newGatewayRoutesTestRouterWith(cfg, service.PlatformCursor)
+
+ req := httptest.NewRequest(http.MethodGet, "/cursor/v1/models", nil)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusNotFound, w.Code)
+ require.Contains(t, w.Body.String(), "Cursor route is disabled")
+}
+
+func TestGatewayRoutesCursorGroupDoesNotFallThroughSharedV1(t *testing.T) {
+ clearCursorRouteEnv(t)
+ router := newGatewayRoutesTestRouterWith(
+ &config.Config{Gateway: config.GatewayConfig{MaxBodySize: 1 << 20}},
+ service.PlatformCursor,
+ )
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"cursor-auto"}`))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusNotFound, w.Code)
+ require.Contains(t, w.Body.String(), "shared /v1")
+}
+
+func TestGatewayRoutesCursorGroupDoesNotFallThroughSharedResponsesWebSocket(t *testing.T) {
+ clearCursorRouteEnv(t)
+ router := newGatewayRoutesTestRouterWith(
+ &config.Config{Gateway: config.GatewayConfig{MaxBodySize: 1 << 20}},
+ service.PlatformCursor,
+ )
+
+ req := httptest.NewRequest(http.MethodGet, "/responses", nil)
+ w := httptest.NewRecorder()
+
+ router.ServeHTTP(w, req)
+
+ require.Equal(t, http.StatusNotFound, w.Code)
+ require.Contains(t, w.Body.String(), "shared /v1")
+}
+
+func clearCursorRouteEnv(t *testing.T) {
+ t.Helper()
+ t.Setenv("CURSOR_ENABLED", "")
+ t.Setenv("CURSOR_ROUTE_ENABLED", "")
+ t.Setenv("CURSOR_AUTO_ROUTE_ON_V1", "")
+ t.Setenv("CURSOR_SIDECAR_URL", "")
+}
diff --git a/backend/internal/server/routes/internal_chat.go b/backend/internal/server/routes/internal_chat.go
new file mode 100644
index 00000000000..32db74a37fe
--- /dev/null
+++ b/backend/internal/server/routes/internal_chat.go
@@ -0,0 +1,43 @@
+package routes
+
+import (
+ "crypto/subtle"
+ "net/http"
+ "os"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+// RegisterInternalChatRoutes keeps a fail-closed tombstone for the retired
+// reusable-key export. Chat integrations must proxy bounded operations instead
+// of taking custody of a customer's long-lived gateway credential.
+func RegisterInternalChatRoutes(r *gin.Engine) {
+ internal := r.Group("/internal/v1/chat")
+ internal.Use(requireChatInternalToken())
+ {
+ internal.GET("/default-api-key", func(c *gin.Context) {
+ c.Header("Cache-Control", "no-store, max-age=0")
+ c.Header("Pragma", "no-cache")
+ c.JSON(http.StatusGone, gin.H{"error": "reusable API-key export is retired"})
+ })
+ }
+}
+
+func requireChatInternalToken() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ expected := strings.TrimSpace(os.Getenv("HFC_CHAT_INTERNAL_TOKEN"))
+ if expected == "" {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": "chat internal token is not configured"})
+ c.Abort()
+ return
+ }
+ got := strings.TrimSpace(c.GetHeader("X-HFC-Internal-Token"))
+ if subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid internal token"})
+ c.Abort()
+ return
+ }
+ c.Next()
+ }
+}
diff --git a/backend/internal/server/routes/internal_chat_test.go b/backend/internal/server/routes/internal_chat_test.go
new file mode 100644
index 00000000000..cc708272b6c
--- /dev/null
+++ b/backend/internal/server/routes/internal_chat_test.go
@@ -0,0 +1,37 @@
+//go:build unit
+
+package routes
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestInternalChatDefaultAPIKeyExportIsRetired(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ t.Setenv("HFC_CHAT_INTERNAL_TOKEN", "service-token")
+
+ router := gin.New()
+ RegisterInternalChatRoutes(router)
+
+ denied := httptest.NewRecorder()
+ badReq := httptest.NewRequest(http.MethodGet, "/internal/v1/chat/default-api-key", nil)
+ badReq.Header.Set("X-HFC-Internal-Token", "wrong")
+ router.ServeHTTP(denied, badReq)
+ require.Equal(t, http.StatusUnauthorized, denied.Code)
+
+ retired := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodGet, "/internal/v1/chat/default-api-key", nil)
+ request.Header.Set("X-HFC-Internal-Token", "service-token")
+ request.Header.Set("X-HFC-User-Authorization", "Bearer replayable-user-jwt")
+ router.ServeHTTP(retired, request)
+
+ require.Equal(t, http.StatusGone, retired.Code)
+ require.Contains(t, retired.Body.String(), "reusable API-key export is retired")
+ require.NotContains(t, retired.Body.String(), "api_key")
+ require.Equal(t, "no-store, max-age=0", retired.Header().Get("Cache-Control"))
+}
diff --git a/backend/internal/server/routes/payment.go b/backend/internal/server/routes/payment.go
index e4828eadf95..1f4eb7d3de6 100644
--- a/backend/internal/server/routes/payment.go
+++ b/backend/internal/server/routes/payment.go
@@ -1,12 +1,23 @@
package routes
import (
+ "net/http"
+ "strconv"
+ "time"
+
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
+ appmiddleware "github.com/Wei-Shaw/sub2api/internal/middleware"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
+ "github.com/redis/go-redis/v9"
+)
+
+const (
+ paymentJSONBodyMaxBytes int64 = 16 * 1024
+ paymentStatusRequestsPerMinute = 120
)
// RegisterPaymentRoutes registers all payment-related routes:
@@ -19,11 +30,31 @@ func RegisterPaymentRoutes(
jwtAuth middleware.JWTAuthMiddleware,
adminAuth middleware.AdminAuthMiddleware,
settingService *service.SettingService,
+ redisClient *redis.Client,
) {
+ rateLimiter := appmiddleware.NewRateLimiter(redisClient)
+ paymentWriteLimit := rateLimiter.LimitWithOptions("payment-order-write", 30, time.Minute, appmiddleware.RateLimitOptions{
+ FailureMode: appmiddleware.RateLimitFailClose,
+ })
+ authenticatedPaymentStatusLimit := rateLimiter.LimitWithOptions("payment-order-auth-status", paymentStatusRequestsPerMinute, time.Minute, appmiddleware.RateLimitOptions{
+ FailureMode: appmiddleware.RateLimitFailClose,
+ KeyFunc: func(c *gin.Context) string {
+ subject, ok := middleware.GetAuthSubjectFromContext(c)
+ if !ok || subject.UserID <= 0 {
+ return ""
+ }
+ return "user-" + strconv.FormatInt(subject.UserID, 10)
+ },
+ })
+ publicPaymentStatusLimit := rateLimiter.LimitWithOptions("payment-order-public-status", paymentStatusRequestsPerMinute, time.Minute, appmiddleware.RateLimitOptions{
+ FailureMode: appmiddleware.RateLimitFailClose,
+ })
+
// --- User-facing payment endpoints (authenticated) ---
authenticated := v1.Group("/payment")
authenticated.Use(gin.HandlerFunc(jwtAuth))
authenticated.Use(middleware.BackendModeUserGuard(settingService))
+ authenticated.Use(limitPaymentJSONBody(paymentJSONBodyMaxBytes))
{
authenticated.GET("/config", paymentHandler.GetPaymentConfig)
authenticated.GET("/checkout-info", paymentHandler.GetCheckoutInfo)
@@ -33,8 +64,8 @@ func RegisterPaymentRoutes(
orders := authenticated.Group("/orders")
{
- orders.POST("", paymentHandler.CreateOrder)
- orders.POST("/verify", paymentHandler.VerifyOrder)
+ orders.POST("", paymentWriteLimit, paymentHandler.CreateOrder)
+ orders.POST("/verify", authenticatedPaymentStatusLimit, paymentHandler.VerifyOrder)
orders.GET("/my", paymentHandler.GetMyOrders)
orders.GET("/:id", paymentHandler.GetOrder)
orders.POST("/:id/cancel", paymentHandler.CancelOrder)
@@ -44,13 +75,13 @@ func RegisterPaymentRoutes(
}
// --- Public payment endpoints (no auth) ---
- // Signed resume-token recovery is the preferred public lookup path.
- // The legacy anonymous out_trade_no verify endpoint remains available as a
- // persisted-state compatibility path for staggered upgrades.
+ // Public order recovery requires a signed resume token. The legacy verify
+ // URL remains during staggered upgrades, but no longer accepts out_trade_no.
public := v1.Group("/payment/public")
+ public.Use(limitPaymentJSONBody(paymentJSONBodyMaxBytes))
{
- public.POST("/orders/verify", paymentHandler.VerifyOrderPublic)
- public.POST("/orders/resolve", paymentHandler.ResolveOrderPublicByResumeToken)
+ public.POST("/orders/verify", publicPaymentStatusLimit, paymentHandler.VerifyOrderPublic)
+ public.POST("/orders/resolve", publicPaymentStatusLimit, paymentHandler.ResolveOrderPublicByResumeToken)
}
// --- Webhook endpoints (no auth) ---
@@ -104,3 +135,12 @@ func RegisterPaymentRoutes(
}
}
}
+
+func limitPaymentJSONBody(maxBytes int64) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if c.Request.Body != nil {
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
+ }
+ c.Next()
+ }
+}
diff --git a/backend/internal/server/routes/payment_body_limit_test.go b/backend/internal/server/routes/payment_body_limit_test.go
new file mode 100644
index 00000000000..5cbb2afe587
--- /dev/null
+++ b/backend/internal/server/routes/payment_body_limit_test.go
@@ -0,0 +1,36 @@
+package routes
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLimitPaymentJSONBodyRejectsOversizedPayload(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ router.POST("/payment", limitPaymentJSONBody(8), func(c *gin.Context) {
+ _, err := io.ReadAll(c.Request.Body)
+ if err != nil {
+ c.Status(http.StatusRequestEntityTooLarge)
+ return
+ }
+ c.Status(http.StatusNoContent)
+ })
+
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodPost, "/payment", strings.NewReader("123456789"))
+ router.ServeHTTP(recorder, request)
+ require.Equal(t, http.StatusRequestEntityTooLarge, recorder.Code)
+}
+
+func TestPaymentStatusRateLimitSupportsConcurrentNormalPolling(t *testing.T) {
+ const pollsPerClientPerMinute = 20
+ const concurrentClientsBehindNAT = 2
+ require.GreaterOrEqual(t, paymentStatusRequestsPerMinute, pollsPerClientPerMinute*concurrentClientsBehindNAT)
+}
diff --git a/backend/internal/server/routes/usage_rate_limit.go b/backend/internal/server/routes/usage_rate_limit.go
new file mode 100644
index 00000000000..2a60903fd5e
--- /dev/null
+++ b/backend/internal/server/routes/usage_rate_limit.go
@@ -0,0 +1,21 @@
+package routes
+
+import (
+ "strconv"
+
+ "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/gin-gonic/gin"
+)
+
+const (
+ userUsageQueryRequestsPerMinute = 120
+ adminUsageQueryRequestsPerMinute = 120
+)
+
+func authenticatedUsageRateLimitKey(c *gin.Context) string {
+ subject, ok := middleware.GetAuthSubjectFromContext(c)
+ if !ok || subject.UserID <= 0 {
+ return ""
+ }
+ return "user-" + strconv.FormatInt(subject.UserID, 10)
+}
diff --git a/backend/internal/server/routes/usage_rate_limit_test.go b/backend/internal/server/routes/usage_rate_limit_test.go
new file mode 100644
index 00000000000..8820bc9ddfb
--- /dev/null
+++ b/backend/internal/server/routes/usage_rate_limit_test.go
@@ -0,0 +1,55 @@
+package routes
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/handler"
+ adminhandler "github.com/Wei-Shaw/sub2api/internal/handler/admin"
+ servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
+ "github.com/gin-gonic/gin"
+ "github.com/redis/go-redis/v9"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUsageQueryRoutesFailCloseWhenRedisUnavailable(t *testing.T) {
+ rdb := redis.NewClient(&redis.Options{
+ Addr: "127.0.0.1:1",
+ DialTimeout: 50 * time.Millisecond,
+ ReadTimeout: 50 * time.Millisecond,
+ WriteTimeout: 50 * time.Millisecond,
+ })
+ t.Cleanup(func() { _ = rdb.Close() })
+
+ auth := func(c *gin.Context) {
+ c.Set(string(servermiddleware.ContextKeyUser), servermiddleware.AuthSubject{UserID: 42})
+ c.Next()
+ }
+
+ userRouter := gin.New()
+ RegisterUserRoutes(
+ userRouter.Group("/api/v1"),
+ &handler.Handlers{Usage: &handler.UsageHandler{}},
+ servermiddleware.JWTAuthMiddleware(auth),
+ nil,
+ rdb,
+ )
+ userRequest := httptest.NewRequest(http.MethodGet, "/api/v1/usage", nil)
+ userRecorder := httptest.NewRecorder()
+ userRouter.ServeHTTP(userRecorder, userRequest)
+ require.Equal(t, http.StatusTooManyRequests, userRecorder.Code)
+
+ adminRouter := gin.New()
+ RegisterAdminRoutes(
+ adminRouter.Group("/api/v1"),
+ &handler.Handlers{Admin: &handler.AdminHandlers{Usage: &adminhandler.UsageHandler{}}},
+ servermiddleware.AdminAuthMiddleware(auth),
+ rdb,
+ )
+ adminRequest := httptest.NewRequest(http.MethodGet, "/api/v1/admin/usage", nil)
+ adminRecorder := httptest.NewRecorder()
+ adminRouter.ServeHTTP(adminRecorder, adminRequest)
+ require.Equal(t, http.StatusTooManyRequests, adminRecorder.Code)
+}
diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go
index 9976954cf34..c2ca67cee30 100644
--- a/backend/internal/server/routes/user.go
+++ b/backend/internal/server/routes/user.go
@@ -1,11 +1,15 @@
package routes
import (
+ "time"
+
"github.com/Wei-Shaw/sub2api/internal/handler"
+ appmiddleware "github.com/Wei-Shaw/sub2api/internal/middleware"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
+ "github.com/redis/go-redis/v9"
)
// RegisterUserRoutes 注册用户相关路由(需要认证)
@@ -14,6 +18,7 @@ func RegisterUserRoutes(
h *handler.Handlers,
jwtAuth middleware.JWTAuthMiddleware,
settingService *service.SettingService,
+ redisClient *redis.Client,
) {
authenticated := v1.Group("")
authenticated.Use(gin.HandlerFunc(jwtAuth))
@@ -59,6 +64,7 @@ func RegisterUserRoutes(
keys.GET("", h.APIKey.List)
keys.GET("/:id", h.APIKey.GetByID)
keys.POST("", h.APIKey.Create)
+ keys.POST("/:id/reveal", h.APIKey.Reveal)
keys.PUT("/:id", h.APIKey.Update)
keys.DELETE("/:id", h.APIKey.Delete)
}
@@ -68,6 +74,7 @@ func RegisterUserRoutes(
{
groups.GET("/available", h.APIKey.GetAvailableGroups)
groups.GET("/rates", h.APIKey.GetUserGroupRates)
+ groups.GET("/model-routes", h.APIKey.GetWalletModelRoutes)
}
// 用户可用渠道(非管理员接口)
@@ -78,6 +85,15 @@ func RegisterUserRoutes(
// 使用记录
usage := authenticated.Group("/usage")
+ usage.Use(appmiddleware.NewRateLimiter(redisClient).LimitWithOptions(
+ "user-usage-query",
+ userUsageQueryRequestsPerMinute,
+ time.Minute,
+ appmiddleware.RateLimitOptions{
+ FailureMode: appmiddleware.RateLimitFailClose,
+ KeyFunc: authenticatedUsageRateLimitKey,
+ },
+ ))
{
usage.GET("", h.Usage.List)
usage.GET("/:id", h.Usage.GetByID)
diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go
index cd06ffa3c49..b8f7e3077ca 100644
--- a/backend/internal/service/account.go
+++ b/backend/internal/service/account.go
@@ -121,6 +121,9 @@ func (a *Account) IsSchedulable() bool {
if a.TempUnschedulableUntil != nil && now.Before(*a.TempUnschedulableUntil) {
return false
}
+ if a.IsOpenAIQuotaGuardedAt(now) {
+ return false
+ }
if a.IsAPIKeyOrBedrock() && a.IsQuotaExceeded() {
return false
}
@@ -583,21 +586,6 @@ func normalizeRequestedModelForLookup(platform, requestedModel string) string {
return trimmed
}
-func mappingSupportsRequestedModel(mapping map[string]string, requestedModel string) bool {
- if requestedModel == "" {
- return false
- }
- if _, exists := mapping[requestedModel]; exists {
- return true
- }
- for pattern := range mapping {
- if matchWildcard(pattern, requestedModel) {
- return true
- }
- }
- return false
-}
-
func resolveRequestedModelInMapping(mapping map[string]string, requestedModel string) (mappedModel string, matched bool) {
if requestedModel == "" {
return "", false
@@ -608,18 +596,55 @@ func resolveRequestedModelInMapping(mapping map[string]string, requestedModel st
return matchWildcardMappingResult(mapping, requestedModel)
}
+func openAIGPT56MappingTargetEntitled(mapping map[string]string, requestedModel, mappedModel string) bool {
+ targetTier, targetIsFamily := classifyOpenAIGPT56PreviewModel(mappedModel)
+ if !targetIsFamily {
+ return true
+ }
+ if targetTier == "" || !ValidateOpenAIGPT56ModelTransition(requestedModel, mappedModel) {
+ return false
+ }
+ exactTarget, exists := mapping[targetTier]
+ if !exists {
+ return false
+ }
+ exactTier, exactIsFamily := classifyOpenAIGPT56PreviewModel(exactTarget)
+ return exactIsFamily && exactTier == targetTier
+}
+
// IsModelSupported 检查模型是否在 model_mapping 中(支持通配符)
// 如果未配置 mapping,返回 true(允许所有模型)
func (a *Account) IsModelSupported(requestedModel string) bool {
mapping := a.GetModelMapping()
+ if a.Platform == PlatformOpenAI {
+ normalizedGPT56, isGPT56Family := classifyOpenAIGPT56PreviewModel(requestedModel)
+ if isGPT56Family {
+ if normalizedGPT56 == "" {
+ return false
+ }
+ mappedModel, hasExactTier := mapping[normalizedGPT56]
+ if !hasExactTier {
+ return false
+ }
+ normalizedMappedModel, mappedIsGPT56Family := classifyOpenAIGPT56PreviewModel(mappedModel)
+ return mappedIsGPT56Family && normalizedMappedModel == normalizedGPT56
+ }
+ }
if len(mapping) == 0 {
return true // 无映射 = 允许所有
}
- if mappingSupportsRequestedModel(mapping, requestedModel) {
- return true
+ if mappedModel, matched := resolveRequestedModelInMapping(mapping, requestedModel); matched {
+ if a.Platform != PlatformOpenAI {
+ return true
+ }
+ return openAIGPT56MappingTargetEntitled(mapping, requestedModel, mappedModel)
}
normalized := normalizeRequestedModelForLookup(a.Platform, requestedModel)
- return normalized != requestedModel && mappingSupportsRequestedModel(mapping, normalized)
+ if normalized == requestedModel {
+ return false
+ }
+ mappedModel, matched := resolveRequestedModelInMapping(mapping, normalized)
+ return matched && (a.Platform != PlatformOpenAI || openAIGPT56MappingTargetEntitled(mapping, normalized, mappedModel))
}
// GetMappedModel 获取映射后的模型名(支持通配符,最长优先匹配)
@@ -636,12 +661,31 @@ func (a *Account) ResolveMappedModel(requestedModel string) (mappedModel string,
if len(mapping) == 0 {
return requestedModel, false
}
+ if a.Platform == PlatformOpenAI {
+ normalizedGPT56, isGPT56Family := classifyOpenAIGPT56PreviewModel(requestedModel)
+ if isGPT56Family {
+ if normalizedGPT56 == "" {
+ return requestedModel, false
+ }
+ mappedModel, exists := mapping[normalizedGPT56]
+ if !exists || !openAIGPT56MappingTargetEntitled(mapping, requestedModel, mappedModel) {
+ return requestedModel, false
+ }
+ return mappedModel, true
+ }
+ }
if mappedModel, matched := resolveRequestedModelInMapping(mapping, requestedModel); matched {
+ if a.Platform == PlatformOpenAI && !openAIGPT56MappingTargetEntitled(mapping, requestedModel, mappedModel) {
+ return requestedModel, false
+ }
return mappedModel, true
}
normalized := normalizeRequestedModelForLookup(a.Platform, requestedModel)
if normalized != requestedModel {
if mappedModel, matched := resolveRequestedModelInMapping(mapping, normalized); matched {
+ if a.Platform == PlatformOpenAI && !openAIGPT56MappingTargetEntitled(mapping, normalized, mappedModel) {
+ return requestedModel, false
+ }
return mappedModel, true
}
}
@@ -714,6 +758,12 @@ func (a *Account) ResolveCompactMappedModel(requestedModel string) (mappedModel
return requestedModel, false
}
if mappedModel, matched := resolveRequestedModelInMapping(mapping, requestedModel); matched {
+ if !ValidateOpenAIGPT56ModelTransition(requestedModel, mappedModel) {
+ return "", true
+ }
+ if _, isGPT56Family := classifyOpenAIGPT56PreviewModel(mappedModel); isGPT56Family && !a.IsModelSupported(mappedModel) {
+ return "", true
+ }
return mappedModel, true
}
return requestedModel, false
diff --git a/backend/internal/service/account_credentials_persistence.go b/backend/internal/service/account_credentials_persistence.go
index 916df5366cc..4cfa148c56f 100644
--- a/backend/internal/service/account_credentials_persistence.go
+++ b/backend/internal/service/account_credentials_persistence.go
@@ -1,6 +1,9 @@
package service
-import "context"
+import (
+ "context"
+ "time"
+)
type accountCredentialsUpdater interface {
UpdateCredentials(ctx context.Context, id int64, credentials map[string]any) error
@@ -12,12 +15,34 @@ func persistAccountCredentials(ctx context.Context, repo AccountRepository, acco
}
account.Credentials = cloneCredentials(credentials)
+ AdvanceOAuthTokenVersion(account)
if updater, ok := any(repo).(accountCredentialsUpdater); ok {
return updater.UpdateCredentials(ctx, account.ID, account.Credentials)
}
return repo.Update(ctx, account)
}
+// AdvanceOAuthTokenVersion makes every persisted OAuth credential generation
+// distinguishable from the previous one. Milliseconds stay exactly representable
+// in JSON numbers; the +1 fallback preserves monotonicity during same-ms writes.
+func AdvanceOAuthTokenVersion(account *Account) {
+ if account == nil || account.Type != AccountTypeOAuth {
+ return
+ }
+ if account.Credentials == nil {
+ account.Credentials = make(map[string]any)
+ }
+ account.Credentials["_token_version"] = nextOAuthTokenVersion(account.GetCredentialAsInt64("_token_version"))
+}
+
+func nextOAuthTokenVersion(current int64) int64 {
+ next := time.Now().UnixMilli()
+ if next <= current {
+ return current + 1
+ }
+ return next
+}
+
func cloneCredentials(in map[string]any) map[string]any {
if in == nil {
return map[string]any{}
diff --git a/backend/internal/service/account_openai_compact_test.go b/backend/internal/service/account_openai_compact_test.go
index 442b00daf4a..8c516c5e3d4 100644
--- a/backend/internal/service/account_openai_compact_test.go
+++ b/backend/internal/service/account_openai_compact_test.go
@@ -340,6 +340,42 @@ func TestAccountResolveCompactMappedModel(t *testing.T) {
expectedModel: "gpt-5.4",
expectedMatch: false,
},
+ {
+ name: "preview cross tier compact target fails closed",
+ credentials: map[string]any{
+ "compact_model_mapping": map[string]any{
+ "gpt-5.6-sol": "gpt-5.6-terra",
+ },
+ },
+ requestedModel: "gpt-5.6-sol",
+ expectedModel: "",
+ expectedMatch: true,
+ },
+ {
+ name: "legacy compact mapping to exact preview without entitlement fails closed",
+ credentials: map[string]any{
+ "compact_model_mapping": map[string]any{
+ "gpt-5.4": "gpt-5.6-luna",
+ },
+ },
+ requestedModel: "gpt-5.4",
+ expectedModel: "",
+ expectedMatch: true,
+ },
+ {
+ name: "legacy compact mapping to exact preview with entitlement remains allowed",
+ credentials: map[string]any{
+ "model_mapping": map[string]any{
+ "gpt-5.6-luna": "gpt-5.6-luna",
+ },
+ "compact_model_mapping": map[string]any{
+ "gpt-5.4": "gpt-5.6-luna",
+ },
+ },
+ requestedModel: "gpt-5.4",
+ expectedModel: "gpt-5.6-luna",
+ expectedMatch: true,
+ },
}
for _, tt := range tests {
diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go
index 3189a7290fd..6243f3f633f 100644
--- a/backend/internal/service/account_service.go
+++ b/backend/internal/service/account_service.go
@@ -142,11 +142,27 @@ func NewAccountService(accountRepo AccountRepository, groupRepo GroupRepository)
// Create 创建账号
func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) (*Account, error) {
+ if err := validateKiroAccountType(req.Platform, req.Type); err != nil {
+ return nil, err
+ }
+ if err := validateCursorAccountType(req.Platform, req.Type); err != nil {
+ return nil, err
+ }
+ if err := validateBedrockAccountCredentials(req.Type, req.Credentials); err != nil {
+ return nil, err
+ }
+
// 验证分组是否存在(如果指定了分组)
if len(req.GroupIDs) > 0 {
if err := s.validateGroupIDsExist(ctx, req.GroupIDs); err != nil {
return nil, err
}
+ if err := validateKiroAccountGroupIsolation(ctx, s.groupRepo, req.Platform, req.GroupIDs); err != nil {
+ return nil, err
+ }
+ if err := validateCursorAccountGroupIsolation(ctx, s.groupRepo, req.Platform, req.GroupIDs); err != nil {
+ return nil, err
+ }
}
// 创建账号
@@ -250,6 +266,9 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount
if req.Credentials != nil {
account.Credentials = *req.Credentials
}
+ if err := validateBedrockAccountCredentials(account.Type, account.Credentials); err != nil {
+ return nil, err
+ }
if req.Extra != nil {
account.Extra = *req.Extra
@@ -282,6 +301,12 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount
if err := s.validateGroupIDsExist(ctx, *req.GroupIDs); err != nil {
return nil, err
}
+ if err := validateKiroAccountGroupIsolation(ctx, s.groupRepo, account.Platform, *req.GroupIDs); err != nil {
+ return nil, err
+ }
+ if err := validateCursorAccountGroupIsolation(ctx, s.groupRepo, account.Platform, *req.GroupIDs); err != nil {
+ return nil, err
+ }
}
// 执行更新
diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go
index 90ff450f48a..221021d85cb 100644
--- a/backend/internal/service/account_stats_pricing.go
+++ b/backend/internal/service/account_stats_pricing.go
@@ -230,7 +230,11 @@ func applyAccountStatsCost(
if model == "" {
model = requestedModel
}
+ requestCount := 1
+ if usageLog != nil && usageLog.ImageCount > 0 {
+ requestCount = usageLog.ImageCount
+ }
usageLog.AccountStatsCost = resolveAccountStatsCost(
- ctx, cs, bs, accountID, groupID, model, tokens, 1, totalCost,
+ ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost,
)
}
diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go
index 391e74757b3..9de3da125d6 100644
--- a/backend/internal/service/account_test_service.go
+++ b/backend/internal/service/account_test_service.go
@@ -4,8 +4,6 @@ import (
"bufio"
"bytes"
"context"
- "crypto/rand"
- "encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -13,7 +11,10 @@ import (
"log"
"net/http"
"net/http/httptest"
+ "net/url"
+ "os"
"regexp"
+ "strconv"
"strings"
"time"
@@ -21,9 +22,9 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
- "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
"github.com/gin-gonic/gin"
- "github.com/google/uuid"
+ "github.com/tidwall/gjson"
)
// sseDataPrefix matches SSE data lines with optional whitespace after colon.
@@ -31,7 +32,6 @@ import (
var sseDataPrefix = regexp.MustCompile(`^data:\s*`)
const (
- testClaudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true"
chatgptCodexAPIURL = "https://chatgpt.com/backend-api/codex/responses"
)
@@ -96,41 +96,16 @@ func (s *AccountTestService) validateUpstreamBaseURL(raw string) (string, error)
if s.cfg == nil {
return "", errors.New("config is not available")
}
- if !s.cfg.Security.URLAllowlist.Enabled {
- return urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
- }
- normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
- AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
- RequireAllowlist: true,
- AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
- })
+ normalized, err := validateUpstreamBaseURLFormat(raw, s.cfg)
if err != nil {
return "", err
}
return normalized, nil
}
-// generateSessionString generates a Claude Code style session string.
-// The output format is determined by the UA version in claude.DefaultHeaders,
-// ensuring consistency between the user_id format and the UA sent to upstream.
-func generateSessionString() (string, error) {
- b := make([]byte, 32)
- if _, err := rand.Read(b); err != nil {
- return "", err
- }
- hex64 := hex.EncodeToString(b)
- sessionUUID := uuid.New().String()
- uaVersion := ExtractCLIVersion(claude.DefaultHeaders["User-Agent"])
- return FormatMetadataUserID(hex64, "", sessionUUID, uaVersion), nil
-}
-
-// createTestPayload creates a Claude Code style test request payload
+// createTestPayload creates a neutral Anthropic API probe. It deliberately
+// avoids Claude Code identity, metadata, cache and billing-attribution fields.
func createTestPayload(modelID string) (map[string]any, error) {
- sessionID, err := generateSessionString()
- if err != nil {
- return nil, err
- }
-
return map[string]any{
"model": modelID,
"messages": []map[string]any{
@@ -140,33 +115,17 @@ func createTestPayload(modelID string) (map[string]any, error) {
{
"type": "text",
"text": "hi",
- "cache_control": map[string]string{
- "type": "ephemeral",
- },
},
},
},
},
- "system": []map[string]any{
- {
- "type": "text",
- "text": claudeCodeSystemPrompt,
- "cache_control": map[string]string{
- "type": "ephemeral",
- },
- },
- },
- "metadata": map[string]string{
- "user_id": sessionID,
- },
- "max_tokens": 1024,
+ "max_tokens": 64,
"temperature": 1,
"stream": true,
}, nil
}
-// TestAccountConnection tests an account's connection by sending a test request
-// All account types use full Claude Code client characteristics, only auth header differs
+// TestAccountConnection tests an account's connection by sending a provider-appropriate probe.
// modelID is optional - if empty, defaults to claude.DefaultTestModel
// mode is optional - "compact" routes OpenAI accounts to the /responses/compact probe path
func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int64, modelID string, prompt string, mode string) error {
@@ -191,12 +150,22 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
return s.routeAntigravityTest(c, account, modelID, prompt)
}
+ if account.Platform == PlatformKiro {
+ return s.testKiroAccountConnection(c, account, modelID, prompt)
+ }
+ if account.Platform == PlatformCursor {
+ return s.testCursorAccountConnection(c, account, modelID, prompt)
+ }
+
return s.testClaudeAccountConnection(c, account, modelID)
}
// testClaudeAccountConnection tests an Anthropic Claude account's connection
func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account *Account, modelID string) error {
ctx := c.Request.Context()
+ if err := rejectAnthropicOAuthGatewayCredential(account); err != nil {
+ return s.sendErrorAndEnd(c, err.Error())
+ }
// Determine the model to use
testModelID := modelID
@@ -219,20 +188,10 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
// Determine authentication method and API URL
var authToken string
- var useBearer bool
var apiURL string
- if account.IsOAuth() {
- // OAuth or Setup Token - use Bearer token
- useBearer = true
- apiURL = testClaudeAPIURL
- authToken = account.GetCredential("access_token")
- if authToken == "" {
- return s.sendErrorAndEnd(c, "No access token available")
- }
- } else if account.Type == "apikey" {
+ if account.Type == "apikey" {
// API Key - use x-api-key header
- useBearer = false
authToken = account.GetCredential("api_key")
if authToken == "" {
return s.sendErrorAndEnd(c, "No API key available")
@@ -258,7 +217,7 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
c.Writer.Header().Set("X-Accel-Buffering", "no")
c.Writer.Flush()
- // Create Claude Code style payload (same for all account types)
+ // Create a neutral provider-API payload.
payload, err := createTestPayload(testModelID)
if err != nil {
return s.sendErrorAndEnd(c, "Failed to create test payload")
@@ -277,19 +236,9 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account
req.Header.Set("Content-Type", "application/json")
req.Header.Set("anthropic-version", "2023-06-01")
- // Apply Claude Code client headers
- for key, value := range claude.DefaultHeaders {
- req.Header.Set(key, value)
- }
-
- // Set authentication header
- if useBearer {
- req.Header.Set("anthropic-beta", claude.DefaultBetaHeader)
- req.Header.Set("Authorization", "Bearer "+authToken)
- } else {
- req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader)
- req.Header.Set("x-api-key", authToken)
- }
+ req.Header.Set("User-Agent", "sub2api-account-test/1")
+ req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader)
+ req.Header.Set("x-api-key", authToken)
// Get proxy URL
proxyURL := ""
@@ -389,7 +338,10 @@ func (s *AccountTestService) testClaudeVertexServiceAccountConnection(c *gin.Con
// testBedrockAccountConnection tests a Bedrock (SigV4 or API Key) account using non-streaming invoke
func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx context.Context, account *Account, testModelID string) error {
- region := bedrockRuntimeRegion(account)
+ region, err := normalizeBedrockRegion(bedrockRuntimeRegion(account))
+ if err != nil {
+ return s.sendErrorAndEnd(c, "Invalid Bedrock AWS region")
+ }
resolvedModelID, ok := ResolveBedrockModelID(account, testModelID)
if !ok {
return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported Bedrock model: %s", testModelID))
@@ -423,7 +375,10 @@ func (s *AccountTestService) testBedrockAccountConnection(c *gin.Context, ctx co
bedrockBody, _ := json.Marshal(bedrockPayload)
// Use non-streaming endpoint (response is standard Claude JSON)
- apiURL := BuildBedrockURL(region, testModelID, false)
+ apiURL, err := BuildBedrockURL(region, testModelID, false)
+ if err != nil {
+ return s.sendErrorAndEnd(c, "Invalid Bedrock AWS region")
+ }
s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
@@ -506,7 +461,11 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
// account model mapping, and compact mode applies compact-only mapping on top.
testModelID = account.GetMappedModel(testModelID)
if mode == AccountTestModeCompact {
- testModelID = resolveOpenAICompactForwardModel(account, testModelID)
+ var compactMappingValid bool
+ testModelID, compactMappingValid = resolveOpenAICompactForwardModelWithValidity(account, testModelID)
+ if !compactMappingValid {
+ return errors.New("invalid GPT-5.6 compact model mapping")
+ }
return s.testOpenAICompactConnection(c, account, testModelID)
}
@@ -554,7 +513,16 @@ func (s *AccountTestService) testOpenAIAccountConnection(c *gin.Context, account
if err != nil {
return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid base URL: %s", err.Error()))
}
- apiURL = strings.TrimSuffix(normalizedBaseURL, "/") + "/responses"
+ // 账号已被探测为不支持 Responses(如 DeepSeek/Kimi 等)时,丢出明确提示。
+ // 账号本身可用(网关会走 CC 直转),仅测试入口需要补齐 CC SSE 处理逻辑。
+ // TODO:实现 CC 格式的账号测试路径(需专门的 CC SSE handler)。
+ if !openai_compat.ShouldUseResponsesAPI(account.Extra) {
+ return s.sendErrorAndEnd(c,
+ "账号已被探测为不支持 OpenAI Responses API(如 DeepSeek/Kimi 等三方兼容上游),"+
+ "账号本身可正常使用,但当前测试接口仅支持 Responses API 路径。请直接通过实际 API 调用验证。",
+ )
+ }
+ apiURL = buildOpenAIResponsesURL(normalizedBaseURL)
} else {
return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type))
}
@@ -900,6 +868,362 @@ func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, ac
return nil
}
+func (s *AccountTestService) testKiroAccountConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
+ ctx := c.Request.Context()
+ testModelID := strings.TrimSpace(modelID)
+ if testModelID == "" {
+ testModelID = "claude-sonnet-4-6"
+ }
+ testPrompt := strings.TrimSpace(prompt)
+ if testPrompt == "" {
+ testPrompt = "hi"
+ }
+
+ sidecarURL, err := s.buildKiroSidecarEndpoint("/v1/messages")
+ if err != nil {
+ return s.sendErrorAndEnd(c, err.Error())
+ }
+ apiKey := strings.TrimSpace(account.GetCredential("api_key"))
+ if apiKey == "" {
+ return s.sendErrorAndEnd(c, "No Kiro API key available")
+ }
+
+ c.Writer.Header().Set("Content-Type", "text/event-stream")
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.Header().Set("X-Accel-Buffering", "no")
+ c.Writer.Flush()
+
+ payload := map[string]any{
+ "model": testModelID,
+ "messages": []map[string]any{
+ {
+ "role": "user",
+ "content": []map[string]any{
+ {"type": "text", "text": testPrompt},
+ },
+ },
+ },
+ "max_tokens": 256,
+ "stream": false,
+ }
+ payloadBytes, _ := json.Marshal(payload)
+
+ s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, sidecarURL, bytes.NewReader(payloadBytes))
+ if err != nil {
+ return s.sendErrorAndEnd(c, "Failed to create Kiro sidecar request")
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Kiro-API-Key", apiKey)
+ req.Header.Set("X-Kiro-Account-ID", strconv.FormatInt(account.ID, 10))
+
+ timeout := 90 * time.Second
+ if s.cfg != nil && s.cfg.Kiro.RequestTimeoutSeconds > 0 {
+ timeout = time.Duration(s.cfg.Kiro.RequestTimeoutSeconds) * time.Second
+ }
+ resp, err := (&http.Client{Timeout: timeout}).Do(req)
+ if err != nil {
+ return s.sendErrorAndEnd(c, fmt.Sprintf("Kiro sidecar request failed: %s", err.Error()))
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024))
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+ return s.sendErrorAndEnd(c, fmt.Sprintf("Kiro sidecar returned %d: %s", resp.StatusCode, string(body)))
+ }
+
+ text := extractKiroTestText(body)
+ if text == "" {
+ text = string(body)
+ }
+ s.sendEvent(c, TestEvent{Type: "content", Text: text})
+ s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
+ return nil
+}
+
+func (s *AccountTestService) buildKiroSidecarEndpoint(path string) (string, error) {
+ if s.cfg == nil || strings.TrimSpace(s.cfg.Kiro.SidecarURL) == "" {
+ return "", fmt.Errorf("kiro sidecar is not configured")
+ }
+ raw := strings.TrimSpace(s.cfg.Kiro.SidecarURL)
+ if err := config.ValidateAbsoluteHTTPURL(raw); err != nil {
+ return "", fmt.Errorf("invalid Kiro sidecar URL: %s", err.Error())
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return "", fmt.Errorf("invalid Kiro sidecar URL: %s", err.Error())
+ }
+ if u.User != nil || u.RawQuery != "" || u.ForceQuery {
+ return "", fmt.Errorf("invalid Kiro sidecar URL: must not include userinfo or query")
+ }
+ u.Path = strings.TrimRight(u.Path, "/") + path
+ u.RawQuery = ""
+ u.Fragment = ""
+ return u.String(), nil
+}
+
+func (s *AccountTestService) testCursorAccountConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
+ ctx := c.Request.Context()
+ testModelID := normalizeCursorTestModel(account, modelID)
+
+ testPrompt := strings.TrimSpace(prompt)
+ if testPrompt == "" {
+ testPrompt = "hi"
+ }
+
+ accountRef := CursorSidecarAccountRef(account)
+ if accountRef == "" {
+ return s.sendErrorAndEnd(c, "Cursor account is missing sidecar_account_ref")
+ }
+
+ sidecarURL, err := s.buildCursorSidecarEndpoint("/v1/chat/completions")
+ if err != nil {
+ return s.sendErrorAndEnd(c, err.Error())
+ }
+
+ c.Writer.Header().Set("Content-Type", "text/event-stream")
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.Header().Set("X-Accel-Buffering", "no")
+ c.Writer.Flush()
+
+ payload := map[string]any{
+ "model": testModelID,
+ "messages": []map[string]any{
+ {"role": "user", "content": testPrompt},
+ },
+ "max_tokens": 64,
+ "stream": false,
+ }
+ payloadBytes, _ := json.Marshal(payload)
+
+ s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
+
+ timeout := s.cursorSidecarTimeout()
+ reqCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, sidecarURL, bytes.NewReader(payloadBytes))
+ if err != nil {
+ return s.sendErrorAndEnd(c, "Failed to create Cursor sidecar request")
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("X-Cursor-Account-ID", strconv.FormatInt(account.ID, 10))
+ req.Header.Set("X-Cursor-Account-Ref", accountRef)
+ req.Header.Set("X-Cursor-Original-Path", c.Request.URL.Path)
+ if apiKey := s.cursorSidecarAPIKey(); apiKey != "" {
+ req.Header.Set("X-Cursor-Sidecar-Key", apiKey)
+ req.Header.Set("Authorization", "Bearer "+apiKey)
+ req.Header.Set("x-api-key", apiKey)
+ }
+
+ resp, err := (&http.Client{Timeout: timeout}).Do(req)
+ if err != nil {
+ return s.sendErrorAndEnd(c, fmt.Sprintf("Cursor sidecar request failed: %s", err.Error()))
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024))
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+ return s.sendErrorAndEnd(c, fmt.Sprintf("Cursor sidecar returned %d: %s", resp.StatusCode, string(body)))
+ }
+
+ text := extractCursorTestText(body)
+ if text == "" {
+ text = string(body)
+ }
+ s.sendEvent(c, TestEvent{Type: "content", Text: text})
+ s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
+ return nil
+}
+
+func (s *AccountTestService) ListCursorSidecarModels(ctx context.Context) ([]CursorModel, error) {
+ sidecarURL, err := s.buildCursorSidecarEndpoint("/v1/models")
+ if err != nil {
+ return nil, err
+ }
+
+ timeout := s.cursorSidecarTimeout()
+ reqCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, sidecarURL, http.NoBody)
+ if err != nil {
+ return nil, fmt.Errorf("create Cursor sidecar models request: %w", err)
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Cursor-Original-Path", "/api/v1/admin/accounts/:id/models")
+ if apiKey := s.cursorSidecarAPIKey(); apiKey != "" {
+ req.Header.Set("X-Cursor-Sidecar-Key", apiKey)
+ req.Header.Set("Authorization", "Bearer "+apiKey)
+ req.Header.Set("x-api-key", apiKey)
+ }
+
+ resp, err := (&http.Client{Timeout: timeout}).Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("cursor sidecar models request failed: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024))
+ if err != nil {
+ return nil, fmt.Errorf("read Cursor sidecar models response: %w", err)
+ }
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+ return nil, fmt.Errorf("cursor sidecar models returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+
+ models := parseCursorSidecarModels(body)
+ if len(models) == 0 {
+ return nil, fmt.Errorf("cursor sidecar models response did not include data")
+ }
+ return models, nil
+}
+
+func parseCursorSidecarModels(body []byte) []CursorModel {
+ data := gjson.GetBytes(body, "data")
+ if !data.IsArray() {
+ return nil
+ }
+ models := make([]CursorModel, 0)
+ seen := make(map[string]struct{})
+ data.ForEach(func(_, item gjson.Result) bool {
+ id := strings.TrimSpace(item.Get("id").String())
+ if id == "" {
+ return true
+ }
+ if _, ok := seen[id]; ok {
+ return true
+ }
+ seen[id] = struct{}{}
+ modelType := strings.TrimSpace(item.Get("type").String())
+ if modelType == "" {
+ modelType = strings.TrimSpace(item.Get("object").String())
+ }
+ if modelType == "" {
+ modelType = "model"
+ }
+ displayName := strings.TrimSpace(item.Get("display_name").String())
+ if displayName == "" {
+ displayName = id
+ }
+ createdAt := strings.TrimSpace(item.Get("created_at").String())
+ if createdAt == "" {
+ createdAt = strings.TrimSpace(item.Get("created").String())
+ }
+ models = append(models, CursorModel{
+ ID: id,
+ Type: modelType,
+ DisplayName: displayName,
+ CreatedAt: createdAt,
+ })
+ return true
+ })
+ return models
+}
+
+func normalizeCursorTestModel(account *Account, modelID string) string {
+ testModelID := strings.TrimSpace(modelID)
+ if testModelID == "" {
+ return DefaultCursorTestModel
+ }
+ mapped := strings.TrimSpace(account.GetMappedModel(testModelID))
+ if strings.HasPrefix(strings.ToLower(mapped), "cursor-") {
+ return mapped
+ }
+ return DefaultCursorTestModel
+}
+
+func (s *AccountTestService) buildCursorSidecarEndpoint(path string) (string, error) {
+ raw := s.cursorSidecarURL()
+ if raw == "" {
+ return "", fmt.Errorf("cursor sidecar is not configured")
+ }
+ if err := config.ValidateAbsoluteHTTPURL(raw); err != nil {
+ return "", fmt.Errorf("invalid Cursor sidecar URL: %s", err.Error())
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return "", fmt.Errorf("invalid Cursor sidecar URL: %s", err.Error())
+ }
+ if u.User != nil || u.RawQuery != "" || u.ForceQuery {
+ return "", fmt.Errorf("invalid Cursor sidecar URL: must not include userinfo or query")
+ }
+ if strings.TrimSpace(path) == "" || !strings.HasPrefix(path, "/") {
+ return "", fmt.Errorf("invalid Cursor sidecar path: must be absolute")
+ }
+ u.Path = strings.TrimRight(u.Path, "/") + path
+ u.RawQuery = ""
+ u.Fragment = ""
+ return u.String(), nil
+}
+
+func (s *AccountTestService) cursorSidecarURL() string {
+ if raw := strings.TrimSpace(os.Getenv("CURSOR_SIDECAR_URL")); raw != "" {
+ return raw
+ }
+ if s != nil && s.cfg != nil {
+ return strings.TrimSpace(s.cfg.Cursor.SidecarURL)
+ }
+ return ""
+}
+
+func (s *AccountTestService) cursorSidecarAPIKey() string {
+ if raw := strings.TrimSpace(os.Getenv("CURSOR_SIDECAR_API_KEY")); raw != "" {
+ return raw
+ }
+ if s != nil && s.cfg != nil {
+ return strings.TrimSpace(s.cfg.Cursor.SidecarAPIKey)
+ }
+ return ""
+}
+
+func (s *AccountTestService) cursorSidecarTimeout() time.Duration {
+ if raw := strings.TrimSpace(os.Getenv("CURSOR_REQUEST_TIMEOUT_SECONDS")); raw != "" {
+ if seconds, err := strconv.Atoi(raw); err == nil && seconds > 0 {
+ return time.Duration(seconds) * time.Second
+ }
+ }
+ if s != nil && s.cfg != nil && s.cfg.Cursor.RequestTimeoutSeconds > 0 {
+ return time.Duration(s.cfg.Cursor.RequestTimeoutSeconds) * time.Second
+ }
+ return 90 * time.Second
+}
+
+func extractKiroTestText(body []byte) string {
+ for _, path := range []string{
+ "content.0.text",
+ "choices.0.message.content",
+ "output.0.content.0.text",
+ "output_text",
+ "text",
+ } {
+ if text := strings.TrimSpace(gjson.GetBytes(body, path).String()); text != "" {
+ return text
+ }
+ }
+ return ""
+}
+
+func extractCursorTestText(body []byte) string {
+ for _, path := range []string{
+ "choices.0.message.content",
+ "content.0.text",
+ "output.0.content.0.text",
+ "output_text",
+ "text",
+ } {
+ if text := strings.TrimSpace(gjson.GetBytes(body, path).String()); text != "" {
+ return text
+ }
+ }
+ return ""
+}
+
// buildGeminiAPIKeyRequest builds request for Gemini API Key accounts
func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, account *Account, modelID string, payload []byte) (*http.Request, error) {
apiKey := account.GetCredential("api_key")
diff --git a/backend/internal/service/account_test_service_cursor_test.go b/backend/internal/service/account_test_service_cursor_test.go
new file mode 100644
index 00000000000..ca07e9607f7
--- /dev/null
+++ b/backend/internal/service/account_test_service_cursor_test.go
@@ -0,0 +1,141 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+type cursorAccountTestRepo struct {
+ AccountRepository
+ account *Account
+}
+
+func (r *cursorAccountTestRepo) GetByID(_ context.Context, id int64) (*Account, error) {
+ if r.account != nil && r.account.ID == id {
+ return r.account, nil
+ }
+ return nil, errors.New("account not found")
+}
+
+func TestAccountTestService_CursorUsesSidecarAccountRef(t *testing.T) {
+ t.Setenv("CURSOR_SIDECAR_URL", "")
+ t.Setenv("CURSOR_SIDECAR_API_KEY", "")
+ t.Setenv("CURSOR_REQUEST_TIMEOUT_SECONDS", "")
+
+ var seenPath string
+ var seenAccountRef string
+ var seenAccountID string
+ var seenSidecarKey string
+ var seenBearer string
+ var seenModel string
+ var seenPrompt string
+
+ sidecar := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ seenPath = r.URL.Path
+ seenAccountRef = r.Header.Get("X-Cursor-Account-Ref")
+ seenAccountID = r.Header.Get("X-Cursor-Account-ID")
+ seenSidecarKey = r.Header.Get("X-Cursor-Sidecar-Key")
+ seenBearer = r.Header.Get("Authorization")
+
+ var body struct {
+ Model string `json:"model"`
+ Messages []struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"messages"`
+ }
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
+ seenModel = body.Model
+ if len(body.Messages) > 0 {
+ seenPrompt = body.Messages[0].Content
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"cursor-ok"}}]}`))
+ }))
+ defer sidecar.Close()
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/83/test", nil)
+
+ account := &Account{
+ ID: 83,
+ Platform: PlatformCursor,
+ Type: AccountTypeUpstream,
+ Concurrency: 1,
+ Credentials: map[string]any{"sidecar_account_ref": "cursor-prod@example.com"},
+ }
+ svc := &AccountTestService{
+ accountRepo: &cursorAccountTestRepo{account: account},
+ cfg: &config.Config{Cursor: config.CursorConfig{
+ SidecarURL: sidecar.URL + "/internal",
+ SidecarAPIKey: "sidecar-key",
+ RequestTimeoutSeconds: 3,
+ }},
+ }
+
+ err := svc.TestAccountConnection(c, 83, "claude-sonnet-4-6", "hi", AccountTestModeDefault)
+
+ require.NoError(t, err)
+ require.Equal(t, "/internal/v1/chat/completions", seenPath)
+ require.Equal(t, "cursor-prod@example.com", seenAccountRef)
+ require.Equal(t, "83", seenAccountID)
+ require.Equal(t, "sidecar-key", seenSidecarKey)
+ require.Equal(t, "Bearer sidecar-key", seenBearer)
+ require.Equal(t, DefaultCursorTestModel, seenModel)
+ require.Equal(t, "hi", seenPrompt)
+ require.Contains(t, rec.Body.String(), "cursor-ok")
+ require.Contains(t, rec.Body.String(), `"type":"test_complete"`)
+}
+
+func TestNormalizeCursorTestModelHonorsCursorMapping(t *testing.T) {
+ account := &Account{
+ Credentials: map[string]any{
+ "model_mapping": map[string]any{
+ "claude-sonnet-4-6": "cursor-gpt-5.5-high",
+ },
+ },
+ }
+
+ require.Equal(t, "cursor-gpt-5.5-high", normalizeCursorTestModel(account, "claude-sonnet-4-6"))
+ require.Equal(t, DefaultCursorTestModel, normalizeCursorTestModel(&Account{}, "claude-sonnet-4-6"))
+ require.Equal(t, "cursor-composer-2", normalizeCursorTestModel(&Account{}, "cursor-composer-2"))
+}
+
+func TestAccountTestService_CursorRequiresSidecarConfigured(t *testing.T) {
+ t.Setenv("CURSOR_SIDECAR_URL", "")
+ t.Setenv("CURSOR_SIDECAR_API_KEY", "")
+ t.Setenv("CURSOR_REQUEST_TIMEOUT_SECONDS", "")
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/84/test", nil)
+
+ account := &Account{
+ ID: 84,
+ Platform: PlatformCursor,
+ Type: AccountTypeUpstream,
+ Concurrency: 1,
+ Credentials: map[string]any{"sidecar_account_ref": "cursor-prod@example.com"},
+ }
+ svc := &AccountTestService{
+ accountRepo: &cursorAccountTestRepo{account: account},
+ cfg: &config.Config{},
+ }
+
+ err := svc.TestAccountConnection(c, 84, "claude-sonnet-4-6", "hi", AccountTestModeDefault)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "cursor sidecar is not configured")
+ require.Contains(t, strings.TrimSpace(rec.Body.String()), "cursor sidecar is not configured")
+}
diff --git a/backend/internal/service/account_test_service_gemini_test.go b/backend/internal/service/account_test_service_gemini_test.go
index f38264a215d..28ae1e67158 100644
--- a/backend/internal/service/account_test_service_gemini_test.go
+++ b/backend/internal/service/account_test_service_gemini_test.go
@@ -7,7 +7,6 @@ import (
"strings"
"testing"
- "github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@@ -40,7 +39,6 @@ func TestCreateGeminiTestPayload_ImageModel(t *testing.T) {
func TestProcessGeminiStream_EmitsImageEvent(t *testing.T) {
t.Parallel()
- gin.SetMode(gin.TestMode)
ctx, recorder := newTestContext()
svc := &AccountTestService{}
diff --git a/backend/internal/service/account_test_service_openai_compact_test.go b/backend/internal/service/account_test_service_openai_compact_test.go
index 9eb98fdc8b0..795706d923a 100644
--- a/backend/internal/service/account_test_service_openai_compact_test.go
+++ b/backend/internal/service/account_test_service_openai_compact_test.go
@@ -15,7 +15,6 @@ import (
)
func TestAccountTestService_TestAccountConnection_OpenAICompactOAuthSuccessPersistsSupport(t *testing.T) {
- gin.SetMode(gin.TestMode)
updateCalls := make(chan map[string]any, 1)
account := Account{
@@ -68,7 +67,6 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactOAuthSuccessPersi
}
func TestAccountTestService_TestAccountConnection_OpenAICompactOAuth404MarksUnsupported(t *testing.T) {
- gin.SetMode(gin.TestMode)
updateCalls := make(chan map[string]any, 1)
account := Account{
@@ -112,7 +110,6 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactOAuth404MarksUnsu
}
func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyUsesCompactPath(t *testing.T) {
- gin.SetMode(gin.TestMode)
updateCalls := make(chan map[string]any, 1)
account := Account{
@@ -158,7 +155,6 @@ func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyUsesCompact
}
func TestAccountTestService_TestAccountConnection_OpenAICompactAPIKeyDefaultBaseURLUsesV1Path(t *testing.T) {
- gin.SetMode(gin.TestMode)
updateCalls := make(chan map[string]any, 1)
account := Account{
diff --git a/backend/internal/service/account_test_service_openai_image_test.go b/backend/internal/service/account_test_service_openai_image_test.go
index 257159c40f6..7ea4ac86ebe 100644
--- a/backend/internal/service/account_test_service_openai_image_test.go
+++ b/backend/internal/service/account_test_service_openai_image_test.go
@@ -14,7 +14,6 @@ import (
)
func TestAccountTestService_OpenAIImageOAuthHandlesOutputItemDoneFallback(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/1/test", nil)
@@ -51,7 +50,6 @@ func TestAccountTestService_OpenAIImageOAuthHandlesOutputItemDoneFallback(t *tes
}
func TestAccountTestService_OpenAIImageAPIKeyUsesConfiguredV1BaseURL(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/1/test", nil)
diff --git a/backend/internal/service/account_test_service_openai_test.go b/backend/internal/service/account_test_service_openai_test.go
index 56204be3e48..6728d9c8b11 100644
--- a/backend/internal/service/account_test_service_openai_test.go
+++ b/backend/internal/service/account_test_service_openai_test.go
@@ -52,7 +52,6 @@ func newJSONResponse(status int, body string) *http.Response {
// --- test functions ---
func newTestContext() (*gin.Context, *httptest.ResponseRecorder) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/1/test", nil)
@@ -92,7 +91,6 @@ func (r *openAIAccountTestRepo) SetError(_ context.Context, id int64, errorMsg s
}
func TestAccountTestService_OpenAISuccessPersistsSnapshotFromHeaders(t *testing.T) {
- gin.SetMode(gin.TestMode)
ctx, recorder := newTestContext()
resp := newJSONResponse(http.StatusOK, "")
@@ -126,7 +124,6 @@ func TestAccountTestService_OpenAISuccessPersistsSnapshotFromHeaders(t *testing.
}
func TestAccountTestService_OpenAIStreamEOFBeforeCompletedFails(t *testing.T) {
- gin.SetMode(gin.TestMode)
ctx, recorder := newTestContext()
resp := newJSONResponse(http.StatusOK, "")
@@ -151,7 +148,6 @@ func TestAccountTestService_OpenAIStreamEOFBeforeCompletedFails(t *testing.T) {
}
func TestAccountTestService_OpenAI429PersistsSnapshotAndRateLimitState(t *testing.T) {
- gin.SetMode(gin.TestMode)
ctx, _ := newTestContext()
resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","resets_at":1777283883}}`)
@@ -187,7 +183,6 @@ func TestAccountTestService_OpenAI429PersistsSnapshotAndRateLimitState(t *testin
}
func TestAccountTestService_OpenAI429BodyOnlyPersistsRateLimitAndClearsStaleError(t *testing.T) {
- gin.SetMode(gin.TestMode)
ctx, _ := newTestContext()
resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","resets_at":"1777283883"}}`)
@@ -217,7 +212,6 @@ func TestAccountTestService_OpenAI429BodyOnlyPersistsRateLimitAndClearsStaleErro
}
func TestAccountTestService_OpenAI429ActiveAccountDoesNotClearError(t *testing.T) {
- gin.SetMode(gin.TestMode)
ctx, _ := newTestContext()
resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached","resets_in_seconds":3600}}`)
@@ -244,7 +238,6 @@ func TestAccountTestService_OpenAI429ActiveAccountDoesNotClearError(t *testing.T
}
func TestAccountTestService_OpenAI429WithoutResetSignalDoesNotMutateRuntimeState(t *testing.T) {
- gin.SetMode(gin.TestMode)
ctx, _ := newTestContext()
resp := newJSONResponse(http.StatusTooManyRequests, `{"error":{"type":"usage_limit_reached","message":"limit reached"}}`)
@@ -273,7 +266,6 @@ func TestAccountTestService_OpenAI429WithoutResetSignalDoesNotMutateRuntimeState
}
func TestAccountTestService_OpenAI401SetsPermanentErrorOnly(t *testing.T) {
- gin.SetMode(gin.TestMode)
ctx, _ := newTestContext()
resp := newJSONResponse(http.StatusUnauthorized, `{"error":"bad token"}`)
diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go
index 68ba8f8ce98..e2c3b4411b1 100644
--- a/backend/internal/service/account_usage_service.go
+++ b/backend/internal/service/account_usage_service.go
@@ -17,7 +17,6 @@ import (
openaipkg "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
- "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/singleflight"
@@ -242,17 +241,14 @@ type ClaudeUsageResponse struct {
// ClaudeUsageFetchOptions 包含获取 Claude 用量数据所需的所有选项
type ClaudeUsageFetchOptions struct {
- AccessToken string // OAuth access token
- ProxyURL string // 代理 URL(可选)
- AccountID int64 // 账号 ID(用于连接池隔离)
- TLSProfile *tlsfingerprint.Profile // TLS 指纹 Profile(nil 表示不启用)
- Fingerprint *Fingerprint // 缓存的指纹信息(User-Agent 等)
+ AccessToken string // OAuth access token
+ ProxyURL string // 代理 URL(可选)
}
// ClaudeUsageFetcher fetches usage data from Anthropic OAuth API
type ClaudeUsageFetcher interface {
FetchUsage(ctx context.Context, accessToken, proxyURL string) (*ClaudeUsageResponse, error)
- // FetchUsageWithOptions 使用完整选项获取用量数据,支持 TLS 指纹和自定义 User-Agent
+ // FetchUsageWithOptions 获取用量数据;调用方不能提供官方客户端指纹。
FetchUsageWithOptions(ctx context.Context, opts *ClaudeUsageFetchOptions) (*ClaudeUsageResponse, error)
}
@@ -265,7 +261,6 @@ type AccountUsageService struct {
antigravityQuotaFetcher *AntigravityQuotaFetcher
cache *UsageCache
identityCache IdentityCache
- tlsFPProfileService *TLSFingerprintProfileService
}
// NewAccountUsageService 创建AccountUsageService实例
@@ -287,7 +282,6 @@ func NewAccountUsageService(
antigravityQuotaFetcher: antigravityQuotaFetcher,
cache: cache,
identityCache: identityCache,
- tlsFPProfileService: tlsFPProfileService,
}
}
@@ -302,7 +296,7 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
}
if account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth {
- usage, err := s.getOpenAIUsage(ctx, account)
+ usage, err := s.getOpenAIUsage(ctx, account, true)
if err == nil {
s.tryClearRecoverableAccountError(ctx, account)
}
@@ -417,16 +411,38 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64) (*U
return nil, fmt.Errorf("account type %s does not support usage query", account.Type)
}
-// GetPassiveUsage 从 Account.Extra 中的被动采样数据构建 UsageInfo,不调用外部 API。
-// 仅适用于 Anthropic OAuth / SetupToken 账号。
+// GetPassiveUsage 使用本地快照、日志或短期缓存构建 UsageInfo,不调用外部 API。
+// 后台账号列表默认使用该路径,避免首屏渲染并发探测上游额度接口。
func (s *AccountUsageService) GetPassiveUsage(ctx context.Context, accountID int64) (*UsageInfo, error) {
account, err := s.accountRepo.GetByID(ctx, accountID)
if err != nil {
return nil, fmt.Errorf("get account failed: %w", err)
}
+ if account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth {
+ info, err := s.getOpenAIUsage(ctx, account, false)
+ if err != nil {
+ return nil, err
+ }
+ info.Source = "passive"
+ return info, nil
+ }
+
+ if account.Platform == PlatformGemini {
+ info, err := s.getGeminiUsage(ctx, account)
+ if err != nil {
+ return nil, err
+ }
+ info.Source = "passive"
+ return info, nil
+ }
+
+ if account.Platform == PlatformAntigravity {
+ return s.getAntigravityPassiveUsage(account), nil
+ }
+
if !account.IsAnthropicOAuthOrSetupToken() {
- return nil, fmt.Errorf("passive usage only supported for Anthropic OAuth/SetupToken accounts")
+ return nil, fmt.Errorf("passive usage only supported for Anthropic OAuth/SetupToken, OpenAI OAuth, Gemini, and Antigravity accounts")
}
// 复用 estimateSetupTokenUsage 构建 5h 窗口(OAuth 和 SetupToken 逻辑一致)
@@ -492,7 +508,7 @@ func (s *AccountUsageService) syncActiveToPassive(ctx context.Context, accountID
}
}
-func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Account) (*UsageInfo, error) {
+func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Account, allowProbe bool) (*UsageInfo, error) {
now := time.Now()
usage := &UsageInfo{UpdatedAt: &now}
@@ -507,7 +523,7 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou
usage.SevenDay = progress
}
- if shouldRefreshOpenAICodexSnapshot(account, usage, now) && s.shouldProbeOpenAICodexSnapshot(account.ID, now) {
+ if allowProbe && shouldRefreshOpenAICodexSnapshot(account, usage, now) && s.shouldProbeOpenAICodexSnapshot(account.ID, now) {
if updates, err := s.probeOpenAICodexSnapshot(ctx, account); err == nil && len(updates) > 0 {
mergeAccountExtra(account, updates)
if usage.UpdatedAt == nil {
@@ -543,6 +559,67 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou
return usage, nil
}
+func (s *AccountUsageService) getAntigravityPassiveUsage(account *Account) *UsageInfo {
+ if account == nil {
+ now := time.Now()
+ return &UsageInfo{Source: "passive", UpdatedAt: &now}
+ }
+
+ if s != nil && s.cache != nil {
+ if cached, ok := s.cache.antigravityCache.Load(account.ID); ok {
+ if cache, ok := cached.(*antigravityUsageCache); ok {
+ ttl := antigravityCacheTTL(cache.usageInfo)
+ if time.Since(cache.timestamp) < ttl {
+ usage := cloneUsageInfo(cache.usageInfo)
+ if usage != nil {
+ recalcAntigravityRemainingSeconds(usage)
+ usage.Source = "passive"
+ enrichUsageWithAccountError(usage, account)
+ return usage
+ }
+ }
+ }
+ }
+ }
+
+ now := time.Now()
+ usage := &UsageInfo{Source: "passive", UpdatedAt: &now}
+ enrichUsageWithAccountError(usage, account)
+ return usage
+}
+
+func cloneUsageInfo(info *UsageInfo) *UsageInfo {
+ if info == nil {
+ return nil
+ }
+ cloned := *info
+ cloned.FiveHour = cloneUsageProgress(info.FiveHour)
+ cloned.SevenDay = cloneUsageProgress(info.SevenDay)
+ cloned.SevenDaySonnet = cloneUsageProgress(info.SevenDaySonnet)
+ cloned.GeminiSharedDaily = cloneUsageProgress(info.GeminiSharedDaily)
+ cloned.GeminiProDaily = cloneUsageProgress(info.GeminiProDaily)
+ cloned.GeminiFlashDaily = cloneUsageProgress(info.GeminiFlashDaily)
+ cloned.GeminiSharedMinute = cloneUsageProgress(info.GeminiSharedMinute)
+ cloned.GeminiProMinute = cloneUsageProgress(info.GeminiProMinute)
+ cloned.GeminiFlashMinute = cloneUsageProgress(info.GeminiFlashMinute)
+ if info.AICredits != nil {
+ cloned.AICredits = append([]AICredit(nil), info.AICredits...)
+ }
+ return &cloned
+}
+
+func cloneUsageProgress(progress *UsageProgress) *UsageProgress {
+ if progress == nil {
+ return nil
+ }
+ cloned := *progress
+ if progress.WindowStats != nil {
+ windowStats := *progress.WindowStats
+ cloned.WindowStats = &windowStats
+ }
+ return &cloned
+}
+
func shouldRefreshOpenAICodexSnapshot(account *Account, usage *UsageInfo, now time.Time) bool {
if account == nil {
return false
@@ -668,7 +745,11 @@ func (s *AccountUsageService) persistOpenAICodexProbeSnapshot(accountID int64, u
go func() {
updateCtx, updateCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer updateCancel()
- _ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates)
+ if err := s.accountRepo.UpdateExtra(updateCtx, accountID, updates); err != nil {
+ slog.Warn("openai_codex_probe_snapshot_update_extra_failed", "account_id", accountID, "error", err)
+ return
+ }
+ applyOpenAIQuotaGuardFromUpdates(updateCtx, s.accountRepo, accountID, updates, time.Now())
}()
}
@@ -1123,9 +1204,9 @@ func (s *AccountUsageService) GetAccountUsageStats(ctx context.Context, accountI
return stats, nil
}
-// fetchOAuthUsageRaw 从 Anthropic API 获取原始响应(不构建 UsageInfo)
-// 如果账号开启了 TLS 指纹,则使用 TLS 指纹伪装
-// 如果有缓存的 Fingerprint,则使用缓存的 User-Agent 等信息
+// fetchOAuthUsageRaw 从 Anthropic API 获取原始响应(不构建 UsageInfo)。
+// Account-management traffic uses a truthful sub2api identity and never
+// reuses a cached Claude Code fingerprint or TLS profile.
func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *Account) (*ClaudeUsageResponse, error) {
accessToken := account.GetCredential("access_token")
if accessToken == "" {
@@ -1137,19 +1218,9 @@ func (s *AccountUsageService) fetchOAuthUsageRaw(ctx context.Context, account *A
proxyURL = account.Proxy.URL()
}
- // 构建完整的选项
opts := &ClaudeUsageFetchOptions{
AccessToken: accessToken,
ProxyURL: proxyURL,
- AccountID: account.ID,
- TLSProfile: s.tlsFPProfileService.ResolveTLSProfile(account),
- }
-
- // 尝试获取缓存的 Fingerprint(包含 User-Agent 等信息)
- if s.identityCache != nil {
- if fp, err := s.identityCache.GetFingerprint(ctx, account.ID); err == nil && fp != nil {
- opts.Fingerprint = fp
- }
}
return s.usageFetcher.FetchUsageWithOptions(ctx, opts)
diff --git a/backend/internal/service/account_usage_service_test.go b/backend/internal/service/account_usage_service_test.go
index 28b49838a5b..5e5a5f01209 100644
--- a/backend/internal/service/account_usage_service_test.go
+++ b/backend/internal/service/account_usage_service_test.go
@@ -140,7 +140,7 @@ func TestAccountUsageService_GetOpenAIUsage_DoesNotPromoteCodexExtraToRateLimit(
},
}
- usage, err := svc.getOpenAIUsage(context.Background(), account)
+ usage, err := svc.getOpenAIUsage(context.Background(), account, true)
if err != nil {
t.Fatalf("getOpenAIUsage() error = %v", err)
}
@@ -157,6 +157,43 @@ func TestAccountUsageService_GetOpenAIUsage_DoesNotPromoteCodexExtraToRateLimit(
}
}
+func TestAccountUsageService_GetPassiveUsage_OpenAISkipsCodexProbe(t *testing.T) {
+ t.Parallel()
+
+ repo := &accountUsageCodexProbeRepo{
+ stubOpenAIAccountRepo: stubOpenAIAccountRepo{
+ accounts: []Account{{
+ ID: 42,
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Extra: map[string]any{
+ "openai_oauth_responses_websockets_v2_enabled": true,
+ "codex_usage_updated_at": time.Now().Add(-(openAIProbeCacheTTL + time.Minute)).UTC().Format(time.RFC3339),
+ "codex_5h_used_percent": 12.0,
+ "codex_5h_reset_at": time.Now().Add(2 * time.Hour).UTC().Truncate(time.Second).Format(time.RFC3339),
+ "codex_7d_used_percent": 34.0,
+ "codex_7d_reset_at": time.Now().Add(5 * 24 * time.Hour).UTC().Truncate(time.Second).Format(time.RFC3339),
+ },
+ }},
+ },
+ }
+ svc := &AccountUsageService{accountRepo: repo}
+
+ usage, err := svc.GetPassiveUsage(context.Background(), 42)
+ if err != nil {
+ t.Fatalf("GetPassiveUsage() error = %v", err)
+ }
+ if usage.Source != "passive" {
+ t.Fatalf("Source = %q, want passive", usage.Source)
+ }
+ if usage.FiveHour == nil || usage.FiveHour.Utilization != 12.0 {
+ t.Fatalf("expected 5h usage from local codex extra, got %#v", usage.FiveHour)
+ }
+ if usage.SevenDay == nil || usage.SevenDay.Utilization != 34.0 {
+ t.Fatalf("expected 7d usage from local codex extra, got %#v", usage.SevenDay)
+ }
+}
+
func TestBuildCodexUsageProgressFromExtra_ZerosExpiredWindow(t *testing.T) {
t.Parallel()
now := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)
diff --git a/backend/internal/service/account_wildcard_test.go b/backend/internal/service/account_wildcard_test.go
index d903b940a51..9a8f5f7b903 100644
--- a/backend/internal/service/account_wildcard_test.go
+++ b/backend/internal/service/account_wildcard_test.go
@@ -4,6 +4,8 @@ package service
import (
"testing"
+
+ "github.com/stretchr/testify/require"
)
func TestMatchWildcard(t *testing.T) {
@@ -222,6 +224,49 @@ func TestAccountIsModelSupported(t *testing.T) {
}
}
+func TestAccountIsModelSupported_GPT56PreviewRequiresExactMapping(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ platform string
+ credentials map[string]any
+ model string
+ want bool
+ }{
+ {name: "openai no mapping rejects preview", platform: PlatformOpenAI, model: "gpt-5.6-sol", want: false},
+ {name: "openai empty mapping rejects preview", platform: PlatformOpenAI, credentials: map[string]any{}, model: "gpt-5.6-sol", want: false},
+ {name: "openai star rejects preview", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"*": "gpt-5.6-sol"}}, model: "gpt-5.6-sol", want: false},
+ {name: "openai gpt wildcard rejects preview", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"gpt-*": "gpt-5.6-sol"}}, model: "gpt-5.6-sol", want: false},
+ {name: "openai exact tier allows preview", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6-sol": "gpt-5.6-sol"}}, model: "gpt-5.6-sol", want: true},
+ {name: "openai exact base tier allows reasoning suffix", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6-sol": "gpt-5.6-sol"}}, model: "gpt-5.6-sol-high", want: true},
+ {name: "openai exact tier accepts same tier target suffix", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6-sol": "gpt-5.6-sol-xhigh"}}, model: "gpt-5.6-sol-high", want: true},
+ {name: "openai exact tier accepts same tier provider spelling", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6-sol": "OPENAI/GPT5.6-SOL-XHIGH"}}, model: "openai/gpt-5.6-sol-low", want: true},
+ {name: "openai exact tier rejects empty target", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6-sol": ""}}, model: "gpt-5.6-sol", want: false},
+ {name: "openai exact tier rejects older target", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6-sol": "gpt-5.4"}}, model: "gpt-5.6-sol", want: false},
+ {name: "openai exact tier rejects cross tier target", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6-sol": "gpt-5.6-terra"}}, model: "gpt-5.6-sol", want: false},
+ {name: "openai wildcard rejects reasoning suffix", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"gpt-*": "gpt-5.6-sol"}}, model: "gpt-5.6-sol-high", want: false},
+ {name: "openai exact terra allows preview", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6-terra": "gpt-5.6-terra"}}, model: "gpt-5.6-terra", want: true},
+ {name: "openai exact luna allows preview", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6-luna": "gpt-5.6-luna"}}, model: "gpt-5.6-luna", want: true},
+ {name: "openai bare family fails closed", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6": "gpt-5.6"}}, model: "gpt-5.6", want: false},
+ {name: "openai malformed tier fails closed", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6-unknown": "gpt-5.6-unknown"}}, model: "gpt-5.6-unknown", want: false},
+ {name: "openai minimal suffix fails closed", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6-sol": "gpt-5.6-sol"}}, model: "gpt-5.6-sol-minimal", want: false},
+ {name: "openai extra high suffix fails closed", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6-sol": "gpt-5.6-sol"}}, model: "gpt-5.6-sol-extrahigh", want: false},
+ {name: "openai legacy no mapping behavior unchanged", platform: PlatformOpenAI, model: "gpt-5.4", want: true},
+ {name: "openai legacy mapping to preview without exact entitlement rejected", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.4": "gpt-5.6-sol"}}, model: "gpt-5.4", want: false},
+ {name: "openai legacy mapping to preview with exact entitlement allowed", platform: PlatformOpenAI, credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.4": "gpt-5.6-sol", "gpt-5.6-sol": "gpt-5.6-sol"}}, model: "gpt-5.4", want: true},
+ {name: "non openai wildcard behavior unchanged", platform: PlatformAnthropic, credentials: map[string]any{"model_mapping": map[string]any{"gpt-*": "gpt-5.6-sol"}}, model: "gpt-5.6-sol", want: true},
+ {name: "non openai legacy wildcard target behavior unchanged", platform: PlatformAnthropic, credentials: map[string]any{"model_mapping": map[string]any{"gpt-*": "gpt-5.6-sol"}}, model: "gpt-5.4", want: true},
+ {name: "legacy claude no mapping behavior unchanged", platform: PlatformAnthropic, model: "claude-sonnet-4-5", want: true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ account := &Account{Platform: tt.platform, Credentials: tt.credentials}
+ require.Equal(t, tt.want, account.IsModelSupported(tt.model))
+ })
+ }
+}
+
func TestAccountGetMappedModel(t *testing.T) {
tests := []struct {
name string
@@ -256,6 +301,17 @@ func TestAccountGetMappedModel(t *testing.T) {
requestedModel: "claude-sonnet-4-5",
expected: "target-model",
},
+ {
+ name: "openai preview suffix resolves through exact base entitlement",
+ platform: PlatformOpenAI,
+ credentials: map[string]any{
+ "model_mapping": map[string]any{
+ "gpt-5.6-sol": "gpt-5.6-sol",
+ },
+ },
+ requestedModel: "openai/gpt_5.6_sol_high",
+ expected: "gpt-5.6-sol",
+ },
// 通配符匹配(最长优先)
{
@@ -320,6 +376,19 @@ func TestAccountGetMappedModel(t *testing.T) {
}
}
+func TestAccountResolveMappedModel_GPT56TargetRequiresExactEntitlement(t *testing.T) {
+ account := &Account{
+ Platform: PlatformOpenAI,
+ Credentials: map[string]any{"model_mapping": map[string]any{
+ "gpt-5.4": "gpt-5.6-sol",
+ }},
+ }
+
+ mapped, matched := account.ResolveMappedModel("gpt-5.4")
+ require.Equal(t, "gpt-5.4", mapped)
+ require.False(t, matched)
+}
+
func TestAccountResolveMappedModel(t *testing.T) {
tests := []struct {
name string
diff --git a/backend/internal/service/admin_balance_history_test.go b/backend/internal/service/admin_balance_history_test.go
new file mode 100644
index 00000000000..291d3f7bd6c
--- /dev/null
+++ b/backend/internal/service/admin_balance_history_test.go
@@ -0,0 +1,86 @@
+package service
+
+import (
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMergeBalanceHistoryCodesIncludesAffiliateTransfersByDefault(t *testing.T) {
+ t.Parallel()
+
+ now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC)
+ older := now.Add(-2 * time.Hour)
+ newer := now.Add(time.Hour)
+
+ usedBy := int64(10)
+ redeemCodes := []RedeemCode{
+ {
+ ID: 1,
+ Type: RedeemTypeBalance,
+ Value: 8,
+ Status: StatusUsed,
+ UsedBy: &usedBy,
+ UsedAt: &now,
+ CreatedAt: now,
+ },
+ {
+ ID: 2,
+ Type: RedeemTypeConcurrency,
+ Value: 1,
+ Status: StatusUsed,
+ UsedBy: &usedBy,
+ UsedAt: &older,
+ CreatedAt: older,
+ },
+ }
+ affiliateCodes := []RedeemCode{
+ {
+ ID: -20,
+ Type: RedeemTypeAffiliateBalance,
+ Value: 3.5,
+ Status: StatusUsed,
+ UsedBy: &usedBy,
+ UsedAt: &newer,
+ CreatedAt: newer,
+ },
+ }
+
+ got := mergeBalanceHistoryCodes(redeemCodes, affiliateCodes, pagination.PaginationParams{
+ Page: 1,
+ PageSize: 2,
+ })
+
+ require.Len(t, got, 2)
+ require.Equal(t, RedeemTypeAffiliateBalance, got[0].Type)
+ require.Equal(t, RedeemTypeBalance, got[1].Type)
+}
+
+func TestMergeBalanceHistoryCodesPaginatesAfterCombiningSources(t *testing.T) {
+ t.Parallel()
+
+ base := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC)
+ usedBy := int64(10)
+ at := func(hours int) *time.Time {
+ v := base.Add(time.Duration(hours) * time.Hour)
+ return &v
+ }
+
+ got := mergeBalanceHistoryCodes(
+ []RedeemCode{
+ {ID: 1, Type: RedeemTypeBalance, UsedBy: &usedBy, UsedAt: at(4), CreatedAt: *at(4)},
+ {ID: 2, Type: RedeemTypeConcurrency, UsedBy: &usedBy, UsedAt: at(2), CreatedAt: *at(2)},
+ },
+ []RedeemCode{
+ {ID: -3, Type: RedeemTypeAffiliateBalance, UsedBy: &usedBy, UsedAt: at(3), CreatedAt: *at(3)},
+ {ID: -4, Type: RedeemTypeAffiliateBalance, UsedBy: &usedBy, UsedAt: at(1), CreatedAt: *at(1)},
+ },
+ pagination.PaginationParams{Page: 2, PageSize: 2},
+ )
+
+ require.Len(t, got, 2)
+ require.Equal(t, RedeemTypeConcurrency, got[0].Type)
+ require.Equal(t, int64(-4), got[1].ID)
+}
diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go
index d966c684b6b..0e1ff98eeff 100644
--- a/backend/internal/service/admin_service.go
+++ b/backend/internal/service/admin_service.go
@@ -2,11 +2,13 @@ package service
import (
"context"
+ "database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
+ "math"
"net/http"
"sort"
"strconv"
@@ -32,6 +34,7 @@ type AdminService interface {
UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error)
DeleteUser(ctx context.Context, id int64) error
UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error)
+ BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error)
GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error)
GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error)
GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error)
@@ -188,11 +191,14 @@ type CreateGroupInput struct {
WeeklyLimitUSD *float64 // 周限额 (USD)
MonthlyLimitUSD *float64 // 月限额 (USD)
// 图片生成计费配置(仅 antigravity 平台使用)
- ImagePrice1K *float64
- ImagePrice2K *float64
- ImagePrice4K *float64
- ClaudeCodeOnly bool // 仅允许 Claude Code 客户端
- FallbackGroupID *int64 // 降级分组 ID
+ AllowImageGeneration bool
+ ImageRateIndependent bool
+ ImageRateMultiplier *float64
+ ImagePrice1K *float64
+ ImagePrice2K *float64
+ ImagePrice4K *float64
+ ClaudeCodeOnly bool // 仅允许 Claude Code 客户端
+ FallbackGroupID *int64 // 降级分组 ID
// 无效请求兜底分组 ID(仅 anthropic 平台使用)
FallbackGroupIDOnInvalidRequest *int64
// 模型路由配置(仅 anthropic 平台使用)
@@ -225,11 +231,14 @@ type UpdateGroupInput struct {
WeeklyLimitUSD *float64 // 周限额 (USD)
MonthlyLimitUSD *float64 // 月限额 (USD)
// 图片生成计费配置(仅 antigravity 平台使用)
- ImagePrice1K *float64
- ImagePrice2K *float64
- ImagePrice4K *float64
- ClaudeCodeOnly *bool // 仅允许 Claude Code 客户端
- FallbackGroupID *int64 // 降级分组 ID
+ AllowImageGeneration *bool
+ ImageRateIndependent *bool
+ ImageRateMultiplier *float64
+ ImagePrice1K *float64
+ ImagePrice2K *float64
+ ImagePrice4K *float64
+ ClaudeCodeOnly *bool // 仅允许 Claude Code 客户端
+ FallbackGroupID *int64 // 降级分组 ID
// 无效请求兜底分组 ID(仅 anthropic 平台使用)
FallbackGroupIDOnInvalidRequest *int64
// 模型路由配置(仅 anthropic 平台使用)
@@ -389,6 +398,9 @@ type GenerateRedeemCodesInput struct {
Value float64
GroupID *int64 // 订阅类型专用:关联的分组ID
ValidityDays int // 订阅类型专用:有效天数
+ // PlanID 钱包模式额度卡专用:兑换时按 plan.WalletQuotaUsd 创建 wallet 订阅。
+ // type='wallet' 时必填;其它 type 必须为 nil(链动小铺 credits SKU,B2.7)。
+ PlanID *int64
}
type ProxyBatchDeleteResult struct {
@@ -524,6 +536,10 @@ type adminServiceImpl struct {
privacyClientFactory PrivacyClientFactory
}
+type adminBalanceAdjustmentRepository interface {
+ AdjustAdminBalance(context.Context, int64, float64, string) (*User, float64, error)
+}
+
type userGroupRateBatchReader interface {
GetByUserIDs(ctx context.Context, userIDs []int64) (map[int64]map[int64]float64, error)
}
@@ -692,23 +708,87 @@ func (s *adminServiceImpl) assignDefaultSubscriptions(ctx context.Context, userI
}
func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error) {
+ if input == nil {
+ return nil, infraerrors.BadRequest("INVALID_INPUT", "update user input is required")
+ }
+ if s.userRepo == nil {
+ return nil, errors.New("user repository is not configured")
+ }
+ if input.GroupRates != nil && s.userGroupRateRepo == nil {
+ return nil, errors.New("user group rate repository is not configured")
+ }
+ if input.GroupRates != nil && dbent.TxFromContext(ctx) == nil && s.entClient == nil {
+ return nil, errors.New("atomic user group policy transaction is not configured")
+ }
+
// 校验用户专属分组倍率:必须 > 0(nil 合法,表示清除专属倍率)
if input.GroupRates != nil {
for groupID, rate := range input.GroupRates {
+ if groupID <= 0 {
+ return nil, fmt.Errorf("group_id must be > 0 (group_id=%d)", groupID)
+ }
if rate != nil && *rate <= 0 {
return nil, fmt.Errorf("rate_multiplier must be > 0 (group_id=%d)", groupID)
}
}
}
+ var (
+ user *User
+ oldConcurrency int
+ shouldInvalidate bool
+ )
+ apply := func(txCtx context.Context) error {
+ var err error
+ user, oldConcurrency, shouldInvalidate, err = s.updateUserPolicy(txCtx, id, input)
+ return err
+ }
+
+ existingTx := dbent.TxFromContext(ctx)
+ if existingTx != nil {
+ if err := apply(ctx); err != nil {
+ return nil, err
+ }
+ if shouldInvalidate {
+ s.invalidateUserAuthCacheAfterCommit(existingTx, ctx, user.ID)
+ }
+ } else if s.entClient != nil {
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("begin admin user update transaction: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ if err := apply(dbent.NewTxContext(ctx, tx)); err != nil {
+ return nil, err
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, fmt.Errorf("commit admin user update transaction: %w", err)
+ }
+ if shouldInvalidate && s.authCacheInvalidator != nil {
+ s.authCacheInvalidator.InvalidateAuthCacheByUserID(context.WithoutCancel(ctx), user.ID)
+ }
+ } else {
+ if err := apply(ctx); err != nil {
+ return nil, err
+ }
+ if shouldInvalidate && s.authCacheInvalidator != nil {
+ s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, user.ID)
+ }
+ }
+
+ s.recordAdminConcurrencyAdjustment(ctx, user, oldConcurrency)
+ return user, nil
+}
+
+func (s *adminServiceImpl) updateUserPolicy(ctx context.Context, id int64, input *UpdateUserInput) (*User, int, bool, error) {
user, err := s.userRepo.GetByID(ctx, id)
if err != nil {
- return nil, err
+ return nil, 0, false, err
}
// Protect admin users: cannot disable admin accounts
if user.Role == "admin" && input.Status == "disabled" {
- return nil, errors.New("cannot disable admin user")
+ return nil, 0, false, errors.New("cannot disable admin user")
}
oldConcurrency := user.Concurrency
@@ -721,7 +801,7 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
}
if input.Password != "" {
if err := user.SetPassword(input.Password); err != nil {
- return nil, err
+ return nil, 0, false, err
}
}
@@ -749,30 +829,34 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
}
if err := s.userRepo.Update(ctx, user); err != nil {
- return nil, err
+ return nil, 0, false, err
}
- // 同步用户专属分组倍率
- if input.GroupRates != nil && s.userGroupRateRepo != nil {
+ // 必须与 users/user_allowed_groups 位于同一事务;任何同步错误直接向上返回,
+ // 由调用方回滚,禁止留下“权限已改、倍率未改”的半成功状态。
+ if input.GroupRates != nil {
if err := s.userGroupRateRepo.SyncUserGroupRates(ctx, user.ID, input.GroupRates); err != nil {
- logger.LegacyPrintf("service.admin", "failed to sync user group rates: user_id=%d err=%v", user.ID, err)
+ return nil, 0, false, fmt.Errorf("sync user group rates: %w", err)
}
}
- if s.authCacheInvalidator != nil {
- // RPMLimit 直接参与 billing_cache_service.checkRPM 的三级级联,
- // 不失效缓存会让修改在一个 L2 TTL 内失去效果。
- if user.Concurrency != oldConcurrency || user.Status != oldStatus || user.Role != oldRole || user.RPMLimit != oldRPMLimit {
- s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, user.ID)
- }
- }
+ // AllowedGroups、专属倍率与 RPMLimit 都嵌入认证/计费快照,提交后统一失效。
+ shouldInvalidate := input.AllowedGroups != nil || input.GroupRates != nil ||
+ user.Concurrency != oldConcurrency || user.Status != oldStatus ||
+ user.Role != oldRole || user.RPMLimit != oldRPMLimit
+ return user, oldConcurrency, shouldInvalidate, nil
+}
+func (s *adminServiceImpl) recordAdminConcurrencyAdjustment(ctx context.Context, user *User, oldConcurrency int) {
+ if user == nil {
+ return
+ }
concurrencyDiff := user.Concurrency - oldConcurrency
if concurrencyDiff != 0 {
code, err := GenerateRedeemCode()
if err != nil {
logger.LegacyPrintf("service.admin", "failed to generate adjustment redeem code: %v", err)
- return user, nil
+ return
}
adjustmentRecord := &RedeemCode{
Code: code,
@@ -783,12 +867,29 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
}
now := time.Now()
adjustmentRecord.UsedAt = &now
+ if s.redeemCodeRepo == nil {
+ logger.LegacyPrintf("service.admin", "failed to create concurrency adjustment redeem code: repository is not configured")
+ return
+ }
if err := s.redeemCodeRepo.Create(ctx, adjustmentRecord); err != nil {
logger.LegacyPrintf("service.admin", "failed to create concurrency adjustment redeem code: %v", err)
}
}
+}
- return user, nil
+func (s *adminServiceImpl) invalidateUserAuthCacheAfterCommit(tx *dbent.Tx, ctx context.Context, userID int64) {
+ if tx == nil || s.authCacheInvalidator == nil {
+ return
+ }
+ tx.OnCommit(func(next dbent.Committer) dbent.Committer {
+ return dbent.CommitFunc(func(commitCtx context.Context, committedTx *dbent.Tx) error {
+ if err := next.Commit(commitCtx, committedTx); err != nil {
+ return err
+ }
+ s.authCacheInvalidator.InvalidateAuthCacheByUserID(context.WithoutCancel(ctx), userID)
+ return nil
+ })
+ })
}
func (s *adminServiceImpl) DeleteUser(ctx context.Context, id int64) error {
@@ -810,71 +911,130 @@ func (s *adminServiceImpl) DeleteUser(ctx context.Context, id int64) error {
return nil
}
-func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) {
- user, err := s.userRepo.GetByID(ctx, userID)
- if err != nil {
- return nil, err
+func (s *adminServiceImpl) BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) {
+ cleaned := make([]int64, 0, len(userIDs))
+ for _, uid := range userIDs {
+ if uid > 0 {
+ cleaned = append(cleaned, uid)
+ }
+ }
+ if len(cleaned) == 0 {
+ return 0, nil
}
- oldBalance := user.Balance
-
- switch operation {
+ var affected int
+ var err error
+ switch mode {
case "set":
- user.Balance = balance
+ affected, err = s.userRepo.BatchSetConcurrency(ctx, cleaned, value)
case "add":
- user.Balance += balance
- case "subtract":
- user.Balance -= balance
+ affected, err = s.userRepo.BatchAddConcurrency(ctx, cleaned, value)
+ default:
+ return 0, errors.New("invalid mode: must be 'set' or 'add'")
+ }
+ if err != nil {
+ return 0, err
}
- if user.Balance < 0 {
- return nil, fmt.Errorf("balance cannot be negative, current balance: %.2f, requested operation would result in: %.2f", oldBalance, user.Balance)
+ if s.authCacheInvalidator != nil {
+ for _, uid := range cleaned {
+ s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, uid)
+ }
}
+ return affected, nil
+}
- if err := s.userRepo.Update(ctx, user); err != nil {
- return nil, err
+func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) {
+ if userID <= 0 {
+ return nil, infraerrors.BadRequest("INVALID_USER_ID", "user_id must be > 0")
}
- balanceDiff := user.Balance - oldBalance
- if s.authCacheInvalidator != nil && balanceDiff != 0 {
- s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
+ if balance <= 0 || math.IsNaN(balance) || math.IsInf(balance, 0) {
+ return nil, infraerrors.BadRequest("INVALID_BALANCE", "balance amount must be finite and > 0")
}
-
- if s.billingCacheService != nil {
- go func() {
- cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- if err := s.billingCacheService.InvalidateUserBalance(cacheCtx, userID); err != nil {
- logger.LegacyPrintf("service.admin", "invalidate user balance cache failed: user_id=%d err=%v", userID, err)
- }
- }()
+ if operation != "set" && operation != "add" && operation != "subtract" {
+ return nil, infraerrors.BadRequest("INVALID_BALANCE_OPERATION", "balance operation must be set, add, or subtract")
+ }
+ adjuster, ok := s.userRepo.(adminBalanceAdjustmentRepository)
+ if !ok || adjuster == nil {
+ return nil, infraerrors.ServiceUnavailable("ADMIN_BALANCE_ATOMICITY_UNAVAILABLE", "atomic admin balance adjustment is unavailable")
+ }
+ if s.redeemCodeRepo == nil {
+ return nil, infraerrors.ServiceUnavailable("ADMIN_BALANCE_AUDIT_UNAVAILABLE", "admin balance audit repository is unavailable")
}
- if balanceDiff != 0 {
+ var (
+ user *User
+ balanceDiff float64
+ )
+ apply := func(txCtx context.Context) error {
+ var err error
+ user, balanceDiff, err = adjuster.AdjustAdminBalance(txCtx, userID, balance, operation)
+ if err != nil || balanceDiff == 0 {
+ return err
+ }
code, err := GenerateRedeemCode()
if err != nil {
- logger.LegacyPrintf("service.admin", "failed to generate adjustment redeem code: %v", err)
- return user, nil
+ return fmt.Errorf("generate admin balance adjustment code: %w", err)
}
-
- adjustmentRecord := &RedeemCode{
+ now := time.Now()
+ return s.redeemCodeRepo.Create(txCtx, &RedeemCode{
Code: code,
Type: AdjustmentTypeAdminBalance,
Value: balanceDiff,
Status: StatusUsed,
UsedBy: &user.ID,
+ UsedAt: &now,
Notes: notes,
- }
- now := time.Now()
- adjustmentRecord.UsedAt = &now
-
- if err := s.redeemCodeRepo.Create(ctx, adjustmentRecord); err != nil {
- logger.LegacyPrintf("service.admin", "failed to create balance adjustment redeem code: %v", err)
- }
+ })
}
+ if err := s.runAdminBalanceTransaction(ctx, apply); err != nil {
+ return nil, err
+ }
+ if balanceDiff != 0 {
+ s.invalidateAdminBalanceCaches(context.WithoutCancel(ctx), userID)
+ }
return user, nil
}
+func (s *adminServiceImpl) runAdminBalanceTransaction(ctx context.Context, execute func(context.Context) error) error {
+ if execute == nil {
+ return errors.New("admin balance transaction executor is nil")
+ }
+ if dbent.TxFromContext(ctx) != nil {
+ return execute(ctx)
+ }
+ if s.entClient == nil {
+ return infraerrors.ServiceUnavailable("ADMIN_BALANCE_TRANSACTION_UNAVAILABLE", "admin balance transaction is unavailable")
+ }
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return fmt.Errorf("begin admin balance transaction: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ if err := execute(dbent.NewTxContext(ctx, tx)); err != nil {
+ return err
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit admin balance transaction: %w", err)
+ }
+ return nil
+}
+
+func (s *adminServiceImpl) invalidateAdminBalanceCaches(ctx context.Context, userID int64) {
+ if s.authCacheInvalidator != nil {
+ s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
+ }
+ if s.billingCacheService == nil {
+ return
+ }
+ cacheCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ if err := s.billingCacheService.InvalidateUserBalance(cacheCtx, userID); err != nil {
+ logger.LegacyPrintf("service.admin", "invalidate user balance cache failed: user_id=%d err=%v", userID, err)
+ }
+}
+
func (s *adminServiceImpl) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error) {
params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder}
keys, result, err := s.apiKeyRepo.ListByUserID(ctx, userID, params, APIKeyListFilters{})
@@ -973,16 +1133,213 @@ func (s *adminServiceImpl) GetUserUsageStats(ctx context.Context, userID int64,
// GetUserBalanceHistory returns paginated balance/concurrency change records for a user.
func (s *adminServiceImpl) GetUserBalanceHistory(ctx context.Context, userID int64, page, pageSize int, codeType string) ([]RedeemCode, int64, float64, error) {
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
+ if codeType == RedeemTypeAffiliateBalance {
+ codes, total, err := s.listAffiliateBalanceHistory(ctx, userID, params)
+ if err != nil {
+ return nil, 0, 0, err
+ }
+ totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID)
+ if err != nil {
+ return nil, 0, 0, err
+ }
+ return codes, total, totalRecharged, nil
+ }
+
+ if codeType == "" {
+ return s.getAllUserBalanceHistory(ctx, userID, params)
+ }
+
codes, result, err := s.redeemCodeRepo.ListByUserPaginated(ctx, userID, params, codeType)
if err != nil {
return nil, 0, 0, err
}
+ total := result.Total
// Aggregate total recharged amount (only once, regardless of type filter)
totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID)
if err != nil {
return nil, 0, 0, err
}
- return codes, result.Total, totalRecharged, nil
+ return codes, total, totalRecharged, nil
+}
+
+func (s *adminServiceImpl) getAllUserBalanceHistory(ctx context.Context, userID int64, params pagination.PaginationParams) ([]RedeemCode, int64, float64, error) {
+ needed := params.Offset() + params.Limit()
+ if needed < params.Limit() {
+ needed = params.Limit()
+ }
+
+ redeemCodes, redeemTotal, err := s.listRedeemBalanceHistoryForMerge(ctx, userID, needed)
+ if err != nil {
+ return nil, 0, 0, err
+ }
+ affiliateCodes, affiliateTotal, err := s.listAffiliateBalanceHistoryForMerge(ctx, userID, needed)
+ if err != nil {
+ return nil, 0, 0, err
+ }
+ codes := mergeBalanceHistoryCodes(redeemCodes, affiliateCodes, params)
+
+ totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID)
+ if err != nil {
+ return nil, 0, 0, err
+ }
+ return codes, redeemTotal + affiliateTotal, totalRecharged, nil
+}
+
+func (s *adminServiceImpl) listRedeemBalanceHistoryForMerge(ctx context.Context, userID int64, needed int) ([]RedeemCode, int64, error) {
+ if needed <= 0 {
+ return nil, 0, nil
+ }
+
+ var (
+ out []RedeemCode
+ total int64
+ )
+ for page := 1; len(out) < needed; page++ {
+ params := pagination.PaginationParams{Page: page, PageSize: 1000}
+ codes, result, err := s.redeemCodeRepo.ListByUserPaginated(ctx, userID, params, "")
+ if err != nil {
+ return nil, 0, err
+ }
+ if result != nil {
+ total = result.Total
+ }
+ out = append(out, codes...)
+ if len(codes) < params.Limit() || int64(len(out)) >= total {
+ break
+ }
+ }
+ if len(out) > needed {
+ out = out[:needed]
+ }
+ return out, total, nil
+}
+
+func (s *adminServiceImpl) listAffiliateBalanceHistoryForMerge(ctx context.Context, userID int64, needed int) ([]RedeemCode, int64, error) {
+ if needed <= 0 {
+ return nil, 0, nil
+ }
+
+ var (
+ out []RedeemCode
+ total int64
+ )
+ for page := 1; len(out) < needed; page++ {
+ params := pagination.PaginationParams{Page: page, PageSize: 1000}
+ codes, currentTotal, err := s.listAffiliateBalanceHistory(ctx, userID, params)
+ if err != nil {
+ return nil, 0, err
+ }
+ total = currentTotal
+ out = append(out, codes...)
+ if len(codes) < params.Limit() || int64(len(out)) >= total {
+ break
+ }
+ }
+ if len(out) > needed {
+ out = out[:needed]
+ }
+ return out, total, nil
+}
+
+func (s *adminServiceImpl) listAffiliateBalanceHistory(ctx context.Context, userID int64, params pagination.PaginationParams) ([]RedeemCode, int64, error) {
+ if s == nil || s.entClient == nil || userID <= 0 {
+ return nil, 0, nil
+ }
+
+ rows, err := s.entClient.QueryContext(ctx, `
+SELECT id,
+ amount::double precision,
+ created_at
+FROM user_affiliate_ledger
+WHERE user_id = $1
+ AND action = 'transfer'
+ORDER BY created_at DESC, id DESC
+OFFSET $2
+LIMIT $3`, userID, params.Offset(), params.Limit())
+ if err != nil {
+ return nil, 0, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ codes := make([]RedeemCode, 0, params.Limit())
+ for rows.Next() {
+ var id int64
+ var amount float64
+ var createdAt time.Time
+ if err := rows.Scan(&id, &amount, &createdAt); err != nil {
+ return nil, 0, err
+ }
+ usedBy := userID
+ usedAt := createdAt
+ codes = append(codes, RedeemCode{
+ ID: -id,
+ Code: fmt.Sprintf("AFF-%d", id),
+ Type: RedeemTypeAffiliateBalance,
+ Value: amount,
+ Status: StatusUsed,
+ UsedBy: &usedBy,
+ UsedAt: &usedAt,
+ CreatedAt: createdAt,
+ })
+ }
+ if err := rows.Err(); err != nil {
+ return nil, 0, err
+ }
+
+ total, err := countAffiliateBalanceHistory(ctx, s.entClient, userID)
+ if err != nil {
+ return nil, 0, err
+ }
+ return codes, total, nil
+}
+
+func countAffiliateBalanceHistory(ctx context.Context, client *dbent.Client, userID int64) (int64, error) {
+ rows, err := client.QueryContext(ctx, `
+SELECT COUNT(*)
+FROM user_affiliate_ledger
+WHERE user_id = $1
+ AND action = 'transfer'`, userID)
+ if err != nil {
+ return 0, err
+ }
+ defer func() { _ = rows.Close() }()
+
+ var total sql.NullInt64
+ if rows.Next() {
+ if err := rows.Scan(&total); err != nil {
+ return 0, err
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return 0, err
+ }
+ if !total.Valid {
+ return 0, nil
+ }
+ return total.Int64, nil
+}
+
+func mergeBalanceHistoryCodes(redeemCodes, affiliateCodes []RedeemCode, params pagination.PaginationParams) []RedeemCode {
+ combined := append(append([]RedeemCode{}, redeemCodes...), affiliateCodes...)
+ sort.SliceStable(combined, func(i, j int) bool {
+ return redeemCodeHistoryTime(combined[i]).After(redeemCodeHistoryTime(combined[j]))
+ })
+ offset := params.Offset()
+ if offset >= len(combined) {
+ return []RedeemCode{}
+ }
+ end := offset + params.Limit()
+ if end > len(combined) {
+ end = len(combined)
+ }
+ return combined[offset:end]
+}
+
+func redeemCodeHistoryTime(code RedeemCode) time.Time {
+ if code.UsedAt != nil {
+ return *code.UsedAt
+ }
+ return code.CreatedAt
}
func (s *adminServiceImpl) BindUserAuthIdentity(ctx context.Context, userID int64, input AdminBindAuthIdentityInput) (*AdminBoundAuthIdentity, error) {
@@ -1336,6 +1693,19 @@ func (s *adminServiceImpl) GetGroup(ctx context.Context, id int64) (*Group, erro
}
func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) {
+ var created *Group
+ err := s.runGroupWriteTransaction(ctx, func(txCtx context.Context) error {
+ var err error
+ created, err = s.createGroup(txCtx, input)
+ return err
+ })
+ if err != nil {
+ return nil, err
+ }
+ return created, nil
+}
+
+func (s *adminServiceImpl) createGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) {
if input.RateMultiplier <= 0 {
return nil, errors.New("rate_multiplier must be > 0")
}
@@ -1359,6 +1729,29 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
imagePrice1K := normalizePrice(input.ImagePrice1K)
imagePrice2K := normalizePrice(input.ImagePrice2K)
imagePrice4K := normalizePrice(input.ImagePrice4K)
+ imageRateMultiplier := 1.0
+ if input.ImageRateMultiplier != nil {
+ if *input.ImageRateMultiplier < 0 {
+ return nil, errors.New("image_rate_multiplier must be >= 0")
+ }
+ imageRateMultiplier = *input.ImageRateMultiplier
+ }
+ fallbackOnInvalidRequest := input.FallbackGroupIDOnInvalidRequest
+ if fallbackOnInvalidRequest != nil && *fallbackOnInvalidRequest <= 0 {
+ fallbackOnInvalidRequest = nil
+ }
+ if err := validateReservedBusinessGroupCreate(&Group{
+ Name: input.Name,
+ Platform: platform,
+ IsExclusive: input.IsExclusive,
+ Status: StatusActive,
+ SubscriptionType: subscriptionType,
+ ClaudeCodeOnly: input.ClaudeCodeOnly,
+ FallbackGroupID: input.FallbackGroupID,
+ FallbackGroupIDOnInvalidRequest: fallbackOnInvalidRequest,
+ }); err != nil {
+ return nil, err
+ }
// 校验降级分组
if input.FallbackGroupID != nil {
@@ -1366,10 +1759,6 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
return nil, err
}
}
- fallbackOnInvalidRequest := input.FallbackGroupIDOnInvalidRequest
- if fallbackOnInvalidRequest != nil && *fallbackOnInvalidRequest <= 0 {
- fallbackOnInvalidRequest = nil
- }
// 校验无效请求兜底分组
if fallbackOnInvalidRequest != nil {
if err := s.validateFallbackGroupOnInvalidRequest(ctx, 0, platform, subscriptionType, *fallbackOnInvalidRequest); err != nil {
@@ -1426,6 +1815,9 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
DailyLimitUSD: dailyLimit,
WeeklyLimitUSD: weeklyLimit,
MonthlyLimitUSD: monthlyLimit,
+ AllowImageGeneration: input.AllowImageGeneration,
+ ImageRateIndependent: input.ImageRateIndependent,
+ ImageRateMultiplier: imageRateMultiplier,
ImagePrice1K: imagePrice1K,
ImagePrice2K: imagePrice2K,
ImagePrice4K: imagePrice4K,
@@ -1565,10 +1957,74 @@ func (s *adminServiceImpl) validateFallbackGroupOnInvalidRequest(ctx context.Con
}
func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error) {
+ if input != nil && len(input.CopyAccountsFromGroupIDs) > 0 && dbent.TxFromContext(ctx) == nil && s.entClient == nil {
+ return nil, errors.New("atomic group account binding transaction is not configured")
+ }
+ if existingTx := dbent.TxFromContext(ctx); existingTx != nil {
+ updated, err := s.updateGroup(ctx, id, input)
+ if err != nil {
+ return nil, err
+ }
+ s.invalidateGroupAuthCacheAfterCommit(existingTx, ctx, id)
+ return updated, nil
+ }
+
+ var updated *Group
+ err := s.runGroupWriteTransaction(ctx, func(txCtx context.Context) error {
+ var err error
+ updated, err = s.updateGroup(txCtx, id, input)
+ return err
+ })
+ if err != nil {
+ return nil, err
+ }
+ if s.authCacheInvalidator != nil {
+ s.authCacheInvalidator.InvalidateAuthCacheByGroupID(context.WithoutCancel(ctx), id)
+ }
+ return updated, nil
+}
+
+func (s *adminServiceImpl) updateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error) {
group, err := s.groupRepo.GetByID(ctx, id)
if err != nil {
return nil, err
}
+ candidate := *group
+ if input.Name != "" {
+ candidate.Name = input.Name
+ }
+ if input.Platform != "" {
+ candidate.Platform = input.Platform
+ }
+ if input.IsExclusive != nil {
+ candidate.IsExclusive = *input.IsExclusive
+ }
+ if input.Status != "" {
+ candidate.Status = input.Status
+ }
+ if input.SubscriptionType != "" {
+ candidate.SubscriptionType = input.SubscriptionType
+ }
+ if input.ClaudeCodeOnly != nil {
+ candidate.ClaudeCodeOnly = *input.ClaudeCodeOnly
+ }
+ if input.FallbackGroupID != nil {
+ if *input.FallbackGroupID > 0 {
+ candidate.FallbackGroupID = input.FallbackGroupID
+ } else {
+ candidate.FallbackGroupID = nil
+ }
+ }
+ if input.FallbackGroupIDOnInvalidRequest != nil {
+ if *input.FallbackGroupIDOnInvalidRequest > 0 {
+ candidate.FallbackGroupIDOnInvalidRequest = input.FallbackGroupIDOnInvalidRequest
+ } else {
+ candidate.FallbackGroupIDOnInvalidRequest = nil
+ }
+ }
+ if err := validateReservedBusinessGroupUpdate(group, &candidate); err != nil {
+ return nil, err
+ }
if input.Name != "" {
group.Name = input.Name
@@ -1596,12 +2052,30 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
if input.SubscriptionType != "" {
group.SubscriptionType = input.SubscriptionType
}
- // 限额字段:nil/负数 表示"无限制",0 表示"不允许用量",正数表示具体限额
- // 前端始终发送这三个字段,无需 nil 守卫
- group.DailyLimitUSD = normalizeLimit(input.DailyLimitUSD)
- group.WeeklyLimitUSD = normalizeLimit(input.WeeklyLimitUSD)
- group.MonthlyLimitUSD = normalizeLimit(input.MonthlyLimitUSD)
+ // 限额字段:nil 表示未提供、不改动;负数显式清除为"无限制";
+ // 0 表示"不允许用量",正数表示具体限额。
+ if input.DailyLimitUSD != nil {
+ group.DailyLimitUSD = normalizeLimit(input.DailyLimitUSD)
+ }
+ if input.WeeklyLimitUSD != nil {
+ group.WeeklyLimitUSD = normalizeLimit(input.WeeklyLimitUSD)
+ }
+ if input.MonthlyLimitUSD != nil {
+ group.MonthlyLimitUSD = normalizeLimit(input.MonthlyLimitUSD)
+ }
// 图片生成计费配置:负数表示清除(使用默认价格)
+ if input.AllowImageGeneration != nil {
+ group.AllowImageGeneration = *input.AllowImageGeneration
+ }
+ if input.ImageRateIndependent != nil {
+ group.ImageRateIndependent = *input.ImageRateIndependent
+ }
+ if input.ImageRateMultiplier != nil {
+ if *input.ImageRateMultiplier < 0 {
+ return nil, errors.New("image_rate_multiplier must be >= 0")
+ }
+ group.ImageRateMultiplier = *input.ImageRateMultiplier
+ }
if input.ImagePrice1K != nil {
group.ImagePrice1K = normalizePrice(input.ImagePrice1K)
}
@@ -1684,10 +2158,6 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
return nil, err
}
- if s.authCacheInvalidator != nil {
- s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, id)
- }
-
// 如果指定了复制账号的源分组,同步绑定(替换当前分组的账号)
if len(input.CopyAccountsFromGroupIDs) > 0 {
// 去重源分组 IDs
@@ -1759,10 +2229,57 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
return group, nil
}
+func (s *adminServiceImpl) runGroupWriteTransaction(ctx context.Context, execute func(context.Context) error) error {
+ if execute == nil {
+ return errors.New("group write transaction executor is nil")
+ }
+ if dbent.TxFromContext(ctx) != nil || s.entClient == nil {
+ return execute(ctx)
+ }
+
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return fmt.Errorf("begin group write transaction: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ txCtx := dbent.NewTxContext(ctx, tx)
+ if err := execute(txCtx); err != nil {
+ return err
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit group write transaction: %w", err)
+ }
+ return nil
+}
+
+func (s *adminServiceImpl) invalidateGroupAuthCacheAfterCommit(tx *dbent.Tx, ctx context.Context, groupID int64) {
+ if tx == nil || s.authCacheInvalidator == nil {
+ return
+ }
+ tx.OnCommit(func(next dbent.Committer) dbent.Committer {
+ return dbent.CommitFunc(func(commitCtx context.Context, committedTx *dbent.Tx) error {
+ if err := next.Commit(commitCtx, committedTx); err != nil {
+ return err
+ }
+ s.authCacheInvalidator.InvalidateAuthCacheByGroupID(context.WithoutCancel(ctx), groupID)
+ return nil
+ })
+ })
+}
+
func (s *adminServiceImpl) DeleteGroup(ctx context.Context, id int64) error {
+ group, err := s.groupRepo.GetByIDLite(ctx, id)
+ if err != nil {
+ return err
+ }
+ if err := rejectReservedBusinessGroupDelete(group); err != nil {
+ return err
+ }
+
var groupKeys []string
if s.authCacheInvalidator != nil {
- keys, err := s.apiKeyRepo.ListKeysByGroupID(ctx, id)
+ keys, err := s.apiKeyRepo.ListAuthCacheLocatorsByGroupID(ctx, id)
if err == nil {
groupKeys = keys
}
@@ -1788,8 +2305,12 @@ func (s *adminServiceImpl) DeleteGroup(ctx context.Context, id int64) error {
}()
}
if s.authCacheInvalidator != nil {
- for _, key := range groupKeys {
- s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, key)
+ if locatorInvalidator, ok := s.authCacheInvalidator.(interface {
+ InvalidateAuthCacheByLocatorReliable(context.Context, string) error
+ }); ok {
+ for _, locator := range groupKeys {
+ _ = locatorInvalidator.InvalidateAuthCacheByLocatorReliable(ctx, locator)
+ }
}
}
@@ -1814,52 +2335,83 @@ func (s *adminServiceImpl) GetGroupRateMultipliers(ctx context.Context, groupID
func (s *adminServiceImpl) ClearGroupRateMultipliers(ctx context.Context, groupID int64) error {
if s.userGroupRateRepo == nil {
- return nil
+ return errors.New("user group rate repository is not configured")
}
- return s.userGroupRateRepo.DeleteByGroupID(ctx, groupID)
+ return s.runAdminGroupPolicyMutation(ctx, groupID, func(txCtx context.Context) error {
+ // 清空 rate 部分但保留同一行上的 rpm_override。
+ return s.userGroupRateRepo.SyncGroupRateMultipliers(txCtx, groupID, nil)
+ })
}
func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error {
if s.userGroupRateRepo == nil {
- return nil
+ return errors.New("user group rate repository is not configured")
+ }
+ if groupID <= 0 {
+ return infraerrors.BadRequest("INVALID_GROUP_ID", "group_id must be > 0")
}
for _, e := range entries {
+ if e.UserID <= 0 {
+ return infraerrors.BadRequest("INVALID_USER_ID", "user_id must be > 0")
+ }
if e.RateMultiplier <= 0 {
return fmt.Errorf("rate_multiplier must be > 0 (user_id=%d)", e.UserID)
}
}
- return s.userGroupRateRepo.SyncGroupRateMultipliers(ctx, groupID, entries)
+ return s.runAdminGroupPolicyMutation(ctx, groupID, func(txCtx context.Context) error {
+ return s.userGroupRateRepo.SyncGroupRateMultipliers(txCtx, groupID, entries)
+ })
}
func (s *adminServiceImpl) ClearGroupRPMOverrides(ctx context.Context, groupID int64) error {
if s.userGroupRateRepo == nil {
- return nil
+ return errors.New("user group rate repository is not configured")
}
- if err := s.userGroupRateRepo.ClearGroupRPMOverrides(ctx, groupID); err != nil {
- return err
- }
- // RPM override 已嵌入 auth cache snapshot (v7),变更后必须失效相关缓存。
- if s.authCacheInvalidator != nil {
- s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, groupID)
- }
- return nil
+ return s.runAdminGroupPolicyMutation(ctx, groupID, func(txCtx context.Context) error {
+ return s.userGroupRateRepo.ClearGroupRPMOverrides(txCtx, groupID)
+ })
}
func (s *adminServiceImpl) BatchSetGroupRPMOverrides(ctx context.Context, groupID int64, entries []GroupRPMOverrideInput) error {
if s.userGroupRateRepo == nil {
- return nil
+ return errors.New("user group rate repository is not configured")
+ }
+ if groupID <= 0 {
+ return infraerrors.BadRequest("INVALID_GROUP_ID", "group_id must be > 0")
}
for _, e := range entries {
- if e.RPMOverride != nil && *e.RPMOverride < 0 {
- return infraerrors.BadRequest("INVALID_RPM_OVERRIDE", fmt.Sprintf("rpm_override must be >= 0 (user_id=%d)", e.UserID))
+ if e.UserID <= 0 {
+ return infraerrors.BadRequest("INVALID_USER_ID", "user_id must be > 0")
+ }
+ if e.RPMOverride != nil && (*e.RPMOverride < 0 || *e.RPMOverride > math.MaxInt32) {
+ return infraerrors.BadRequest(
+ "INVALID_RPM_OVERRIDE",
+ fmt.Sprintf("rpm_override must be between 0 and %d (user_id=%d)", math.MaxInt32, e.UserID),
+ )
+ }
+ }
+ return s.runAdminGroupPolicyMutation(ctx, groupID, func(txCtx context.Context) error {
+ return s.userGroupRateRepo.SyncGroupRPMOverrides(txCtx, groupID, entries)
+ })
+}
+
+func (s *adminServiceImpl) runAdminGroupPolicyMutation(ctx context.Context, groupID int64, mutate func(context.Context) error) error {
+ if mutate == nil {
+ return errors.New("admin group policy mutation is nil")
+ }
+ if existingTx := dbent.TxFromContext(ctx); existingTx != nil {
+ if err := mutate(ctx); err != nil {
+ return err
}
+ s.invalidateGroupAuthCacheAfterCommit(existingTx, ctx, groupID)
+ return nil
}
- if err := s.userGroupRateRepo.SyncGroupRPMOverrides(ctx, groupID, entries); err != nil {
+ if err := s.runGroupWriteTransaction(ctx, mutate); err != nil {
return err
}
- // RPM override 已嵌入 auth cache snapshot (v7),变更后必须失效相关缓存。
+ // Rate multiplier 与 RPM override 都嵌入认证/计费快照,只能在提交成功后失效。
if s.authCacheInvalidator != nil {
- s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, groupID)
+ s.authCacheInvalidator.InvalidateAuthCacheByGroupID(context.WithoutCancel(ctx), groupID)
}
return nil
}
@@ -1875,6 +2427,11 @@ func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i
if err != nil {
return nil, err
}
+ if apiKey.IsWalletUniversal() {
+ if !apiKey.HasValidWalletUniversalShape() || groupID != nil {
+ return nil, ErrWalletUniversalKeyImmutable
+ }
+ }
if groupID == nil {
// nil 表示不修改,直接返回
@@ -1920,15 +2477,18 @@ func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i
// 专属标准分组:使用事务保证「添加分组权限」与「更新 API Key」的原子性
if group.IsExclusive && !group.IsSubscriptionType() {
opCtx := ctx
- var tx *dbent.Tx
- if s.entClient == nil {
- logger.LegacyPrintf("service.admin", "Warning: entClient is nil, skipping transaction protection for exclusive group binding")
- } else {
+ tx := dbent.TxFromContext(ctx)
+ ownsTx := false
+ if tx == nil {
+ if s.entClient == nil {
+ return nil, errors.New("atomic exclusive group binding transaction is not configured")
+ }
var txErr error
tx, txErr = s.entClient.Tx(ctx)
if txErr != nil {
return nil, fmt.Errorf("begin transaction: %w", txErr)
}
+ ownsTx = true
defer func() { _ = tx.Rollback() }()
opCtx = dbent.NewTxContext(ctx, tx)
}
@@ -1939,7 +2499,7 @@ func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i
if err := s.apiKeyRepo.Update(opCtx, apiKey); err != nil {
return nil, fmt.Errorf("update api key: %w", err)
}
- if tx != nil {
+ if ownsTx {
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit transaction: %w", err)
}
@@ -1949,9 +2509,19 @@ func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i
result.GrantedGroupID = &gid
result.GrantedGroupName = group.Name
- // 失效认证缓存(在事务提交后执行)
- if s.authCacheInvalidator != nil {
+ // 自建事务提交后立即失效;外层事务则挂到提交钩子,避免提前放行未提交状态。
+ if ownsTx && s.authCacheInvalidator != nil {
s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key)
+ } else if tx != nil && s.authCacheInvalidator != nil {
+ tx.OnCommit(func(next dbent.Committer) dbent.Committer {
+ return dbent.CommitFunc(func(commitCtx context.Context, committedTx *dbent.Tx) error {
+ if err := next.Commit(commitCtx, committedTx); err != nil {
+ return err
+ }
+ s.authCacheInvalidator.InvalidateAuthCacheByKey(context.WithoutCancel(ctx), apiKey.Key)
+ return nil
+ })
+ })
}
result.APIKey = apiKey
@@ -2051,12 +2621,7 @@ func (s *adminServiceImpl) ReplaceUserGroup(ctx context.Context, userID, oldGrou
// 失效该用户所有 Key 的认证缓存
if s.authCacheInvalidator != nil {
- keys, keyErr := s.apiKeyRepo.ListKeysByUserID(ctx, userID)
- if keyErr == nil {
- for _, k := range keys {
- s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, k)
- }
- }
+ s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
}
return &ReplaceUserGroupResult{MigratedKeys: migrated}, nil
@@ -2090,6 +2655,16 @@ func (s *adminServiceImpl) GetAccountsByIDs(ctx context.Context, ids []int64) ([
}
func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) {
+ if err := validateKiroAccountType(input.Platform, input.Type); err != nil {
+ return nil, err
+ }
+ if err := validateCursorAccountType(input.Platform, input.Type); err != nil {
+ return nil, err
+ }
+ if err := validateBedrockAccountCredentials(input.Type, input.Credentials); err != nil {
+ return nil, err
+ }
+
// 绑定分组
groupIDs := input.GroupIDs
// 如果没有指定分组,自动绑定对应平台的默认分组
@@ -2106,6 +2681,13 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
}
}
+ if err := validateKiroAccountGroupIsolation(ctx, s.groupRepo, input.Platform, groupIDs); err != nil {
+ return nil, err
+ }
+ if err := validateCursorAccountGroupIsolation(ctx, s.groupRepo, input.Platform, groupIDs); err != nil {
+ return nil, err
+ }
+
// 检查混合渠道风险(除非用户已确认)
if len(groupIDs) > 0 && !input.SkipMixedChannelCheck {
if err := s.checkMixedChannelRisk(ctx, 0, input.Platform, groupIDs); err != nil {
@@ -2204,6 +2786,12 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
account.Name = input.Name
}
if input.Type != "" {
+ if err := validateKiroAccountType(account.Platform, input.Type); err != nil {
+ return nil, err
+ }
+ if err := validateCursorAccountType(account.Platform, input.Type); err != nil {
+ return nil, err
+ }
account.Type = input.Type
}
if input.Notes != nil {
@@ -2212,6 +2800,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
if len(input.Credentials) > 0 {
account.Credentials = input.Credentials
}
+ if err := validateBedrockAccountCredentials(account.Type, account.Credentials); err != nil {
+ return nil, err
+ }
// Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。
// 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。
if input.Extra != nil {
@@ -2291,6 +2882,12 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil {
return nil, err
}
+ if err := validateKiroAccountGroupIsolation(ctx, s.groupRepo, account.Platform, *input.GroupIDs); err != nil {
+ return nil, err
+ }
+ if err := validateCursorAccountGroupIsolation(ctx, s.groupRepo, account.Platform, *input.GroupIDs); err != nil {
+ return nil, err
+ }
// 检查混合渠道风险(除非用户已确认)
if !input.SkipMixedChannelCheck {
@@ -2322,6 +2919,11 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
// BulkUpdateAccounts updates multiple accounts in one request.
// It merges credentials/extra keys instead of overwriting the whole object.
func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) {
+ if _, updatesRegion := input.Credentials["aws_region"]; updatesRegion {
+ if err := validateBedrockAccountCredentials(AccountTypeBedrock, input.Credentials); err != nil {
+ return nil, err
+ }
+ }
if len(input.AccountIDs) == 0 && input.Filters != nil {
accountIDs, err := s.resolveBulkUpdateTargetIDs(ctx, input.Filters)
if err != nil {
@@ -2345,11 +2947,12 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
}
}
+ needAccountPlatformPreload := input.GroupIDs != nil
needMixedChannelCheck := input.GroupIDs != nil && !input.SkipMixedChannelCheck
// 预加载账号平台信息(混合渠道检查需要)。
platformByID := map[int64]string{}
- if needMixedChannelCheck {
+ if needAccountPlatformPreload {
accounts, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs)
if err != nil {
return nil, err
@@ -2361,6 +2964,21 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
}
}
+ if input.GroupIDs != nil {
+ for _, accountID := range input.AccountIDs {
+ platform := platformByID[accountID]
+ if platform == "" {
+ continue
+ }
+ if err := validateKiroAccountGroupIsolation(ctx, s.groupRepo, platform, *input.GroupIDs); err != nil {
+ return nil, err
+ }
+ if err := validateCursorAccountGroupIsolation(ctx, s.groupRepo, platform, *input.GroupIDs); err != nil {
+ return nil, err
+ }
+ }
+ }
+
// 预检查混合渠道风险:在任何写操作之前,若发现风险立即返回错误。
if needMixedChannelCheck {
for _, accountID := range input.AccountIDs {
@@ -2706,11 +3324,17 @@ func (s *adminServiceImpl) GetRedeemCode(ctx context.Context, id int64) (*Redeem
}
func (s *adminServiceImpl) GenerateRedeemCodes(ctx context.Context, input *GenerateRedeemCodesInput) ([]RedeemCode, error) {
+ if err := validateNewRedeemCodeType(input.Type); err != nil {
+ return nil, err
+ }
// 如果是订阅类型,验证必须有 GroupID
if input.Type == RedeemTypeSubscription {
if input.GroupID == nil {
return nil, errors.New("group_id is required for subscription type")
}
+ if input.PlanID != nil {
+ return nil, errors.New("plan_id must be empty for subscription type")
+ }
// 验证分组存在且为订阅类型
group, err := s.groupRepo.GetByID(ctx, *input.GroupID)
if err != nil {
@@ -2721,6 +3345,26 @@ func (s *adminServiceImpl) GenerateRedeemCodes(ctx context.Context, input *Gener
}
}
+ // 钱包模式额度卡:必须有 PlanID + plan 是 credits 钱包 plan(B2.7)
+ if input.Type == RedeemTypeWallet {
+ if input.PlanID == nil {
+ return nil, errors.New("plan_id is required for wallet type")
+ }
+ if input.GroupID != nil {
+ return nil, errors.New("group_id must be empty for wallet type")
+ }
+ plan, err := s.entClient.SubscriptionPlan.Get(ctx, *input.PlanID)
+ if err != nil {
+ return nil, fmt.Errorf("plan not found: %w", err)
+ }
+ if plan.WalletQuotaUsd == nil || *plan.WalletQuotaUsd <= 0 {
+ return nil, errors.New("plan is not a wallet plan (wallet_quota_usd missing)")
+ }
+ if plan.PlanType != PlanTypeCredits {
+ return nil, errors.New("plan must be credits type for wallet redeem codes")
+ }
+ }
+
codes := make([]RedeemCode, 0, input.Count)
for i := 0; i < input.Count; i++ {
codeValue, err := GenerateRedeemCode()
@@ -2741,6 +3385,10 @@ func (s *adminServiceImpl) GenerateRedeemCodes(ctx context.Context, input *Gener
code.ValidityDays = 30 // 默认30天
}
}
+ // 钱包模式额度卡:挂 plan_id;validity_days 不参与(永久有效,由 plan_type=credits 决定)
+ if input.Type == RedeemTypeWallet {
+ code.PlanID = input.PlanID
+ }
if err := s.redeemCodeRepo.Create(ctx, &code); err != nil {
return nil, err
}
@@ -2750,13 +3398,15 @@ func (s *adminServiceImpl) GenerateRedeemCodes(ctx context.Context, input *Gener
}
func (s *adminServiceImpl) DeleteRedeemCode(ctx context.Context, id int64) error {
- return s.redeemCodeRepo.Delete(ctx, id)
+ _, err := deleteRedeemCodeIfUnused(ctx, s.redeemCodeRepo, id)
+ return err
}
func (s *adminServiceImpl) BatchDeleteRedeemCodes(ctx context.Context, ids []int64) (int64, error) {
var deleted int64
for _, id := range ids {
- if err := s.redeemCodeRepo.Delete(ctx, id); err == nil {
+ wasDeleted, err := deleteRedeemCodeIfUnused(ctx, s.redeemCodeRepo, id)
+ if err == nil && wasDeleted {
deleted++
}
}
@@ -2764,15 +3414,21 @@ func (s *adminServiceImpl) BatchDeleteRedeemCodes(ctx context.Context, ids []int
}
func (s *adminServiceImpl) ExpireRedeemCode(ctx context.Context, id int64) (*RedeemCode, error) {
- code, err := s.redeemCodeRepo.GetByID(ctx, id)
+ expired, err := s.redeemCodeRepo.ExpireIfUnused(ctx, id)
if err != nil {
return nil, err
}
- code.Status = StatusExpired
- if err := s.redeemCodeRepo.Update(ctx, code); err != nil {
+ code, err := s.redeemCodeRepo.GetByID(ctx, id)
+ if err != nil {
return nil, err
}
- return code, nil
+ if expired || code.Status == StatusExpired {
+ return code, nil
+ }
+ if code.Status == StatusUsed {
+ return nil, ErrRedeemCodeExpireUsed
+ }
+ return nil, ErrRedeemCodeExpireState
}
func (s *adminServiceImpl) TestProxy(ctx context.Context, id int64) (*ProxyTestResult, error) {
diff --git a/backend/internal/service/admin_service_apikey_test.go b/backend/internal/service/admin_service_apikey_test.go
index fcde5cbf4ab..85505a9b084 100644
--- a/backend/internal/service/admin_service_apikey_test.go
+++ b/backend/internal/service/admin_service_apikey_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"time"
+ dbent "github.com/Wei-Shaw/sub2api/ent"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/stretchr/testify/require"
@@ -68,6 +69,13 @@ func (s *userRepoStubForGroupUpdate) DeductBalance(context.Context, int64, float
func (s *userRepoStubForGroupUpdate) UpdateConcurrency(context.Context, int64, int) error {
panic("unexpected")
}
+
+func (s *userRepoStubForGroupUpdate) BatchSetConcurrency(context.Context, []int64, int) (int, error) {
+ return 0, nil
+}
+func (s *userRepoStubForGroupUpdate) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
+ return 0, nil
+}
func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) {
panic("unexpected")
}
@@ -160,10 +168,10 @@ func (s *apiKeyRepoStubForGroupUpdate) ClearGroupIDByGroupID(context.Context, in
func (s *apiKeyRepoStubForGroupUpdate) CountByGroupID(context.Context, int64) (int64, error) {
panic("unexpected")
}
-func (s *apiKeyRepoStubForGroupUpdate) ListKeysByUserID(context.Context, int64) ([]string, error) {
+func (s *apiKeyRepoStubForGroupUpdate) ListAuthCacheLocatorsByUserID(context.Context, int64) ([]string, error) {
panic("unexpected")
}
-func (s *apiKeyRepoStubForGroupUpdate) ListKeysByGroupID(context.Context, int64) ([]string, error) {
+func (s *apiKeyRepoStubForGroupUpdate) ListAuthCacheLocatorsByGroupID(context.Context, int64) ([]string, error) {
panic("unexpected")
}
func (s *apiKeyRepoStubForGroupUpdate) IncrementQuotaUsed(context.Context, int64, float64) (float64, error) {
@@ -289,6 +297,23 @@ func TestAdminService_AdminUpdateAPIKeyGroupID_NilGroupID_NoOp(t *testing.T) {
require.Nil(t, repo.updated)
}
+func TestAdminServiceAdminUpdateAPIKeyGroupIDRejectsWalletUniversalPurpose(t *testing.T) {
+ existing := &APIKey{
+ ID: 1,
+ UserID: 42,
+ Key: "wallet-system-key",
+ Name: WalletUniversalAPIKeyName,
+ Purpose: APIKeyPurposeWalletUniversal,
+ }
+ repo := &apiKeyRepoStubForGroupUpdate{key: existing}
+ svc := &adminServiceImpl{apiKeyRepo: repo}
+
+ _, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(3))
+
+ require.ErrorIs(t, err, ErrWalletUniversalKeyImmutable)
+ require.Nil(t, repo.updated)
+}
+
func TestAdminService_AdminUpdateAPIKeyGroupID_Unbind(t *testing.T) {
existing := &APIKey{ID: 1, Key: "sk-test", GroupID: int64Ptr(5), Group: &Group{ID: 5, Name: "Old"}}
repo := &apiKeyRepoStubForGroupUpdate{key: existing}
@@ -420,10 +445,10 @@ func TestAdminService_AdminUpdateAPIKeyGroupID_ExclusiveGroup_AddsAllowedGroup(t
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Exclusive", Status: StatusActive, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard}}
userRepo := &userRepoStubForGroupUpdate{}
- cache := &authCacheInvalidatorStub{}
- svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo, authCacheInvalidator: cache}
+ svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo}
- got, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
+ txCtx := dbent.NewTxContext(context.Background(), &dbent.Tx{})
+ got, err := svc.AdminUpdateAPIKeyGroupID(txCtx, 1, int64Ptr(10))
require.NoError(t, err)
require.NotNil(t, got.APIKey.GroupID)
require.Equal(t, int64(10), *got.APIKey.GroupID)
@@ -438,6 +463,23 @@ func TestAdminService_AdminUpdateAPIKeyGroupID_ExclusiveGroup_AddsAllowedGroup(t
require.Equal(t, "Exclusive", got.GrantedGroupName)
}
+func TestAdminService_AdminUpdateAPIKeyGroupID_ExclusiveGroupRequiresAtomicTransaction(t *testing.T) {
+ existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test"}
+ apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
+ groupRepo := &groupRepoStubForGroupUpdate{group: &Group{
+ ID: 10, Name: "Exclusive", Status: StatusActive, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard,
+ }}
+ userRepo := &userRepoStubForGroupUpdate{}
+ svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo}
+
+ _, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "atomic exclusive group binding transaction is not configured")
+ require.False(t, userRepo.addGroupCalled)
+ require.Nil(t, apiKeyRepo.updated)
+}
+
func TestAdminService_AdminUpdateAPIKeyGroupID_NonExclusiveGroup_NoAllowedGroupUpdate(t *testing.T) {
existing := &APIKey{ID: 1, UserID: 42, Key: "sk-test", GroupID: nil}
apiKeyRepo := &apiKeyRepoStubForGroupUpdate{key: existing}
@@ -491,7 +533,7 @@ func TestAdminService_AdminUpdateAPIKeyGroupID_SubscriptionGroup_AllowsActiveSub
groupRepo := &groupRepoStubForGroupUpdate{group: &Group{ID: 10, Name: "Sub", Status: StatusActive, IsExclusive: true, SubscriptionType: SubscriptionTypeSubscription}}
userRepo := &userRepoStubForGroupUpdate{}
userSubRepo := &userSubRepoStubForGroupUpdate{
- getActiveSub: &UserSubscription{ID: 99, UserID: 42, GroupID: 10},
+ getActiveSub: &UserSubscription{ID: 99, UserID: 42, GroupID: ptrInt64(10)},
}
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo, userSubRepo: userSubRepo}
@@ -511,7 +553,8 @@ func TestAdminService_AdminUpdateAPIKeyGroupID_ExclusiveGroup_AllowedGroupAddFai
svc := &adminServiceImpl{apiKeyRepo: apiKeyRepo, groupRepo: groupRepo, userRepo: userRepo}
// 严格模式:AddGroupToAllowedGroups 失败时,整体操作报错
- _, err := svc.AdminUpdateAPIKeyGroupID(context.Background(), 1, int64Ptr(10))
+ txCtx := dbent.NewTxContext(context.Background(), &dbent.Tx{})
+ _, err := svc.AdminUpdateAPIKeyGroupID(txCtx, 1, int64Ptr(10))
require.Error(t, err)
require.Contains(t, err.Error(), "add group to user allowed groups")
require.True(t, userRepo.addGroupCalled)
diff --git a/backend/internal/service/admin_service_bulk_update_test.go b/backend/internal/service/admin_service_bulk_update_test.go
index df415295b1b..f8e74171d97 100644
--- a/backend/internal/service/admin_service_bulk_update_test.go
+++ b/backend/internal/service/admin_service_bulk_update_test.go
@@ -14,6 +14,8 @@ import (
type accountRepoStubForBulkUpdate struct {
accountRepoStub
+ createdAccount *Account
+ updatedAccount *Account
bulkUpdateErr error
bulkUpdateIDs []int64
bindGroupErrByID map[int64]error
@@ -42,6 +44,19 @@ type accountRepoStubForBulkUpdate struct {
}
}
+func (s *accountRepoStubForBulkUpdate) Create(_ context.Context, account *Account) error {
+ s.createdAccount = account
+ if account.ID == 0 {
+ account.ID = 1
+ }
+ return nil
+}
+
+func (s *accountRepoStubForBulkUpdate) Update(_ context.Context, account *Account) error {
+ s.updatedAccount = account
+ return nil
+}
+
func (s *accountRepoStubForBulkUpdate) BulkUpdate(_ context.Context, ids []int64, _ AccountBulkUpdate) (int64, error) {
s.bulkUpdateIDs = append([]int64{}, ids...)
if s.bulkUpdateErr != nil {
@@ -246,3 +261,163 @@ func TestAdminServiceBulkUpdateAccounts_ResolvesIDsFromFilters(t *testing.T) {
require.Equal(t, 0, result.Failed)
require.Equal(t, []int64{7, 11}, result.SuccessIDs)
}
+
+func TestAdminService_CreateAccount_KiroAllowsAnthropicGroup(t *testing.T) {
+ repo := &accountRepoStubForBulkUpdate{}
+ svc := &adminServiceImpl{
+ accountRepo: repo,
+ groupRepo: &groupRepoStubForAdmin{getByID: &Group{ID: 10, Name: "anthropic-group", Platform: PlatformAnthropic}},
+ }
+
+ account, err := svc.CreateAccount(context.Background(), &CreateAccountInput{
+ Name: "kiro-upstream",
+ Platform: PlatformKiro,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{"api_key": "placeholder"},
+ GroupIDs: []int64{10},
+ SkipMixedChannelCheck: true,
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, account)
+ require.Equal(t, PlatformKiro, repo.createdAccount.Platform)
+ require.Equal(t, []int64{account.ID}, repo.bindGroupsCalls)
+}
+
+func TestAdminService_CreateAccount_KiroRejectsNonAPIKeyType(t *testing.T) {
+ repo := &accountRepoStubForBulkUpdate{}
+ svc := &adminServiceImpl{
+ accountRepo: repo,
+ groupRepo: &groupRepoStubForAdmin{getByID: &Group{ID: 10, Name: "kiro-group", Platform: PlatformKiro}},
+ }
+
+ account, err := svc.CreateAccount(context.Background(), &CreateAccountInput{
+ Name: "kiro-upstream",
+ Platform: PlatformKiro,
+ Type: AccountTypeOAuth,
+ Credentials: map[string]any{"access_token": "placeholder"},
+ GroupIDs: []int64{10},
+ SkipMixedChannelCheck: true,
+ })
+
+ require.Nil(t, account)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "kiro accounts must use apikey type")
+ require.Nil(t, repo.createdAccount)
+}
+
+func TestAdminService_UpdateAccount_NonKiroRejectsKiroGroup(t *testing.T) {
+ groupIDs := []int64{10}
+ repo := &accountRepoStubForBulkUpdate{
+ getByIDAccounts: map[int64]*Account{
+ 1: {ID: 1, Name: "claude-upstream", Platform: PlatformAnthropic, Type: AccountTypeOAuth},
+ },
+ }
+ svc := &adminServiceImpl{
+ accountRepo: repo,
+ groupRepo: &groupRepoStubForAdmin{getByID: &Group{ID: 10, Name: "kiro-group", Platform: PlatformKiro}},
+ }
+
+ account, err := svc.UpdateAccount(context.Background(), 1, &UpdateAccountInput{
+ GroupIDs: &groupIDs,
+ SkipMixedChannelCheck: true,
+ })
+
+ require.Nil(t, account)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "kiro groups only accept kiro accounts")
+ require.Nil(t, repo.updatedAccount)
+}
+
+func TestAdminService_BulkUpdateAccounts_KiroIsolationPrecheckBlocksBeforeWrite(t *testing.T) {
+ groupIDs := []int64{10}
+ repo := &accountRepoStubForBulkUpdate{
+ getByIDsAccounts: []*Account{
+ {ID: 1, Platform: PlatformKiro},
+ },
+ }
+ svc := &adminServiceImpl{
+ accountRepo: repo,
+ groupRepo: &groupRepoStubForAdmin{getByID: &Group{ID: 10, Name: "openai-group", Platform: PlatformOpenAI}},
+ }
+
+ result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
+ AccountIDs: []int64{1},
+ GroupIDs: &groupIDs,
+ SkipMixedChannelCheck: true,
+ })
+
+ require.Nil(t, result)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "kiro accounts can only be assigned to kiro or anthropic groups")
+ require.Empty(t, repo.bulkUpdateIDs)
+ require.Empty(t, repo.bindGroupsCalls)
+}
+
+func TestAdminService_CreateAccount_CursorAllowsCursorGroup(t *testing.T) {
+ repo := &accountRepoStubForBulkUpdate{}
+ svc := &adminServiceImpl{
+ accountRepo: repo,
+ groupRepo: &groupRepoStubForAdmin{getByID: &Group{ID: 10, Name: "cursor-group", Platform: PlatformCursor}},
+ }
+
+ account, err := svc.CreateAccount(context.Background(), &CreateAccountInput{
+ Name: "cursor-upstream",
+ Platform: PlatformCursor,
+ Type: AccountTypeUpstream,
+ Credentials: map[string]any{"sidecar_account_ref": "cursor-prod-001"},
+ GroupIDs: []int64{10},
+ SkipMixedChannelCheck: true,
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, account)
+ require.Equal(t, PlatformCursor, repo.createdAccount.Platform)
+ require.Equal(t, AccountTypeUpstream, repo.createdAccount.Type)
+ require.Equal(t, []int64{account.ID}, repo.bindGroupsCalls)
+}
+
+func TestAdminService_CreateAccount_CursorRejectsOAuthType(t *testing.T) {
+ repo := &accountRepoStubForBulkUpdate{}
+ svc := &adminServiceImpl{
+ accountRepo: repo,
+ groupRepo: &groupRepoStubForAdmin{getByID: &Group{ID: 10, Name: "cursor-group", Platform: PlatformCursor}},
+ }
+
+ account, err := svc.CreateAccount(context.Background(), &CreateAccountInput{
+ Name: "cursor-upstream",
+ Platform: PlatformCursor,
+ Type: AccountTypeOAuth,
+ Credentials: map[string]any{"access_token": "placeholder"},
+ GroupIDs: []int64{10},
+ SkipMixedChannelCheck: true,
+ })
+
+ require.Nil(t, account)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "cursor accounts must use upstream type")
+ require.Nil(t, repo.createdAccount)
+}
+
+func TestAdminService_UpdateAccount_NonCursorRejectsCursorGroup(t *testing.T) {
+ groupIDs := []int64{10}
+ repo := &accountRepoStubForBulkUpdate{
+ getByIDAccounts: map[int64]*Account{
+ 1: {ID: 1, Name: "openai-upstream", Platform: PlatformOpenAI, Type: AccountTypeOAuth},
+ },
+ }
+ svc := &adminServiceImpl{
+ accountRepo: repo,
+ groupRepo: &groupRepoStubForAdmin{getByID: &Group{ID: 10, Name: "cursor-group", Platform: PlatformCursor}},
+ }
+
+ account, err := svc.UpdateAccount(context.Background(), 1, &UpdateAccountInput{
+ GroupIDs: &groupIDs,
+ SkipMixedChannelCheck: true,
+ })
+
+ require.Nil(t, account)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "cursor groups only accept cursor accounts")
+ require.Nil(t, repo.updatedAccount)
+}
diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go
index fe9e7701a26..f56fd9aab01 100644
--- a/backend/internal/service/admin_service_delete_test.go
+++ b/backend/internal/service/admin_service_delete_test.go
@@ -131,6 +131,9 @@ func (s *userRepoStub) UpdateConcurrency(ctx context.Context, id int64, amount i
panic("unexpected UpdateConcurrency call")
}
+func (s *userRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
+func (s *userRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
+
func (s *userRepoStub) ExistsByEmail(ctx context.Context, email string) (bool, error) {
if s.existsErr != nil {
return false, s.existsErr
@@ -185,7 +188,14 @@ func (s *groupRepoStub) GetByID(ctx context.Context, id int64) (*Group, error) {
}
func (s *groupRepoStub) GetByIDLite(ctx context.Context, id int64) (*Group, error) {
- panic("unexpected GetByIDLite call")
+ return &Group{
+ ID: id,
+ Name: "ordinary-group",
+ Platform: PlatformAnthropic,
+ Status: StatusActive,
+ Hydrated: true,
+ SubscriptionType: SubscriptionTypeStandard,
+ }, nil
}
func (s *groupRepoStub) Update(ctx context.Context, group *Group) error {
@@ -305,8 +315,13 @@ func (s *proxyRepoStub) ListAccountSummariesByProxyID(ctx context.Context, proxy
}
type redeemRepoStub struct {
- deleteErrByID map[int64]error
- deletedIDs []int64
+ deleteErrByID map[int64]error
+ deleteAllowedByID map[int64]bool
+ expireErrByID map[int64]error
+ expireAllowedByID map[int64]bool
+ codesByID map[int64]*RedeemCode
+ deletedIDs []int64
+ expiredIDs []int64
}
func (s *redeemRepoStub) Create(ctx context.Context, code *RedeemCode) error {
@@ -318,7 +333,11 @@ func (s *redeemRepoStub) CreateBatch(ctx context.Context, codes []RedeemCode) er
}
func (s *redeemRepoStub) GetByID(ctx context.Context, id int64) (*RedeemCode, error) {
- panic("unexpected GetByID call")
+ if code, ok := s.codesByID[id]; ok {
+ cloned := *code
+ return &cloned, nil
+ }
+ return nil, ErrRedeemCodeNotFound
}
func (s *redeemRepoStub) GetByCode(ctx context.Context, code string) (*RedeemCode, error) {
@@ -329,14 +348,36 @@ func (s *redeemRepoStub) Update(ctx context.Context, code *RedeemCode) error {
panic("unexpected Update call")
}
-func (s *redeemRepoStub) Delete(ctx context.Context, id int64) error {
+func (s *redeemRepoStub) DeleteIfUnused(ctx context.Context, id int64) (bool, error) {
s.deletedIDs = append(s.deletedIDs, id)
if s.deleteErrByID != nil {
if err, ok := s.deleteErrByID[id]; ok {
- return err
+ return false, err
}
}
- return nil
+ if s.deleteAllowedByID != nil {
+ return s.deleteAllowedByID[id], nil
+ }
+ return true, nil
+}
+
+func (s *redeemRepoStub) ExpireIfUnused(ctx context.Context, id int64) (bool, error) {
+ s.expiredIDs = append(s.expiredIDs, id)
+ if s.expireErrByID != nil {
+ if err, ok := s.expireErrByID[id]; ok {
+ return false, err
+ }
+ }
+ allowed := true
+ if s.expireAllowedByID != nil {
+ allowed = s.expireAllowedByID[id]
+ }
+ if allowed {
+ if code, ok := s.codesByID[id]; ok {
+ code.Status = StatusExpired
+ }
+ }
+ return allowed, nil
}
func (s *redeemRepoStub) Use(ctx context.Context, id, userID int64) error {
@@ -560,7 +601,7 @@ func TestAdminService_DeleteRedeemCode_Success(t *testing.T) {
}
func TestAdminService_DeleteRedeemCode_Idempotent(t *testing.T) {
- repo := &redeemRepoStub{}
+ repo := &redeemRepoStub{deleteAllowedByID: map[int64]bool{999: false}}
svc := &adminServiceImpl{redeemCodeRepo: repo}
err := svc.DeleteRedeemCode(context.Background(), 999)
@@ -568,6 +609,40 @@ func TestAdminService_DeleteRedeemCode_Idempotent(t *testing.T) {
require.Equal(t, []int64{999}, repo.deletedIDs)
}
+func TestAdminService_DeleteRedeemCode_UsedCodeIsProtected(t *testing.T) {
+ usedBy := int64(42)
+ usedAt := time.Now().UTC()
+ repo := &redeemRepoStub{
+ deleteAllowedByID: map[int64]bool{7: false},
+ codesByID: map[int64]*RedeemCode{
+ 7: {ID: 7, Status: StatusUsed, UsedBy: &usedBy, UsedAt: &usedAt},
+ },
+ }
+ svc := &adminServiceImpl{redeemCodeRepo: repo}
+
+ err := svc.DeleteRedeemCode(context.Background(), 7)
+ require.ErrorIs(t, err, ErrRedeemCodeDeleteUsed)
+ require.Equal(t, []int64{7}, repo.deletedIDs)
+ require.Equal(t, StatusUsed, repo.codesByID[7].Status)
+ require.Equal(t, usedBy, *repo.codesByID[7].UsedBy)
+ require.Equal(t, usedAt, *repo.codesByID[7].UsedAt)
+}
+
+func TestAdminService_DeleteRedeemCode_ExpiredCodeIsPreserved(t *testing.T) {
+ repo := &redeemRepoStub{
+ deleteAllowedByID: map[int64]bool{8: false},
+ codesByID: map[int64]*RedeemCode{
+ 8: {ID: 8, Status: StatusExpired},
+ },
+ }
+ svc := &adminServiceImpl{redeemCodeRepo: repo}
+
+ err := svc.DeleteRedeemCode(context.Background(), 8)
+ require.ErrorIs(t, err, ErrRedeemCodeDeleteState)
+ require.Equal(t, []int64{8}, repo.deletedIDs)
+ require.Equal(t, StatusExpired, repo.codesByID[8].Status)
+}
+
func TestAdminService_DeleteRedeemCode_Error(t *testing.T) {
deleteErr := errors.New("delete failed")
repo := &redeemRepoStub{deleteErrByID: map[int64]error{1: deleteErr}}
@@ -601,3 +676,70 @@ func TestAdminService_BatchDeleteRedeemCodes_PartialFailures(t *testing.T) {
require.Equal(t, int64(2), deleted)
require.Equal(t, []int64{1, 2, 3}, repo.deletedIDs)
}
+
+func TestAdminService_BatchDeleteRedeemCodes_DoesNotCountUsedOrMissingCodes(t *testing.T) {
+ usedBy := int64(51)
+ usedAt := time.Now().UTC()
+ repo := &redeemRepoStub{
+ deleteAllowedByID: map[int64]bool{1: true, 2: false, 3: false},
+ codesByID: map[int64]*RedeemCode{
+ 2: {ID: 2, Status: StatusUsed, UsedBy: &usedBy, UsedAt: &usedAt},
+ },
+ }
+ svc := &adminServiceImpl{redeemCodeRepo: repo}
+
+ deleted, err := svc.BatchDeleteRedeemCodes(context.Background(), []int64{1, 2, 3})
+ require.NoError(t, err)
+ require.Equal(t, int64(1), deleted)
+ require.Equal(t, []int64{1, 2, 3}, repo.deletedIDs)
+ require.Equal(t, StatusUsed, repo.codesByID[2].Status)
+ require.Equal(t, usedBy, *repo.codesByID[2].UsedBy)
+ require.Equal(t, usedAt, *repo.codesByID[2].UsedAt)
+}
+
+func TestAdminService_ExpireRedeemCode_UnusedCodeExpiresAtomically(t *testing.T) {
+ repo := &redeemRepoStub{
+ codesByID: map[int64]*RedeemCode{
+ 8: {ID: 8, Status: StatusUnused},
+ },
+ }
+ svc := &adminServiceImpl{redeemCodeRepo: repo}
+
+ code, err := svc.ExpireRedeemCode(context.Background(), 8)
+ require.NoError(t, err)
+ require.Equal(t, StatusExpired, code.Status)
+ require.Equal(t, []int64{8}, repo.expiredIDs)
+}
+
+func TestAdminService_ExpireRedeemCode_UsedCodeIsProtected(t *testing.T) {
+ usedBy := int64(61)
+ usedAt := time.Now().UTC()
+ repo := &redeemRepoStub{
+ expireAllowedByID: map[int64]bool{9: false},
+ codesByID: map[int64]*RedeemCode{
+ 9: {ID: 9, Status: StatusUsed, UsedBy: &usedBy, UsedAt: &usedAt},
+ },
+ }
+ svc := &adminServiceImpl{redeemCodeRepo: repo}
+
+ _, err := svc.ExpireRedeemCode(context.Background(), 9)
+ require.ErrorIs(t, err, ErrRedeemCodeExpireUsed)
+ require.Equal(t, []int64{9}, repo.expiredIDs)
+ require.Equal(t, StatusUsed, repo.codesByID[9].Status)
+ require.Equal(t, usedBy, *repo.codesByID[9].UsedBy)
+ require.Equal(t, usedAt, *repo.codesByID[9].UsedAt)
+}
+
+func TestAdminService_ExpireRedeemCode_AlreadyExpiredIsIdempotent(t *testing.T) {
+ repo := &redeemRepoStub{
+ expireAllowedByID: map[int64]bool{10: false},
+ codesByID: map[int64]*RedeemCode{
+ 10: {ID: 10, Status: StatusExpired},
+ },
+ }
+ svc := &adminServiceImpl{redeemCodeRepo: repo}
+
+ code, err := svc.ExpireRedeemCode(context.Background(), 10)
+ require.NoError(t, err)
+ require.Equal(t, StatusExpired, code.Status)
+}
diff --git a/backend/internal/service/admin_service_email_identity_sync_test.go b/backend/internal/service/admin_service_email_identity_sync_test.go
index 2232c9c38b6..3036bb1c5f0 100644
--- a/backend/internal/service/admin_service_email_identity_sync_test.go
+++ b/backend/internal/service/admin_service_email_identity_sync_test.go
@@ -113,6 +113,13 @@ func (s *emailSyncRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64)
return 0, nil
}
+func (s *emailSyncRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) {
+ return 0, nil
+}
+func (s *emailSyncRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
+ return 0, nil
+}
+
func (s *emailSyncRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
func (s *emailSyncRepoStub) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {
diff --git a/backend/internal/service/admin_service_group_rate_test.go b/backend/internal/service/admin_service_group_rate_test.go
index d2efb644194..e05cff42c82 100644
--- a/backend/internal/service/admin_service_group_rate_test.go
+++ b/backend/internal/service/admin_service_group_rate_test.go
@@ -5,6 +5,7 @@ package service
import (
"context"
"errors"
+ "math"
"net/http"
"testing"
@@ -19,6 +20,8 @@ type userGroupRateRepoStubForGroupRate struct {
deletedGroupIDs []int64
deleteByGroupErr error
+ clearRPMGroupIDs []int64
+ clearRPMErr error
syncedGroupID int64
syncedEntries []GroupRateMultiplierInput
@@ -27,6 +30,11 @@ type userGroupRateRepoStubForGroupRate struct {
rpmSyncedGroupID int64
rpmSyncedEntries []GroupRPMOverrideInput
rpmSyncErr error
+
+ userRatesSynced bool
+ userRateUserID int64
+ userRates map[int64]*float64
+ userRateErr error
}
func (s *userGroupRateRepoStubForGroupRate) GetByUserID(_ context.Context, _ int64) (map[int64]float64, error) {
@@ -48,8 +56,11 @@ func (s *userGroupRateRepoStubForGroupRate) GetByGroupID(_ context.Context, grou
return s.getByGroupIDData[groupID], nil
}
-func (s *userGroupRateRepoStubForGroupRate) SyncUserGroupRates(_ context.Context, _ int64, _ map[int64]*float64) error {
- panic("unexpected SyncUserGroupRates call")
+func (s *userGroupRateRepoStubForGroupRate) SyncUserGroupRates(_ context.Context, userID int64, rates map[int64]*float64) error {
+ s.userRatesSynced = true
+ s.userRateUserID = userID
+ s.userRates = rates
+ return s.userRateErr
}
func (s *userGroupRateRepoStubForGroupRate) SyncGroupRateMultipliers(_ context.Context, groupID int64, entries []GroupRateMultiplierInput) error {
@@ -64,8 +75,9 @@ func (s *userGroupRateRepoStubForGroupRate) SyncGroupRPMOverrides(_ context.Cont
return s.rpmSyncErr
}
-func (s *userGroupRateRepoStubForGroupRate) ClearGroupRPMOverrides(_ context.Context, _ int64) error {
- panic("unexpected ClearGroupRPMOverrides call")
+func (s *userGroupRateRepoStubForGroupRate) ClearGroupRPMOverrides(_ context.Context, groupID int64) error {
+ s.clearRPMGroupIDs = append(s.clearRPMGroupIDs, groupID)
+ return s.clearRPMErr
}
func (s *userGroupRateRepoStubForGroupRate) DeleteByGroupID(_ context.Context, groupID int64) error {
@@ -133,31 +145,33 @@ func TestAdminService_GetGroupRateMultipliers(t *testing.T) {
}
func TestAdminService_ClearGroupRateMultipliers(t *testing.T) {
- t.Run("deletes by group ID", func(t *testing.T) {
+ t.Run("clears only rate entries through the atomic sync path", func(t *testing.T) {
repo := &userGroupRateRepoStubForGroupRate{}
svc := &adminServiceImpl{userGroupRateRepo: repo}
err := svc.ClearGroupRateMultipliers(context.Background(), 42)
require.NoError(t, err)
- require.Equal(t, []int64{42}, repo.deletedGroupIDs)
+ require.Equal(t, int64(42), repo.syncedGroupID)
+ require.Empty(t, repo.syncedEntries)
+ require.Empty(t, repo.deletedGroupIDs, "clearing rates must preserve RPM overrides")
})
- t.Run("returns nil when repo is nil", func(t *testing.T) {
+ t.Run("fails closed when repo is nil", func(t *testing.T) {
svc := &adminServiceImpl{userGroupRateRepo: nil}
err := svc.ClearGroupRateMultipliers(context.Background(), 42)
- require.NoError(t, err)
+ require.Error(t, err)
})
t.Run("propagates repo error", func(t *testing.T) {
repo := &userGroupRateRepoStubForGroupRate{
- deleteByGroupErr: errors.New("delete failed"),
+ syncGroupErr: errors.New("clear failed"),
}
svc := &adminServiceImpl{userGroupRateRepo: repo}
err := svc.ClearGroupRateMultipliers(context.Background(), 42)
require.Error(t, err)
- require.Contains(t, err.Error(), "delete failed")
+ require.Contains(t, err.Error(), "clear failed")
})
}
@@ -176,11 +190,11 @@ func TestAdminService_BatchSetGroupRateMultipliers(t *testing.T) {
require.Equal(t, entries, repo.syncedEntries)
})
- t.Run("returns nil when repo is nil", func(t *testing.T) {
+ t.Run("fails closed when repo is nil", func(t *testing.T) {
svc := &adminServiceImpl{userGroupRateRepo: nil}
err := svc.BatchSetGroupRateMultipliers(context.Background(), 10, nil)
- require.NoError(t, err)
+ require.Error(t, err)
})
t.Run("propagates repo error", func(t *testing.T) {
@@ -210,6 +224,16 @@ func TestAdminService_BatchSetGroupRPMOverrides(t *testing.T) {
require.Equal(t, entries, repo.rpmSyncedEntries)
})
+ t.Run("fails closed when repo is nil", func(t *testing.T) {
+ override := 20
+ svc := &adminServiceImpl{}
+
+ err := svc.BatchSetGroupRPMOverrides(context.Background(), 10, []GroupRPMOverrideInput{
+ {UserID: 2, RPMOverride: &override},
+ })
+ require.Error(t, err)
+ })
+
t.Run("rejects negative override as bad request", func(t *testing.T) {
repo := &userGroupRateRepoStubForGroupRate{}
svc := &adminServiceImpl{userGroupRateRepo: repo}
@@ -222,4 +246,35 @@ func TestAdminService_BatchSetGroupRPMOverrides(t *testing.T) {
require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
require.Zero(t, repo.rpmSyncedGroupID)
})
+
+ t.Run("rejects override that cannot fit PostgreSQL integer", func(t *testing.T) {
+ repo := &userGroupRateRepoStubForGroupRate{}
+ svc := &adminServiceImpl{userGroupRateRepo: repo}
+ tooLarge := int(math.MaxInt32) + 1
+
+ err := svc.BatchSetGroupRPMOverrides(context.Background(), 10, []GroupRPMOverrideInput{
+ {UserID: 2, RPMOverride: &tooLarge},
+ })
+ require.Error(t, err)
+ require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
+ require.Zero(t, repo.rpmSyncedGroupID)
+ })
+}
+
+func TestAdminService_ClearGroupRPMOverrides(t *testing.T) {
+ t.Run("clears through repository", func(t *testing.T) {
+ repo := &userGroupRateRepoStubForGroupRate{}
+ svc := &adminServiceImpl{userGroupRateRepo: repo}
+
+ err := svc.ClearGroupRPMOverrides(context.Background(), 10)
+ require.NoError(t, err)
+ require.Equal(t, []int64{10}, repo.clearRPMGroupIDs)
+ })
+
+ t.Run("fails closed when repo is nil", func(t *testing.T) {
+ svc := &adminServiceImpl{}
+
+ err := svc.ClearGroupRPMOverrides(context.Background(), 10)
+ require.Error(t, err)
+ })
}
diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go
index eef02240699..811a011a541 100644
--- a/backend/internal/service/admin_service_group_test.go
+++ b/backend/internal/service/admin_service_group_test.go
@@ -266,6 +266,105 @@ func TestAdminService_UpdateGroup_PartialImagePricing(t *testing.T) {
require.Nil(t, repo.updated.ImagePrice4K)
}
+func TestAdminService_UpdateGroup_PreservesImageGenerationControlsWhenOmitted(t *testing.T) {
+ imageMultiplier := 0.5
+ existingGroup := &Group{
+ ID: 1,
+ Name: "existing-group",
+ Platform: PlatformOpenAI,
+ Status: StatusActive,
+ AllowImageGeneration: true,
+ ImageRateIndependent: true,
+ ImageRateMultiplier: imageMultiplier,
+ }
+ repo := &groupRepoStubForAdmin{getByID: existingGroup}
+ svc := &adminServiceImpl{groupRepo: repo}
+
+ group, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{
+ Description: "updated",
+ })
+ require.NoError(t, err)
+ require.NotNil(t, group)
+ require.NotNil(t, repo.updated)
+ require.True(t, repo.updated.AllowImageGeneration)
+ require.True(t, repo.updated.ImageRateIndependent)
+ require.InDelta(t, 0.5, repo.updated.ImageRateMultiplier, 1e-12)
+}
+
+func TestAdminService_UpdateGroup_PreservesUsageLimitsWhenOmitted(t *testing.T) {
+ daily := 10.0
+ weekly := 50.0
+ monthly := 100.0
+ existingGroup := &Group{
+ ID: 1, Name: "limited-group", Platform: PlatformAnthropic, Status: StatusActive,
+ DailyLimitUSD: &daily, WeeklyLimitUSD: &weekly, MonthlyLimitUSD: &monthly,
+ }
+ repo := &groupRepoStubForAdmin{getByID: existingGroup}
+ svc := &adminServiceImpl{groupRepo: repo}
+
+ group, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{Description: "metadata only"})
+
+ require.NoError(t, err)
+ require.NotNil(t, group)
+ require.NotNil(t, repo.updated.DailyLimitUSD)
+ require.NotNil(t, repo.updated.WeeklyLimitUSD)
+ require.NotNil(t, repo.updated.MonthlyLimitUSD)
+ require.Equal(t, 10.0, *repo.updated.DailyLimitUSD)
+ require.Equal(t, 50.0, *repo.updated.WeeklyLimitUSD)
+ require.Equal(t, 100.0, *repo.updated.MonthlyLimitUSD)
+}
+
+func TestAdminService_UpdateGroup_AllowsExplicitUsageLimitRemoval(t *testing.T) {
+ daily := 10.0
+ clear := -1.0
+ existingGroup := &Group{
+ ID: 1, Name: "limited-group", Platform: PlatformAnthropic, Status: StatusActive,
+ DailyLimitUSD: &daily,
+ }
+ repo := &groupRepoStubForAdmin{getByID: existingGroup}
+ svc := &adminServiceImpl{groupRepo: repo}
+
+ group, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{DailyLimitUSD: &clear})
+
+ require.NoError(t, err)
+ require.NotNil(t, group)
+ require.Nil(t, repo.updated.DailyLimitUSD)
+}
+
+func TestAdminService_UpdateGroup_CopyAccountsRequiresAtomicTransactionBeforeWrites(t *testing.T) {
+ existingGroup := &Group{ID: 1, Name: "group", Platform: PlatformAnthropic, Status: StatusActive}
+ repo := &groupRepoStubForAdmin{getByID: existingGroup}
+ svc := &adminServiceImpl{groupRepo: repo}
+
+ _, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{
+ Description: "must not persist",
+ CopyAccountsFromGroupIDs: []int64{1},
+ })
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "atomic group account binding transaction is not configured")
+ require.Nil(t, repo.updated)
+}
+
+func TestAdminService_UpdateGroup_RejectsNegativeImageRateMultiplier(t *testing.T) {
+ existingGroup := &Group{
+ ID: 1,
+ Name: "existing-group",
+ Platform: PlatformOpenAI,
+ Status: StatusActive,
+ ImageRateMultiplier: 1,
+ }
+ repo := &groupRepoStubForAdmin{getByID: existingGroup}
+ svc := &adminServiceImpl{groupRepo: repo}
+ negative := -0.1
+
+ _, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{
+ ImageRateMultiplier: &negative,
+ })
+ require.Error(t, err)
+ require.Nil(t, repo.updated)
+}
+
func TestAdminService_UpdateGroup_InvalidatesAuthCacheOnRPMLimitChange(t *testing.T) {
existingGroup := &Group{
ID: 1,
diff --git a/backend/internal/service/admin_service_update_balance_test.go b/backend/internal/service/admin_service_update_balance_test.go
index d3b3c700727..afff3e2f0ec 100644
--- a/backend/internal/service/admin_service_update_balance_test.go
+++ b/backend/internal/service/admin_service_update_balance_test.go
@@ -6,6 +6,7 @@ import (
"context"
"testing"
+ dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/stretchr/testify/require"
)
@@ -15,6 +16,28 @@ type balanceUserRepoStub struct {
updated []*User
}
+func (s *balanceUserRepoStub) AdjustAdminBalance(_ context.Context, id int64, amount float64, operation string) (*User, float64, error) {
+ if s.updateErr != nil {
+ return nil, 0, s.updateErr
+ }
+ user, err := s.GetByID(context.Background(), id)
+ if err != nil {
+ return nil, 0, err
+ }
+ oldBalance := user.Balance
+ switch operation {
+ case "set":
+ user.Balance = amount
+ case "add":
+ user.Balance += amount
+ case "subtract":
+ user.Balance -= amount
+ }
+ clone := *user
+ s.userRepoStub.user = &clone
+ return &clone, clone.Balance - oldBalance, nil
+}
+
func (s *balanceUserRepoStub) Update(ctx context.Context, user *User) error {
if s.updateErr != nil {
return s.updateErr
@@ -73,7 +96,8 @@ func TestAdminService_UpdateUserBalance_InvalidatesAuthCache(t *testing.T) {
authCacheInvalidator: invalidator,
}
- _, err := svc.UpdateUserBalance(context.Background(), 7, 5, "add", "")
+ txCtx := dbent.NewTxContext(context.Background(), &dbent.Tx{})
+ _, err := svc.UpdateUserBalance(txCtx, 7, 5, "add", "")
require.NoError(t, err)
require.Equal(t, []int64{7}, invalidator.userIDs)
require.Len(t, redeemRepo.created, 1)
@@ -90,8 +114,24 @@ func TestAdminService_UpdateUserBalance_NoChangeNoInvalidate(t *testing.T) {
authCacheInvalidator: invalidator,
}
- _, err := svc.UpdateUserBalance(context.Background(), 7, 10, "set", "")
+ txCtx := dbent.NewTxContext(context.Background(), &dbent.Tx{})
+ _, err := svc.UpdateUserBalance(txCtx, 7, 10, "set", "")
require.NoError(t, err)
require.Empty(t, invalidator.userIDs)
require.Empty(t, redeemRepo.created)
}
+
+func TestAdminService_UpdateUserAllowedGroups_InvalidatesAuthCache(t *testing.T) {
+ baseRepo := &userRepoStub{user: &User{ID: 7, Status: StatusActive, Role: RoleUser, AllowedGroups: []int64{3}}}
+ repo := &balanceUserRepoStub{userRepoStub: baseRepo}
+ invalidator := &authCacheInvalidatorStub{}
+ svc := &adminServiceImpl{
+ userRepo: repo,
+ authCacheInvalidator: invalidator,
+ }
+
+ allowedGroups := []int64{3, 22}
+ _, err := svc.UpdateUser(context.Background(), 7, &UpdateUserInput{AllowedGroups: &allowedGroups})
+ require.NoError(t, err)
+ require.Equal(t, []int64{7}, invalidator.userIDs, "VIP grants must invalidate cached wallet authorization immediately")
+}
diff --git a/backend/internal/service/admin_service_update_user_group_atomicity_test.go b/backend/internal/service/admin_service_update_user_group_atomicity_test.go
new file mode 100644
index 00000000000..cb0066b1f34
--- /dev/null
+++ b/backend/internal/service/admin_service_update_user_group_atomicity_test.go
@@ -0,0 +1,41 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestAdminServiceUpdateUserGroupRatesFailsClosedWithoutRepository(t *testing.T) {
+ base := &userRepoStub{user: &User{ID: 77, Email: "atomic@example.test", Status: StatusActive, Role: RoleUser}}
+ userRepo := &balanceUserRepoStub{userRepoStub: base}
+ svc := &adminServiceImpl{userRepo: userRepo}
+ rate := 1.5
+
+ _, err := svc.UpdateUser(context.Background(), 77, &UpdateUserInput{
+ GroupRates: map[int64]*float64{3: &rate},
+ })
+ require.Error(t, err)
+ require.Empty(t, userRepo.updated, "configuration failure must be detected before the user is mutated")
+}
+
+func TestAdminServiceUpdateUserGroupRatesFailsClosedWithoutTransactionClient(t *testing.T) {
+ base := &userRepoStub{user: &User{ID: 77, Email: "atomic@example.test", Status: StatusActive, Role: RoleUser}}
+ userRepo := &balanceUserRepoStub{userRepoStub: base}
+ rateRepo := &userGroupRateRepoStubForGroupRate{}
+ svc := &adminServiceImpl{
+ userRepo: userRepo,
+ userGroupRateRepo: rateRepo,
+ }
+ rate := 1.5
+
+ _, err := svc.UpdateUser(context.Background(), 77, &UpdateUserInput{
+ GroupRates: map[int64]*float64{3: &rate},
+ })
+ require.Error(t, err)
+ require.False(t, rateRepo.userRatesSynced)
+ require.Empty(t, userRepo.updated, "missing transaction support must fail before either side is mutated")
+}
diff --git a/backend/internal/service/affiliate_service.go b/backend/internal/service/affiliate_service.go
index 5a4e91e7113..cf0978c787f 100644
--- a/backend/internal/service/affiliate_service.go
+++ b/backend/internal/service/affiliate_service.go
@@ -98,7 +98,7 @@ type AffiliateRepository interface {
EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error)
GetAffiliateByCode(ctx context.Context, code string) (*AffiliateSummary, error)
BindInviter(ctx context.Context, userID, inviterID int64) (bool, error)
- AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int) (bool, error)
+ AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int, sourceOrderID *int64) (bool, error)
GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error)
ThawFrozenQuota(ctx context.Context, userID int64) (float64, error)
TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error)
@@ -110,6 +110,17 @@ type AffiliateRepository interface {
SetUserRebateRate(ctx context.Context, userID int64, ratePercent *float64) error
BatchSetUserRebateRate(ctx context.Context, userIDs []int64, ratePercent *float64) error
ListUsersWithCustomSettings(ctx context.Context, filter AffiliateAdminFilter) ([]AffiliateAdminEntry, int64, error)
+ ListAffiliateInviteRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateInviteRecord, int64, error)
+ ListAffiliateRebateRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateRebateRecord, int64, error)
+ ListAffiliateTransferRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateTransferRecord, int64, error)
+ GetAffiliateUserOverview(ctx context.Context, userID int64) (*AffiliateUserOverview, error)
+ // GetUserSignupIPPrefix 查询指定用户的注册 IP 前缀(/24 段),用于防自邀比对。
+ // 用户不存在或字段为空时返回空字符串,不报错。
+ GetUserSignupIPPrefix(ctx context.Context, userID int64) (string, error)
+ // HasInviteeFirstOrderRebate 检查被邀请人是否已收到过首单返利(防重触发)。
+ HasInviteeFirstOrderRebate(ctx context.Context, inviteeUserID int64) (bool, error)
+ // AccrueInviteeFirstOrderQuota 向被邀请人发放首单返利至 aff_quota。
+ AccrueInviteeFirstOrderQuota(ctx context.Context, inviteeUserID int64, amount float64) (bool, error)
}
// AffiliateAdminFilter 列表筛选条件
@@ -130,6 +141,76 @@ type AffiliateAdminEntry struct {
AffCount int `json:"aff_count"`
}
+type AffiliateRecordFilter struct {
+ Search string
+ Page int
+ PageSize int
+ StartAt *time.Time
+ EndAt *time.Time
+ SortBy string
+ SortDesc bool
+}
+
+type AffiliateInviteRecord struct {
+ InviterID int64 `json:"inviter_id"`
+ InviterEmail string `json:"inviter_email"`
+ InviterUsername string `json:"inviter_username"`
+ InviteeID int64 `json:"invitee_id"`
+ InviteeEmail string `json:"invitee_email"`
+ InviteeUsername string `json:"invitee_username"`
+ AffCode string `json:"aff_code"`
+ TotalRebate float64 `json:"total_rebate"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type AffiliateRebateRecord struct {
+ OrderID int64 `json:"order_id"`
+ OutTradeNo string `json:"out_trade_no"`
+ InviterID int64 `json:"inviter_id"`
+ InviterEmail string `json:"inviter_email"`
+ InviterUsername string `json:"inviter_username"`
+ InviteeID int64 `json:"invitee_id"`
+ InviteeEmail string `json:"invitee_email"`
+ InviteeUsername string `json:"invitee_username"`
+ OrderAmount float64 `json:"order_amount"`
+ PayAmount float64 `json:"pay_amount"`
+ RebateAmount float64 `json:"rebate_amount"`
+ PaymentType string `json:"payment_type"`
+ OrderStatus string `json:"order_status"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type AffiliateTransferRecord struct {
+ LedgerID int64 `json:"ledger_id"`
+ UserID int64 `json:"user_id"`
+ UserEmail string `json:"user_email"`
+ Username string `json:"username"`
+ Amount float64 `json:"amount"`
+ BalanceAfter *float64 `json:"balance_after,omitempty"`
+ AvailableQuotaAfter *float64 `json:"available_quota_after,omitempty"`
+ FrozenQuotaAfter *float64 `json:"frozen_quota_after,omitempty"`
+ HistoryQuotaAfter *float64 `json:"history_quota_after,omitempty"`
+ SnapshotAvailable bool `json:"snapshot_available"`
+ CurrentBalance float64 `json:"-"`
+ RemainingQuota float64 `json:"-"`
+ FrozenQuota float64 `json:"-"`
+ HistoryQuota float64 `json:"-"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type AffiliateUserOverview struct {
+ UserID int64 `json:"user_id"`
+ Email string `json:"email"`
+ Username string `json:"username"`
+ AffCode string `json:"aff_code"`
+ RebateRatePercent float64 `json:"rebate_rate_percent"`
+ RebateRateCustom bool `json:"-"`
+ InvitedCount int `json:"invited_count"`
+ RebatedInviteeCount int `json:"rebated_invitee_count"`
+ AvailableQuota float64 `json:"available_quota"`
+ HistoryQuota float64 `json:"history_quota"`
+}
+
type AffiliateService struct {
repo AffiliateRepository
settingService *SettingService
@@ -192,7 +273,9 @@ func (s *AffiliateService) GetAffiliateDetail(ctx context.Context, userID int64)
}, nil
}
-func (s *AffiliateService) BindInviterByCode(ctx context.Context, userID int64, rawCode string) error {
+// BindInviterByCode 绑定邀请人。inviteeIPPrefix 为被邀请人注册时的 /24 IP 段,
+// 与邀请人注册 IP 段相同则静默跳过(防自邀),传空字符串则跳过 IP 检查。
+func (s *AffiliateService) BindInviterByCode(ctx context.Context, userID int64, rawCode, inviteeIPPrefix string) error {
code := strings.ToUpper(strings.TrimSpace(rawCode))
if code == "" {
return nil
@@ -227,6 +310,14 @@ func (s *AffiliateService) BindInviterByCode(ctx context.Context, userID int64,
return ErrAffiliateCodeInvalid
}
+ // 同 /24 IP 段防自邀:静默跳过绑定,不报错、不阻断注册
+ if inviteeIPPrefix != "" {
+ inviterIPPrefix, _ := s.repo.GetUserSignupIPPrefix(ctx, inviterSummary.UserID)
+ if inviterIPPrefix != "" && strings.EqualFold(inviteeIPPrefix, inviterIPPrefix) {
+ return nil
+ }
+ }
+
bound, err := s.repo.BindInviter(ctx, userID, inviterSummary.UserID)
if err != nil {
return err
@@ -238,6 +329,10 @@ func (s *AffiliateService) BindInviterByCode(ctx context.Context, userID int64,
}
func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID int64, baseRechargeAmount float64) (float64, error) {
+ return s.AccrueInviteRebateForOrder(ctx, inviteeUserID, baseRechargeAmount, nil)
+}
+
+func (s *AffiliateService) AccrueInviteRebateForOrder(ctx context.Context, inviteeUserID int64, baseRechargeAmount float64, sourceOrderID *int64) (float64, error) {
if s == nil || s.repo == nil {
return 0, nil
}
@@ -298,7 +393,76 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID
freezeHours = s.settingService.GetAffiliateRebateFreezeHours(ctx)
}
- applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate, freezeHours)
+ applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate, freezeHours, sourceOrderID)
+ if err != nil {
+ return 0, err
+ }
+ if !applied {
+ return 0, nil
+ }
+ return rebate, nil
+}
+
+// AccrueInviteRebateForOrderWithOverride 与 AccrueInviteRebateForOrder 相同,但允许
+// 调用方传入比例 override(如卡类型差异化比例)。优先级:邀请人专属率 > override > 全局。
+func (s *AffiliateService) AccrueInviteRebateForOrderWithOverride(ctx context.Context, inviteeUserID int64, baseRechargeAmount float64, rateOverride *float64, sourceOrderID *int64) (float64, error) {
+ if s == nil || s.repo == nil {
+ return 0, nil
+ }
+ if inviteeUserID <= 0 || baseRechargeAmount <= 0 || math.IsNaN(baseRechargeAmount) || math.IsInf(baseRechargeAmount, 0) {
+ return 0, nil
+ }
+ if !s.IsEnabled(ctx) {
+ return 0, nil
+ }
+
+ inviteeSummary, err := s.repo.EnsureUserAffiliate(ctx, inviteeUserID)
+ if err != nil {
+ return 0, err
+ }
+ if inviteeSummary.InviterID == nil || *inviteeSummary.InviterID <= 0 {
+ return 0, nil
+ }
+
+ inviterSummary, err := s.repo.EnsureUserAffiliate(ctx, *inviteeSummary.InviterID)
+ if err != nil {
+ return 0, err
+ }
+ if s.settingService != nil {
+ if durationDays := s.settingService.GetAffiliateRebateDurationDays(ctx); durationDays > 0 {
+ if time.Now().After(inviteeSummary.CreatedAt.AddDate(0, 0, durationDays)) {
+ return 0, nil
+ }
+ }
+ }
+
+ rebateRatePercent := s.resolveRebateRatePercentWithOverride(ctx, inviterSummary, rateOverride)
+ rebate := roundTo(baseRechargeAmount*(rebateRatePercent/100), 8)
+ if rebate <= 0 {
+ return 0, nil
+ }
+
+ if s.settingService != nil {
+ if perInviteeCap := s.settingService.GetAffiliateRebatePerInviteeCap(ctx); perInviteeCap > 0 {
+ existing, err := s.repo.GetAccruedRebateFromInvitee(ctx, *inviteeSummary.InviterID, inviteeUserID)
+ if err != nil {
+ return 0, err
+ }
+ if existing >= perInviteeCap {
+ return 0, nil
+ }
+ if remaining := perInviteeCap - existing; rebate > remaining {
+ rebate = roundTo(remaining, 8)
+ }
+ }
+ }
+
+ var freezeHours int
+ if s.settingService != nil {
+ freezeHours = s.settingService.GetAffiliateRebateFreezeHours(ctx)
+ }
+
+ applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate, freezeHours, sourceOrderID)
if err != nil {
return 0, err
}
@@ -321,6 +485,20 @@ func (s *AffiliateService) resolveRebateRatePercent(ctx context.Context, inviter
return s.globalRebateRatePercent(ctx)
}
+// resolveRebateRatePercentWithOverride 优先级:邀请人专属率 > override(卡类型)> 全局率。
+func (s *AffiliateService) resolveRebateRatePercentWithOverride(ctx context.Context, inviter *AffiliateSummary, override *float64) float64 {
+ if inviter != nil && inviter.AffRebateRatePercent != nil {
+ v := *inviter.AffRebateRatePercent
+ if !math.IsNaN(v) && !math.IsInf(v, 0) {
+ return clampAffiliateRebateRate(v)
+ }
+ }
+ if override != nil {
+ return clampAffiliateRebateRate(*override)
+ }
+ return s.globalRebateRatePercent(ctx)
+}
+
// globalRebateRatePercent reads the system-wide rebate rate via SettingService,
// returning the documented default when SettingService is unavailable.
func (s *AffiliateService) globalRebateRatePercent(ctx context.Context) float64 {
@@ -330,6 +508,42 @@ func (s *AffiliateService) globalRebateRatePercent(ctx context.Context) float64
return s.settingService.GetAffiliateRebateRatePercent(ctx)
}
+// AccrueInviteeFirstOrderRebate 新人首单 5%:被邀请人首次兑换正价链动卡时,
+// 按面值 AffiliateRebateInviteeFirst% 入本人余额。per-user 首单锁防重触发。
+// 被邀请人无邀请人、总开关关闭、或已触发过则静默返回 0。
+func (s *AffiliateService) AccrueInviteeFirstOrderRebate(ctx context.Context, inviteeUserID int64, baseAmount float64) (float64, error) {
+ if s == nil || s.repo == nil {
+ return 0, nil
+ }
+ if !s.IsEnabled(ctx) {
+ return 0, nil
+ }
+ inviteeSummary, err := s.repo.EnsureUserAffiliate(ctx, inviteeUserID)
+ if err != nil {
+ return 0, err
+ }
+ if inviteeSummary.InviterID == nil || *inviteeSummary.InviterID <= 0 {
+ return 0, nil // 没有邀请人,不给首单返利
+ }
+ // 首单判定:检查 ledger 是否已有该用户的 first_order 记录
+ alreadyRewarded, err := s.repo.HasInviteeFirstOrderRebate(ctx, inviteeUserID)
+ if err != nil || alreadyRewarded {
+ return 0, err
+ }
+ rebate := roundTo(baseAmount*(AffiliateRebateInviteeFirst/100), 8)
+ if rebate <= 0 {
+ return 0, nil
+ }
+ applied, err := s.repo.AccrueInviteeFirstOrderQuota(ctx, inviteeUserID, rebate)
+ if err != nil {
+ return 0, err
+ }
+ if !applied {
+ return 0, nil
+ }
+ return rebate, nil
+}
+
func (s *AffiliateService) TransferAffiliateQuota(ctx context.Context, userID int64) (float64, float64, error) {
if s == nil || s.repo == nil {
return 0, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
@@ -488,3 +702,59 @@ func (s *AffiliateService) AdminListCustomUsers(ctx context.Context, filter Affi
}
return s.repo.ListUsersWithCustomSettings(ctx, filter)
}
+
+func (s *AffiliateService) AdminListInviteRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateInviteRecord, int64, error) {
+ if s == nil || s.repo == nil {
+ return nil, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
+ }
+ return s.repo.ListAffiliateInviteRecords(ctx, normalizeAffiliateRecordFilter(filter))
+}
+
+func (s *AffiliateService) AdminListRebateRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateRebateRecord, int64, error) {
+ if s == nil || s.repo == nil {
+ return nil, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
+ }
+ return s.repo.ListAffiliateRebateRecords(ctx, normalizeAffiliateRecordFilter(filter))
+}
+
+func (s *AffiliateService) AdminListTransferRecords(ctx context.Context, filter AffiliateRecordFilter) ([]AffiliateTransferRecord, int64, error) {
+ if s == nil || s.repo == nil {
+ return nil, 0, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
+ }
+ return s.repo.ListAffiliateTransferRecords(ctx, normalizeAffiliateRecordFilter(filter))
+}
+
+func (s *AffiliateService) AdminGetUserOverview(ctx context.Context, userID int64) (*AffiliateUserOverview, error) {
+ if userID <= 0 {
+ return nil, infraerrors.BadRequest("INVALID_USER", "invalid user")
+ }
+ if s == nil || s.repo == nil {
+ return nil, infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "affiliate service unavailable")
+ }
+ overview, err := s.repo.GetAffiliateUserOverview(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ if overview != nil {
+ if !overview.RebateRateCustom {
+ overview.RebateRatePercent = s.globalRebateRatePercent(ctx)
+ }
+ overview.RebateRatePercent = clampAffiliateRebateRate(overview.RebateRatePercent)
+ }
+ return overview, nil
+}
+
+func normalizeAffiliateRecordFilter(filter AffiliateRecordFilter) AffiliateRecordFilter {
+ if filter.Page <= 0 {
+ filter.Page = 1
+ }
+ if filter.PageSize <= 0 {
+ filter.PageSize = 20
+ }
+ if filter.PageSize > 100 {
+ filter.PageSize = 100
+ }
+ filter.Search = strings.TrimSpace(filter.Search)
+ filter.SortBy = strings.TrimSpace(filter.SortBy)
+ return filter
+}
diff --git a/backend/internal/service/announcement_service.go b/backend/internal/service/announcement_service.go
index 124790419b8..ef3f4970fe7 100644
--- a/backend/internal/service/announcement_service.go
+++ b/backend/internal/service/announcement_service.go
@@ -227,7 +227,9 @@ func (s *AnnouncementService) ListForUser(ctx context.Context, userID int64, unr
}
activeGroupIDs := make(map[int64]struct{}, len(activeSubs))
for i := range activeSubs {
- activeGroupIDs[activeSubs[i].GroupID] = struct{}{}
+ if activeSubs[i].GroupID != nil {
+ activeGroupIDs[*activeSubs[i].GroupID] = struct{}{}
+ }
}
now := time.Now()
@@ -312,7 +314,9 @@ func (s *AnnouncementService) MarkRead(ctx context.Context, userID, announcement
}
activeGroupIDs := make(map[int64]struct{}, len(activeSubs))
for i := range activeSubs {
- activeGroupIDs[activeSubs[i].GroupID] = struct{}{}
+ if activeSubs[i].GroupID != nil {
+ activeGroupIDs[*activeSubs[i].GroupID] = struct{}{}
+ }
}
if !a.Targeting.Matches(user.Balance, activeGroupIDs) {
@@ -364,7 +368,9 @@ func (s *AnnouncementService) ListUserReadStatus(
}
activeGroupIDs := make(map[int64]struct{}, len(subs))
for j := range subs {
- activeGroupIDs[subs[j].GroupID] = struct{}{}
+ if subs[j].GroupID != nil {
+ activeGroupIDs[*subs[j].GroupID] = struct{}{}
+ }
}
readAt, ok := readMap[u.ID]
diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go
index a76e59fbea9..c0df23710b9 100644
--- a/backend/internal/service/antigravity_gateway_service.go
+++ b/backend/internal/service/antigravity_gateway_service.go
@@ -3050,10 +3050,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
// 使用 Scanner 并限制单行大小,避免 ReadString 无上限导致 OOM
scanner := bufio.NewScanner(resp.Body)
- maxLineSize := defaultMaxLineSize
- if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.settingService.cfg.Gateway.MaxLineSize
- }
+ maxLineSize := resolveGatewayMaxLineSize(s.settingService.cfg)
scanBuf := getSSEScannerBuf64K()
scanner.Buffer(scanBuf[:0], maxLineSize)
usage := &ClaudeUsage{}
@@ -3236,10 +3233,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
// Gemini 流式响应是增量的,需要累积所有 chunk 的内容
func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Context, resp *http.Response, startTime time.Time) (*antigravityStreamResult, error) {
scanner := bufio.NewScanner(resp.Body)
- maxLineSize := defaultMaxLineSize
- if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.settingService.cfg.Gateway.MaxLineSize
- }
+ maxLineSize := resolveGatewayMaxLineSize(s.settingService.cfg)
scanBuf := getSSEScannerBuf64K()
scanner.Buffer(scanBuf[:0], maxLineSize)
@@ -3701,10 +3695,7 @@ func (s *AntigravityGatewayService) writeGoogleError(c *gin.Context, status int,
// 用于处理客户端非流式请求但上游只支持流式的情况
func (s *AntigravityGatewayService) handleClaudeStreamToNonStreaming(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string) (*antigravityStreamResult, error) {
scanner := bufio.NewScanner(resp.Body)
- maxLineSize := defaultMaxLineSize
- if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.settingService.cfg.Gateway.MaxLineSize
- }
+ maxLineSize := resolveGatewayMaxLineSize(s.settingService.cfg)
scanBuf := getSSEScannerBuf64K()
scanner.Buffer(scanBuf[:0], maxLineSize)
@@ -3853,7 +3844,7 @@ returnResponse:
// 转换 Gemini 响应为 Claude 格式
claudeResp, agUsage, err := antigravity.TransformGeminiToClaude(geminiBody, originalModel)
if err != nil {
- logger.LegacyPrintf("service.antigravity_gateway", "[antigravity-Forward] transform_error error=%v body=%s", err, string(geminiBody))
+ logAntigravityTransformError(err, geminiBody)
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to parse upstream response")
}
@@ -3870,6 +3861,15 @@ returnResponse:
return &antigravityStreamResult{usage: usage, firstTokenMs: firstTokenMs}, nil
}
+func logAntigravityTransformError(err error, geminiBody []byte) {
+ logger.LegacyPrintf(
+ "service.antigravity_gateway",
+ "[antigravity-Forward] transform_error error_type=%T body_bytes=%d",
+ err,
+ len(geminiBody),
+ )
+}
+
// handleClaudeStreamingResponse 处理 Claude 流式响应(Gemini SSE → Claude SSE 转换)
func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context, resp *http.Response, startTime time.Time, originalModel string) (*antigravityStreamResult, error) {
c.Header("Content-Type", "text/event-stream")
@@ -3887,10 +3887,7 @@ func (s *AntigravityGatewayService) handleClaudeStreamingResponse(c *gin.Context
var firstTokenMs *int
// 使用 Scanner 并限制单行大小,避免 ReadString 无上限导致 OOM
scanner := bufio.NewScanner(resp.Body)
- maxLineSize := defaultMaxLineSize
- if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.settingService.cfg.Gateway.MaxLineSize
- }
+ maxLineSize := resolveGatewayMaxLineSize(s.settingService.cfg)
scanBuf := getSSEScannerBuf64K()
scanner.Buffer(scanBuf[:0], maxLineSize)
@@ -4339,10 +4336,7 @@ func (s *AntigravityGatewayService) streamUpstreamResponse(c *gin.Context, resp
var firstTokenMs *int
scanner := bufio.NewScanner(resp.Body)
- maxLineSize := defaultMaxLineSize
- if s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.settingService.cfg.Gateway.MaxLineSize
- }
+ maxLineSize := resolveGatewayMaxLineSize(s.settingService.cfg)
scanner.Buffer(make([]byte, 64*1024), maxLineSize)
type scanEvent struct {
diff --git a/backend/internal/service/antigravity_gateway_service_test.go b/backend/internal/service/antigravity_gateway_service_test.go
index 1eb1451e8a7..2a28cf66f4c 100644
--- a/backend/internal/service/antigravity_gateway_service_test.go
+++ b/backend/internal/service/antigravity_gateway_service_test.go
@@ -207,7 +207,6 @@ func (s *antigravitySettingRepoStub) Delete(ctx context.Context, key string) err
}
func TestAntigravityGatewayService_Forward_PromptTooLong(t *testing.T) {
- gin.SetMode(gin.TestMode)
writer := httptest.NewRecorder()
c, _ := gin.CreateTestContext(writer)
@@ -270,7 +269,6 @@ func TestAntigravityGatewayService_Forward_PromptTooLong(t *testing.T) {
// 验证:当账号存在模型限流且剩余时间 >= antigravityRateLimitThreshold 时,
// Forward 方法应返回 UpstreamFailoverError,触发 Handler 切换账号
func TestAntigravityGatewayService_Forward_ModelRateLimitTriggersFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
writer := httptest.NewRecorder()
c, _ := gin.CreateTestContext(writer)
@@ -329,7 +327,6 @@ func TestAntigravityGatewayService_Forward_ModelRateLimitTriggersFailover(t *tes
// TestAntigravityGatewayService_ForwardGemini_ModelRateLimitTriggersFailover
// 验证:ForwardGemini 方法同样能正确将 AntigravityAccountSwitchError 转换为 UpstreamFailoverError
func TestAntigravityGatewayService_ForwardGemini_ModelRateLimitTriggersFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
writer := httptest.NewRecorder()
c, _ := gin.CreateTestContext(writer)
@@ -385,7 +382,6 @@ func TestAntigravityGatewayService_ForwardGemini_ModelRateLimitTriggersFailover(
// TestAntigravityGatewayService_Forward_StickySessionForceCacheBilling
// 验证:粘性会话切换时,UpstreamFailoverError.ForceCacheBilling 应为 true
func TestAntigravityGatewayService_Forward_StickySessionForceCacheBilling(t *testing.T) {
- gin.SetMode(gin.TestMode)
writer := httptest.NewRecorder()
c, _ := gin.CreateTestContext(writer)
@@ -439,7 +435,6 @@ func TestAntigravityGatewayService_Forward_StickySessionForceCacheBilling(t *tes
// TestAntigravityGatewayService_ForwardGemini_StickySessionForceCacheBilling verifies
// that ForwardGemini sets ForceCacheBilling=true for sticky session switch.
func TestAntigravityGatewayService_ForwardGemini_StickySessionForceCacheBilling(t *testing.T) {
- gin.SetMode(gin.TestMode)
writer := httptest.NewRecorder()
c, _ := gin.CreateTestContext(writer)
@@ -494,7 +489,6 @@ func TestAntigravityGatewayService_ForwardGemini_StickySessionForceCacheBilling(
// TestAntigravityGatewayService_Forward_BillsWithMappedModel
// 验证:Antigravity Claude 转发返回的计费模型使用映射后的模型
func TestAntigravityGatewayService_Forward_BillsWithMappedModel(t *testing.T) {
- gin.SetMode(gin.TestMode)
writer := httptest.NewRecorder()
c, _ := gin.CreateTestContext(writer)
@@ -550,7 +544,6 @@ func TestAntigravityGatewayService_Forward_BillsWithMappedModel(t *testing.T) {
// TestAntigravityGatewayService_ForwardGemini_BillsWithMappedModel
// 验证:Antigravity Gemini 转发返回的计费模型使用映射后的模型
func TestAntigravityGatewayService_ForwardGemini_BillsWithMappedModel(t *testing.T) {
- gin.SetMode(gin.TestMode)
writer := httptest.NewRecorder()
c, _ := gin.CreateTestContext(writer)
@@ -601,7 +594,6 @@ func TestAntigravityGatewayService_ForwardGemini_BillsWithMappedModel(t *testing
}
func TestAntigravityGatewayService_ForwardGemini_RetriesCorruptedThoughtSignature(t *testing.T) {
- gin.SetMode(gin.TestMode)
writer := httptest.NewRecorder()
c, _ := gin.CreateTestContext(writer)
@@ -688,7 +680,6 @@ func TestAntigravityGatewayService_ForwardGemini_RetriesCorruptedThoughtSignatur
}
func TestAntigravityGatewayService_ForwardGemini_SignatureRetryPropagatesFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
writer := httptest.NewRecorder()
c, _ := gin.CreateTestContext(writer)
@@ -775,7 +766,6 @@ func TestAntigravityGatewayService_ForwardGemini_SignatureRetryPropagatesFailove
// TestStreamUpstreamResponse_UsageAndFirstToken
// 验证:usage 字段可被累积/覆盖更新,并且能记录首 token 时间
func TestStreamUpstreamResponse_UsageAndFirstToken(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
})
@@ -815,7 +805,6 @@ func TestStreamUpstreamResponse_UsageAndFirstToken(t *testing.T) {
// TestStreamUpstreamResponse_NormalComplete
// 验证:正常流式转发完成时,数据正确透传、usage 正确收集、clientDisconnect=false
func TestStreamUpstreamResponse_NormalComplete(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
})
@@ -859,7 +848,6 @@ func TestStreamUpstreamResponse_NormalComplete(t *testing.T) {
// TestHandleGeminiStreamingResponse_NormalComplete
// 验证:正常 Gemini 流式转发,数据正确透传、usage 正确收集
func TestHandleGeminiStreamingResponse_NormalComplete(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
})
@@ -906,7 +894,6 @@ func TestHandleGeminiStreamingResponse_NormalComplete(t *testing.T) {
// TestHandleClaudeStreamingResponse_NormalComplete
// 验证:正常 Claude 流式转发(Gemini→Claude 转换),数据正确转换并输出
func TestHandleClaudeStreamingResponse_NormalComplete(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
})
@@ -949,7 +936,6 @@ func TestHandleClaudeStreamingResponse_NormalComplete(t *testing.T) {
// TestHandleGeminiStreamingResponse_ThoughtsTokenCount
// 验证:Gemini 流式转发时 thoughtsTokenCount 被计入 OutputTokens
func TestHandleGeminiStreamingResponse_ThoughtsTokenCount(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
})
@@ -985,7 +971,6 @@ func TestHandleGeminiStreamingResponse_ThoughtsTokenCount(t *testing.T) {
// TestHandleClaudeStreamingResponse_ThoughtsTokenCount
// 验证:Gemini→Claude 流式转换时 thoughtsTokenCount 被计入 OutputTokens
func TestHandleClaudeStreamingResponse_ThoughtsTokenCount(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
})
@@ -1020,7 +1005,6 @@ func TestHandleClaudeStreamingResponse_ThoughtsTokenCount(t *testing.T) {
// TestStreamUpstreamResponse_ClientDisconnectDrainsUsage
// 验证:客户端写入失败后,streamUpstreamResponse 继续读取上游以收集 usage
func TestStreamUpstreamResponse_ClientDisconnectDrainsUsage(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
})
@@ -1055,7 +1039,6 @@ func TestStreamUpstreamResponse_ClientDisconnectDrainsUsage(t *testing.T) {
// TestStreamUpstreamResponse_ContextCanceled
// 验证:context 取消时返回 usage 且标记 clientDisconnect
func TestStreamUpstreamResponse_ContextCanceled(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
})
@@ -1078,7 +1061,6 @@ func TestStreamUpstreamResponse_ContextCanceled(t *testing.T) {
// TestStreamUpstreamResponse_Timeout
// 验证:上游超时时返回已收集的 usage
func TestStreamUpstreamResponse_Timeout(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{StreamDataIntervalTimeout: 1, MaxLineSize: defaultMaxLineSize},
})
@@ -1101,7 +1083,6 @@ func TestStreamUpstreamResponse_Timeout(t *testing.T) {
// TestStreamUpstreamResponse_TimeoutAfterClientDisconnect
// 验证:客户端断开后上游超时,返回 usage 并标记 clientDisconnect
func TestStreamUpstreamResponse_TimeoutAfterClientDisconnect(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{StreamDataIntervalTimeout: 1, MaxLineSize: defaultMaxLineSize},
})
@@ -1131,7 +1112,6 @@ func TestStreamUpstreamResponse_TimeoutAfterClientDisconnect(t *testing.T) {
// TestHandleGeminiStreamingResponse_ClientDisconnect
// 验证:Gemini 流式转发中客户端断开后继续 drain 上游
func TestHandleGeminiStreamingResponse_ClientDisconnect(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
})
@@ -1162,7 +1142,6 @@ func TestHandleGeminiStreamingResponse_ClientDisconnect(t *testing.T) {
// TestHandleGeminiStreamingResponse_ContextCanceled
// 验证:context 取消时不注入错误事件
func TestHandleGeminiStreamingResponse_ContextCanceled(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
})
@@ -1186,7 +1165,6 @@ func TestHandleGeminiStreamingResponse_ContextCanceled(t *testing.T) {
// TestHandleClaudeStreamingResponse_ClientDisconnect
// 验证:Claude 流式转发中客户端断开后继续 drain 上游
func TestHandleClaudeStreamingResponse_ClientDisconnect(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
})
@@ -1217,7 +1195,6 @@ func TestHandleClaudeStreamingResponse_ClientDisconnect(t *testing.T) {
// TestHandleClaudeStreamingResponse_EmptyStream
// 验证:上游只返回无法解析的 SSE 行时,触发 UpstreamFailoverError 而不是向客户端发出残缺流
func TestHandleClaudeStreamingResponse_EmptyStream(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
})
@@ -1257,7 +1234,6 @@ func TestHandleClaudeStreamingResponse_EmptyStream(t *testing.T) {
// TestHandleClaudeStreamingResponse_ContextCanceled
// 验证:context 取消时不注入错误事件
func TestHandleClaudeStreamingResponse_ContextCanceled(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
})
@@ -1314,7 +1290,6 @@ func TestExtractSSEUsage(t *testing.T) {
// TestAntigravityClientWriter 验证 antigravityClientWriter 的断开检测
func TestAntigravityClientWriter(t *testing.T) {
t.Run("normal write succeeds", func(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
flusher, _ := c.Writer.(http.Flusher)
@@ -1327,7 +1302,6 @@ func TestAntigravityClientWriter(t *testing.T) {
})
t.Run("write failure marks disconnected", func(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
fw := &antigravityFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
@@ -1340,7 +1314,6 @@ func TestAntigravityClientWriter(t *testing.T) {
})
t.Run("subsequent writes are no-op", func(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
fw := &antigravityFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
diff --git a/backend/internal/service/antigravity_quota_fetcher.go b/backend/internal/service/antigravity_quota_fetcher.go
index 9e09c904436..3a3890cf501 100644
--- a/backend/internal/service/antigravity_quota_fetcher.go
+++ b/backend/internal/service/antigravity_quota_fetcher.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
+ "net/url"
"regexp"
"strings"
"time"
@@ -235,6 +236,25 @@ func classifyForbiddenType(body string) string {
// urlPattern 用于从 403 响应体中提取 URL(降级方案)
var urlPattern = regexp.MustCompile(`https://[^\s"'\\]+`)
+var trustedAntigravityValidationHosts = map[string]struct{}{
+ "accounts.google.com": {},
+ "support.google.com": {},
+}
+
+func normalizeAntigravityValidationURL(raw string) string {
+ parsed, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil || parsed.Scheme != "https" || parsed.User != nil {
+ return ""
+ }
+ if _, ok := trustedAntigravityValidationHosts[strings.ToLower(parsed.Hostname())]; !ok {
+ return ""
+ }
+ if parsed.Port() != "" && parsed.Port() != "443" {
+ return ""
+ }
+ return parsed.String()
+}
+
// extractValidationURL 从 403 响应 JSON 中提取验证/申诉链接
func extractValidationURL(body string) string {
// 1. 尝试结构化 JSON 提取: /error/details[*]/metadata/validation_url 或 appeal_url
@@ -247,10 +267,10 @@ func extractValidationURL(body string) string {
}
if json.Unmarshal([]byte(body), &parsed) == nil {
for _, detail := range parsed.Error.Details {
- if u := detail.Metadata["validation_url"]; u != "" {
+ if u := normalizeAntigravityValidationURL(detail.Metadata["validation_url"]); u != "" {
return u
}
- if u := detail.Metadata["appeal_url"]; u != "" {
+ if u := normalizeAntigravityValidationURL(detail.Metadata["appeal_url"]); u != "" {
return u
}
}
@@ -266,7 +286,7 @@ func extractValidationURL(body string) string {
// 先解码常见转义再匹配
normalized := strings.ReplaceAll(body, `\u0026`, "&")
if m := urlPattern.FindString(normalized); m != "" {
- return m
+ return normalizeAntigravityValidationURL(m)
}
return ""
}
diff --git a/backend/internal/service/antigravity_quota_fetcher_test.go b/backend/internal/service/antigravity_quota_fetcher_test.go
index e0f5705141f..481e69c4c0d 100644
--- a/backend/internal/service/antigravity_quota_fetcher_test.go
+++ b/backend/internal/service/antigravity_quota_fetcher_test.go
@@ -482,9 +482,34 @@ func TestExtractValidationURL(t *testing.T) {
expected: "https://support.google.com/appeal/123",
},
{
- name: "validation_url takes priority over appeal_url",
- body: `{"error":{"details":[{"metadata":{"validation_url":"https://v.com","appeal_url":"https://a.com"}}]}}`,
- expected: "https://v.com",
+ name: "trusted validation_url takes priority over trusted appeal_url",
+ body: `{"error":{"details":[{"metadata":{"validation_url":"https://accounts.google.com/verify","appeal_url":"https://support.google.com/appeal"}}]}}`,
+ expected: "https://accounts.google.com/verify",
+ },
+ {
+ name: "untrusted validation_url falls through to trusted appeal_url",
+ body: `{"error":{"details":[{"metadata":{"validation_url":"https://evil.example/verify","appeal_url":"https://support.google.com/appeal"}}]}}`,
+ expected: "https://support.google.com/appeal",
+ },
+ {
+ name: "rejects unsafe scheme",
+ body: `{"error":{"details":[{"metadata":{"validation_url":"javascript:alert(1)"}}]}}`,
+ expected: "",
+ },
+ {
+ name: "rejects lookalike google host",
+ body: `{"error":{"details":[{"metadata":{"validation_url":"https://accounts.google.com.evil.example/verify"}}]}}`,
+ expected: "",
+ },
+ {
+ name: "rejects embedded credentials",
+ body: `{"error":{"details":[{"metadata":{"validation_url":"https://accounts.google.com@evil.example/verify"}}]}}`,
+ expected: "",
+ },
+ {
+ name: "rejects non-default port",
+ body: `{"error":{"details":[{"metadata":{"validation_url":"https://accounts.google.com:8443/verify"}}]}}`,
+ expected: "",
},
{
name: "fallback regex with verify keyword",
diff --git a/backend/internal/service/antigravity_token_provider.go b/backend/internal/service/antigravity_token_provider.go
index 1b360d930b3..29a756eccb0 100644
--- a/backend/internal/service/antigravity_token_provider.go
+++ b/backend/internal/service/antigravity_token_provider.go
@@ -90,7 +90,15 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
// 1) Try cache first.
if p.tokenCache != nil {
if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" {
- return token, nil
+ latestAccount, cacheHitIsCurrent, validationErr := validateOAuthTokenCacheHit(ctx, account, p.accountRepo)
+ if validationErr != nil {
+ return "", validationErr
+ }
+ if cacheHitIsCurrent {
+ return token, nil
+ }
+ account = latestAccount
+ cacheKey = AntigravityTokenCacheKey(account)
}
}
@@ -111,7 +119,16 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
} else if result.LockHeld {
if p.refreshPolicy.OnLockHeld == ProviderLockHeldWaitForCache && p.tokenCache != nil {
if token, cacheErr := p.tokenCache.GetAccessToken(ctx, cacheKey); cacheErr == nil && strings.TrimSpace(token) != "" {
- return token, nil
+ latestAccount, cacheHitIsCurrent, validationErr := validateOAuthTokenCacheHit(ctx, account, p.accountRepo)
+ if validationErr != nil {
+ return "", validationErr
+ }
+ if cacheHitIsCurrent {
+ return token, nil
+ }
+ account = latestAccount
+ cacheKey = AntigravityTokenCacheKey(account)
+ expiresAt = account.GetCredentialAsTime("expires_at")
}
}
// default policy: continue with existing token.
@@ -121,9 +138,9 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
}
} else if needsRefresh && p.tokenCache != nil {
// Backward-compatible test path when refreshAPI is not injected.
- locked, err := p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
+ locked, ownershipToken, err := p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
if err == nil && locked {
- defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey) }()
+ defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey, ownershipToken) }()
}
}
@@ -150,8 +167,10 @@ func (p *AntigravityTokenProvider) GetAccessToken(ctx context.Context, account *
// 3) Populate cache with TTL.
if p.tokenCache != nil {
- latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
- if isStale && latestAccount != nil {
+ latestAccount, isStale, versionErr := CheckTokenVersion(ctx, account, p.accountRepo)
+ if versionErr != nil {
+ slog.Warn("antigravity_token_version_check_unavailable", "account_id", account.ID, "error", versionErr)
+ } else if isStale && latestAccount != nil {
slog.Debug("antigravity_token_version_stale_use_latest", "account_id", account.ID)
accessToken = latestAccount.GetCredential("access_token")
if strings.TrimSpace(accessToken) == "" {
@@ -231,6 +250,10 @@ func (p *AntigravityTokenProvider) markBackfillAttempted(accountID int64) {
}
func AntigravityTokenCacheKey(account *Account) string {
+ return credentialBoundOAuthTokenCacheKey(antigravityTokenCacheBaseKey(account), account)
+}
+
+func antigravityTokenCacheBaseKey(account *Account) string {
projectID := strings.TrimSpace(account.GetCredential("project_id"))
if projectID != "" {
return "ag:" + projectID
diff --git a/backend/internal/service/api_key.go b/backend/internal/service/api_key.go
index ec20b0a9bf1..02fe39b8bde 100644
--- a/backend/internal/service/api_key.go
+++ b/backend/internal/service/api_key.go
@@ -31,7 +31,10 @@ type APIKey struct {
ID int64
UserID int64
Key string
+ KeyHash string
+ KeyPrefix string
Name string
+ Purpose string
GroupID *int64
Status string
IPWhitelist []string
@@ -62,6 +65,22 @@ type APIKey struct {
Window7dStart *time.Time // Start of current 7d window
}
+const (
+ APIKeyPurposeStandard = "standard"
+ APIKeyPurposeWalletUniversal = "wallet_universal"
+)
+
+func (k *APIKey) IsWalletUniversal() bool {
+ return k != nil && k.Purpose == APIKeyPurposeWalletUniversal
+}
+
+func (k *APIKey) HasValidWalletUniversalShape() bool {
+ return k != nil &&
+ k.IsWalletUniversal() &&
+ k.GroupID == nil &&
+ k.Name == WalletUniversalAPIKeyName
+}
+
func (k *APIKey) IsActive() bool {
return k.Status == StatusActive
}
diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go
index 1a1c78b8de1..955699063bb 100644
--- a/backend/internal/service/api_key_auth_cache.go
+++ b/backend/internal/service/api_key_auth_cache.go
@@ -8,6 +8,8 @@ type APIKeyAuthSnapshot struct {
APIKeyID int64 `json:"api_key_id"`
UserID int64 `json:"user_id"`
GroupID *int64 `json:"group_id,omitempty"`
+ Name string `json:"name"`
+ Purpose string `json:"purpose"`
Status string `json:"status"`
IPWhitelist []string `json:"ip_whitelist,omitempty"`
IPBlacklist []string `json:"ip_blacklist,omitempty"`
@@ -47,6 +49,11 @@ type APIKeyAuthUserSnapshot struct {
// RPMLimit 用户级每分钟请求数上限(0 = 不限制);用于 billing_cache_service.checkRPM 兜底判断。
RPMLimit int `json:"rpm_limit"`
+ // AllowedGroups is required by wallet authorization. Keeping it in the
+ // snapshot lets operator grant/revoke invalidation take effect without
+ // falling back to trusting an API key's historical group_id.
+ AllowedGroups []int64 `json:"allowed_groups,omitempty"`
+
// UserGroupRPMOverride 该 API Key 对应的 (user, group) 专属 RPM 覆盖值。
// nil = 无 override(回退到 group/user 级);0 = 不限流;>0 = 专属上限。
UserGroupRPMOverride *int `json:"user_group_rpm_override,omitempty"`
@@ -58,11 +65,15 @@ type APIKeyAuthGroupSnapshot struct {
Name string `json:"name"`
Platform string `json:"platform"`
Status string `json:"status"`
+ IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type"`
RateMultiplier float64 `json:"rate_multiplier"`
DailyLimitUSD *float64 `json:"daily_limit_usd,omitempty"`
WeeklyLimitUSD *float64 `json:"weekly_limit_usd,omitempty"`
MonthlyLimitUSD *float64 `json:"monthly_limit_usd,omitempty"`
+ AllowImageGeneration bool `json:"allow_image_generation"`
+ ImageRateIndependent bool `json:"image_rate_independent"`
+ ImageRateMultiplier float64 `json:"image_rate_multiplier"`
ImagePrice1K *float64 `json:"image_price_1k,omitempty"`
ImagePrice2K *float64 `json:"image_price_2k,omitempty"`
ImagePrice4K *float64 `json:"image_price_4k,omitempty"`
@@ -86,6 +97,11 @@ type APIKeyAuthGroupSnapshot struct {
// RPMLimit 分组级每分钟请求数上限(0 = 不限制);用于 billing_cache_service.checkRPM 级联判断。
RPMLimit int `json:"rpm_limit"`
+
+ // UpdatedAt is the database-backed policy revision. Every admin group
+ // mutation advances it, so pricing, quota, routing, and authorization field
+ // changes cannot survive a failed Redis invalidation.
+ UpdatedAt time.Time `json:"updated_at"`
}
// APIKeyAuthCacheEntry 缓存条目,支持负缓存
diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go
index 974ea66efad..c2b5abceeec 100644
--- a/backend/internal/service/api_key_auth_cache_impl.go
+++ b/backend/internal/service/api_key_auth_cache_impl.go
@@ -2,8 +2,6 @@ package service
import (
"context"
- "crypto/sha256"
- "encoding/hex"
"errors"
"fmt"
"log/slog"
@@ -14,7 +12,7 @@ import (
"github.com/dgraph-io/ristretto"
)
-const apiKeyAuthSnapshotVersion = 7 // v7: added UserGroupRPMOverride on user snapshot
+const apiKeyAuthSnapshotVersion = 12 // v12: immutable API-key purpose is part of authorization
type apiKeyAuthCacheConfig struct {
l1Size int
@@ -105,8 +103,18 @@ func (s *APIKeyService) StartAuthCacheInvalidationSubscriber(ctx context.Context
}
func (s *APIKeyService) authCacheKey(key string) string {
- sum := sha256.Sum256([]byte(key))
- return hex.EncodeToString(sum[:])
+ if s != nil && s.authCacheLocatorProvider != nil {
+ return s.authCacheLocatorProvider.APIKeyAuthCacheLocator(key)
+ }
+ return APIKeyAuthCacheLocator(key)
+}
+
+// APIKeyAuthCacheLocator returns the one-way locator used by both local and
+// distributed auth caches. Durable billing stores only this SHA-256 locator,
+// never the raw API key, so replay can invalidate a key even after soft delete
+// has replaced the database value with a tombstone.
+func APIKeyAuthCacheLocator(key string) string {
+ return HashAPIKey(key)
}
func (s *APIKeyService) getAuthCacheEntry(ctx context.Context, cacheKey string) (*APIKeyAuthCacheEntry, bool) {
@@ -152,15 +160,20 @@ func (s *APIKeyService) setAuthCacheEntry(ctx context.Context, cacheKey string,
}
func (s *APIKeyService) deleteAuthCache(ctx context.Context, cacheKey string) {
+ _ = s.deleteAuthCacheReliable(ctx, cacheKey)
+}
+
+func (s *APIKeyService) deleteAuthCacheReliable(ctx context.Context, cacheKey string) error {
if s.authCacheL1 != nil {
s.authCacheL1.Del(cacheKey)
}
if s.cache == nil {
- return
+ return nil
}
- _ = s.cache.DeleteAuthCache(ctx, cacheKey)
+ deleteErr := s.cache.DeleteAuthCache(ctx, cacheKey)
// Publish invalidation message to other instances
- _ = s.cache.PublishAuthCacheInvalidation(ctx, cacheKey)
+ publishErr := s.cache.PublishAuthCacheInvalidation(ctx, cacheKey)
+ return errors.Join(deleteErr, publishErr)
}
func (s *APIKeyService) loadAuthCacheEntry(ctx context.Context, key, cacheKey string) (*APIKeyAuthCacheEntry, error) {
@@ -201,6 +214,21 @@ func (s *APIKeyService) applyAuthCacheEntry(key string, entry *APIKeyAuthCacheEn
return s.snapshotToAPIKey(key, entry.Snapshot), true, nil
}
+// validateCachedAuthSnapshot makes Redis/L1 invalidation an optimization rather
+// than an authorization boundary. A missing validator means the positive cache
+// entry is not trusted and the caller must reload it from the repository. A
+// database error is propagated so authentication fails closed.
+func (s *APIKeyService) validateCachedAuthSnapshot(ctx context.Context, cacheLocator string, snapshot *APIKeyAuthSnapshot) (bool, error) {
+ if snapshot == nil || s.authCacheSecurityValidator == nil {
+ return false, nil
+ }
+ valid, err := s.authCacheSecurityValidator.ValidateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+ if err != nil {
+ return false, fmt.Errorf("validate cached authorization state: %w", err)
+ }
+ return valid, nil
+}
+
func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) *APIKeyAuthSnapshot {
if apiKey == nil || apiKey.User == nil {
return nil
@@ -210,6 +238,8 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey)
APIKeyID: apiKey.ID,
UserID: apiKey.UserID,
GroupID: apiKey.GroupID,
+ Name: apiKey.Name,
+ Purpose: apiKey.Purpose,
Status: apiKey.Status,
IPWhitelist: apiKey.IPWhitelist,
IPBlacklist: apiKey.IPBlacklist,
@@ -233,6 +263,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey)
BalanceNotifyExtraEmails: apiKey.User.BalanceNotifyExtraEmails,
TotalRecharged: apiKey.User.TotalRecharged,
RPMLimit: apiKey.User.RPMLimit,
+ AllowedGroups: append([]int64(nil), apiKey.User.AllowedGroups...),
},
}
@@ -250,11 +281,15 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey)
Name: apiKey.Group.Name,
Platform: apiKey.Group.Platform,
Status: apiKey.Group.Status,
+ IsExclusive: apiKey.Group.IsExclusive,
SubscriptionType: apiKey.Group.SubscriptionType,
RateMultiplier: apiKey.Group.RateMultiplier,
DailyLimitUSD: apiKey.Group.DailyLimitUSD,
WeeklyLimitUSD: apiKey.Group.WeeklyLimitUSD,
MonthlyLimitUSD: apiKey.Group.MonthlyLimitUSD,
+ AllowImageGeneration: apiKey.Group.AllowImageGeneration,
+ ImageRateIndependent: apiKey.Group.ImageRateIndependent,
+ ImageRateMultiplier: apiKey.Group.ImageRateMultiplier,
ImagePrice1K: apiKey.Group.ImagePrice1K,
ImagePrice2K: apiKey.Group.ImagePrice2K,
ImagePrice4K: apiKey.Group.ImagePrice4K,
@@ -269,6 +304,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey)
DefaultMappedModel: apiKey.Group.DefaultMappedModel,
MessagesDispatchModelConfig: apiKey.Group.MessagesDispatchModelConfig,
RPMLimit: apiKey.Group.RPMLimit,
+ UpdatedAt: apiKey.Group.UpdatedAt,
}
}
return snapshot
@@ -283,6 +319,9 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
UserID: snapshot.UserID,
GroupID: snapshot.GroupID,
Key: key,
+ KeyHash: s.authCacheKey(key),
+ Name: snapshot.Name,
+ Purpose: snapshot.Purpose,
Status: snapshot.Status,
IPWhitelist: snapshot.IPWhitelist,
IPBlacklist: snapshot.IPBlacklist,
@@ -306,6 +345,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
BalanceNotifyExtraEmails: snapshot.User.BalanceNotifyExtraEmails,
TotalRecharged: snapshot.User.TotalRecharged,
RPMLimit: snapshot.User.RPMLimit,
+ AllowedGroups: append([]int64(nil), snapshot.User.AllowedGroups...),
UserGroupRPMOverride: snapshot.User.UserGroupRPMOverride,
},
}
@@ -316,11 +356,15 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
Platform: snapshot.Group.Platform,
Status: snapshot.Group.Status,
Hydrated: true,
+ IsExclusive: snapshot.Group.IsExclusive,
SubscriptionType: snapshot.Group.SubscriptionType,
RateMultiplier: snapshot.Group.RateMultiplier,
DailyLimitUSD: snapshot.Group.DailyLimitUSD,
WeeklyLimitUSD: snapshot.Group.WeeklyLimitUSD,
MonthlyLimitUSD: snapshot.Group.MonthlyLimitUSD,
+ AllowImageGeneration: snapshot.Group.AllowImageGeneration,
+ ImageRateIndependent: snapshot.Group.ImageRateIndependent,
+ ImageRateMultiplier: snapshot.Group.ImageRateMultiplier,
ImagePrice1K: snapshot.Group.ImagePrice1K,
ImagePrice2K: snapshot.Group.ImagePrice2K,
ImagePrice4K: snapshot.Group.ImagePrice4K,
@@ -335,6 +379,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho
DefaultMappedModel: snapshot.Group.DefaultMappedModel,
MessagesDispatchModelConfig: snapshot.Group.MessagesDispatchModelConfig,
RPMLimit: snapshot.Group.RPMLimit,
+ UpdatedAt: snapshot.Group.UpdatedAt,
}
}
s.compileAPIKeyIPRules(apiKey)
diff --git a/backend/internal/service/api_key_auth_cache_invalidate.go b/backend/internal/service/api_key_auth_cache_invalidate.go
index aeb58bcccd4..19b532091bf 100644
--- a/backend/internal/service/api_key_auth_cache_invalidate.go
+++ b/backend/internal/service/api_key_auth_cache_invalidate.go
@@ -1,6 +1,11 @@
package service
-import "context"
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+)
// InvalidateAuthCacheByKey 清除指定 API Key 的认证缓存
func (s *APIKeyService) InvalidateAuthCacheByKey(ctx context.Context, key string) {
@@ -13,14 +18,33 @@ func (s *APIKeyService) InvalidateAuthCacheByKey(ctx context.Context, key string
// InvalidateAuthCacheByUserID 清除用户相关的 API Key 认证缓存
func (s *APIKeyService) InvalidateAuthCacheByUserID(ctx context.Context, userID int64) {
+ _ = s.InvalidateAuthCacheByUserIDReliable(ctx, userID)
+}
+
+// InvalidateAuthCacheByUserIDReliable invalidates every local and distributed
+// auth cache entry and reports repository, Redis delete, and pub/sub failures.
+// Durable billing replay uses this form so a quota exhaustion cannot be
+// acknowledged while stale authorization remains active.
+func (s *APIKeyService) InvalidateAuthCacheByUserIDReliable(ctx context.Context, userID int64) error {
if userID <= 0 {
- return
+ return nil
}
- keys, err := s.apiKeyRepo.ListKeysByUserID(ctx, userID)
+ locators, err := s.apiKeyRepo.ListAuthCacheLocatorsByUserID(ctx, userID)
if err != nil {
- return
+ return err
}
- s.deleteAuthCacheByKeys(ctx, keys)
+ return s.deleteAuthCacheByLocatorsReliable(ctx, locators)
+}
+
+// InvalidateAuthCacheByLocatorReliable invalidates one frozen cache locator
+// without consulting active API-key rows. This remains reliable after the key
+// has been soft-deleted and its raw database value replaced by a tombstone.
+func (s *APIKeyService) InvalidateAuthCacheByLocatorReliable(ctx context.Context, locator string) error {
+ locator = strings.ToLower(strings.TrimSpace(locator))
+ if !validSHA256(locator) {
+ return fmt.Errorf("invalid API key auth cache locator")
+ }
+ return s.deleteAuthCacheReliable(ctx, locator)
}
// InvalidateAuthCacheByGroupID 清除分组相关的 API Key 认证缓存
@@ -28,21 +52,27 @@ func (s *APIKeyService) InvalidateAuthCacheByGroupID(ctx context.Context, groupI
if groupID <= 0 {
return
}
- keys, err := s.apiKeyRepo.ListKeysByGroupID(ctx, groupID)
+ locators, err := s.apiKeyRepo.ListAuthCacheLocatorsByGroupID(ctx, groupID)
if err != nil {
return
}
- s.deleteAuthCacheByKeys(ctx, keys)
+ _ = s.deleteAuthCacheByLocatorsReliable(ctx, locators)
}
-func (s *APIKeyService) deleteAuthCacheByKeys(ctx context.Context, keys []string) {
- if len(keys) == 0 {
- return
+func (s *APIKeyService) deleteAuthCacheByLocatorsReliable(ctx context.Context, locators []string) error {
+ if len(locators) == 0 {
+ return nil
}
- for _, key := range keys {
- if key == "" {
+ var invalidateErrors []error
+ for _, locator := range locators {
+ locator = strings.ToLower(strings.TrimSpace(locator))
+ if !validSHA256(locator) {
+ invalidateErrors = append(invalidateErrors, fmt.Errorf("invalid API key auth cache locator"))
continue
}
- s.deleteAuthCache(ctx, s.authCacheKey(key))
+ if err := s.deleteAuthCacheReliable(ctx, locator); err != nil {
+ invalidateErrors = append(invalidateErrors, err)
+ }
}
+ return errors.Join(invalidateErrors...)
}
diff --git a/backend/internal/service/api_key_hash.go b/backend/internal/service/api_key_hash.go
new file mode 100644
index 00000000000..ae1e47d235f
--- /dev/null
+++ b/backend/internal/service/api_key_hash.go
@@ -0,0 +1,39 @@
+package service
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "strings"
+)
+
+const apiKeyPrefixStorageLength = 8
+
+// HashAPIKey returns the deterministic SHA-256 digest used for API key lookup.
+func HashAPIKey(key string) string {
+ sum := sha256.Sum256([]byte(key))
+ return hex.EncodeToString(sum[:])
+}
+
+// APIKeyStoredAuthCacheLocator returns the repository-provided keyed locator.
+// The SHA-256 fallback exists only for test doubles and legacy in-memory
+// objects; production repository reads always populate KeyHash with HMAC.
+func APIKeyStoredAuthCacheLocator(key *APIKey) string {
+ if key == nil {
+ return ""
+ }
+ if locator := strings.ToLower(strings.TrimSpace(key.KeyHash)); len(locator) == 64 {
+ return locator
+ }
+ if strings.TrimSpace(key.Key) == "" {
+ return ""
+ }
+ return APIKeyAuthCacheLocator(key.Key)
+}
+
+// APIKeyPrefixForStorage keeps a small non-secret prefix for admin search/display.
+func APIKeyPrefixForStorage(key string) string {
+ if len(key) <= apiKeyPrefixStorageLength {
+ return key
+ }
+ return key[:apiKeyPrefixStorageLength]
+}
diff --git a/backend/internal/service/api_key_hash_test.go b/backend/internal/service/api_key_hash_test.go
new file mode 100644
index 00000000000..90758c0f464
--- /dev/null
+++ b/backend/internal/service/api_key_hash_test.go
@@ -0,0 +1,21 @@
+package service
+
+import "testing"
+
+func TestHashAPIKeyIsStableSHA256Hex(t *testing.T) {
+ got := HashAPIKey("hfc_test_key")
+ want := "73d1c9f908f87b8a69c8314ff24303ce3f97f8b55f840ac7de25cdf6b3079afd"
+ if got != want {
+ t.Fatalf("HashAPIKey() = %q, want %q", got, want)
+ }
+}
+
+func TestAPIKeyPrefixForStorage(t *testing.T) {
+ if got := APIKeyPrefixForStorage("short"); got != "short" {
+ t.Fatalf("short prefix = %q", got)
+ }
+
+ if got := APIKeyPrefixForStorage("hfc_1234567890abcdef"); got != "hfc_1234" {
+ t.Fatalf("long prefix = %q", got)
+ }
+}
diff --git a/backend/internal/service/api_key_migration_188_contract_test.go b/backend/internal/service/api_key_migration_188_contract_test.go
new file mode 100644
index 00000000000..f432d5c6f80
--- /dev/null
+++ b/backend/internal/service/api_key_migration_188_contract_test.go
@@ -0,0 +1,23 @@
+package service
+
+import (
+ "strings"
+ "testing"
+
+ dbmigrations "github.com/Wei-Shaw/sub2api/migrations"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAPIKeyMigration188WidenAndHistoricalReplayScrubContract(t *testing.T) {
+ raw, err := dbmigrations.FS.ReadFile("188_encrypt_api_keys_at_rest.sql")
+ require.NoError(t, err)
+ sql := string(raw)
+ require.Contains(t, sql, "ALTER COLUMN key TYPE varchar(512)")
+ require.Contains(t, sql, "DROP INDEX IF EXISTS idx_api_keys_key_trgm")
+ require.Contains(t, sql, "user.api_keys.create")
+ require.Contains(t, sql, "admin.subscriptions.assign")
+ require.Contains(t, sql, "regexp_replace")
+ require.Contains(t, sql, "chk_api_keys_encrypted_storage")
+ require.Contains(t, sql, "NOT VALID")
+ require.False(t, strings.Contains(sql, "DROP COLUMN key"), "runtime compatibility still requires encrypted key storage")
+}
diff --git a/backend/internal/service/api_key_reveal_security_test.go b/backend/internal/service/api_key_reveal_security_test.go
new file mode 100644
index 00000000000..b00efab188a
--- /dev/null
+++ b/backend/internal/service/api_key_reveal_security_test.go
@@ -0,0 +1,136 @@
+package service
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+type revealAPIKeyRepoStub struct {
+ APIKeyRepository
+ key *APIKey
+}
+
+func (s revealAPIKeyRepoStub) GetByID(context.Context, int64) (*APIKey, error) {
+ if s.key == nil {
+ return nil, ErrAPIKeyNotFound
+ }
+ copy := *s.key
+ return ©, nil
+}
+
+type revealUserRepoStub struct {
+ UserRepository
+ user *User
+}
+
+type revealAPIKeyCacheStub struct {
+ APIKeyCache
+ count int
+ incrErr error
+ clearErr error
+}
+
+func (s *revealAPIKeyCacheStub) GetCreateAttemptCount(context.Context, int64) (int, error) {
+ return s.count, nil
+}
+
+func (s *revealAPIKeyCacheStub) IncrementCreateAttemptCount(context.Context, int64) error {
+ s.count++
+ return nil
+}
+
+func (s *revealAPIKeyCacheStub) DeleteCreateAttemptCount(context.Context, int64) error {
+ s.count = 0
+ return nil
+}
+
+func (s *revealAPIKeyCacheStub) IncrementAPIKeyStepUpAttempt(context.Context, string, int64) (int, error) {
+ if s.incrErr != nil {
+ return 0, s.incrErr
+ }
+ s.count++
+ return s.count, nil
+}
+
+func (s *revealAPIKeyCacheStub) DeleteAPIKeyStepUpAttempts(context.Context, string, int64) error {
+ if s.clearErr != nil {
+ return s.clearErr
+ }
+ s.count = 0
+ return nil
+}
+
+func (s revealUserRepoStub) GetByID(context.Context, int64) (*User, error) {
+ if s.user == nil {
+ return nil, ErrUserNotFound
+ }
+ copy := *s.user
+ return ©, nil
+}
+
+func TestAPIKeyRevealRequiresFreshPasswordAndOwnership(t *testing.T) {
+ user := &User{ID: 7}
+ require.NoError(t, user.SetPassword("fresh-password"))
+ svc := &APIKeyService{
+ apiKeyRepo: revealAPIKeyRepoStub{key: &APIKey{ID: 11, UserID: 7, Key: "sk-reveal-secret"}},
+ userRepo: revealUserRepoStub{user: user},
+ cache: &revealAPIKeyCacheStub{},
+ }
+
+ require.ErrorIs(t, svc.VerifyRevealPassword(context.Background(), 7, "wrong"), ErrAPIKeyRevealVerification)
+ require.NoError(t, svc.VerifyRevealPassword(context.Background(), 7, "fresh-password"))
+ key, err := svc.Reveal(context.Background(), 11, 7)
+ require.NoError(t, err)
+ require.Equal(t, "sk-reveal-secret", key)
+
+ _, err = svc.Reveal(context.Background(), 11, 8)
+ require.ErrorIs(t, err, ErrInsufficientPerms)
+}
+
+func TestAPIKeyUpdateAcceptsDedicatedFreshPasswordPurpose(t *testing.T) {
+ user := &User{ID: 7}
+ require.NoError(t, user.SetPassword("fresh-password"))
+ svc := &APIKeyService{
+ userRepo: revealUserRepoStub{user: user},
+ cache: &revealAPIKeyCacheStub{},
+ }
+
+ require.NoError(t, svc.VerifyStepUpPassword(
+ context.Background(), 7, "fresh-password", APIKeyStepUpPurposeUpdate,
+ ))
+}
+
+func TestAPIKeyRevealVerificationModePrefersTOTP(t *testing.T) {
+ svc := &APIKeyService{userRepo: revealUserRepoStub{user: &User{ID: 7, TotpEnabled: true}}}
+ mode, err := svc.RevealVerificationMode(context.Background(), 7)
+ require.NoError(t, err)
+ require.Equal(t, "totp", mode)
+}
+
+func TestAPIKeyRevealPasswordLimiterFailsClosed(t *testing.T) {
+ user := &User{ID: 7}
+ require.NoError(t, user.SetPassword("fresh-password"))
+
+ for name, cache := range map[string]*revealAPIKeyCacheStub{
+ "increment": {incrErr: context.Canceled},
+ "clear": {clearErr: context.Canceled},
+ } {
+ t.Run(name, func(t *testing.T) {
+ svc := &APIKeyService{userRepo: revealUserRepoStub{user: user}, cache: cache}
+ password := "wrong"
+ if name == "clear" {
+ password = "fresh-password"
+ }
+ require.ErrorIs(t, svc.VerifyRevealPassword(context.Background(), 7, password), ErrAPIKeyRevealUnavailable)
+ })
+ }
+}
+
+func TestAPIKeyCreateRejectsUserChosenSecret(t *testing.T) {
+ custom := "user-chosen-secret-must-not-be-stored"
+ svc := &APIKeyService{}
+ _, err := svc.Create(context.Background(), 7, CreateAPIKeyRequest{CustomKey: &custom})
+ require.ErrorIs(t, err, ErrAPIKeyCustomKeyDisabled)
+}
diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go
index 48e0ab2f3ba..ec5b60ad382 100644
--- a/backend/internal/service/api_key_service.go
+++ b/backend/internal/service/api_key_service.go
@@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"encoding/hex"
+ "errors"
"fmt"
"strconv"
"strings"
@@ -20,13 +21,25 @@ import (
)
var (
- ErrAPIKeyNotFound = infraerrors.NotFound("API_KEY_NOT_FOUND", "api key not found")
- ErrGroupNotAllowed = infraerrors.Forbidden("GROUP_NOT_ALLOWED", "user is not allowed to bind this group")
- ErrAPIKeyExists = infraerrors.Conflict("API_KEY_EXISTS", "api key already exists")
- ErrAPIKeyTooShort = infraerrors.BadRequest("API_KEY_TOO_SHORT", "api key must be at least 16 characters")
- ErrAPIKeyInvalidChars = infraerrors.BadRequest("API_KEY_INVALID_CHARS", "api key can only contain letters, numbers, underscores, and hyphens")
- ErrAPIKeyRateLimited = infraerrors.TooManyRequests("API_KEY_RATE_LIMITED", "too many failed attempts, please try again later")
- ErrInvalidIPPattern = infraerrors.BadRequest("INVALID_IP_PATTERN", "invalid IP or CIDR pattern")
+ ErrAPIKeyNotFound = infraerrors.NotFound("API_KEY_NOT_FOUND", "api key not found")
+ ErrGroupNotAllowed = infraerrors.Forbidden("GROUP_NOT_ALLOWED", "user is not allowed to bind this group")
+ ErrAPIKeyGroupRequired = infraerrors.BadRequest("API_KEY_GROUP_REQUIRED", "user-created api keys must select a group")
+ ErrWalletUniversalKeyImmutable = infraerrors.BadRequest("WALLET_KEY_IMMUTABLE", "the system wallet key name and group cannot be changed")
+ ErrWalletUniversalKeyReserved = infraerrors.BadRequest("WALLET_KEY_NAME_RESERVED", "the system wallet key name is reserved")
+ ErrWalletUniversalKeyDelete = infraerrors.BadRequest("WALLET_KEY_DELETE_FORBIDDEN", "the system wallet key cannot be deleted; disable or rotate it instead")
+ ErrActiveCreditsWalletRequired = infraerrors.Forbidden("ACTIVE_CREDITS_WALLET_REQUIRED", "an active permanent credits wallet is required")
+ ErrAPIKeyExists = infraerrors.Conflict("API_KEY_EXISTS", "api key already exists")
+ ErrAPIKeyTooShort = infraerrors.BadRequest("API_KEY_TOO_SHORT", "api key must be at least 16 characters")
+ ErrAPIKeyInvalidChars = infraerrors.BadRequest("API_KEY_INVALID_CHARS", "api key can only contain letters, numbers, underscores, and hyphens")
+ ErrAPIKeyCustomKeyDisabled = infraerrors.BadRequest("API_KEY_CUSTOM_KEY_DISABLED", "custom API keys are disabled; use a generated key")
+ ErrAPIKeyRateLimited = infraerrors.TooManyRequests("API_KEY_RATE_LIMITED", "too many failed attempts, please try again later")
+ ErrAPIKeyCreateUnavailable = infraerrors.ServiceUnavailable("API_KEY_CREATE_UNAVAILABLE", "API key creation is temporarily unavailable")
+ ErrAPIKeyLimitReached = infraerrors.Conflict("API_KEY_LIMIT_REACHED", "active API key limit reached; delete an unused key before creating another")
+ ErrAPIKeyRevealVerification = infraerrors.Forbidden("API_KEY_REVEAL_VERIFICATION_FAILED", "API key reveal verification failed")
+ ErrAPIKeyRevealUnavailable = infraerrors.ServiceUnavailable("API_KEY_REVEAL_UNAVAILABLE", "API key reveal verification is temporarily unavailable")
+ ErrAPIKeyUpdateVerification = infraerrors.Forbidden("API_KEY_UPDATE_VERIFICATION_FAILED", "API key update verification failed")
+ ErrAPIKeyReactivationForbidden = infraerrors.Forbidden("API_KEY_REACTIVATION_FORBIDDEN", "disabled API keys require administrator reactivation")
+ ErrInvalidIPPattern = infraerrors.BadRequest("INVALID_IP_PATTERN", "invalid IP or CIDR pattern")
// ErrAPIKeyExpired = infraerrors.Forbidden("API_KEY_EXPIRED", "api key has expired")
ErrAPIKeyExpired = infraerrors.Forbidden("API_KEY_EXPIRED", "api key 已过期")
// ErrAPIKeyQuotaExhausted = infraerrors.TooManyRequests("API_KEY_QUOTA_EXHAUSTED", "api key quota exhausted")
@@ -39,12 +52,43 @@ var (
)
const (
- apiKeyMaxErrorsPerHour = 20
- apiKeyLastUsedMinTouch = 30 * time.Second
+ apiKeyMaxErrorsPerHour = 20
+ apiKeyMaxCreatesPerHour = 20
+ apiKeyMaxActivePerUser = 50
+ APIKeyStepUpPurposeReveal = "reveal"
+ APIKeyStepUpPurposeCreate = "create"
+ APIKeyStepUpPurposeUpdate = "update"
+ apiKeyLastUsedMinTouch = 30 * time.Second
// DB 写失败后的短退避,避免请求路径持续同步重试造成写风暴与高延迟。
apiKeyLastUsedFailBackoff = 5 * time.Second
)
+// 钱包多 key 模式:每把 key 命名为 "钱包-" + group.Name(如 "钱包-gpt-5")。
+// 前端可按 prefix 识别钱包 key(HasPrefix),与 v3 单 group 老 key 区分。
+// 注意:5/14 反转决策后激活流程不再走多 key 路径;EnsureWalletGroupKeys 实现保留作底层能力。
+const WalletGroupKeyNamePrefix = "钱包-"
+
+// WalletUniversalAPIKeyName 单 key 模式自动建的钱包通用 key 命名(5/14 反转决策回归 B1.4 形态)。
+const WalletUniversalAPIKeyName = "钱包通用 key(自动路由)"
+
+// IsWalletGroupKeyName 判断 key 名是否为钱包多 key 模式生成的命名格式。
+func IsWalletGroupKeyName(name string) bool {
+ return strings.HasPrefix(name, WalletGroupKeyNamePrefix)
+}
+
+// IsWalletUniversalKeyName 判断 key 名是否为单 key 模式自动建的钱包通用 key。
+func IsWalletUniversalKeyName(name string) bool {
+ return name == WalletUniversalAPIKeyName
+}
+
+// walletGroupKeyName 为指定 group 拼装钱包 key 命名。
+func walletGroupKeyName(group *Group) string {
+ if group == nil {
+ return WalletGroupKeyNamePrefix
+ }
+ return WalletGroupKeyNamePrefix + group.Name
+}
+
type APIKeyRepository interface {
Create(ctx context.Context, key *APIKey) error
GetByID(ctx context.Context, id int64) (*APIKey, error)
@@ -66,8 +110,8 @@ type APIKeyRepository interface {
// UpdateGroupIDByUserAndGroup 将用户下绑定 oldGroupID 的所有 Key 迁移到 newGroupID
UpdateGroupIDByUserAndGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (int64, error)
CountByGroupID(ctx context.Context, groupID int64) (int64, error)
- ListKeysByUserID(ctx context.Context, userID int64) ([]string, error)
- ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error)
+ ListAuthCacheLocatorsByUserID(ctx context.Context, userID int64) ([]string, error)
+ ListAuthCacheLocatorsByGroupID(ctx context.Context, groupID int64) ([]string, error)
// Quota methods
IncrementQuotaUsed(ctx context.Context, id int64, amount float64) (float64, error)
@@ -79,6 +123,43 @@ type APIKeyRepository interface {
GetRateLimitData(ctx context.Context, id int64) (*APIKeyRateLimitData, error)
}
+// APIKeyProtector provides domain-separated encryption and keyed lookup for
+// customer API keys. It is deliberately separate from TOTP/payment secrets so
+// a compromise or rotation in one domain does not expose another.
+type APIKeyProtector interface {
+ EncryptAPIKey(plaintext, associatedData string) (string, error)
+ DecryptAPIKey(ciphertext, associatedData string) (string, error)
+ LookupLocator(plaintext string) string
+}
+
+// APIKeyPlaintextMigrator is implemented by the production repository. The
+// server invokes it before opening the HTTP listener.
+type APIKeyPlaintextMigrator interface {
+ MigratePlaintextAPIKeysToEncrypted(ctx context.Context) (int, error)
+}
+
+type APIKeyAuthCacheLocatorProvider interface {
+ APIKeyAuthCacheLocator(plaintext string) string
+}
+
+// APIKeyPurposeRepository is the production point lookup used for the unique
+// live wallet-universal key. It intentionally includes disabled keys so an
+// emergency user disable is preserved across wallet top-ups.
+type APIKeyPurposeRepository interface {
+ GetByUserIDAndPurpose(ctx context.Context, userID int64, purpose string) (*APIKey, error)
+}
+
+// APIKeyAuthCacheSecurityValidator verifies the small, permission-sensitive
+// portion of an auth snapshot against the database. Positive cache entries are
+// never trusted when this validator is unavailable or returns an error.
+//
+// This is intentionally separate from APIKeyRepository so test/dedicated
+// repositories that do not support auth caching are not forced to implement a
+// production-only fast path.
+type APIKeyAuthCacheSecurityValidator interface {
+ ValidateAuthCacheSnapshot(ctx context.Context, cacheLocator string, snapshot *APIKeyAuthSnapshot) (bool, error)
+}
+
// APIKeyRateLimitData holds rate limit usage and window state for an API key.
type APIKeyRateLimitData struct {
Usage5h float64
@@ -116,10 +197,20 @@ func (d *APIKeyRateLimitData) EffectiveUsage7d() float64 {
// APIKeyQuotaUsageState captures the latest quota fields after an atomic quota update.
// It is intentionally small so repositories can return it from a single SQL statement.
type APIKeyQuotaUsageState struct {
- QuotaUsed float64
- Quota float64
- Key string
- Status string
+ QuotaUsed float64
+ Quota float64
+ AuthCacheLocator string
+ Status string
+}
+
+type WalletModelRouteInfo struct {
+ Pattern string `json:"pattern"`
+ ExampleModel string `json:"example_model"`
+ GroupID int64 `json:"group_id"`
+ GroupName string `json:"group_name"`
+ Platform string `json:"platform"`
+ RateMultiplier float64 `json:"rate_multiplier"`
+ EffectiveRateMultiplier float64 `json:"effective_rate_multiplier"`
}
// APIKeyCache defines cache operations for API key service
@@ -140,6 +231,17 @@ type APIKeyCache interface {
SubscribeAuthCacheInvalidation(ctx context.Context, handler func(cacheKey string)) error
}
+// APIKeyStepUpAttemptCache is deliberately separate from the legacy custom-key
+// limiter and scoped per protected action. Redis failure must fail closed.
+type APIKeyStepUpAttemptCache interface {
+ IncrementAPIKeyStepUpAttempt(ctx context.Context, purpose string, userID int64) (int, error)
+ DeleteAPIKeyStepUpAttempts(ctx context.Context, purpose string, userID int64) error
+}
+
+type APIKeyCreateReservationCache interface {
+ ReserveAPIKeyCreate(ctx context.Context, userID int64) (int, error)
+}
+
// APIKeyAuthCacheInvalidator 提供认证缓存失效能力
type APIKeyAuthCacheInvalidator interface {
InvalidateAuthCacheByKey(ctx context.Context, key string)
@@ -167,11 +269,11 @@ type CreateAPIKeyRequest struct {
// UpdateAPIKeyRequest 更新API Key请求
type UpdateAPIKeyRequest struct {
- Name *string `json:"name"`
- GroupID *int64 `json:"group_id"`
- Status *string `json:"status"`
- IPWhitelist []string `json:"ip_whitelist"` // IP 白名单(空数组清空)
- IPBlacklist []string `json:"ip_blacklist"` // IP 黑名单(空数组清空)
+ Name *string `json:"name"`
+ GroupID *int64 `json:"group_id"`
+ Status *string `json:"status"`
+ IPWhitelist *[]string `json:"ip_whitelist"` // 省略=不修改,显式 []=清空
+ IPBlacklist *[]string `json:"ip_blacklist"` // 省略=不修改,显式 []=清空
// Quota fields
Quota *float64 `json:"quota"` // Quota limit in USD (nil = no change, 0 = unlimited)
@@ -184,6 +286,25 @@ type UpdateAPIKeyRequest struct {
RateLimit1d *float64 `json:"rate_limit_1d"`
RateLimit7d *float64 `json:"rate_limit_7d"`
ResetRateLimitUsage *bool `json:"reset_rate_limit_usage"` // Reset all usage counters to 0
+ StepUpVerified bool `json:"-"` // Set only after fresh password/TOTP proof
+}
+
+// APIKeyUpdateRequiresStepUp identifies owner mutations that can expand the
+// authority or usable lifetime of an existing key. Name changes and explicit
+// deactivation remain available as containment actions without fresh proof.
+func APIKeyUpdateRequiresStepUp(req UpdateAPIKeyRequest) bool {
+ if req.GroupID != nil || req.IPWhitelist != nil || req.IPBlacklist != nil ||
+ req.Quota != nil || req.ExpiresAt != nil || req.ClearExpiration ||
+ req.RateLimit5h != nil || req.RateLimit1d != nil || req.RateLimit7d != nil {
+ return true
+ }
+ if req.ResetQuota != nil && *req.ResetQuota {
+ return true
+ }
+ if req.ResetRateLimitUsage != nil && *req.ResetRateLimitUsage {
+ return true
+ }
+ return req.Status != nil && *req.Status == StatusAPIKeyActive
}
// APIKeyService API Key服务
@@ -193,19 +314,21 @@ type RateLimitCacheInvalidator interface {
}
type APIKeyService struct {
- apiKeyRepo APIKeyRepository
- userRepo UserRepository
- groupRepo GroupRepository
- userSubRepo UserSubscriptionRepository
- userGroupRateRepo UserGroupRateRepository
- cache APIKeyCache
- rateLimitCacheInvalid RateLimitCacheInvalidator // optional: invalidate Redis rate limit cache
- cfg *config.Config
- authCacheL1 *ristretto.Cache
- authCfg apiKeyAuthCacheConfig
- authGroup singleflight.Group
- lastUsedTouchL1 sync.Map // keyID -> nextAllowedAt(time.Time)
- lastUsedTouchSF singleflight.Group
+ apiKeyRepo APIKeyRepository
+ authCacheSecurityValidator APIKeyAuthCacheSecurityValidator
+ authCacheLocatorProvider APIKeyAuthCacheLocatorProvider
+ userRepo UserRepository
+ groupRepo GroupRepository
+ userSubRepo UserSubscriptionRepository
+ userGroupRateRepo UserGroupRateRepository
+ cache APIKeyCache
+ rateLimitCacheInvalid RateLimitCacheInvalidator // optional: invalidate Redis rate limit cache
+ cfg *config.Config
+ authCacheL1 *ristretto.Cache
+ authCfg apiKeyAuthCacheConfig
+ authGroup singleflight.Group
+ lastUsedTouchL1 sync.Map // keyID -> nextAllowedAt(time.Time)
+ lastUsedTouchSF singleflight.Group
}
// NewAPIKeyService 创建API Key服务实例
@@ -218,19 +341,35 @@ func NewAPIKeyService(
cache APIKeyCache,
cfg *config.Config,
) *APIKeyService {
+ authCacheSecurityValidator, _ := apiKeyRepo.(APIKeyAuthCacheSecurityValidator)
+ authCacheLocatorProvider, _ := apiKeyRepo.(APIKeyAuthCacheLocatorProvider)
svc := &APIKeyService{
- apiKeyRepo: apiKeyRepo,
- userRepo: userRepo,
- groupRepo: groupRepo,
- userSubRepo: userSubRepo,
- userGroupRateRepo: userGroupRateRepo,
- cache: cache,
- cfg: cfg,
+ apiKeyRepo: apiKeyRepo,
+ authCacheSecurityValidator: authCacheSecurityValidator,
+ authCacheLocatorProvider: authCacheLocatorProvider,
+ userRepo: userRepo,
+ groupRepo: groupRepo,
+ userSubRepo: userSubRepo,
+ userGroupRateRepo: userGroupRateRepo,
+ cache: cache,
+ cfg: cfg,
}
svc.initAuthCache(cfg)
return svc
}
+// MigratePlaintextAPIKeysToEncrypted secures historical rows before startup.
+func (s *APIKeyService) MigratePlaintextAPIKeysToEncrypted(ctx context.Context) (int, error) {
+ if s == nil || s.apiKeyRepo == nil {
+ return 0, fmt.Errorf("API key repository is unavailable")
+ }
+ migrator, ok := s.apiKeyRepo.(APIKeyPlaintextMigrator)
+ if !ok {
+ return 0, fmt.Errorf("API key plaintext migration is not configured")
+ }
+ return migrator.MigratePlaintextAPIKeysToEncrypted(ctx)
+}
+
// SetRateLimitCacheInvalidator sets the optional rate limit cache invalidator.
// Called after construction (e.g. in wire) to avoid circular dependencies.
func (s *APIKeyService) SetRateLimitCacheInvalidator(inv RateLimitCacheInvalidator) {
@@ -325,13 +464,61 @@ func (s *APIKeyService) canUserBindGroup(ctx context.Context, user *User, group
return user.CanBindGroup(group.ID, group.IsExclusive)
}
+func validateWalletUniversalKeyMutation(apiKey *APIKey, req UpdateAPIKeyRequest) error {
+ if apiKey == nil {
+ return nil
+ }
+ if apiKey.IsWalletUniversal() {
+ if !apiKey.HasValidWalletUniversalShape() {
+ return ErrWalletUniversalKeyImmutable
+ }
+ if req.GroupID != nil || (req.Name != nil && *req.Name != WalletUniversalAPIKeyName) {
+ return ErrWalletUniversalKeyImmutable
+ }
+ return nil
+ }
+ if req.Name != nil && IsWalletUniversalKeyName(*req.Name) {
+ return ErrWalletUniversalKeyReserved
+ }
+ return nil
+}
+
// Create 创建API Key
func (s *APIKeyService) Create(ctx context.Context, userID int64, req CreateAPIKeyRequest) (*APIKey, error) {
+ return s.create(ctx, userID, req, APIKeyPurposeStandard)
+}
+
+func (s *APIKeyService) create(ctx context.Context, userID int64, req CreateAPIKeyRequest, purpose string) (*APIKey, error) {
+ if req.CustomKey != nil && strings.TrimSpace(*req.CustomKey) != "" {
+ return nil, ErrAPIKeyCustomKeyDisabled
+ }
// 验证用户存在
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("get user: %w", err)
}
+ if purpose == APIKeyPurposeWalletUniversal {
+ if req.GroupID != nil || !IsWalletUniversalKeyName(req.Name) {
+ return nil, ErrWalletUniversalKeyImmutable
+ }
+ } else {
+ purpose = APIKeyPurposeStandard
+ if IsWalletUniversalKeyName(req.Name) {
+ return nil, ErrWalletUniversalKeyReserved
+ }
+ }
+ if req.GroupID == nil && purpose != APIKeyPurposeWalletUniversal {
+ return nil, ErrAPIKeyGroupRequired
+ }
+ if purpose == APIKeyPurposeStandard {
+ activeCount, err := s.apiKeyRepo.CountByUserID(ctx, userID)
+ if err != nil {
+ return nil, fmt.Errorf("count active api keys: %w", err)
+ }
+ if activeCount >= apiKeyMaxActivePerUser {
+ return nil, ErrAPIKeyLimitReached
+ }
+ }
// 验证 IP 白名单格式
if len(req.IPWhitelist) > 0 {
@@ -359,47 +546,35 @@ func (s *APIKeyService) Create(ctx context.Context, userID int64, req CreateAPIK
return nil, ErrGroupNotAllowed
}
}
-
- var key string
-
- // 判断是否使用自定义Key
- if req.CustomKey != nil && *req.CustomKey != "" {
- // 检查限流(仅对自定义key进行限流)
- if err := s.checkAPIKeyRateLimit(ctx, userID); err != nil {
- return nil, err
+ if purpose == APIKeyPurposeStandard && s.cache != nil {
+ reservations, ok := s.cache.(APIKeyCreateReservationCache)
+ if !ok {
+ return nil, ErrAPIKeyCreateUnavailable
}
-
- // 验证自定义Key格式
- if err := s.ValidateCustomKey(*req.CustomKey); err != nil {
- return nil, err
- }
-
- // 检查Key是否已存在
- exists, err := s.apiKeyRepo.ExistsByKey(ctx, *req.CustomKey)
+ count, err := reservations.ReserveAPIKeyCreate(ctx, userID)
if err != nil {
- return nil, fmt.Errorf("check key exists: %w", err)
+ return nil, ErrAPIKeyCreateUnavailable
}
- if exists {
- // Key已存在,增加错误计数
- s.incrementAPIKeyErrorCount(ctx, userID)
- return nil, ErrAPIKeyExists
+ if count > apiKeyMaxCreatesPerHour {
+ return nil, ErrAPIKeyRateLimited
}
+ }
- key = *req.CustomKey
- } else {
- // 生成随机API Key
- var err error
- key, err = s.GenerateKey()
- if err != nil {
- return nil, fmt.Errorf("generate key: %w", err)
- }
+ // New credentials are always generated with CSPRNG entropy. Existing custom
+ // keys remain valid, but the API no longer accepts user-chosen secrets.
+ key, err := s.GenerateKey()
+ if err != nil {
+ return nil, fmt.Errorf("generate key: %w", err)
}
// 创建API Key记录
apiKey := &APIKey{
UserID: userID,
Key: key,
+ KeyHash: HashAPIKey(key),
+ KeyPrefix: APIKeyPrefixForStorage(key),
Name: req.Name,
+ Purpose: purpose,
GroupID: req.GroupID,
Status: StatusActive,
IPWhitelist: req.IPWhitelist,
@@ -436,6 +611,179 @@ func (s *APIKeyService) List(ctx context.Context, userID int64, params paginatio
return keys, pagination, nil
}
+// EnsureWalletUniversalKey 为用户建/复用 1 把通用 key(group_id=NULL),靠 B1.1/B1.2
+// 的 model_router 按调用模型自动路由到对应 group。
+//
+// 5/14 反转决策(参见 docs/plans/2026-05-14-wallet-single-key-reversal.md):
+// 钱包激活/topup 走单 key 路径,废弃 B2.2 多 key 改造。
+//
+// 幂等:按不可变 purpose 查找唯一 live key。disabled key 也会复用,避免
+// wallet top-up 绕过用户的紧急撤权并偷偷创建一把新 key。
+// 返回 (key, created, err)。
+func (s *APIKeyService) EnsureWalletUniversalKey(ctx context.Context, userID int64) (*APIKey, bool, error) {
+ if err := s.requireActiveCreditsWallet(ctx, userID); err != nil {
+ return nil, false, err
+ }
+ purposeRepo, ok := s.apiKeyRepo.(APIKeyPurposeRepository)
+ if !ok {
+ return nil, false, infraerrors.InternalServer("API_KEY_PURPOSE_REPOSITORY_UNAVAILABLE", "wallet key purpose lookup is not configured")
+ }
+ key, err := purposeRepo.GetByUserIDAndPurpose(ctx, userID, APIKeyPurposeWalletUniversal)
+ if err == nil {
+ if !key.HasValidWalletUniversalShape() {
+ return nil, false, ErrWalletUniversalKeyImmutable
+ }
+ return key, false, nil
+ }
+ if !errors.Is(err, ErrAPIKeyNotFound) {
+ return nil, false, err
+ }
+
+ newKey, err := s.create(ctx, userID, CreateAPIKeyRequest{
+ Name: WalletUniversalAPIKeyName,
+ GroupID: nil,
+ }, APIKeyPurposeWalletUniversal)
+ if err != nil {
+ return nil, false, fmt.Errorf("create wallet universal key: %w", err)
+ }
+ return newKey, true, nil
+}
+
+func (s *APIKeyService) requireActiveCreditsWallet(ctx context.Context, userID int64) error {
+ if s.userSubRepo == nil {
+ return infraerrors.InternalServer("SUBSCRIPTION_REPOSITORY_UNAVAILABLE", "subscription repository is not configured")
+ }
+ wallet, err := s.userSubRepo.GetActiveCreditsWalletByUserID(ctx, userID)
+ if err != nil {
+ if errors.Is(err, ErrSubscriptionNotFound) {
+ return ErrActiveCreditsWalletRequired
+ }
+ return fmt.Errorf("get active credits wallet: %w", err)
+ }
+ if wallet == nil || !wallet.IsActive() || !wallet.IsUniversalWalletMode() {
+ return ErrActiveCreditsWalletRequired
+ }
+ return nil
+}
+
+// EnsureWalletGroupKeys 钱包激活 / 充值时为用户按 groupIDs 建/复用 N 把分组 key。
+//
+// 命名:每把 key 命名为 "钱包-{group.Name}"。
+// 幂等:若用户名下已有 (groupID, name="钱包-{Name}", status=active, not expired/exhausted)
+//
+// 的 key → 复用;否则新建。
+//
+// 返回:(allKeys, createdCount, err)。allKeys 顺序按入参 groupIDs。
+func (s *APIKeyService) EnsureWalletGroupKeys(ctx context.Context, userID int64, groupIDs []int64) ([]APIKey, int, error) {
+ if len(groupIDs) == 0 {
+ return nil, 0, nil
+ }
+
+ // 查用户所有 active key 一次,避免每个 group 查一次
+ existingKeys, _, err := s.List(ctx, userID, pagination.PaginationParams{
+ Page: 1,
+ PageSize: 500,
+ SortBy: "created_at",
+ SortOrder: "desc",
+ }, APIKeyListFilters{Status: StatusAPIKeyActive})
+ if err != nil {
+ return nil, 0, err
+ }
+
+ // 按 groupID 索引可复用的钱包 key
+ reusable := make(map[int64]*APIKey, len(existingKeys))
+ for i := range existingKeys {
+ key := &existingKeys[i]
+ if key.GroupID == nil || !IsWalletGroupKeyName(key.Name) {
+ continue
+ }
+ if !key.IsActive() || key.IsExpired() || key.IsQuotaExhausted() {
+ continue
+ }
+ reusable[*key.GroupID] = key
+ }
+
+ result := make([]APIKey, 0, len(groupIDs))
+ created := 0
+ for _, gid := range groupIDs {
+ if exist, ok := reusable[gid]; ok {
+ result = append(result, *exist)
+ continue
+ }
+ group, err := s.groupRepo.GetByID(ctx, gid)
+ if err != nil {
+ return nil, created, fmt.Errorf("get group %d: %w", gid, err)
+ }
+ if group == nil {
+ continue
+ }
+ newKey, err := s.Create(ctx, userID, CreateAPIKeyRequest{
+ Name: walletGroupKeyName(group),
+ GroupID: &gid,
+ })
+ if err != nil {
+ return nil, created, fmt.Errorf("create wallet group key for group %d: %w", gid, err)
+ }
+ created++
+ result = append(result, *newKey)
+ }
+ return result, created, nil
+}
+
+func (s *APIKeyService) GetWalletModelRoutes(ctx context.Context, userID int64, modelRoutes []ModelRoute) ([]WalletModelRouteInfo, error) {
+ if len(modelRoutes) == 0 {
+ modelRoutes = DefaultModelRoutes()
+ }
+ if s.userRepo == nil {
+ return nil, fmt.Errorf("wallet route user repository is unavailable")
+ }
+ user, err := s.userRepo.GetByID(ctx, userID)
+ if err != nil {
+ return nil, fmt.Errorf("get wallet route user: %w", err)
+ }
+ if user == nil {
+ return nil, ErrUserNotFound
+ }
+
+ groups, err := s.groupRepo.ListActive(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("list active groups: %w", err)
+ }
+ groupsByName := make(map[string]Group, len(groups))
+ for _, group := range groups {
+ if group.Status == StatusActive {
+ groupsByName[group.Name] = group
+ }
+ }
+
+ userRates, err := s.GetUserGroupRates(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+
+ out := make([]WalletModelRouteInfo, 0, len(modelRoutes))
+ for _, route := range modelRoutes {
+ group, ok := groupsByName[route.GroupName]
+ if !ok || !CanUseWalletGroup(user, &group) {
+ continue
+ }
+ effectiveRate := group.RateMultiplier
+ if override, ok := userRates[group.ID]; ok {
+ effectiveRate = override
+ }
+ out = append(out, WalletModelRouteInfo{
+ Pattern: route.Pattern,
+ ExampleModel: route.ExampleModel,
+ GroupID: group.ID,
+ GroupName: group.Name,
+ Platform: group.Platform,
+ RateMultiplier: group.RateMultiplier,
+ EffectiveRateMultiplier: effectiveRate,
+ })
+ }
+ return out, nil
+}
+
func (s *APIKeyService) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
if len(apiKeyIDs) == 0 {
return []int64{}, nil
@@ -458,6 +806,77 @@ func (s *APIKeyService) GetByID(ctx context.Context, id int64) (*APIKey, error)
return apiKey, nil
}
+// RevealVerificationMode tells the handler which fresh proof is required.
+// TOTP is preferred whenever the user enabled it; otherwise the current
+// password is required.
+func (s *APIKeyService) RevealVerificationMode(ctx context.Context, userID int64) (string, error) {
+ if s == nil || s.userRepo == nil {
+ return "", fmt.Errorf("user repository is unavailable")
+ }
+ user, err := s.userRepo.GetByID(ctx, userID)
+ if err != nil {
+ return "", fmt.Errorf("get API key owner: %w", err)
+ }
+ if user.TotpEnabled {
+ return "totp", nil
+ }
+ return "password", nil
+}
+
+func (s *APIKeyService) VerifyRevealPassword(ctx context.Context, userID int64, password string) error {
+ return s.VerifyStepUpPassword(ctx, userID, password, APIKeyStepUpPurposeReveal)
+}
+
+func (s *APIKeyService) VerifyStepUpPassword(ctx context.Context, userID int64, password, purpose string) error {
+ if purpose != APIKeyStepUpPurposeReveal && purpose != APIKeyStepUpPurposeCreate && purpose != APIKeyStepUpPurposeUpdate {
+ return ErrAPIKeyRevealUnavailable
+ }
+ if s == nil || s.cache == nil {
+ return ErrAPIKeyRevealUnavailable
+ }
+ stepUpCache, ok := s.cache.(APIKeyStepUpAttemptCache)
+ if !ok {
+ return ErrAPIKeyRevealUnavailable
+ }
+ count, err := stepUpCache.IncrementAPIKeyStepUpAttempt(ctx, purpose, userID)
+ if err != nil {
+ return ErrAPIKeyRevealUnavailable
+ }
+ if count > apiKeyMaxErrorsPerHour {
+ return ErrAPIKeyRateLimited
+ }
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if s.userRepo == nil || strings.TrimSpace(password) == "" {
+ return ErrAPIKeyRevealVerification
+ }
+ user, err := s.userRepo.GetByID(ctx, userID)
+ if err != nil || user == nil || !user.CheckPassword(password) {
+ return ErrAPIKeyRevealVerification
+ }
+ if err := stepUpCache.DeleteAPIKeyStepUpAttempts(ctx, purpose, userID); err != nil {
+ return ErrAPIKeyRevealUnavailable
+ }
+ return nil
+}
+
+// Reveal returns one owned API key after the handler has completed fresh
+// password/TOTP verification. Callers must never persist the returned value.
+func (s *APIKeyService) Reveal(ctx context.Context, id, userID int64) (string, error) {
+ apiKey, err := s.apiKeyRepo.GetByID(ctx, id)
+ if err != nil {
+ return "", fmt.Errorf("get API key: %w", err)
+ }
+ if apiKey.UserID != userID {
+ return "", ErrInsufficientPerms
+ }
+ if strings.TrimSpace(apiKey.Key) == "" {
+ return "", ErrAPIKeyNotFound
+ }
+ return apiKey.Key, nil
+}
+
// GetByKey 根据Key字符串获取API Key(用于认证)
func (s *APIKeyService) GetByKey(ctx context.Context, key string) (*APIKey, error) {
cacheKey := s.authCacheKey(key)
@@ -467,8 +886,19 @@ func (s *APIKeyService) GetByKey(ctx context.Context, key string) (*APIKey, erro
if err != nil {
return nil, fmt.Errorf("get api key: %w", err)
}
- s.compileAPIKeyIPRules(apiKey)
- return apiKey, nil
+ valid, validationErr := s.validateCachedAuthSnapshot(ctx, cacheKey, entry.Snapshot)
+ if validationErr != nil {
+ return nil, fmt.Errorf("get api key: %w", validationErr)
+ }
+ if valid {
+ s.compileAPIKeyIPRules(apiKey)
+ return apiKey, nil
+ }
+ // The cached authorization state changed (or this repository cannot
+ // safely validate it). Clear what we can, then reload from the DB.
+ // Redis failures are deliberately ignored here: every future cache hit
+ // is independently validated before it can authorize a request.
+ s.deleteAuthCache(ctx, cacheKey)
}
}
@@ -521,17 +951,26 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
if apiKey.UserID != userID {
return nil, ErrInsufficientPerms
}
+ if err := validateWalletUniversalKeyMutation(apiKey, req); err != nil {
+ return nil, err
+ }
+ if req.Status != nil && apiKey.Status == StatusAPIKeyDisabled && *req.Status == StatusAPIKeyActive {
+ return nil, ErrAPIKeyReactivationForbidden
+ }
+ if APIKeyUpdateRequiresStepUp(req) && !req.StepUpVerified {
+ return nil, ErrAPIKeyUpdateVerification
+ }
// 验证 IP 白名单格式
- if len(req.IPWhitelist) > 0 {
- if invalid := ip.ValidateIPPatterns(req.IPWhitelist); len(invalid) > 0 {
+ if req.IPWhitelist != nil && len(*req.IPWhitelist) > 0 {
+ if invalid := ip.ValidateIPPatterns(*req.IPWhitelist); len(invalid) > 0 {
return nil, fmt.Errorf("%w: %v", ErrInvalidIPPattern, invalid)
}
}
// 验证 IP 黑名单格式
- if len(req.IPBlacklist) > 0 {
- if invalid := ip.ValidateIPPatterns(req.IPBlacklist); len(invalid) > 0 {
+ if req.IPBlacklist != nil && len(*req.IPBlacklist) > 0 {
+ if invalid := ip.ValidateIPPatterns(*req.IPBlacklist); len(invalid) > 0 {
return nil, fmt.Errorf("%w: %v", ErrInvalidIPPattern, invalid)
}
}
@@ -597,9 +1036,14 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
}
}
- // 更新 IP 限制(空数组会清空设置)
- apiKey.IPWhitelist = req.IPWhitelist
- apiKey.IPBlacklist = req.IPBlacklist
+ // Omitted policies retain their current security boundary. Only an explicit
+ // JSON array updates the field; an explicit empty array clears it.
+ if req.IPWhitelist != nil {
+ apiKey.IPWhitelist = append([]string(nil), (*req.IPWhitelist)...)
+ }
+ if req.IPBlacklist != nil {
+ apiKey.IPBlacklist = append([]string(nil), (*req.IPBlacklist)...)
+ }
// Update rate limit configuration
if req.RateLimit5h != nil {
@@ -638,21 +1082,24 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
// Delete 删除API Key
func (s *APIKeyService) Delete(ctx context.Context, id int64, userID int64) error {
- key, ownerID, err := s.apiKeyRepo.GetKeyAndOwnerID(ctx, id)
+ apiKey, err := s.apiKeyRepo.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("get api key: %w", err)
}
// 验证当前用户是否为该 API Key 的所有者
- if ownerID != userID {
+ if apiKey.UserID != userID {
return ErrInsufficientPerms
}
+ if apiKey.IsWalletUniversal() {
+ return ErrWalletUniversalKeyDelete
+ }
// 清除Redis缓存(使用 userID 而非 apiKey.UserID)
if s.cache != nil {
_ = s.cache.DeleteCreateAttemptCount(ctx, userID)
}
- s.InvalidateAuthCacheByKey(ctx, key)
+ s.InvalidateAuthCacheByKey(ctx, apiKey.Key)
if err := s.apiKeyRepo.Delete(ctx, id); err != nil {
return fmt.Errorf("delete api key: %w", err)
@@ -761,7 +1208,9 @@ func (s *APIKeyService) GetAvailableGroups(ctx context.Context, userID int64) ([
// 构建订阅分组 ID 集合
subscribedGroupIDs := make(map[int64]bool)
for _, sub := range activeSubscriptions {
- subscribedGroupIDs[sub.GroupID] = true
+ if sub.GroupID != nil {
+ subscribedGroupIDs[*sub.GroupID] = true
+ }
}
// 过滤出用户有权限的分组
@@ -838,8 +1287,10 @@ func (s *APIKeyService) UpdateQuotaUsed(ctx context.Context, apiKeyID int64, cos
if err != nil {
return fmt.Errorf("increment quota used: %w", err)
}
- if state != nil && state.Status == StatusAPIKeyQuotaExhausted && strings.TrimSpace(state.Key) != "" {
- s.InvalidateAuthCacheByKey(ctx, state.Key)
+ if state != nil && state.Status == StatusAPIKeyQuotaExhausted && strings.TrimSpace(state.AuthCacheLocator) != "" {
+ if err := s.InvalidateAuthCacheByLocatorReliable(ctx, state.AuthCacheLocator); err != nil {
+ return fmt.Errorf("invalidate exhausted API key authorization: %w", err)
+ }
}
return nil
}
diff --git a/backend/internal/service/api_key_service_cache_test.go b/backend/internal/service/api_key_service_cache_test.go
index 8cb1b8c4267..797896bb9cd 100644
--- a/backend/internal/service/api_key_service_cache_test.go
+++ b/backend/internal/service/api_key_service_cache_test.go
@@ -17,9 +17,11 @@ import (
)
type authRepoStub struct {
- getByKeyForAuth func(ctx context.Context, key string) (*APIKey, error)
- listKeysByUserID func(ctx context.Context, userID int64) ([]string, error)
- listKeysByGroupID func(ctx context.Context, groupID int64) ([]string, error)
+ getByKeyForAuth func(ctx context.Context, key string) (*APIKey, error)
+ validateAuthCacheSnapshot func(ctx context.Context, cacheLocator string, snapshot *APIKeyAuthSnapshot) (bool, error)
+ listByUserID func(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error)
+ listKeysByUserID func(ctx context.Context, userID int64) ([]string, error)
+ listKeysByGroupID func(ctx context.Context, groupID int64) ([]string, error)
}
func (s *authRepoStub) Create(ctx context.Context, key *APIKey) error {
@@ -45,6 +47,13 @@ func (s *authRepoStub) GetByKeyForAuth(ctx context.Context, key string) (*APIKey
return s.getByKeyForAuth(ctx, key)
}
+func (s *authRepoStub) ValidateAuthCacheSnapshot(ctx context.Context, cacheLocator string, snapshot *APIKeyAuthSnapshot) (bool, error) {
+ if s.validateAuthCacheSnapshot == nil {
+ return true, nil
+ }
+ return s.validateAuthCacheSnapshot(ctx, cacheLocator, snapshot)
+}
+
func (s *authRepoStub) Update(ctx context.Context, key *APIKey) error {
panic("unexpected Update call")
}
@@ -54,7 +63,10 @@ func (s *authRepoStub) Delete(ctx context.Context, id int64) error {
}
func (s *authRepoStub) ListByUserID(ctx context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
- panic("unexpected ListByUserID call")
+ if s.listByUserID == nil {
+ panic("unexpected ListByUserID call")
+ }
+ return s.listByUserID(ctx, userID, params, filters)
}
func (s *authRepoStub) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
@@ -88,16 +100,16 @@ func (s *authRepoStub) CountByGroupID(ctx context.Context, groupID int64) (int64
panic("unexpected CountByGroupID call")
}
-func (s *authRepoStub) ListKeysByUserID(ctx context.Context, userID int64) ([]string, error) {
+func (s *authRepoStub) ListAuthCacheLocatorsByUserID(ctx context.Context, userID int64) ([]string, error) {
if s.listKeysByUserID == nil {
- panic("unexpected ListKeysByUserID call")
+ panic("unexpected ListAuthCacheLocatorsByUserID call")
}
return s.listKeysByUserID(ctx, userID)
}
-func (s *authRepoStub) ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error) {
+func (s *authRepoStub) ListAuthCacheLocatorsByGroupID(ctx context.Context, groupID int64) ([]string, error) {
if s.listKeysByGroupID == nil {
- panic("unexpected ListKeysByGroupID call")
+ panic("unexpected ListAuthCacheLocatorsByGroupID call")
}
return s.listKeysByGroupID(ctx, groupID)
}
@@ -120,9 +132,12 @@ func (s *authRepoStub) GetRateLimitData(ctx context.Context, id int64) (*APIKeyR
}
type authCacheStub struct {
- getAuthCache func(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error)
- setAuthKeys []string
- deleteAuthKeys []string
+ getAuthCache func(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error)
+ setAuthKeys []string
+ deleteAuthKeys []string
+ publishAuthKeys []string
+ deleteAuthErr error
+ publishAuthErr error
}
func (s *authCacheStub) GetCreateAttemptCount(ctx context.Context, userID int64) (int, error) {
@@ -159,11 +174,12 @@ func (s *authCacheStub) SetAuthCache(ctx context.Context, key string, entry *API
func (s *authCacheStub) DeleteAuthCache(ctx context.Context, key string) error {
s.deleteAuthKeys = append(s.deleteAuthKeys, key)
- return nil
+ return s.deleteAuthErr
}
func (s *authCacheStub) PublishAuthCacheInvalidation(ctx context.Context, cacheKey string) error {
- return nil
+ s.publishAuthKeys = append(s.publishAuthKeys, cacheKey)
+ return s.publishAuthErr
}
func (s *authCacheStub) SubscribeAuthCacheInvalidation(ctx context.Context, handler func(cacheKey string)) error {
@@ -227,21 +243,130 @@ func TestAPIKeyService_GetByKey_UsesL2Cache(t *testing.T) {
require.Equal(t, map[string][]int64{"claude-opus-*": {1, 2}}, apiKey.Group.ModelRouting)
}
+func TestAPIKeyService_GetByKey_RejectsStalePermissionSnapshotEvenWhenRedisInvalidationFailed(t *testing.T) {
+ groupID := int64(22)
+ cacheDeleteErr := errors.New("redis delete unavailable")
+ cachePublishErr := errors.New("redis publish unavailable")
+ cache := &authCacheStub{
+ deleteAuthErr: cacheDeleteErr,
+ publishAuthErr: cachePublishErr,
+ }
+ var repoCalls int32
+ expectedLocator := APIKeyAuthCacheLocator("stale-vip-key")
+ repo := &authRepoStub{
+ validateAuthCacheSnapshot: func(_ context.Context, cacheLocator string, _ *APIKeyAuthSnapshot) (bool, error) {
+ require.Equal(t, expectedLocator, cacheLocator)
+ return false, nil
+ },
+ getByKeyForAuth: func(context.Context, string) (*APIKey, error) {
+ atomic.AddInt32(&repoCalls, 1)
+ return &APIKey{
+ ID: 1,
+ UserID: 2,
+ GroupID: &groupID,
+ Name: "VIP key",
+ Status: StatusActive,
+ User: &User{
+ ID: 2,
+ Status: StatusActive,
+ Role: RoleUser,
+ AllowedGroups: nil,
+ },
+ Group: &Group{
+ ID: groupID,
+ Name: "vip",
+ Platform: PlatformAnthropic,
+ Status: StatusActive,
+ Hydrated: true,
+ IsExclusive: true,
+ SubscriptionType: SubscriptionTypeStandard,
+ },
+ }, nil
+ },
+ }
+ cache.getAuthCache = func(context.Context, string) (*APIKeyAuthCacheEntry, error) {
+ return &APIKeyAuthCacheEntry{Snapshot: &APIKeyAuthSnapshot{
+ Version: apiKeyAuthSnapshotVersion,
+ APIKeyID: 1,
+ UserID: 2,
+ GroupID: &groupID,
+ Name: "VIP key",
+ Status: StatusActive,
+ User: APIKeyAuthUserSnapshot{
+ ID: 2,
+ Status: StatusActive,
+ Role: RoleUser,
+ AllowedGroups: []int64{groupID},
+ },
+ Group: &APIKeyAuthGroupSnapshot{
+ ID: groupID,
+ Name: "vip",
+ Platform: PlatformAnthropic,
+ Status: StatusActive,
+ IsExclusive: true,
+ SubscriptionType: SubscriptionTypeStandard,
+ },
+ }}, nil
+ }
+ cfg := &config.Config{APIKeyAuth: config.APIKeyAuthCacheConfig{L2TTLSeconds: 60}}
+ svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg)
+
+ got, err := svc.GetByKey(context.Background(), "stale-vip-key")
+ require.NoError(t, err)
+ require.Empty(t, got.User.AllowedGroups, "the stale VIP grant must not survive a failed Redis invalidation")
+ require.Equal(t, int32(1), atomic.LoadInt32(&repoCalls), "a stale snapshot must be reloaded from the database")
+}
+
+func TestAPIKeyService_GetByKey_FailsClosedWhenPermissionSnapshotCannotBeValidated(t *testing.T) {
+ validationErr := errors.New("database unavailable")
+ cache := &authCacheStub{}
+ repo := &authRepoStub{
+ validateAuthCacheSnapshot: func(context.Context, string, *APIKeyAuthSnapshot) (bool, error) {
+ return false, validationErr
+ },
+ getByKeyForAuth: func(context.Context, string) (*APIKey, error) {
+ panic("a validation failure must not fall through to an unguarded authorization path")
+ },
+ }
+ cache.getAuthCache = func(context.Context, string) (*APIKeyAuthCacheEntry, error) {
+ return &APIKeyAuthCacheEntry{Snapshot: &APIKeyAuthSnapshot{
+ Version: apiKeyAuthSnapshotVersion,
+ APIKeyID: 1,
+ UserID: 2,
+ Status: StatusActive,
+ User: APIKeyAuthUserSnapshot{
+ ID: 2,
+ Status: StatusActive,
+ Role: RoleUser,
+ },
+ }}, nil
+ }
+ cfg := &config.Config{APIKeyAuth: config.APIKeyAuthCacheConfig{L2TTLSeconds: 60}}
+ svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg)
+
+ _, err := svc.GetByKey(context.Background(), "cached-key")
+ require.ErrorIs(t, err, validationErr)
+}
+
func TestAPIKeyService_SnapshotRoundTrip_PreservesMessagesDispatchModelConfig(t *testing.T) {
svc := NewAPIKeyService(nil, nil, nil, nil, nil, nil, &config.Config{})
groupID := int64(9)
+ groupUpdatedAt := time.Now().UTC().Truncate(time.Microsecond)
apiKey := &APIKey{
ID: 1,
UserID: 2,
GroupID: &groupID,
Key: "k-roundtrip",
+ Name: "Audit Key",
+ Purpose: APIKeyPurposeStandard,
Status: StatusActive,
User: &User{
- ID: 2,
- Status: StatusActive,
- Role: RoleUser,
- Balance: 10,
- Concurrency: 3,
+ ID: 2,
+ Status: StatusActive,
+ Role: RoleUser,
+ Balance: 10,
+ Concurrency: 3,
+ AllowedGroups: []int64{groupID, 22},
},
Group: &Group{
ID: groupID,
@@ -249,6 +374,7 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesMessagesDispatchModelConfig(t
Platform: PlatformOpenAI,
Status: StatusActive,
SubscriptionType: SubscriptionTypeStandard,
+ IsExclusive: true,
RateMultiplier: 1,
AllowMessagesDispatch: true,
DefaultMappedModel: "gpt-5.4",
@@ -260,6 +386,7 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesMessagesDispatchModelConfig(t
"claude-sonnet-4.5": "gpt-5.4-nano",
},
},
+ UpdatedAt: groupUpdatedAt,
},
}
@@ -267,8 +394,13 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesMessagesDispatchModelConfig(t
roundTrip := svc.snapshotToAPIKey(apiKey.Key, snapshot)
require.NotNil(t, roundTrip)
+ require.Equal(t, apiKey.Name, roundTrip.Name)
+ require.Equal(t, apiKey.Purpose, roundTrip.Purpose)
require.NotNil(t, roundTrip.Group)
require.Equal(t, apiKey.Group.MessagesDispatchModelConfig, roundTrip.Group.MessagesDispatchModelConfig)
+ require.Equal(t, apiKey.User.AllowedGroups, roundTrip.User.AllowedGroups)
+ require.True(t, roundTrip.Group.IsExclusive)
+ require.True(t, roundTrip.Group.UpdatedAt.Equal(groupUpdatedAt))
}
func TestAPIKeyService_GetByKey_IgnoresLegacyAuthCacheSnapshotWithoutMessagesDispatchConfig(t *testing.T) {
@@ -451,7 +583,7 @@ func TestAPIKeyService_InvalidateAuthCacheByUserID(t *testing.T) {
cache := &authCacheStub{}
repo := &authRepoStub{
listKeysByUserID: func(ctx context.Context, userID int64) ([]string, error) {
- return []string{"k1", "k2"}, nil
+ return []string{APIKeyAuthCacheLocator("k1"), APIKeyAuthCacheLocator("k2")}, nil
},
}
cfg := &config.Config{
@@ -466,11 +598,56 @@ func TestAPIKeyService_InvalidateAuthCacheByUserID(t *testing.T) {
require.Len(t, cache.deleteAuthKeys, 2)
}
+func TestAPIKeyService_InvalidateAuthCacheByUserIDReliable_ReportsRepositoryAndCacheFailures(t *testing.T) {
+ t.Run("repository", func(t *testing.T) {
+ sentinel := errors.New("list keys failed")
+ svc := NewAPIKeyService(&authRepoStub{
+ listKeysByUserID: func(context.Context, int64) ([]string, error) { return nil, sentinel },
+ }, nil, nil, nil, nil, &authCacheStub{}, &config.Config{})
+ require.ErrorIs(t, svc.InvalidateAuthCacheByUserIDReliable(context.Background(), 7), sentinel)
+ })
+
+ t.Run("delete and publish", func(t *testing.T) {
+ deleteErr := errors.New("delete failed")
+ publishErr := errors.New("publish failed")
+ cache := &authCacheStub{deleteAuthErr: deleteErr, publishAuthErr: publishErr}
+ svc := NewAPIKeyService(&authRepoStub{
+ listKeysByUserID: func(context.Context, int64) ([]string, error) {
+ return []string{APIKeyAuthCacheLocator("k1"), APIKeyAuthCacheLocator("k2")}, nil
+ },
+ }, nil, nil, nil, nil, cache, &config.Config{})
+
+ err := svc.InvalidateAuthCacheByUserIDReliable(context.Background(), 7)
+ require.ErrorIs(t, err, deleteErr)
+ require.ErrorIs(t, err, publishErr)
+ require.Len(t, cache.deleteAuthKeys, 2)
+ })
+}
+
+func TestAPIKeyService_InvalidateAuthCacheByLocatorReliable_DoesNotDependOnActiveKeyRows(t *testing.T) {
+ deleteErr := errors.New("delete failed")
+ publishErr := errors.New("publish failed")
+ cache := &authCacheStub{deleteAuthErr: deleteErr, publishAuthErr: publishErr}
+ svc := NewAPIKeyService(&authRepoStub{
+ listKeysByUserID: func(context.Context, int64) ([]string, error) {
+ panic("locator invalidation must not enumerate active API keys")
+ },
+ }, nil, nil, nil, nil, cache, &config.Config{})
+ locator := APIKeyAuthCacheLocator("sk-soft-deleted")
+
+ err := svc.InvalidateAuthCacheByLocatorReliable(context.Background(), locator)
+ require.ErrorIs(t, err, deleteErr)
+ require.ErrorIs(t, err, publishErr)
+ require.Equal(t, []string{locator}, cache.deleteAuthKeys)
+ require.Equal(t, []string{locator}, cache.publishAuthKeys)
+ require.Error(t, svc.InvalidateAuthCacheByLocatorReliable(context.Background(), "not-a-sha256"))
+}
+
func TestAPIKeyService_InvalidateAuthCacheByGroupID(t *testing.T) {
cache := &authCacheStub{}
repo := &authRepoStub{
listKeysByGroupID: func(ctx context.Context, groupID int64) ([]string, error) {
- return []string{"k1", "k2"}, nil
+ return []string{APIKeyAuthCacheLocator("k1"), APIKeyAuthCacheLocator("k2")}, nil
},
}
cfg := &config.Config{
diff --git a/backend/internal/service/api_key_service_delete_test.go b/backend/internal/service/api_key_service_delete_test.go
index 392d52b92c1..d95f57ae5c8 100644
--- a/backend/internal/service/api_key_service_delete_test.go
+++ b/backend/internal/service/api_key_service_delete_test.go
@@ -116,12 +116,12 @@ func (s *apiKeyRepoStub) CountByGroupID(ctx context.Context, groupID int64) (int
panic("unexpected CountByGroupID call")
}
-func (s *apiKeyRepoStub) ListKeysByUserID(ctx context.Context, userID int64) ([]string, error) {
- panic("unexpected ListKeysByUserID call")
+func (s *apiKeyRepoStub) ListAuthCacheLocatorsByUserID(ctx context.Context, userID int64) ([]string, error) {
+ panic("unexpected ListAuthCacheLocatorsByUserID call")
}
-func (s *apiKeyRepoStub) ListKeysByGroupID(ctx context.Context, groupID int64) ([]string, error) {
- panic("unexpected ListKeysByGroupID call")
+func (s *apiKeyRepoStub) ListAuthCacheLocatorsByGroupID(ctx context.Context, groupID int64) ([]string, error) {
+ panic("unexpected ListAuthCacheLocatorsByGroupID call")
}
func (s *apiKeyRepoStub) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) (float64, error) {
@@ -252,6 +252,24 @@ func TestApiKeyService_Delete_Success(t *testing.T) {
require.False(t, exists, "delete should clear touch debounce cache")
}
+func TestAPIKeyServiceDeleteRejectsWalletUniversalPurpose(t *testing.T) {
+ repo := &apiKeyRepoStub{
+ apiKey: &APIKey{
+ ID: 43,
+ UserID: 7,
+ Key: "wallet-system-key",
+ Name: WalletUniversalAPIKeyName,
+ Purpose: APIKeyPurposeWalletUniversal,
+ },
+ }
+ svc := &APIKeyService{apiKeyRepo: repo, cache: &apiKeyCacheStub{}}
+
+ err := svc.Delete(context.Background(), 43, 7)
+
+ require.ErrorIs(t, err, ErrWalletUniversalKeyDelete)
+ require.Empty(t, repo.deletedIDs)
+}
+
// TestApiKeyService_Delete_NotFound 测试删除不存在的 API Key 时返回正确的错误。
// 预期行为:
// - GetKeyAndOwnerID 返回 ErrAPIKeyNotFound 错误
diff --git a/backend/internal/service/api_key_service_quota_test.go b/backend/internal/service/api_key_service_quota_test.go
index cf05e16c487..eee4c565738 100644
--- a/backend/internal/service/api_key_service_quota_test.go
+++ b/backend/internal/service/api_key_service_quota_test.go
@@ -128,11 +128,11 @@ func (s *quotaBaseAPIKeyRepoStub) UpdateGroupIDByUserAndGroup(context.Context, i
func (s *quotaBaseAPIKeyRepoStub) CountByGroupID(context.Context, int64) (int64, error) {
panic("unexpected CountByGroupID call")
}
-func (s *quotaBaseAPIKeyRepoStub) ListKeysByUserID(context.Context, int64) ([]string, error) {
- panic("unexpected ListKeysByUserID call")
+func (s *quotaBaseAPIKeyRepoStub) ListAuthCacheLocatorsByUserID(context.Context, int64) ([]string, error) {
+ panic("unexpected ListAuthCacheLocatorsByUserID call")
}
-func (s *quotaBaseAPIKeyRepoStub) ListKeysByGroupID(context.Context, int64) ([]string, error) {
- panic("unexpected ListKeysByGroupID call")
+func (s *quotaBaseAPIKeyRepoStub) ListAuthCacheLocatorsByGroupID(context.Context, int64) ([]string, error) {
+ panic("unexpected ListAuthCacheLocatorsByGroupID call")
}
func (s *quotaBaseAPIKeyRepoStub) IncrementQuotaUsed(context.Context, int64, float64) (float64, error) {
panic("unexpected IncrementQuotaUsed call")
@@ -153,10 +153,10 @@ func (s *quotaBaseAPIKeyRepoStub) GetRateLimitData(context.Context, int64) (*API
func TestAPIKeyService_UpdateQuotaUsed_UsesAtomicStatePath(t *testing.T) {
repo := "aStateRepoStub{
state: &APIKeyQuotaUsageState{
- QuotaUsed: 12,
- Quota: 10,
- Key: "sk-test-quota",
- Status: StatusAPIKeyQuotaExhausted,
+ QuotaUsed: 12,
+ Quota: 10,
+ AuthCacheLocator: APIKeyAuthCacheLocator("sk-test-quota"),
+ Status: StatusAPIKeyQuotaExhausted,
},
}
cache := "aStateCacheStub{}
@@ -169,5 +169,5 @@ func TestAPIKeyService_UpdateQuotaUsed_UsesAtomicStatePath(t *testing.T) {
require.NoError(t, err)
require.Equal(t, 1, repo.stateCalls)
require.Equal(t, 0, repo.getByIDCalls, "fast path should not re-read API key by id")
- require.Equal(t, []string{svc.authCacheKey("sk-test-quota")}, cache.deleteAuthKeys)
+ require.Equal(t, []string{APIKeyAuthCacheLocator("sk-test-quota")}, cache.deleteAuthKeys)
}
diff --git a/backend/internal/service/api_key_update_ip_policy_test.go b/backend/internal/service/api_key_update_ip_policy_test.go
new file mode 100644
index 00000000000..58d3fc52ec1
--- /dev/null
+++ b/backend/internal/service/api_key_update_ip_policy_test.go
@@ -0,0 +1,132 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/stretchr/testify/require"
+)
+
+type apiKeyUpdateCaptureRepo struct {
+ *apiKeyRepoStub
+ updated *APIKey
+}
+
+func (r *apiKeyUpdateCaptureRepo) Update(_ context.Context, key *APIKey) error {
+ copy := *key
+ copy.IPWhitelist = append([]string(nil), key.IPWhitelist...)
+ copy.IPBlacklist = append([]string(nil), key.IPBlacklist...)
+ r.updated = ©
+ return nil
+}
+
+func TestAPIKeyUpdatePreservesOmittedIPPolicies(t *testing.T) {
+ repo := &apiKeyUpdateCaptureRepo{apiKeyRepoStub: &apiKeyRepoStub{apiKey: &APIKey{
+ ID: 17,
+ UserID: 23,
+ Name: "before",
+ Key: "sk-sensitive",
+ Status: StatusActive,
+ IPWhitelist: []string{"10.0.0.1"},
+ IPBlacklist: []string{"192.0.2.0/24"},
+ }}}
+ svc := NewAPIKeyService(repo, nil, nil, nil, nil, nil, &config.Config{})
+ newName := "after"
+
+ _, err := svc.Update(context.Background(), 17, 23, UpdateAPIKeyRequest{Name: &newName})
+ require.NoError(t, err)
+ require.Equal(t, []string{"10.0.0.1"}, repo.updated.IPWhitelist)
+ require.Equal(t, []string{"192.0.2.0/24"}, repo.updated.IPBlacklist)
+}
+
+func TestAPIKeyUpdateClearsOnlyExplicitEmptyIPPolicies(t *testing.T) {
+ repo := &apiKeyUpdateCaptureRepo{apiKeyRepoStub: &apiKeyRepoStub{apiKey: &APIKey{
+ ID: 18,
+ UserID: 24,
+ Name: "before",
+ Key: "sk-sensitive",
+ Status: StatusActive,
+ IPWhitelist: []string{"10.0.0.1"},
+ IPBlacklist: []string{"192.0.2.0/24"},
+ }}}
+ svc := NewAPIKeyService(repo, nil, nil, nil, nil, nil, &config.Config{})
+ empty := []string{}
+
+ _, err := svc.Update(context.Background(), 18, 24, UpdateAPIKeyRequest{IPWhitelist: &empty, StepUpVerified: true})
+ require.NoError(t, err)
+ require.Empty(t, repo.updated.IPWhitelist)
+ require.Equal(t, []string{"192.0.2.0/24"}, repo.updated.IPBlacklist)
+}
+
+func TestAPIKeyUpdateRejectsSensitiveMutationWithoutFreshStepUp(t *testing.T) {
+ repo := &apiKeyUpdateCaptureRepo{apiKeyRepoStub: &apiKeyRepoStub{apiKey: &APIKey{
+ ID: 20,
+ UserID: 26,
+ Name: "restricted",
+ Key: "sk-sensitive",
+ Status: StatusActive,
+ IPWhitelist: []string{"10.0.0.1"},
+ }}}
+ svc := NewAPIKeyService(repo, nil, nil, nil, nil, nil, &config.Config{})
+ empty := []string{}
+
+ _, err := svc.Update(context.Background(), 20, 26, UpdateAPIKeyRequest{IPWhitelist: &empty})
+
+ require.ErrorIs(t, err, ErrAPIKeyUpdateVerification)
+ require.Nil(t, repo.updated)
+}
+
+func TestAPIKeyUpdateStepUpClassification(t *testing.T) {
+ name := "renamed"
+ inactive := "inactive"
+ active := StatusAPIKeyActive
+ groupID := int64(9)
+ empty := []string{}
+ quota := 10.0
+ expiresAt := time.Now().Add(time.Hour)
+ reset := true
+ noReset := false
+ rate := 1.0
+
+ tests := []struct {
+ name string
+ req UpdateAPIKeyRequest
+ want bool
+ }{
+ {name: "rename", req: UpdateAPIKeyRequest{Name: &name}},
+ {name: "deactivate", req: UpdateAPIKeyRequest{Status: &inactive}},
+ {name: "false resets", req: UpdateAPIKeyRequest{ResetQuota: &noReset, ResetRateLimitUsage: &noReset}},
+ {name: "activate", req: UpdateAPIKeyRequest{Status: &active}, want: true},
+ {name: "group", req: UpdateAPIKeyRequest{GroupID: &groupID}, want: true},
+ {name: "ip policy", req: UpdateAPIKeyRequest{IPWhitelist: &empty}, want: true},
+ {name: "quota", req: UpdateAPIKeyRequest{Quota: "a}, want: true},
+ {name: "expiration", req: UpdateAPIKeyRequest{ExpiresAt: &expiresAt}, want: true},
+ {name: "clear expiration", req: UpdateAPIKeyRequest{ClearExpiration: true}, want: true},
+ {name: "reset quota", req: UpdateAPIKeyRequest{ResetQuota: &reset}, want: true},
+ {name: "rate limit", req: UpdateAPIKeyRequest{RateLimit5h: &rate}, want: true},
+ {name: "reset rate usage", req: UpdateAPIKeyRequest{ResetRateLimitUsage: &reset}, want: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.Equal(t, tt.want, APIKeyUpdateRequiresStepUp(tt.req))
+ })
+ }
+}
+
+func TestAPIKeyUpdateRejectsOwnerReactivationOfDisabledKey(t *testing.T) {
+ repo := &apiKeyUpdateCaptureRepo{apiKeyRepoStub: &apiKeyRepoStub{apiKey: &APIKey{
+ ID: 19, UserID: 25, Name: "disabled by abuse review", Key: "sk-sensitive", Status: StatusAPIKeyDisabled,
+ }}}
+ svc := NewAPIKeyService(repo, nil, nil, nil, nil, nil, &config.Config{})
+ active := StatusAPIKeyActive
+
+ _, err := svc.Update(context.Background(), 19, 25, UpdateAPIKeyRequest{Status: &active})
+
+ require.ErrorIs(t, err, ErrAPIKeyReactivationForbidden)
+ require.Nil(t, repo.updated)
+}
diff --git a/backend/internal/service/api_key_wallet_universal_test.go b/backend/internal/service/api_key_wallet_universal_test.go
new file mode 100644
index 00000000000..c1b4cf34311
--- /dev/null
+++ b/backend/internal/service/api_key_wallet_universal_test.go
@@ -0,0 +1,412 @@
+package service
+
+import (
+ "context"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+ "github.com/stretchr/testify/require"
+)
+
+type walletGroupKeyAPIKeyRepoStub struct {
+ APIKeyRepository
+
+ keys []APIKey
+ createCalls int
+ listCalls int
+ created []APIKey
+ listUserID int64
+ listParams pagination.PaginationParams
+ listFilters APIKeyListFilters
+}
+
+func (s *walletGroupKeyAPIKeyRepoStub) GetByUserIDAndPurpose(_ context.Context, userID int64, purpose string) (*APIKey, error) {
+ for i := range s.keys {
+ key := s.keys[i]
+ if key.UserID == userID && key.Purpose == purpose {
+ return &key, nil
+ }
+ }
+ return nil, ErrAPIKeyNotFound
+}
+
+func (s *walletGroupKeyAPIKeyRepoStub) ListByUserID(_ context.Context, userID int64, params pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
+ s.listCalls++
+ s.listUserID = userID
+ s.listParams = params
+ s.listFilters = filters
+ out := make([]APIKey, len(s.keys))
+ copy(out, s.keys)
+ return out, &pagination.PaginationResult{Total: int64(len(out))}, nil
+}
+
+func (s *walletGroupKeyAPIKeyRepoStub) Create(_ context.Context, key *APIKey) error {
+ s.createCalls++
+ key.ID = 100 + int64(s.createCalls)
+ s.created = append(s.created, *key)
+ // 模拟新建后追加到 keys
+ s.keys = append(s.keys, *key)
+ return nil
+}
+
+func (s *walletGroupKeyAPIKeyRepoStub) CountByUserID(_ context.Context, userID int64) (int64, error) {
+ var count int64
+ for i := range s.keys {
+ if s.keys[i].UserID == userID {
+ count++
+ }
+ }
+ return count, nil
+}
+
+type walletGroupKeyUserRepoStub struct {
+ UserRepository
+ allowedGroups []int64
+}
+
+type walletGroupKeyUserSubRepoStub struct {
+ UserSubscriptionRepository
+}
+
+func (walletGroupKeyUserSubRepoStub) GetActiveCreditsWalletByUserID(_ context.Context, userID int64) (*UserSubscription, error) {
+ balance := 100.0
+ return &UserSubscription{
+ ID: 9001,
+ UserID: userID,
+ Status: SubscriptionStatusActive,
+ ExpiresAt: MaxExpiresAt,
+ WalletBalanceUSD: &balance,
+ }, nil
+}
+
+func (s walletGroupKeyUserRepoStub) GetByID(_ context.Context, id int64) (*User, error) {
+ return &User{ID: id, Status: StatusActive, AllowedGroups: append([]int64(nil), s.allowedGroups...)}, nil
+}
+
+type walletGroupKeyGroupRepoStub struct {
+ GroupRepository
+
+ groups map[int64]*Group
+}
+
+type apiKeyCreateReservationCacheStub struct {
+ APIKeyCache
+ reservation int
+ err error
+}
+
+func (s *apiKeyCreateReservationCacheStub) ReserveAPIKeyCreate(context.Context, int64) (int, error) {
+ return s.reservation, s.err
+}
+
+func (*apiKeyCreateReservationCacheStub) DeleteAuthCache(context.Context, string) error {
+ return nil
+}
+
+func (*apiKeyCreateReservationCacheStub) PublishAuthCacheInvalidation(context.Context, string) error {
+ return nil
+}
+
+func (s walletGroupKeyGroupRepoStub) GetByID(_ context.Context, id int64) (*Group, error) {
+ if g, ok := s.groups[id]; ok {
+ return g, nil
+ }
+ return nil, nil
+}
+
+func newWalletGroupKeyTestService(repo APIKeyRepository, groupRepo GroupRepository) *APIKeyService {
+ return NewAPIKeyService(repo, walletGroupKeyUserRepoStub{}, groupRepo, walletGroupKeyUserSubRepoStub{}, nil, nil, &config.Config{})
+}
+
+func TestAPIKeyServiceEnsureWalletGroupKeysCreatesAllForFreshUser(t *testing.T) {
+ repo := &walletGroupKeyAPIKeyRepoStub{}
+ groupRepo := walletGroupKeyGroupRepoStub{groups: map[int64]*Group{
+ 2: {ID: 2, Name: "gpt-5", Status: StatusActive},
+ 3: {ID: 3, Name: "claude-sonnet", Status: StatusActive},
+ 4: {ID: 4, Name: "gemini-2-pro", Status: StatusActive},
+ }}
+ svc := newWalletGroupKeyTestService(repo, groupRepo)
+
+ keys, createdCount, err := svc.EnsureWalletGroupKeys(context.Background(), 42, []int64{2, 3, 4})
+
+ require.NoError(t, err)
+ require.Equal(t, 3, createdCount)
+ require.Len(t, keys, 3)
+ require.Equal(t, 3, repo.createCalls)
+ require.Equal(t, "钱包-gpt-5", keys[0].Name)
+ require.Equal(t, "钱包-claude-sonnet", keys[1].Name)
+ require.Equal(t, "钱包-gemini-2-pro", keys[2].Name)
+ require.NotNil(t, keys[0].GroupID)
+ require.Equal(t, int64(2), *keys[0].GroupID)
+ require.Equal(t, int64(3), *keys[1].GroupID)
+ require.Equal(t, int64(4), *keys[2].GroupID)
+}
+
+func TestAPIKeyServiceEnsureWalletGroupKeysReusesExisting(t *testing.T) {
+ gid2 := int64(2)
+ gid3 := int64(3)
+ repo := &walletGroupKeyAPIKeyRepoStub{
+ keys: []APIKey{
+ {ID: 7, UserID: 42, Name: "钱包-gpt-5", GroupID: &gid2, Status: StatusAPIKeyActive},
+ {ID: 8, UserID: 42, Name: "钱包-claude-sonnet", GroupID: &gid3, Status: StatusAPIKeyActive},
+ },
+ }
+ groupRepo := walletGroupKeyGroupRepoStub{groups: map[int64]*Group{
+ 2: {ID: 2, Name: "gpt-5", Status: StatusActive},
+ 3: {ID: 3, Name: "claude-sonnet", Status: StatusActive},
+ 4: {ID: 4, Name: "gemini-2-pro", Status: StatusActive},
+ }}
+ svc := newWalletGroupKeyTestService(repo, groupRepo)
+
+ keys, createdCount, err := svc.EnsureWalletGroupKeys(context.Background(), 42, []int64{2, 3, 4})
+
+ require.NoError(t, err)
+ require.Equal(t, 1, createdCount)
+ require.Len(t, keys, 3)
+ require.Equal(t, int64(7), keys[0].ID, "应复用已存在的 GPT 钱包 key")
+ require.Equal(t, int64(8), keys[1].ID, "应复用已存在的 Sonnet 钱包 key")
+ require.Equal(t, "钱包-gemini-2-pro", keys[2].Name, "Gemini 之前缺,应新建")
+}
+
+func TestAPIKeyServiceEnsureWalletGroupKeysIgnoresNonWalletKeys(t *testing.T) {
+ // 已存在的 v3 普通 key(不带"钱包-"前缀)不应被复用为钱包 key
+ gid2 := int64(2)
+ repo := &walletGroupKeyAPIKeyRepoStub{
+ keys: []APIKey{
+ {ID: 9, UserID: 42, Name: "old-v3-gpt-key", GroupID: &gid2, Status: StatusAPIKeyActive},
+ },
+ }
+ groupRepo := walletGroupKeyGroupRepoStub{groups: map[int64]*Group{
+ 2: {ID: 2, Name: "gpt-5", Status: StatusActive},
+ }}
+ svc := newWalletGroupKeyTestService(repo, groupRepo)
+
+ keys, createdCount, err := svc.EnsureWalletGroupKeys(context.Background(), 42, []int64{2})
+
+ require.NoError(t, err)
+ require.Equal(t, 1, createdCount)
+ require.Len(t, keys, 1)
+ require.Equal(t, "钱包-gpt-5", keys[0].Name)
+ require.NotEqual(t, int64(9), keys[0].ID, "v3 普通 key 不应被复用")
+}
+
+func TestAPIKeyServiceEnsureWalletGroupKeysEmptyInput(t *testing.T) {
+ repo := &walletGroupKeyAPIKeyRepoStub{}
+ svc := newWalletGroupKeyTestService(repo, walletGroupKeyGroupRepoStub{})
+
+ keys, createdCount, err := svc.EnsureWalletGroupKeys(context.Background(), 42, nil)
+
+ require.NoError(t, err)
+ require.Equal(t, 0, createdCount)
+ require.Nil(t, keys)
+ require.Equal(t, 0, repo.listCalls)
+ require.Equal(t, 0, repo.createCalls)
+}
+
+// --------------------------------------------------------------------------
+// EnsureWalletUniversalKey 测试(5/14 反转决策:单 key 模式回归)
+// --------------------------------------------------------------------------
+
+func TestAPIKeyServiceEnsureWalletUniversalKeyCreatesForFreshUser(t *testing.T) {
+ repo := &walletGroupKeyAPIKeyRepoStub{}
+ svc := newWalletGroupKeyTestService(repo, walletGroupKeyGroupRepoStub{})
+
+ key, created, err := svc.EnsureWalletUniversalKey(context.Background(), 42)
+
+ require.NoError(t, err)
+ require.True(t, created, "fresh user 应新建 universal key")
+ require.NotNil(t, key)
+ require.Equal(t, WalletUniversalAPIKeyName, key.Name)
+ require.Equal(t, APIKeyPurposeWalletUniversal, key.Purpose)
+ require.Nil(t, key.GroupID, "universal key 的 group_id 必须为 NULL")
+ require.Equal(t, 1, repo.createCalls)
+}
+
+func TestAPIKeyServiceEnsureWalletUniversalKeyReusesExisting(t *testing.T) {
+ repo := &walletGroupKeyAPIKeyRepoStub{
+ keys: []APIKey{
+ {
+ ID: 55,
+ UserID: 42,
+ Name: WalletUniversalAPIKeyName,
+ Purpose: APIKeyPurposeWalletUniversal,
+ GroupID: nil,
+ Status: StatusAPIKeyActive,
+ },
+ },
+ }
+ svc := newWalletGroupKeyTestService(repo, walletGroupKeyGroupRepoStub{})
+
+ key, created, err := svc.EnsureWalletUniversalKey(context.Background(), 42)
+
+ require.NoError(t, err)
+ require.False(t, created, "存在 universal key 时应复用")
+ require.NotNil(t, key)
+ require.Equal(t, int64(55), key.ID)
+ require.Equal(t, 0, repo.createCalls)
+}
+
+// TestAPIKeyServiceEnsureWalletUniversalKeyIgnoresGroupBoundKeys 验证:
+// 用户名下绑 group 的 key(trial-bonus-auto 等)不会被当作 universal key 复用,
+// 系统会新建一把真正的 group_id=NULL key。这正是 5/14 报障用户的场景。
+func TestAPIKeyServiceEnsureWalletUniversalKeyIgnoresGroupBoundKeys(t *testing.T) {
+ groupID := int64(17) // trial-bonus group
+ repo := &walletGroupKeyAPIKeyRepoStub{
+ keys: []APIKey{
+ {
+ ID: 86,
+ UserID: 42,
+ Name: "trial-bonus-auto",
+ GroupID: &groupID,
+ Status: StatusAPIKeyActive,
+ },
+ },
+ }
+ svc := newWalletGroupKeyTestService(repo, walletGroupKeyGroupRepoStub{})
+
+ key, created, err := svc.EnsureWalletUniversalKey(context.Background(), 42)
+
+ require.NoError(t, err)
+ require.True(t, created, "绑 group 的老 key 不算 universal,应新建")
+ require.NotNil(t, key)
+ require.Nil(t, key.GroupID)
+ require.Equal(t, WalletUniversalAPIKeyName, key.Name)
+ require.Equal(t, 1, repo.createCalls)
+}
+
+func TestAPIKeyServiceCreateRejectsUserCreatedNullGroupKey(t *testing.T) {
+ repo := &walletGroupKeyAPIKeyRepoStub{}
+ svc := newWalletGroupKeyTestService(repo, walletGroupKeyGroupRepoStub{})
+
+ _, err := svc.Create(context.Background(), 42, CreateAPIKeyRequest{Name: "manual unscoped key"})
+
+ require.ErrorIs(t, err, ErrAPIKeyGroupRequired)
+ require.Zero(t, repo.createCalls)
+}
+
+func TestAPIKeyServiceCreateRejectsUnboundedActiveKeyGrowth(t *testing.T) {
+ keys := make([]APIKey, 50)
+ for i := range keys {
+ keys[i] = APIKey{ID: int64(i + 1), UserID: 42, Purpose: APIKeyPurposeStandard, Status: StatusActive}
+ }
+ repo := &walletGroupKeyAPIKeyRepoStub{keys: keys}
+ groupID := int64(2)
+ svc := newWalletGroupKeyTestService(repo, walletGroupKeyGroupRepoStub{groups: map[int64]*Group{
+ groupID: {ID: groupID, Name: "openai-default", Status: StatusActive},
+ }})
+
+ _, err := svc.Create(context.Background(), 42, CreateAPIKeyRequest{Name: "key-51", GroupID: &groupID})
+ require.Error(t, err)
+ require.Zero(t, repo.createCalls)
+}
+
+func TestAPIKeyServiceCreateEnforcesSuccessfulCreateRateLimit(t *testing.T) {
+ repo := &walletGroupKeyAPIKeyRepoStub{}
+ groupID := int64(2)
+ cache := &apiKeyCreateReservationCacheStub{
+ reservation: 21,
+ }
+ svc := NewAPIKeyService(
+ repo,
+ walletGroupKeyUserRepoStub{},
+ walletGroupKeyGroupRepoStub{groups: map[int64]*Group{
+ groupID: {ID: groupID, Name: "openai-default", Status: StatusActive},
+ }},
+ walletGroupKeyUserSubRepoStub{},
+ nil,
+ cache,
+ &config.Config{},
+ )
+
+ _, err := svc.Create(context.Background(), 42, CreateAPIKeyRequest{Name: "rate-limited", GroupID: &groupID})
+ require.ErrorIs(t, err, ErrAPIKeyRateLimited)
+ require.Zero(t, repo.createCalls)
+}
+
+func TestValidateWalletUniversalKeyMutationKeepsSystemIdentityImmutable(t *testing.T) {
+ newName := "renamed wallet key"
+ exactName := WalletUniversalAPIKeyName
+ groupID := int64(3)
+ exact := &APIKey{Name: WalletUniversalAPIKeyName, Purpose: APIKeyPurposeWalletUniversal, GroupID: nil}
+ legacy := &APIKey{Name: "manual null key", Purpose: APIKeyPurposeStandard, GroupID: nil}
+
+ require.ErrorIs(t, validateWalletUniversalKeyMutation(exact, UpdateAPIKeyRequest{Name: &newName}), ErrWalletUniversalKeyImmutable)
+ require.ErrorIs(t, validateWalletUniversalKeyMutation(exact, UpdateAPIKeyRequest{GroupID: &groupID}), ErrWalletUniversalKeyImmutable)
+ require.NoError(t, validateWalletUniversalKeyMutation(exact, UpdateAPIKeyRequest{Name: &exactName}))
+ require.ErrorIs(t, validateWalletUniversalKeyMutation(legacy, UpdateAPIKeyRequest{Name: &exactName}), ErrWalletUniversalKeyReserved)
+ require.NoError(t, validateWalletUniversalKeyMutation(legacy, UpdateAPIKeyRequest{GroupID: &groupID}))
+}
+
+// --------------------------------------------------------------------------
+// GetWalletModelRoutes 测试(B1.5 路由列表,保留作底层能力)
+// --------------------------------------------------------------------------
+
+type walletModelRouteGroupRepoStub struct {
+ GroupRepository
+
+ groups []Group
+}
+
+func (s walletModelRouteGroupRepoStub) ListActive(context.Context) ([]Group, error) {
+ out := make([]Group, len(s.groups))
+ copy(out, s.groups)
+ return out, nil
+}
+
+type walletModelRouteUserRateRepoStub struct {
+ UserGroupRateRepository
+
+ rates map[int64]float64
+}
+
+func (s walletModelRouteUserRateRepoStub) GetByUserID(context.Context, int64) (map[int64]float64, error) {
+ out := make(map[int64]float64, len(s.rates))
+ for groupID, rate := range s.rates {
+ out[groupID] = rate
+ }
+ return out, nil
+}
+
+func TestAPIKeyServiceGetWalletModelRoutesUsesConfiguredRoutesAndGroups(t *testing.T) {
+ groupRepo := walletModelRouteGroupRepoStub{groups: []Group{
+ {ID: 3, Name: WalletDefaultOpenAIGroupName, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, SubscriptionType: SubscriptionTypeStandard, RateMultiplier: 1.0},
+ {ID: 22, Name: WalletDefaultVIPGroupName, Platform: PlatformAnthropic, Status: StatusActive, Hydrated: true, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard, RateMultiplier: 1.5},
+ }}
+ rateRepo := walletModelRouteUserRateRepoStub{rates: map[int64]float64{22: 1.25}}
+ svc := NewAPIKeyService(nil, walletGroupKeyUserRepoStub{allowedGroups: []int64{22}}, groupRepo, nil, rateRepo, nil, &config.Config{})
+
+ routes, err := svc.GetWalletModelRoutes(context.Background(), 42, []ModelRoute{
+ {Pattern: "claude-sonnet-*", GroupName: WalletDefaultVIPGroupName, ExampleModel: "claude-sonnet-4-6"},
+ {Pattern: "gpt-*", GroupName: WalletDefaultOpenAIGroupName, ExampleModel: "gpt-5"},
+ {Pattern: "missing-*", GroupName: "missing", ExampleModel: "missing-model"},
+ })
+
+ require.NoError(t, err)
+ require.Len(t, routes, 2)
+ require.Equal(t, "claude-sonnet-*", routes[0].Pattern)
+ require.Equal(t, "claude-sonnet-4-6", routes[0].ExampleModel)
+ require.Equal(t, int64(22), routes[0].GroupID)
+ require.Equal(t, WalletDefaultVIPGroupName, routes[0].GroupName)
+ require.Equal(t, 1.5, routes[0].RateMultiplier)
+ require.Equal(t, 1.25, routes[0].EffectiveRateMultiplier)
+ require.Equal(t, "gpt-*", routes[1].Pattern)
+ require.Equal(t, int64(3), routes[1].GroupID)
+ require.Equal(t, 1.0, routes[1].EffectiveRateMultiplier)
+}
+
+func TestAPIKeyServiceGetWalletModelRoutesHidesVIPWithoutExplicitGrant(t *testing.T) {
+ groupRepo := walletModelRouteGroupRepoStub{groups: []Group{
+ {ID: 3, Name: WalletDefaultOpenAIGroupName, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, SubscriptionType: SubscriptionTypeStandard},
+ {ID: 22, Name: WalletDefaultVIPGroupName, Platform: PlatformAnthropic, Status: StatusActive, Hydrated: true, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard},
+ }}
+ svc := NewAPIKeyService(nil, walletGroupKeyUserRepoStub{}, groupRepo, nil, walletModelRouteUserRateRepoStub{}, nil, &config.Config{})
+
+ routes, err := svc.GetWalletModelRoutes(context.Background(), 42, DefaultModelRoutes())
+
+ require.NoError(t, err)
+ require.NotEmpty(t, routes)
+ for _, route := range routes {
+ require.Equal(t, WalletDefaultOpenAIGroupName, route.GroupName)
+ }
+}
diff --git a/backend/internal/service/auth_admin_ws_ticket.go b/backend/internal/service/auth_admin_ws_ticket.go
new file mode 100644
index 00000000000..747ce82c44b
--- /dev/null
+++ b/backend/internal/service/auth_admin_ws_ticket.go
@@ -0,0 +1,118 @@
+package service
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "strconv"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+const (
+ adminOpsWSTicketAudience = "sub2api-admin-ops-ws"
+ adminOpsWSTicketIssuer = "sub2api"
+ adminOpsWSTicketPurpose = "admin_ops_ws"
+ AdminOpsWSTicketTTL = 20 * time.Second
+)
+
+var adminOpsWSTicketKeyContext = []byte("sub2api/admin-ops-ws-ticket/v1")
+
+type AdminOpsWSTicketClaims struct {
+ UserID int64 `json:"user_id"`
+ TokenVersion int64 `json:"token_version"`
+ Purpose string `json:"purpose"`
+ jwt.RegisteredClaims
+}
+
+func deriveAdminOpsWSTicketSigningKey(secret string) []byte {
+ mac := hmac.New(sha256.New, []byte(secret))
+ _, _ = mac.Write(adminOpsWSTicketKeyContext)
+ return mac.Sum(nil)
+}
+
+// GenerateAdminOpsWSTicket creates a short-lived, purpose-bound credential for
+// the browser WebSocket handshake. A domain-separated signing key prevents the
+// ticket from being replayed as an ordinary admin access token.
+func (s *AuthService) GenerateAdminOpsWSTicket(user *User) (string, time.Time, error) {
+ if s == nil || s.cfg == nil || s.cfg.JWT.Secret == "" {
+ return "", time.Time{}, fmt.Errorf("admin ops websocket ticket signing is unavailable")
+ }
+ if user == nil || user.ID <= 0 || !user.IsActive() || !user.IsAdmin() {
+ return "", time.Time{}, ErrInvalidToken
+ }
+
+ now := time.Now().UTC()
+ expiresAt := now.Add(AdminOpsWSTicketTTL)
+ ticketID, err := randomHexString(16)
+ if err != nil {
+ return "", time.Time{}, fmt.Errorf("generate admin ops websocket ticket id: %w", err)
+ }
+
+ claims := &AdminOpsWSTicketClaims{
+ UserID: user.ID,
+ TokenVersion: resolvedTokenVersion(user),
+ Purpose: adminOpsWSTicketPurpose,
+ RegisteredClaims: jwt.RegisteredClaims{
+ Audience: jwt.ClaimStrings{adminOpsWSTicketAudience},
+ ExpiresAt: jwt.NewNumericDate(expiresAt),
+ ID: ticketID,
+ IssuedAt: jwt.NewNumericDate(now),
+ Issuer: adminOpsWSTicketIssuer,
+ NotBefore: jwt.NewNumericDate(now),
+ Subject: strconv.FormatInt(user.ID, 10),
+ },
+ }
+
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ signed, err := token.SignedString(deriveAdminOpsWSTicketSigningKey(s.cfg.JWT.Secret))
+ if err != nil {
+ return "", time.Time{}, fmt.Errorf("sign admin ops websocket ticket: %w", err)
+ }
+ return signed, expiresAt, nil
+}
+
+func (s *AuthService) ValidateAdminOpsWSTicket(tokenString string) (*AdminOpsWSTicketClaims, error) {
+ if s == nil || s.cfg == nil || s.cfg.JWT.Secret == "" || tokenString == "" {
+ return nil, ErrInvalidToken
+ }
+ if len(tokenString) > maxTokenLength {
+ return nil, ErrTokenTooLarge
+ }
+
+ claims := &AdminOpsWSTicketClaims{}
+ token, err := jwt.ParseWithClaims(
+ tokenString,
+ claims,
+ func(token *jwt.Token) (any, error) {
+ if token.Method != jwt.SigningMethodHS256 {
+ return nil, fmt.Errorf("unexpected signing method")
+ }
+ return deriveAdminOpsWSTicketSigningKey(s.cfg.JWT.Secret), nil
+ },
+ jwt.WithAudience(adminOpsWSTicketAudience),
+ jwt.WithExpirationRequired(),
+ jwt.WithIssuedAt(),
+ jwt.WithIssuer(adminOpsWSTicketIssuer),
+ jwt.WithLeeway(time.Second),
+ jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}),
+ )
+ if err != nil || !token.Valid {
+ if errors.Is(err, jwt.ErrTokenExpired) {
+ return nil, ErrTokenExpired
+ }
+ return nil, ErrInvalidToken
+ }
+ if claims.UserID <= 0 ||
+ claims.Purpose != adminOpsWSTicketPurpose ||
+ claims.ID == "" ||
+ claims.Subject != strconv.FormatInt(claims.UserID, 10) ||
+ claims.IssuedAt == nil ||
+ claims.ExpiresAt == nil ||
+ claims.ExpiresAt.Time.Sub(claims.IssuedAt.Time) > AdminOpsWSTicketTTL+time.Second {
+ return nil, ErrInvalidToken
+ }
+ return claims, nil
+}
diff --git a/backend/internal/service/auth_admin_ws_ticket_test.go b/backend/internal/service/auth_admin_ws_ticket_test.go
new file mode 100644
index 00000000000..264adb55f98
--- /dev/null
+++ b/backend/internal/service/auth_admin_ws_ticket_test.go
@@ -0,0 +1,87 @@
+//go:build unit
+
+package service
+
+import (
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/stretchr/testify/require"
+)
+
+func TestAdminOpsWSTicketIsPurposeBoundAndDomainSeparated(t *testing.T) {
+ cfg := &config.Config{JWT: config.JWTConfig{Secret: "unit-test-secret-that-is-long-enough", ExpireHour: 1}}
+ authService := NewAuthService(nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil)
+ admin := &User{
+ ID: 42,
+ Email: "admin@example.com",
+ Role: RoleAdmin,
+ Status: StatusActive,
+ TokenVersion: 7,
+ TokenVersionResolved: true,
+ }
+
+ ticket, expiresAt, err := authService.GenerateAdminOpsWSTicket(admin)
+ require.NoError(t, err)
+ require.NotEmpty(t, ticket)
+ require.WithinDuration(t, time.Now().Add(AdminOpsWSTicketTTL), expiresAt, 2*time.Second)
+
+ claims, err := authService.ValidateAdminOpsWSTicket(ticket)
+ require.NoError(t, err)
+ require.Equal(t, admin.ID, claims.UserID)
+ require.Equal(t, admin.TokenVersion, claims.TokenVersion)
+ require.Equal(t, adminOpsWSTicketPurpose, claims.Purpose)
+
+ _, err = authService.ValidateToken(ticket)
+ require.ErrorIs(t, err, ErrInvalidToken)
+
+ accessToken, err := authService.GenerateToken(admin)
+ require.NoError(t, err)
+ _, err = authService.ValidateAdminOpsWSTicket(accessToken)
+ require.ErrorIs(t, err, ErrInvalidToken)
+}
+
+func TestAdminOpsWSTicketRejectsExpiredAndWrongPurposeClaims(t *testing.T) {
+ cfg := &config.Config{JWT: config.JWTConfig{Secret: "unit-test-secret-that-is-long-enough", ExpireHour: 1}}
+ authService := NewAuthService(nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil)
+
+ sign := func(claims *AdminOpsWSTicketClaims) string {
+ t.Helper()
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ signed, err := token.SignedString(deriveAdminOpsWSTicketSigningKey(cfg.JWT.Secret))
+ require.NoError(t, err)
+ return signed
+ }
+
+ now := time.Now().UTC()
+ baseClaims := func() *AdminOpsWSTicketClaims {
+ return &AdminOpsWSTicketClaims{
+ UserID: 42,
+ TokenVersion: 7,
+ Purpose: adminOpsWSTicketPurpose,
+ RegisteredClaims: jwt.RegisteredClaims{
+ Audience: jwt.ClaimStrings{adminOpsWSTicketAudience},
+ ExpiresAt: jwt.NewNumericDate(now.Add(AdminOpsWSTicketTTL)),
+ ID: "ticket-id",
+ IssuedAt: jwt.NewNumericDate(now),
+ Issuer: adminOpsWSTicketIssuer,
+ NotBefore: jwt.NewNumericDate(now),
+ Subject: "42",
+ },
+ }
+ }
+
+ wrongPurpose := baseClaims()
+ wrongPurpose.Purpose = "admin_api"
+ _, err := authService.ValidateAdminOpsWSTicket(sign(wrongPurpose))
+ require.ErrorIs(t, err, ErrInvalidToken)
+
+ expired := baseClaims()
+ expired.IssuedAt = jwt.NewNumericDate(now.Add(-2 * time.Minute))
+ expired.NotBefore = jwt.NewNumericDate(now.Add(-2 * time.Minute))
+ expired.ExpiresAt = jwt.NewNumericDate(now.Add(-time.Minute))
+ _, err = authService.ValidateAdminOpsWSTicket(sign(expired))
+ require.ErrorIs(t, err, ErrTokenExpired)
+}
diff --git a/backend/internal/service/auth_email_oauth_auto.go b/backend/internal/service/auth_email_oauth_auto.go
new file mode 100644
index 00000000000..ec84e0626ce
--- /dev/null
+++ b/backend/internal/service/auth_email_oauth_auto.go
@@ -0,0 +1,290 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/mail"
+ "strings"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/authidentity"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
+)
+
+type EmailOAuthIdentityInput struct {
+ ProviderType string
+ ProviderKey string
+ ProviderSubject string
+ Email string
+ EmailVerified bool
+ Username string
+ DisplayName string
+ AvatarURL string
+ UpstreamMetadata map[string]any
+}
+
+// LoginOrRegisterVerifiedEmailOAuth is retained for service compatibility.
+// Deprecated: HTTP OAuth flows must use the browser-bound pending-auth flow so
+// a new provider subject cannot bind an existing local email without proof.
+func (s *AuthService) LoginOrRegisterVerifiedEmailOAuth(ctx context.Context, input EmailOAuthIdentityInput) (*TokenPair, *User, error) {
+ return s.LoginOrRegisterVerifiedEmailOAuthWithInvitation(ctx, input, "", "")
+}
+
+// LoginOrRegisterVerifiedEmailOAuthWithInvitation is retained for service
+// compatibility and preserves the same existing-email proof gate.
+// Deprecated: HTTP OAuth flows must use the browser-bound pending-auth flow.
+func (s *AuthService) LoginOrRegisterVerifiedEmailOAuthWithInvitation(
+ ctx context.Context,
+ input EmailOAuthIdentityInput,
+ invitationCode string,
+ affiliateCode string,
+) (*TokenPair, *User, error) {
+ user, err := s.resolveVerifiedEmailOAuth(ctx, input, invitationCode, affiliateCode)
+ if err != nil {
+ return nil, nil, err
+ }
+ s.RecordSuccessfulLogin(ctx, user.ID)
+ tokenPair, err := s.GenerateTokenPair(ctx, user, "")
+ if err != nil {
+ return nil, nil, fmt.Errorf("generate token pair: %w", err)
+ }
+ return tokenPair, user, nil
+}
+
+func (s *AuthService) ResolveVerifiedEmailOAuthWithInvitation(
+ ctx context.Context,
+ input EmailOAuthIdentityInput,
+ invitationCode string,
+ affiliateCode string,
+) (*User, error) {
+ return s.resolveVerifiedEmailOAuth(ctx, input, invitationCode, affiliateCode)
+}
+
+func (s *AuthService) resolveVerifiedEmailOAuth(
+ ctx context.Context,
+ input EmailOAuthIdentityInput,
+ invitationCode string,
+ affiliateCode string,
+) (*User, error) {
+ if s == nil || s.userRepo == nil || s.entClient == nil {
+ return nil, ErrServiceUnavailable
+ }
+
+ providerType := normalizeOAuthSignupSource(input.ProviderType)
+ if providerType != "github" && providerType != "google" {
+ return nil, infraerrors.BadRequest("OAUTH_PROVIDER_INVALID", "oauth provider is invalid")
+ }
+ providerKey := strings.TrimSpace(input.ProviderKey)
+ if providerKey == "" {
+ providerKey = providerType
+ }
+ providerSubject := strings.TrimSpace(input.ProviderSubject)
+ if providerSubject == "" {
+ return nil, infraerrors.BadRequest("OAUTH_SUBJECT_MISSING", "oauth subject is missing")
+ }
+ if !input.EmailVerified {
+ return nil, infraerrors.Forbidden("OAUTH_EMAIL_NOT_VERIFIED", "oauth email is not verified")
+ }
+
+ email := strings.TrimSpace(strings.ToLower(input.Email))
+ if email == "" || len(email) > 255 {
+ return nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
+ }
+ if _, err := mail.ParseAddress(email); err != nil {
+ return nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email")
+ }
+ if isReservedEmail(email) {
+ return nil, ErrEmailReserved
+ }
+ if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
+ return nil, err
+ }
+
+ identityUser, err := s.findEmailOAuthIdentityOwner(ctx, providerType, providerKey, providerSubject)
+ if err != nil {
+ return nil, err
+ }
+ if identityUser != nil && !strings.EqualFold(strings.TrimSpace(identityUser.Email), email) {
+ return nil, infraerrors.Conflict("AUTH_IDENTITY_EMAIL_MISMATCH", "oauth identity belongs to a different email")
+ }
+
+ user := identityUser
+ created := false
+ if user == nil {
+ user, err = s.userRepo.GetByEmail(ctx, email)
+ if err != nil {
+ if errors.Is(err, ErrUserNotFound) {
+ user, err = s.createEmailOAuthUser(ctx, email, input.Username, providerType, invitationCode, affiliateCode)
+ if err != nil {
+ return nil, err
+ }
+ created = true
+ } else {
+ logger.LegacyPrintf("service.auth", "[Auth] Database error during %s oauth login: %v", providerType, err)
+ return nil, ErrServiceUnavailable
+ }
+ } else {
+ return nil, ErrOAuthAccountLinkProofRequired
+ }
+ }
+
+ if !user.IsActive() {
+ return nil, ErrUserNotActive
+ }
+ if err := s.ensureEmailOAuthIdentity(ctx, user.ID, EmailOAuthIdentityInput{
+ ProviderType: providerType,
+ ProviderKey: providerKey,
+ ProviderSubject: providerSubject,
+ Email: email,
+ EmailVerified: input.EmailVerified,
+ Username: input.Username,
+ DisplayName: input.DisplayName,
+ AvatarURL: input.AvatarURL,
+ UpstreamMetadata: input.UpstreamMetadata,
+ }); err != nil {
+ return nil, err
+ }
+
+ if user.Username == "" && strings.TrimSpace(input.Username) != "" {
+ user.Username = strings.TrimSpace(input.Username)
+ if err := s.userRepo.Update(ctx, user); err != nil {
+ logger.LegacyPrintf("service.auth", "[Auth] Failed to update username after %s oauth login: %v", providerType, err)
+ }
+ }
+ if !created {
+ if err := s.ApplyProviderDefaultSettingsOnFirstBind(ctx, user.ID, providerType); err != nil {
+ logger.LegacyPrintf("service.auth", "[Auth] Failed to apply %s first bind defaults: %v", providerType, err)
+ }
+ }
+ return user, nil
+}
+
+func (s *AuthService) createEmailOAuthUser(ctx context.Context, email, username, providerType, invitationCode, affiliateCode string) (*User, error) {
+ if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
+ return nil, ErrRegDisabled
+ }
+ invitationRedeemCode, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode)
+ if err != nil {
+ if errors.Is(err, ErrInvitationCodeRequired) {
+ return nil, ErrOAuthInvitationRequired
+ }
+ return nil, err
+ }
+
+ randomPassword, err := randomHexString(32)
+ if err != nil {
+ return nil, ErrServiceUnavailable
+ }
+ hashedPassword, err := s.HashPassword(randomPassword)
+ if err != nil {
+ return nil, fmt.Errorf("hash password: %w", err)
+ }
+ grantPlan := s.resolveSignupGrantPlan(ctx, providerType)
+ var defaultRPMLimit int
+ if s.settingService != nil {
+ defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx)
+ }
+ user := &User{
+ Email: email,
+ Username: strings.TrimSpace(username),
+ PasswordHash: hashedPassword,
+ Role: RoleUser,
+ Balance: grantPlan.Balance,
+ Concurrency: grantPlan.Concurrency,
+ RPMLimit: defaultRPMLimit,
+ Status: StatusActive,
+ SignupSource: providerType,
+ }
+ if err := s.userRepo.Create(ctx, user); err != nil {
+ if errors.Is(err, ErrEmailExists) {
+ return nil, ErrOAuthAccountLinkProofRequired
+ }
+ return nil, ErrServiceUnavailable
+ }
+ s.postAuthUserBootstrap(ctx, user, providerType, false)
+ s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
+ s.bindOAuthAffiliate(ctx, user.ID, affiliateCode, user.SignupIPPrefix)
+ if invitationRedeemCode != nil {
+ if err := s.useOAuthRegistrationInvitation(ctx, invitationRedeemCode.ID, user.ID); err != nil {
+ _ = s.RollbackOAuthEmailAccountCreation(ctx, user.ID, invitationCode)
+ return nil, ErrInvitationCodeInvalid
+ }
+ }
+ return user, nil
+}
+
+func (s *AuthService) findEmailOAuthIdentityOwner(ctx context.Context, providerType, providerKey, providerSubject string) (*User, error) {
+ identity, err := s.entClient.AuthIdentity.Query().
+ Where(
+ authidentity.ProviderTypeEQ(providerType),
+ authidentity.ProviderKeyEQ(providerKey),
+ authidentity.ProviderSubjectEQ(providerSubject),
+ ).
+ Only(ctx)
+ if err != nil {
+ if dbent.IsNotFound(err) {
+ return nil, nil
+ }
+ return nil, infraerrors.InternalServer("AUTH_IDENTITY_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err)
+ }
+ user, err := s.userRepo.GetByID(ctx, identity.UserID)
+ if err != nil {
+ if errors.Is(err, ErrUserNotFound) {
+ return nil, nil
+ }
+ return nil, ErrServiceUnavailable
+ }
+ return user, nil
+}
+
+func (s *AuthService) ensureEmailOAuthIdentity(ctx context.Context, userID int64, input EmailOAuthIdentityInput) error {
+ metadata := map[string]any{
+ "email": strings.TrimSpace(strings.ToLower(input.Email)),
+ "email_verified": input.EmailVerified,
+ }
+ for key, value := range input.UpstreamMetadata {
+ metadata[key] = value
+ }
+ if strings.TrimSpace(input.Username) != "" {
+ metadata["username"] = strings.TrimSpace(input.Username)
+ }
+ if strings.TrimSpace(input.DisplayName) != "" {
+ metadata["display_name"] = strings.TrimSpace(input.DisplayName)
+ }
+ if strings.TrimSpace(input.AvatarURL) != "" {
+ metadata["avatar_url"] = strings.TrimSpace(input.AvatarURL)
+ }
+
+ providerType := normalizeOAuthSignupSource(input.ProviderType)
+ providerKey := strings.TrimSpace(input.ProviderKey)
+ providerSubject := strings.TrimSpace(input.ProviderSubject)
+ identity, err := s.entClient.AuthIdentity.Query().
+ Where(
+ authidentity.ProviderTypeEQ(providerType),
+ authidentity.ProviderKeyEQ(providerKey),
+ authidentity.ProviderSubjectEQ(providerSubject),
+ ).
+ Only(ctx)
+ if err != nil && !dbent.IsNotFound(err) {
+ return infraerrors.InternalServer("AUTH_IDENTITY_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err)
+ }
+ if identity != nil {
+ if identity.UserID != userID {
+ return infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user")
+ }
+ _, err = s.entClient.AuthIdentity.UpdateOneID(identity.ID).
+ SetMetadata(metadata).
+ Save(ctx)
+ return err
+ }
+ _, err = s.entClient.AuthIdentity.Create().
+ SetUserID(userID).
+ SetProviderType(providerType).
+ SetProviderKey(providerKey).
+ SetProviderSubject(providerSubject).
+ SetMetadata(metadata).
+ Save(ctx)
+ return err
+}
diff --git a/backend/internal/service/auth_email_oauth_auto_security_test.go b/backend/internal/service/auth_email_oauth_auto_security_test.go
new file mode 100644
index 00000000000..7008a55dfe9
--- /dev/null
+++ b/backend/internal/service/auth_email_oauth_auto_security_test.go
@@ -0,0 +1,36 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestCreateEmailOAuthUserRejectsConcurrentEmailCollision(t *testing.T) {
+ repo := &userRepoStub{
+ createErr: ErrEmailExists,
+ user: &User{
+ ID: 42,
+ Email: "victim@example.com",
+ Status: StatusActive,
+ },
+ }
+ svc := newAuthService(repo, map[string]string{
+ SettingKeyRegistrationEnabled: "true",
+ }, nil)
+
+ user, err := svc.createEmailOAuthUser(
+ context.Background(),
+ "victim@example.com",
+ "attacker-controlled-name",
+ "google",
+ "",
+ "",
+ )
+
+ require.Nil(t, user)
+ require.ErrorIs(t, err, ErrOAuthAccountLinkProofRequired)
+}
diff --git a/backend/internal/service/auth_oauth_email_flow.go b/backend/internal/service/auth_oauth_email_flow.go
index 9815f31be35..16feece7986 100644
--- a/backend/internal/service/auth_oauth_email_flow.go
+++ b/backend/internal/service/auth_oauth_email_flow.go
@@ -10,6 +10,7 @@ import (
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/redeemcode"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
func normalizeOAuthSignupSource(signupSource string) string {
@@ -17,7 +18,7 @@ func normalizeOAuthSignupSource(signupSource string) string {
switch signupSource {
case "", "email":
return "email"
- case "linuxdo", "wechat", "oidc":
+ case "linuxdo", "wechat", "oidc", "github", "google":
return signupSource
default:
return "email"
@@ -168,6 +169,87 @@ func (s *AuthService) RegisterOAuthEmailAccount(
return tokenPair, user, nil
}
+// RegisterVerifiedOAuthEmailAccount creates a local account from an OAuth
+// provider that has already returned a verified email address.
+func (s *AuthService) RegisterVerifiedOAuthEmailAccount(
+ ctx context.Context,
+ email string,
+ password string,
+ invitationCode string,
+ signupSource string,
+) (*TokenPair, *User, error) {
+ if s == nil {
+ return nil, nil, ErrServiceUnavailable
+ }
+ if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
+ return nil, nil, ErrRegDisabled
+ }
+
+ email = strings.TrimSpace(strings.ToLower(email))
+ if email == "" || len(email) > 255 {
+ return nil, nil, ErrEmailVerifyRequired
+ }
+ if _, err := mail.ParseAddress(email); err != nil {
+ return nil, nil, ErrEmailVerifyRequired
+ }
+ if isReservedEmail(email) {
+ return nil, nil, ErrEmailReserved
+ }
+ if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil {
+ return nil, nil, err
+ }
+ if strings.TrimSpace(password) == "" {
+ return nil, nil, infraerrors.BadRequest("PASSWORD_REQUIRED", "password is required")
+ }
+ if _, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode); err != nil {
+ return nil, nil, err
+ }
+
+ existsEmail, err := s.userRepo.ExistsByEmail(ctx, email)
+ if err != nil {
+ return nil, nil, ErrServiceUnavailable
+ }
+ if existsEmail {
+ return nil, nil, ErrEmailExists
+ }
+
+ hashedPassword, err := s.HashPassword(password)
+ if err != nil {
+ return nil, nil, fmt.Errorf("hash password: %w", err)
+ }
+
+ signupSource = normalizeOAuthSignupSource(signupSource)
+ grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
+ var defaultRPMLimit int
+ if s.settingService != nil {
+ defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx)
+ }
+ user := &User{
+ Email: email,
+ PasswordHash: hashedPassword,
+ Role: RoleUser,
+ Balance: grantPlan.Balance,
+ Concurrency: grantPlan.Concurrency,
+ RPMLimit: defaultRPMLimit,
+ Status: StatusActive,
+ SignupSource: signupSource,
+ }
+
+ if err := s.userRepo.Create(ctx, user); err != nil {
+ if errors.Is(err, ErrEmailExists) {
+ return nil, nil, ErrEmailExists
+ }
+ return nil, nil, ErrServiceUnavailable
+ }
+
+ tokenPair, err := s.GenerateTokenPair(ctx, user, "")
+ if err != nil {
+ _ = s.RollbackOAuthEmailAccountCreation(ctx, user.ID, "")
+ return nil, nil, fmt.Errorf("generate token pair: %w", err)
+ }
+ return tokenPair, user, nil
+}
+
// FinalizeOAuthEmailAccount applies invitation usage and normal signup bootstrap
// only after the pending OAuth flow has fully reached its last reversible step.
func (s *AuthService) FinalizeOAuthEmailAccount(
@@ -195,7 +277,7 @@ func (s *AuthService) FinalizeOAuthEmailAccount(
s.updateOAuthSignupSource(ctx, user.ID, signupSource)
grantPlan := s.resolveSignupGrantPlan(ctx, signupSource)
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
- s.bindOAuthAffiliate(ctx, user.ID, affiliateCode)
+ s.bindOAuthAffiliate(ctx, user.ID, affiliateCode, user.SignupIPPrefix)
return nil
}
diff --git a/backend/internal/service/auth_oauth_email_flow_test.go b/backend/internal/service/auth_oauth_email_flow_test.go
index 21d9d6e9342..1d71cb01d5e 100644
--- a/backend/internal/service/auth_oauth_email_flow_test.go
+++ b/backend/internal/service/auth_oauth_email_flow_test.go
@@ -63,6 +63,14 @@ func (s *redeemCodeRepoStub) Delete(context.Context, int64) error {
panic("unexpected Delete call")
}
+func (s *redeemCodeRepoStub) DeleteIfUnused(context.Context, int64) (bool, error) {
+ panic("unexpected DeleteIfUnused call")
+}
+
+func (s *redeemCodeRepoStub) ExpireIfUnused(context.Context, int64) (bool, error) {
+ panic("unexpected ExpireIfUnused call")
+}
+
func (s *redeemCodeRepoStub) Use(_ context.Context, id, userID int64) error {
for code, redeemCode := range s.codesByCode {
if redeemCode.ID != id {
@@ -229,6 +237,67 @@ func TestRegisterOAuthEmailAccountSetsNormalizedSignupSourceOnCreatedUser(t *tes
require.Equal(t, "oidc", userRepo.created[0].SignupSource)
}
+func TestRegisterOAuthEmailAccountKeepsGitHubAndGoogleSignupSource(t *testing.T) {
+ tests := []struct {
+ name string
+ email string
+ signupSource string
+ want string
+ }{
+ {
+ name: "github",
+ email: "github@example.com",
+ signupSource: " GitHub ",
+ want: "github",
+ },
+ {
+ name: "google",
+ email: "google@example.com",
+ signupSource: " Google ",
+ want: "google",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ userRepo := &userRepoStub{nextID: 43}
+ emailCache := &emailCacheStub{
+ data: &VerificationCodeData{
+ Code: "246810",
+ Attempts: 0,
+ CreatedAt: time.Now().UTC(),
+ ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
+ },
+ }
+ authService := newOAuthEmailFlowAuthService(
+ userRepo,
+ &redeemCodeRepoStub{},
+ &refreshTokenCacheStub{},
+ map[string]string{
+ SettingKeyRegistrationEnabled: "true",
+ SettingKeyEmailVerifyEnabled: "true",
+ },
+ emailCache,
+ )
+
+ tokenPair, user, err := authService.RegisterOAuthEmailAccount(
+ context.Background(),
+ tt.email,
+ "secret-123",
+ "246810",
+ "",
+ tt.signupSource,
+ )
+
+ require.NoError(t, err)
+ require.NotNil(t, tokenPair)
+ require.NotNil(t, user)
+ require.Len(t, userRepo.created, 1)
+ require.Equal(t, tt.want, userRepo.created[0].SignupSource)
+ })
+ }
+}
+
func TestRegisterOAuthEmailAccountFallsBackUnknownSignupSourceToEmail(t *testing.T) {
userRepo := &userRepoStub{nextID: 43}
emailCache := &emailCacheStub{
@@ -256,7 +325,7 @@ func TestRegisterOAuthEmailAccountFallsBackUnknownSignupSourceToEmail(t *testing
"secret-123",
"246810",
"",
- "github",
+ "unknown-provider",
)
require.NoError(t, err)
diff --git a/backend/internal/service/auth_refresh_rotation_security_test.go b/backend/internal/service/auth_refresh_rotation_security_test.go
new file mode 100644
index 00000000000..a56388915eb
--- /dev/null
+++ b/backend/internal/service/auth_refresh_rotation_security_test.go
@@ -0,0 +1,205 @@
+//go:build unit
+
+package service_test
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/service"
+ "github.com/stretchr/testify/require"
+)
+
+type refreshRotationBarrierCache struct {
+ mu sync.Mutex
+ tokens map[string]*service.RefreshTokenData
+ getCount int
+ bothRead chan struct{}
+ releaseRead chan struct{}
+}
+
+type refreshTokenIndexFailureCache struct {
+ *refreshRotationBarrierCache
+ userIndexErr error
+ familyIndexErr error
+}
+
+func (c *refreshTokenIndexFailureCache) AddToUserTokenSet(context.Context, int64, string, time.Duration) error {
+ return c.userIndexErr
+}
+
+func (c *refreshTokenIndexFailureCache) AddToFamilyTokenSet(context.Context, string, string, time.Duration) error {
+ return c.familyIndexErr
+}
+
+func newRefreshRotationBarrierCache() *refreshRotationBarrierCache {
+ return &refreshRotationBarrierCache{
+ tokens: make(map[string]*service.RefreshTokenData),
+ bothRead: make(chan struct{}),
+ releaseRead: make(chan struct{}),
+ }
+}
+
+func (c *refreshRotationBarrierCache) StoreRefreshToken(_ context.Context, tokenHash string, data *service.RefreshTokenData, _ time.Duration) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ cloned := *data
+ c.tokens[tokenHash] = &cloned
+ return nil
+}
+
+func (c *refreshRotationBarrierCache) GetRefreshToken(_ context.Context, tokenHash string) (*service.RefreshTokenData, error) {
+ c.mu.Lock()
+ data, ok := c.tokens[tokenHash]
+ if !ok {
+ c.mu.Unlock()
+ return nil, service.ErrRefreshTokenNotFound
+ }
+ cloned := *data
+ c.getCount++
+ if c.getCount == 2 {
+ close(c.bothRead)
+ }
+ c.mu.Unlock()
+
+ <-c.releaseRead
+ return &cloned, nil
+}
+
+func (c *refreshRotationBarrierCache) ConsumeRefreshToken(_ context.Context, tokenHash string) (*service.RefreshTokenData, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ data, ok := c.tokens[tokenHash]
+ if !ok {
+ return nil, service.ErrRefreshTokenNotFound
+ }
+ delete(c.tokens, tokenHash)
+ cloned := *data
+ return &cloned, nil
+}
+
+func (c *refreshRotationBarrierCache) DeleteRefreshToken(_ context.Context, tokenHash string) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ delete(c.tokens, tokenHash)
+ return nil
+}
+
+func (c *refreshRotationBarrierCache) DeleteUserRefreshTokens(context.Context, int64) error {
+ return nil
+}
+
+func (c *refreshRotationBarrierCache) DeleteTokenFamily(context.Context, string) error {
+ return nil
+}
+
+func (c *refreshRotationBarrierCache) AddToUserTokenSet(context.Context, int64, string, time.Duration) error {
+ return nil
+}
+
+func (c *refreshRotationBarrierCache) AddToFamilyTokenSet(context.Context, string, string, time.Duration) error {
+ return nil
+}
+
+func (c *refreshRotationBarrierCache) GetUserTokenHashes(context.Context, int64) ([]string, error) {
+ return nil, nil
+}
+
+func (c *refreshRotationBarrierCache) GetFamilyTokenHashes(context.Context, string) ([]string, error) {
+ return nil, nil
+}
+
+func (c *refreshRotationBarrierCache) IsTokenInFamily(context.Context, string, string) (bool, error) {
+ return false, nil
+}
+
+func TestRefreshTokenPairConsumesParentExactlyOnceUnderConcurrency(t *testing.T) {
+ ctx := context.Background()
+ cache := newRefreshRotationBarrierCache()
+ user := &service.User{
+ ID: 91,
+ Email: "refresh-race@example.com",
+ PasswordHash: "test-password-hash",
+ Role: service.RoleUser,
+ Status: service.StatusActive,
+ TokenVersion: 3,
+ TokenVersionResolved: true,
+ }
+ userRepo := newEmailBindUserRepoStub(user)
+ cfg := &config.Config{JWT: config.JWTConfig{
+ Secret: "test-refresh-race-secret",
+ ExpireHour: 1,
+ AccessTokenExpireMinutes: 60,
+ RefreshTokenExpireDays: 7,
+ }}
+ svc := service.NewAuthService(nil, userRepo, nil, cache, cfg, nil, nil, nil, nil, nil, nil, nil)
+
+ parent, err := svc.GenerateTokenPair(ctx, user, "")
+ require.NoError(t, err)
+
+ type result struct {
+ pair *service.TokenPairWithUser
+ err error
+ }
+ results := make(chan result, 2)
+ for range 2 {
+ go func() {
+ pair, refreshErr := svc.RefreshTokenPair(ctx, parent.RefreshToken)
+ results <- result{pair: pair, err: refreshErr}
+ }()
+ }
+
+ select {
+ case <-cache.bothRead:
+ case <-time.After(2 * time.Second):
+ t.Fatal("concurrent refresh calls did not both reach the vulnerable read boundary")
+ }
+ close(cache.releaseRead)
+
+ first := <-results
+ second := <-results
+ successes := 0
+ for _, got := range []result{first, second} {
+ if got.err == nil {
+ successes++
+ require.NotNil(t, got.pair)
+ require.NotEmpty(t, got.pair.AccessToken)
+ require.NotEmpty(t, got.pair.RefreshToken)
+ continue
+ }
+ require.True(t, errors.Is(got.err, service.ErrRefreshTokenReused) || errors.Is(got.err, service.ErrRefreshTokenInvalid))
+ }
+ require.Equal(t, 1, successes, "one parent refresh token must mint at most one child token pair")
+}
+
+func TestGenerateTokenPairRejectsAndCleansUpWhenRefreshTokenIndexingFails(t *testing.T) {
+ t.Parallel()
+
+ for name, indexFailure := range map[string]func(*refreshTokenIndexFailureCache){
+ "user index": func(cache *refreshTokenIndexFailureCache) {
+ cache.userIndexErr = errors.New("user index unavailable")
+ },
+ "family index": func(cache *refreshTokenIndexFailureCache) {
+ cache.familyIndexErr = errors.New("family index unavailable")
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ cache := &refreshTokenIndexFailureCache{refreshRotationBarrierCache: newRefreshRotationBarrierCache()}
+ indexFailure(cache)
+ user := &service.User{ID: 92, Email: "refresh-index@example.com", PasswordHash: "test-password-hash", Role: service.RoleUser, Status: service.StatusActive, TokenVersion: 1, TokenVersionResolved: true}
+ svc := service.NewAuthService(nil, newEmailBindUserRepoStub(user), nil, cache, &config.Config{JWT: config.JWTConfig{Secret: "test-refresh-index-secret", ExpireHour: 1, AccessTokenExpireMinutes: 60, RefreshTokenExpireDays: 7}}, nil, nil, nil, nil, nil, nil, nil)
+
+ pair, err := svc.GenerateTokenPair(context.Background(), user, "family-92")
+
+ require.Error(t, err)
+ require.Nil(t, pair)
+ cache.mu.Lock()
+ require.Empty(t, cache.tokens, "a token with incomplete revocation indexes must not remain usable")
+ cache.mu.Unlock()
+ })
+ }
+}
diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go
index b1adf071c47..71fef4e1b6b 100644
--- a/backend/internal/service/auth_service.go
+++ b/backend/internal/service/auth_service.go
@@ -9,40 +9,45 @@ import (
"errors"
"fmt"
"net/mail"
+ "net/netip"
"strconv"
"strings"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/authidentity"
+ "github.com/Wei-Shaw/sub2api/ent/predicate"
+ dbuser "github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
+ entsql "entgo.io/ent/dialect/sql"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
var (
- ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password")
- ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active")
- ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists")
- ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved")
- ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token")
- ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired")
- ErrAccessTokenExpired = infraerrors.Unauthorized("ACCESS_TOKEN_EXPIRED", "access token has expired")
- ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large")
- ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked")
- ErrRefreshTokenInvalid = infraerrors.Unauthorized("REFRESH_TOKEN_INVALID", "invalid refresh token")
- ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired")
- ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused")
- ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required")
- ErrEmailSuffixNotAllowed = infraerrors.BadRequest("EMAIL_SUFFIX_NOT_ALLOWED", "email suffix is not allowed")
- ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
- ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
- ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required")
- ErrInvitationCodeInvalid = infraerrors.BadRequest("INVITATION_CODE_INVALID", "invalid or used invitation code")
- ErrOAuthInvitationRequired = infraerrors.Forbidden("OAUTH_INVITATION_REQUIRED", "invitation code required to complete oauth registration")
+ ErrInvalidCredentials = infraerrors.Unauthorized("INVALID_CREDENTIALS", "invalid email or password")
+ ErrUserNotActive = infraerrors.Forbidden("USER_NOT_ACTIVE", "user is not active")
+ ErrEmailExists = infraerrors.Conflict("EMAIL_EXISTS", "email already exists")
+ ErrEmailReserved = infraerrors.BadRequest("EMAIL_RESERVED", "email is reserved")
+ ErrInvalidToken = infraerrors.Unauthorized("INVALID_TOKEN", "invalid token")
+ ErrTokenExpired = infraerrors.Unauthorized("TOKEN_EXPIRED", "token has expired")
+ ErrAccessTokenExpired = infraerrors.Unauthorized("ACCESS_TOKEN_EXPIRED", "access token has expired")
+ ErrTokenTooLarge = infraerrors.BadRequest("TOKEN_TOO_LARGE", "token too large")
+ ErrTokenRevoked = infraerrors.Unauthorized("TOKEN_REVOKED", "token has been revoked")
+ ErrRefreshTokenInvalid = infraerrors.Unauthorized("REFRESH_TOKEN_INVALID", "invalid refresh token")
+ ErrRefreshTokenExpired = infraerrors.Unauthorized("REFRESH_TOKEN_EXPIRED", "refresh token has expired")
+ ErrRefreshTokenReused = infraerrors.Unauthorized("REFRESH_TOKEN_REUSED", "refresh token has been reused")
+ ErrEmailVerifyRequired = infraerrors.BadRequest("EMAIL_VERIFY_REQUIRED", "email verification is required")
+ ErrEmailSuffixNotAllowed = infraerrors.BadRequest("EMAIL_SUFFIX_NOT_ALLOWED", "email suffix is not allowed")
+ ErrRegDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
+ ErrServiceUnavailable = infraerrors.ServiceUnavailable("SERVICE_UNAVAILABLE", "service temporarily unavailable")
+ ErrInvitationCodeRequired = infraerrors.BadRequest("INVITATION_CODE_REQUIRED", "invitation code is required")
+ ErrInvitationCodeInvalid = infraerrors.BadRequest("INVITATION_CODE_INVALID", "invalid or used invitation code")
+ ErrOAuthInvitationRequired = infraerrors.Forbidden("OAUTH_INVITATION_REQUIRED", "invitation code required to complete oauth registration")
+ ErrOAuthAccountLinkProofRequired = infraerrors.Conflict("OAUTH_ACCOUNT_LINK_PROOF_REQUIRED", "existing account must be authenticated before linking oauth identity")
)
// maxTokenLength 限制 token 大小,避免超长 header 触发解析时的异常内存分配。
@@ -74,6 +79,7 @@ type AuthService struct {
promoService *PromoService
affiliateService *AffiliateService
defaultSubAssigner DefaultSubscriptionAssigner
+ abuseRiskRecorder HFCAbuseRiskRecorder
}
type DefaultSubscriptionAssigner interface {
@@ -86,6 +92,34 @@ type signupGrantPlan struct {
Subscriptions []DefaultSubscriptionSetting
}
+type RegistrationRiskInput struct {
+ ClientIP string
+ UserAgent string
+ DeviceFingerprint string
+}
+
+type signupRiskSnapshot struct {
+ SignupIP string
+ SignupIPPrefix string
+ SignupUserAgentHash string
+ SignupDeviceFingerprintHash string
+ TrialBonusEligible bool
+ TrialBonusHoldReason string
+ TrialBonusRiskScore int
+}
+
+const (
+ signupIPPrefix24hTrialLimit = 3
+ signupDeviceFingerprintTrialLimit = 1
+ signupRiskWindow = 24 * time.Hour
+ maxSignupUserAgentSignalBytes = 2048
+ maxSignupFingerprintSignalBytes = 512
+)
+
+// A valid bcrypt hash used only to keep unknown-email login failures on the
+// same password-verification path as wrong-password failures.
+const invalidLoginPasswordHash = "$2y$10$BHZrBKt0JNlUtxg8olLx3eBoEyUgNDMwh95sfmUij7aDRXUJ39M/C"
+
// NewAuthService 创建认证服务实例
func NewAuthService(
entClient *dbent.Client,
@@ -124,6 +158,13 @@ func (s *AuthService) EntClient() *dbent.Client {
return s.entClient
}
+func (s *AuthService) SetHFCAbuseRiskRecorder(recorder HFCAbuseRiskRecorder) {
+ if s == nil {
+ return
+ }
+ s.abuseRiskRecorder = recorder
+}
+
// Register 用户注册,返回token和用户
func (s *AuthService) Register(ctx context.Context, email, password string) (string, *User, error) {
return s.RegisterWithVerification(ctx, email, password, "", "", "", "")
@@ -131,6 +172,12 @@ func (s *AuthService) Register(ctx context.Context, email, password string) (str
// RegisterWithVerification 用户注册(支持邮件验证、优惠码、邀请码和邀请返利码),返回token和用户。
func (s *AuthService) RegisterWithVerification(ctx context.Context, email, password, verifyCode, promoCode, invitationCode, affiliateCode string) (string, *User, error) {
+ return s.RegisterWithVerificationAndRisk(ctx, email, password, verifyCode, promoCode, invitationCode, affiliateCode, RegistrationRiskInput{})
+}
+
+// RegisterWithVerificationAndRisk 用户注册,并记录注册时刻的风控信号。
+// 风控命中只会影响 trial bonus 发放资格,不会锁号或阻断正常注册。
+func (s *AuthService) RegisterWithVerificationAndRisk(ctx context.Context, email, password, verifyCode, promoCode, invitationCode, affiliateCode string, riskInput RegistrationRiskInput) (string, *User, error) {
// 检查是否开放注册(默认关闭:settingService 未配置时不允许注册)
if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) {
return "", nil, ErrRegDisabled
@@ -204,16 +251,25 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
if s.settingService != nil {
defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx)
}
+ signupRisk := s.evaluateSignupRisk(ctx, riskInput)
// 创建用户
user := &User{
- Email: email,
- PasswordHash: hashedPassword,
- Role: RoleUser,
- Balance: grantPlan.Balance,
- Concurrency: grantPlan.Concurrency,
- RPMLimit: defaultRPMLimit,
- Status: StatusActive,
+ Email: email,
+ PasswordHash: hashedPassword,
+ Role: RoleUser,
+ Balance: grantPlan.Balance,
+ Concurrency: grantPlan.Concurrency,
+ RPMLimit: defaultRPMLimit,
+ Status: StatusActive,
+ SignupIP: signupRisk.SignupIP,
+ SignupIPPrefix: signupRisk.SignupIPPrefix,
+ SignupUserAgentHash: signupRisk.SignupUserAgentHash,
+ SignupDeviceFingerprintHash: signupRisk.SignupDeviceFingerprintHash,
+ SignupRiskRecorded: true,
+ TrialBonusEligible: signupRisk.TrialBonusEligible,
+ TrialBonusHoldReason: signupRisk.TrialBonusHoldReason,
+ TrialBonusRiskScore: signupRisk.TrialBonusRiskScore,
}
if err := s.userRepo.Create(ctx, user); err != nil {
@@ -224,6 +280,7 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
logger.LegacyPrintf("service.auth", "[Auth] Database error creating user: %v", err)
return "", nil, ErrServiceUnavailable
}
+ s.notifySignupRiskTelegramAlert(user, signupRisk)
s.postAuthUserBootstrap(ctx, user, "email", true)
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
if s.affiliateService != nil {
@@ -231,7 +288,7 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
logger.LegacyPrintf("service.auth", "[Auth] Failed to initialize affiliate profile for user %d: %v", user.ID, err)
}
if code := strings.TrimSpace(affiliateCode); code != "" {
- if err := s.affiliateService.BindInviterByCode(ctx, user.ID, code); err != nil {
+ if err := s.affiliateService.BindInviterByCode(ctx, user.ID, code, signupRisk.SignupIPPrefix); err != nil {
// 邀请返利码绑定失败不影响注册,只记录日志
logger.LegacyPrintf("service.auth", "[Auth] Failed to bind affiliate inviter for user %d: %v", user.ID, err)
}
@@ -267,6 +324,188 @@ func (s *AuthService) RegisterWithVerification(ctx context.Context, email, passw
return token, user, nil
}
+func (s *AuthService) notifySignupRiskTelegramAlert(user *User, risk signupRiskSnapshot) {
+ if s == nil || user == nil || risk.TrialBonusEligible || risk.TrialBonusRiskScore <= 0 || s.settingService == nil || s.settingService.settingRepo == nil {
+ return
+ }
+ evidence := []string{
+ "hold_reason=" + risk.TrialBonusHoldReason,
+ }
+ if risk.SignupIPPrefix != "" {
+ evidence = append(evidence, "signup_ip_prefix="+risk.SignupIPPrefix)
+ }
+ if risk.SignupDeviceFingerprintHash != "" {
+ evidence = append(evidence, "device_fingerprint_hash="+truncateMiddle(risk.SignupDeviceFingerprintHash, 8, 8))
+ }
+ if risk.SignupUserAgentHash != "" {
+ evidence = append(evidence, "user_agent_hash="+truncateMiddle(risk.SignupUserAgentHash, 8, 8))
+ }
+ alert := HFCAbuseRiskTelegramAlert{
+ Source: "signup_risk",
+ Severity: signupRiskTelegramSeverity(risk.TrialBonusRiskScore),
+ Summary: "注册风控命中,已保留正常注册,仅暂停 trial bonus 资格",
+ UserID: user.ID,
+ UserEmail: user.Email,
+ RiskScore: risk.TrialBonusRiskScore,
+ SignupIPPrefix: risk.SignupIPPrefix,
+ DeviceFingerprintHash: risk.SignupDeviceFingerprintHash,
+ Evidence: evidence,
+ Action: "registration allowed; trial bonus held; manual review if abuse continues",
+ OccurredAt: time.Now(),
+ }
+ if s.abuseRiskRecorder == nil {
+ DispatchHFCAbuseRiskTelegramAlert(s.settingService.settingRepo, alert)
+ return
+ }
+ recorder := s.abuseRiskRecorder
+ settingRepo := s.settingService.settingRepo
+ go func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ event, err := recorder.RecordEvent(ctx, &HFCAbuseRiskEvent{
+ Source: HFCAbuseRiskSourceSignupRisk,
+ Severity: alert.Severity,
+ Status: HFCAbuseRiskStatusOpen,
+ UserID: &user.ID,
+ UserEmail: user.Email,
+ RiskScore: risk.TrialBonusRiskScore,
+ SignupIPPrefix: risk.SignupIPPrefix,
+ DeviceFingerprintHash: risk.SignupDeviceFingerprintHash,
+ SignupUserAgentHash: risk.SignupUserAgentHash,
+ Summary: alert.Summary,
+ Evidence: []HFCAbuseRiskEvidence{
+ hfcRiskEvidence("hold_reason", "暂停 trial bonus 原因", risk.TrialBonusHoldReason),
+ hfcRiskEvidence("signup_ip_prefix", "注册 IP 段", risk.SignupIPPrefix),
+ hfcRiskEvidence("device_fingerprint_hash", "设备指纹 hash", truncateMiddle(risk.SignupDeviceFingerprintHash, 8, 8)),
+ hfcRiskEvidence("user_agent_hash", "User-Agent hash", truncateMiddle(risk.SignupUserAgentHash, 8, 8)),
+ },
+ })
+ if err != nil {
+ logHFCAbuseRiskRecordFailure(HFCAbuseRiskSourceSignupRisk, user.ID, 0, err)
+ }
+ if event != nil && event.ID > 0 {
+ alert.EventID = event.ID
+ }
+ DispatchHFCAbuseRiskTelegramAlert(settingRepo, alert)
+ }()
+}
+
+func signupRiskTelegramSeverity(score int) string {
+ if score >= 120 {
+ return HFCAbuseRiskSeverityCritical
+ }
+ if score >= 80 {
+ return HFCAbuseRiskSeverityHigh
+ }
+ if score >= 50 {
+ return HFCAbuseRiskSeverityMedium
+ }
+ return HFCAbuseRiskSeverityLow
+}
+
+func (s *AuthService) evaluateSignupRisk(ctx context.Context, input RegistrationRiskInput) signupRiskSnapshot {
+ snapshot := signupRiskSnapshot{TrialBonusEligible: true}
+
+ snapshot.SignupIP = normalizeSignupClientIP(input.ClientIP)
+ snapshot.SignupIPPrefix = signupIPPrefix(snapshot.SignupIP)
+ snapshot.SignupUserAgentHash = hashSignupSignal(input.UserAgent, maxSignupUserAgentSignalBytes)
+ snapshot.SignupDeviceFingerprintHash = hashSignupSignal(input.DeviceFingerprint, maxSignupFingerprintSignalBytes)
+
+ var holdReasons []string
+ if snapshot.SignupIPPrefix != "" {
+ count := s.countRecentSignupSignal(ctx, "signup_ip_prefix", snapshot.SignupIPPrefix)
+ if count >= signupIPPrefix24hTrialLimit {
+ holdReasons = append(holdReasons, "signup_ip_prefix_24h_cap")
+ snapshot.TrialBonusRiskScore += 50
+ }
+ }
+ if snapshot.SignupDeviceFingerprintHash != "" {
+ count := s.countRecentSignupSignal(ctx, "signup_device_fingerprint_hash", snapshot.SignupDeviceFingerprintHash)
+ if count >= signupDeviceFingerprintTrialLimit {
+ holdReasons = append(holdReasons, "duplicate_device_fingerprint")
+ snapshot.TrialBonusRiskScore += 80
+ }
+ }
+
+ if len(holdReasons) > 0 {
+ snapshot.TrialBonusEligible = false
+ snapshot.TrialBonusHoldReason = strings.Join(holdReasons, ",")
+ }
+ return snapshot
+}
+
+func (s *AuthService) countRecentSignupSignal(ctx context.Context, column, value string) int {
+ if s == nil || s.entClient == nil || strings.TrimSpace(value) == "" {
+ return 0
+ }
+ switch column {
+ case "signup_ip_prefix", "signup_device_fingerprint_hash":
+ default:
+ return 0
+ }
+
+ cutoff := time.Now().Add(-signupRiskWindow)
+ count, err := s.entClient.User.Query().
+ Where(
+ dbuser.DeletedAtIsNil(),
+ dbuser.RoleEQ(RoleUser),
+ dbuser.CreatedAtGTE(cutoff),
+ predicate.User(func(selector *entsql.Selector) {
+ selector.Where(entsql.P(func(b *entsql.Builder) {
+ b.Ident(selector.C(column)).
+ WriteString(" = ").
+ Arg(value)
+ }))
+ }),
+ ).
+ Count(ctx)
+ if err != nil {
+ logger.LegacyPrintf("service.auth", "[Auth] Failed to count signup risk signal column=%s: %v", column, err)
+ return 0
+ }
+ return count
+}
+
+func normalizeSignupClientIP(value string) string {
+ raw := strings.TrimSpace(value)
+ if raw == "" {
+ return ""
+ }
+ addr, err := netip.ParseAddr(raw)
+ if err != nil {
+ return ""
+ }
+ return addr.Unmap().String()
+}
+
+func signupIPPrefix(value string) string {
+ addr, err := netip.ParseAddr(strings.TrimSpace(value))
+ if err != nil {
+ return ""
+ }
+ addr = addr.Unmap()
+ if addr.Is4() {
+ return netip.PrefixFrom(addr, 24).Masked().String()
+ }
+ if addr.Is6() {
+ return netip.PrefixFrom(addr, 64).Masked().String()
+ }
+ return ""
+}
+
+func hashSignupSignal(value string, maxBytes int) string {
+ normalized := strings.TrimSpace(value)
+ if normalized == "" {
+ return ""
+ }
+ if maxBytes > 0 && len(normalized) > maxBytes {
+ normalized = normalized[:maxBytes]
+ }
+ sum := sha256.Sum256([]byte(normalized))
+ return hex.EncodeToString(sum[:])
+}
+
// SendVerifyCodeResult 发送验证码返回结果
type SendVerifyCodeResult struct {
Countdown int `json:"countdown"` // 倒计时秒数
@@ -334,8 +573,11 @@ func (s *AuthService) SendVerifyCodeAsync(ctx context.Context, email string) (*S
return nil, ErrServiceUnavailable
}
if existsEmail {
- logger.LegacyPrintf("service.auth", "[Auth] Email already exists: %s", email)
- return nil, ErrEmailExists
+ // Keep the public response indistinguishable from a request for an
+ // unregistered address. Do not enqueue a registration code for an
+ // existing account.
+ logger.LegacyPrintf("service.auth", "%s", "[Auth] Verify code request accepted without enqueue")
+ return &SendVerifyCodeResult{Countdown: 60}, nil
}
// 检查邮件队列服务是否配置
@@ -436,6 +678,7 @@ func (s *AuthService) Login(ctx context.Context, email, password string) (string
user, err := s.userRepo.GetByEmail(ctx, email)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
+ _ = s.CheckPassword(password, invalidLoginPasswordHash)
return "", nil, ErrInvalidCredentials
}
// 记录数据库错误但不暴露给用户
@@ -667,7 +910,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
user = newUser
s.postAuthUserBootstrap(ctx, user, signupSource, false)
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
- s.bindOAuthAffiliate(ctx, user.ID, affiliateCode)
+ s.bindOAuthAffiliate(ctx, user.ID, affiliateCode, newUser.SignupIPPrefix)
}
} else {
if err := s.userRepo.Create(ctx, newUser); err != nil {
@@ -685,7 +928,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema
user = newUser
s.postAuthUserBootstrap(ctx, user, signupSource, false)
s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults")
- s.bindOAuthAffiliate(ctx, user.ID, affiliateCode)
+ s.bindOAuthAffiliate(ctx, user.ID, affiliateCode, newUser.SignupIPPrefix)
if invitationRedeemCode != nil {
if err := s.redeemRepo.Use(ctx, invitationRedeemCode.ID, user.ID); err != nil {
return nil, nil, ErrInvitationCodeInvalid
@@ -775,6 +1018,10 @@ func authSourceSignupSettings(defaults *AuthSourceDefaultSettings, signupSource
return defaults.OIDC, true
case "wechat":
return defaults.WeChat, true
+ case "github":
+ return defaults.GitHub, true
+ case "google":
+ return defaults.Google, true
default:
return ProviderDefaultGrantSettings{}, false
}
@@ -782,7 +1029,7 @@ func authSourceSignupSettings(defaults *AuthSourceDefaultSettings, signupSource
// bindOAuthAffiliate initializes the affiliate profile and binds the inviter
// for an OAuth-registered user. Failures are logged but never block registration.
-func (s *AuthService) bindOAuthAffiliate(ctx context.Context, userID int64, affiliateCode string) {
+func (s *AuthService) bindOAuthAffiliate(ctx context.Context, userID int64, affiliateCode, signupIPPrefix string) {
if s.affiliateService == nil || userID <= 0 {
return
}
@@ -790,7 +1037,7 @@ func (s *AuthService) bindOAuthAffiliate(ctx context.Context, userID int64, affi
logger.LegacyPrintf("service.auth", "[Auth] Failed to initialize affiliate profile for user %d: %v", userID, err)
}
if code := strings.TrimSpace(affiliateCode); code != "" {
- if err := s.affiliateService.BindInviterByCode(ctx, userID, code); err != nil {
+ if err := s.affiliateService.BindInviterByCode(ctx, userID, code, signupIPPrefix); err != nil {
logger.LegacyPrintf("service.auth", "[Auth] Failed to bind affiliate inviter for user %d: %v", userID, err)
}
}
@@ -1408,19 +1655,24 @@ func (s *AuthService) generateRefreshToken(ctx context.Context, user *User, fami
// 添加到用户Token集合
if err := s.refreshTokenCache.AddToUserTokenSet(ctx, user.ID, tokenHash, ttl); err != nil {
- logger.LegacyPrintf("service.auth", "[Auth] Failed to add token to user set: %v", err)
- // 不影响主流程
+ return "", s.rejectRefreshTokenWithIncompleteIndexes(ctx, tokenHash, "user", err)
}
// 添加到家族Token集合
if err := s.refreshTokenCache.AddToFamilyTokenSet(ctx, familyID, tokenHash, ttl); err != nil {
- logger.LegacyPrintf("service.auth", "[Auth] Failed to add token to family set: %v", err)
- // 不影响主流程
+ return "", s.rejectRefreshTokenWithIncompleteIndexes(ctx, tokenHash, "family", err)
}
return rawToken, nil
}
+func (s *AuthService) rejectRefreshTokenWithIncompleteIndexes(ctx context.Context, tokenHash, indexName string, indexErr error) error {
+ if cleanupErr := s.refreshTokenCache.DeleteRefreshToken(ctx, tokenHash); cleanupErr != nil {
+ return fmt.Errorf("store refresh token %s index: %w (cleanup token: %v)", indexName, indexErr, cleanupErr)
+ }
+ return fmt.Errorf("store refresh token %s index: %w", indexName, indexErr)
+}
+
// RefreshTokenPair 使用Refresh Token刷新Token对
// 实现Token轮转:每次刷新都会生成新的Refresh Token,旧Token立即失效
func (s *AuthService) RefreshTokenPair(ctx context.Context, refreshToken string) (*TokenPairWithUser, error) {
@@ -1481,10 +1733,24 @@ func (s *AuthService) RefreshTokenPair(ctx context.Context, refreshToken string)
return nil, ErrTokenRevoked
}
- // Token轮转:立即使旧Token失效
- if err := s.refreshTokenCache.DeleteRefreshToken(ctx, tokenHash); err != nil {
- logger.LegacyPrintf("service.auth", "[Auth] Failed to delete old refresh token: %v", err)
- // 继续处理,不影响主流程
+ // Atomically consume the parent only after all ordinary checks pass. A
+ // separate GET+DEL allows two concurrent callers to mint descendants from
+ // the same one-time credential.
+ consumed, err := s.refreshTokenCache.ConsumeRefreshToken(ctx, tokenHash)
+ if err != nil {
+ if errors.Is(err, ErrRefreshTokenNotFound) {
+ _ = s.refreshTokenCache.DeleteTokenFamily(ctx, data.FamilyID)
+ return nil, ErrRefreshTokenReused
+ }
+ logger.LegacyPrintf("service.auth", "[Auth] Failed to atomically consume refresh token: %v", err)
+ return nil, ErrServiceUnavailable
+ }
+ if !sameRefreshTokenData(data, consumed) {
+ _ = s.refreshTokenCache.DeleteTokenFamily(ctx, data.FamilyID)
+ if consumed.FamilyID != data.FamilyID {
+ _ = s.refreshTokenCache.DeleteTokenFamily(ctx, consumed.FamilyID)
+ }
+ return nil, ErrRefreshTokenInvalid
}
// 生成新的Token对,保持同一个家族ID
@@ -1559,3 +1825,14 @@ func resolvedTokenVersion(user *User) int64 {
fingerprint := int64(binary.BigEndian.Uint64(sum[:8]) & 0x7fffffffffffffff)
return user.TokenVersion ^ fingerprint
}
+
+func sameRefreshTokenData(expected, consumed *RefreshTokenData) bool {
+ if expected == nil || consumed == nil {
+ return false
+ }
+ return expected.UserID == consumed.UserID &&
+ expected.TokenVersion == consumed.TokenVersion &&
+ expected.FamilyID == consumed.FamilyID &&
+ expected.CreatedAt.Equal(consumed.CreatedAt) &&
+ expected.ExpiresAt.Equal(consumed.ExpiresAt)
+}
diff --git a/backend/internal/service/auth_service_email_bind_test.go b/backend/internal/service/auth_service_email_bind_test.go
index ea2308f78e9..a864582877c 100644
--- a/backend/internal/service/auth_service_email_bind_test.go
+++ b/backend/internal/service/auth_service_email_bind_test.go
@@ -34,7 +34,8 @@ func (s *emailBindDefaultSubAssignerStub) AssignOrExtendSubscription(
) (*service.UserSubscription, bool, error) {
cloned := *input
s.calls = append(s.calls, &cloned)
- return &service.UserSubscription{UserID: input.UserID, GroupID: input.GroupID}, false, nil
+ gid := input.GroupID
+ return &service.UserSubscription{UserID: input.UserID, GroupID: &gid}, false, nil
}
type flakyEmailBindDefaultSubAssignerStub struct {
@@ -629,6 +630,24 @@ func (s *emailBindRefreshTokenCacheStub) GetRefreshToken(_ context.Context, toke
return &cloned, nil
}
+func (s *emailBindRefreshTokenCacheStub) ConsumeRefreshToken(_ context.Context, tokenHash string) (*service.RefreshTokenData, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ data, ok := s.tokens[tokenHash]
+ if !ok {
+ return nil, service.ErrRefreshTokenNotFound
+ }
+ delete(s.tokens, tokenHash)
+ for _, tokenSet := range s.userSets {
+ delete(tokenSet, tokenHash)
+ }
+ for _, tokenSet := range s.families {
+ delete(tokenSet, tokenHash)
+ }
+ cloned := *data
+ return &cloned, nil
+}
+
func (s *emailBindRefreshTokenCacheStub) DeleteRefreshToken(_ context.Context, tokenHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -810,8 +829,8 @@ func (s *emailBindUserRepoStub) UpdateUserLastActiveAt(context.Context, int64, t
}
func (s *emailBindUserRepoStub) UpdateBalance(context.Context, int64, float64) error { return nil }
-func (s *emailBindUserRepoStub) DeductBalance(context.Context, int64, float64) error { return nil }
-func (s *emailBindUserRepoStub) UpdateConcurrency(context.Context, int64, int) error { return nil }
+func (s *emailBindUserRepoStub) DeductBalance(context.Context, int64, float64) error { return nil }
+func (s *emailBindUserRepoStub) UpdateConcurrency(context.Context, int64, int) error { return nil }
func (s *emailBindUserRepoStub) ExistsByEmail(_ context.Context, email string) (bool, error) {
s.mu.Lock()
@@ -820,6 +839,13 @@ func (s *emailBindUserRepoStub) ExistsByEmail(_ context.Context, email string) (
return ok, nil
}
+func (s *emailBindUserRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) {
+ return 0, nil
+}
+func (s *emailBindUserRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
+ return 0, nil
+}
+
func (s *emailBindUserRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
return 0, nil
}
diff --git a/backend/internal/service/auth_service_identity_sync_test.go b/backend/internal/service/auth_service_identity_sync_test.go
index 53048b92c56..1d375648f09 100644
--- a/backend/internal/service/auth_service_identity_sync_test.go
+++ b/backend/internal/service/auth_service_identity_sync_test.go
@@ -32,7 +32,8 @@ func (s *authIdentityDefaultSubAssignerStub) AssignOrExtendSubscription(
) (*service.UserSubscription, bool, error) {
cloned := *input
s.calls = append(s.calls, &cloned)
- return &service.UserSubscription{UserID: input.UserID, GroupID: input.GroupID}, true, nil
+ gid := input.GroupID
+ return &service.UserSubscription{UserID: input.UserID, GroupID: &gid}, true, nil
}
type flakyAuthIdentityDefaultSubAssignerStub struct {
@@ -50,7 +51,8 @@ func (s *flakyAuthIdentityDefaultSubAssignerStub) AssignOrExtendSubscription(
s.failuresRemaining--
return nil, false, errors.New("temporary assign failure")
}
- return &service.UserSubscription{UserID: input.UserID, GroupID: input.GroupID}, true, nil
+ gid := input.GroupID
+ return &service.UserSubscription{UserID: input.UserID, GroupID: &gid}, true, nil
}
type authIdentitySettingRepoStub struct {
diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go
index acc44a381a8..d10c740a43e 100644
--- a/backend/internal/service/auth_service_register_test.go
+++ b/backend/internal/service/auth_service_register_test.go
@@ -80,7 +80,8 @@ func (s *defaultSubscriptionAssignerStub) AssignOrExtendSubscription(_ context.C
if s.err != nil {
return nil, false, s.err
}
- return &UserSubscription{UserID: input.UserID, GroupID: input.GroupID}, false, nil
+ gid := input.GroupID
+ return &UserSubscription{UserID: input.UserID, GroupID: &gid}, false, nil
}
func (s *refreshTokenCacheStub) StoreRefreshToken(context.Context, string, *RefreshTokenData, time.Duration) error {
@@ -91,6 +92,10 @@ func (s *refreshTokenCacheStub) GetRefreshToken(context.Context, string) (*Refre
return nil, ErrRefreshTokenNotFound
}
+func (s *refreshTokenCacheStub) ConsumeRefreshToken(context.Context, string) (*RefreshTokenData, error) {
+ return nil, ErrRefreshTokenNotFound
+}
+
func (s *refreshTokenCacheStub) DeleteRefreshToken(context.Context, string) error {
return nil
}
@@ -350,6 +355,18 @@ func TestAuthService_SendVerifyCode_EmailSuffixNotAllowed(t *testing.T) {
require.Equal(t, "2", appErr.Metadata["allowed_suffix_count"])
}
+func TestAuthService_SendVerifyCodeAsync_ExistingEmailDoesNotRevealAccount(t *testing.T) {
+ repo := &userRepoStub{exists: true}
+ service := newAuthService(repo, map[string]string{
+ SettingKeyRegistrationEnabled: "true",
+ }, nil)
+
+ result, err := service.SendVerifyCodeAsync(context.Background(), "existing@test.com")
+
+ require.NoError(t, err)
+ require.Equal(t, 60, result.Countdown)
+}
+
func TestAuthService_Register_CreateError(t *testing.T) {
repo := &userRepoStub{createErr: errors.New("create failed")}
service := newAuthService(repo, map[string]string{
@@ -392,6 +409,72 @@ func TestAuthService_Register_Success(t *testing.T) {
require.True(t, user.CheckPassword("password"))
}
+func TestAuthService_Login_UnknownEmailAndWrongPasswordUseGenericFailure(t *testing.T) {
+ unknownService := newAuthService(&userRepoStub{}, nil, nil)
+ _, _, unknownErr := unknownService.Login(
+ context.Background(),
+ "unknown@test.com",
+ "sub2api-login-dummy-password",
+ )
+
+ knownUser := &User{Email: "known@test.com", PasswordHash: invalidLoginPasswordHash, Status: StatusActive}
+ knownService := newAuthService(&userRepoStub{user: knownUser}, nil, nil)
+ require.True(t, knownService.CheckPassword("sub2api-login-dummy-password", invalidLoginPasswordHash))
+ _, _, wrongPasswordErr := knownService.Login(context.Background(), knownUser.Email, "wrong-password")
+
+ require.ErrorIs(t, unknownErr, ErrInvalidCredentials)
+ require.ErrorIs(t, wrongPasswordErr, ErrInvalidCredentials)
+ require.Equal(t, infraerrors.Reason(unknownErr), infraerrors.Reason(wrongPasswordErr))
+ require.Equal(t, infraerrors.Message(unknownErr), infraerrors.Message(wrongPasswordErr))
+}
+
+func TestAuthService_RegisterWithVerificationAndRisk_StoresSignupRiskSignals(t *testing.T) {
+ repo := &userRepoStub{nextID: 15}
+ service := newAuthService(repo, map[string]string{
+ SettingKeyRegistrationEnabled: "true",
+ }, nil)
+
+ _, user, err := service.RegisterWithVerificationAndRisk(
+ context.Background(),
+ "user@test.com",
+ "password",
+ "",
+ "",
+ "",
+ "",
+ RegistrationRiskInput{
+ ClientIP: "203.0.113.42",
+ UserAgent: "Mozilla/5.0 test",
+ DeviceFingerprint: "fingerprint-visitor-id",
+ },
+ )
+ require.NoError(t, err)
+ require.NotNil(t, user)
+ require.Len(t, repo.created, 1)
+
+ created := repo.created[0]
+ require.Equal(t, "203.0.113.42", created.SignupIP)
+ require.Equal(t, "203.0.113.0/24", created.SignupIPPrefix)
+ require.Len(t, created.SignupUserAgentHash, 64)
+ require.Len(t, created.SignupDeviceFingerprintHash, 64)
+ require.NotEqual(t, "fingerprint-visitor-id", created.SignupDeviceFingerprintHash)
+ require.True(t, created.SignupRiskRecorded)
+ require.True(t, created.TrialBonusEligible)
+ require.Empty(t, created.TrialBonusHoldReason)
+ require.Zero(t, created.TrialBonusRiskScore)
+}
+
+func TestSignupRiskHelpersNormalizeIPAndHashSignals(t *testing.T) {
+ require.Equal(t, "198.51.100.0/24", signupIPPrefix("198.51.100.25"))
+ require.Equal(t, "2001:db8:abcd:1200::/64", signupIPPrefix("2001:db8:abcd:1200::1234"))
+ require.Empty(t, signupIPPrefix("not-an-ip"))
+
+ hash := hashSignupSignal(" same-device ", 512)
+ require.Len(t, hash, 64)
+ require.Equal(t, hash, hashSignupSignal("same-device", 512))
+ require.NotEqual(t, hash, hashSignupSignal("other-device", 512))
+}
+
func TestAuthService_ValidateToken_ExpiredReturnsClaimsWithError(t *testing.T) {
repo := &userRepoStub{}
service := newAuthService(repo, nil, nil)
diff --git a/backend/internal/service/backup_service.go b/backend/internal/service/backup_service.go
index 2fcf2da89f7..e73091012cc 100644
--- a/backend/internal/service/backup_service.go
+++ b/backend/internal/service/backup_service.go
@@ -3,10 +3,15 @@ package service
import (
"compress/gzip"
"context"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
+ "math"
+ "os"
"sort"
"strings"
"sync"
@@ -30,12 +35,14 @@ const (
)
var (
- ErrBackupS3NotConfigured = infraerrors.BadRequest("BACKUP_S3_NOT_CONFIGURED", "backup S3 storage is not configured")
- ErrBackupNotFound = infraerrors.NotFound("BACKUP_NOT_FOUND", "backup record not found")
- ErrBackupInProgress = infraerrors.Conflict("BACKUP_IN_PROGRESS", "a backup is already in progress")
- ErrRestoreInProgress = infraerrors.Conflict("RESTORE_IN_PROGRESS", "a restore is already in progress")
- ErrBackupRecordsCorrupt = infraerrors.InternalServer("BACKUP_RECORDS_CORRUPT", "backup records data is corrupted")
- ErrBackupS3ConfigCorrupt = infraerrors.InternalServer("BACKUP_S3_CONFIG_CORRUPT", "backup S3 config data is corrupted")
+ ErrBackupS3NotConfigured = infraerrors.BadRequest("BACKUP_S3_NOT_CONFIGURED", "backup S3 storage is not configured")
+ ErrBackupNotFound = infraerrors.NotFound("BACKUP_NOT_FOUND", "backup record not found")
+ ErrBackupInProgress = infraerrors.Conflict("BACKUP_IN_PROGRESS", "a backup is already in progress")
+ ErrRestoreInProgress = infraerrors.Conflict("RESTORE_IN_PROGRESS", "a restore is already in progress")
+ ErrBackupRecordsCorrupt = infraerrors.InternalServer("BACKUP_RECORDS_CORRUPT", "backup records data is corrupted")
+ ErrBackupS3ConfigCorrupt = infraerrors.InternalServer("BACKUP_S3_CONFIG_CORRUPT", "backup S3 config data is corrupted")
+ ErrBackupIntegrityMetadataMissing = infraerrors.BadRequest("BACKUP_INTEGRITY_METADATA_MISSING", "backup cannot be restored because integrity metadata is missing")
+ ErrBackupIntegrityMismatch = infraerrors.BadRequest("BACKUP_INTEGRITY_MISMATCH", "backup integrity verification failed")
)
// ─── 接口定义 ───
@@ -92,6 +99,7 @@ type BackupRecord struct {
FileName string `json:"file_name"`
S3Key string `json:"s3_key"`
SizeBytes int64 `json:"size_bytes"`
+ SHA256 string `json:"sha256,omitempty"`
TriggeredBy string `json:"triggered_by"` // manual, scheduled
ErrorMsg string `json:"error_message,omitempty"`
StartedAt string `json:"started_at"`
@@ -252,13 +260,17 @@ func (s *BackupService) GetS3Config(ctx context.Context) (*BackupS3Config, error
func (s *BackupService) UpdateS3Config(ctx context.Context, cfg BackupS3Config) (*BackupS3Config, error) {
// 如果没提供 secret,保留原有值
if cfg.SecretAccessKey == "" {
- old, _ := s.loadS3Config(ctx)
+ old, err := s.loadS3Config(ctx)
+ if err != nil {
+ return nil, err
+ }
if old != nil {
cfg.SecretAccessKey = old.SecretAccessKey
}
- } else {
- // 加密 SecretAccessKey
- encrypted, err := s.encryptor.Encrypt(cfg.SecretAccessKey)
+ }
+ if cfg.SecretAccessKey != "" {
+ // 新密钥和从旧配置解密出的保留密钥都必须在持久化前重新加密。
+ encrypted, err := EncryptForSecretDomain(s.encryptor, SecretDomainBackupS3, cfg.SecretAccessKey)
if err != nil {
return nil, fmt.Errorf("encrypt secret: %w", err)
}
@@ -286,8 +298,17 @@ func (s *BackupService) UpdateS3Config(ctx context.Context, cfg BackupS3Config)
func (s *BackupService) TestS3Connection(ctx context.Context, cfg BackupS3Config) error {
// 如果没提供 secret,用已保存的
if cfg.SecretAccessKey == "" {
- old, _ := s.loadS3Config(ctx)
+ old, err := s.loadS3Config(ctx)
+ if err != nil {
+ return err
+ }
if old != nil {
+ if strings.TrimSpace(cfg.Endpoint) != strings.TrimSpace(old.Endpoint) || cfg.AccessKeyID != old.AccessKeyID {
+ return infraerrors.BadRequest(
+ "BACKUP_S3_SECRET_REENTRY_REQUIRED",
+ "secret_access_key must be provided when endpoint or access_key_id changes",
+ )
+ }
cfg.SecretAccessKey = old.SecretAccessKey
}
}
@@ -513,7 +534,8 @@ func (s *BackupService) CreateBackup(ctx context.Context, triggeredBy string, ex
}()
contentType := "application/gzip"
- sizeBytes, err := objectStore.Upload(ctx, s3Key, pr, contentType)
+ compressedHash := sha256.New()
+ sizeBytes, err := objectStore.Upload(ctx, s3Key, io.TeeReader(pr, compressedHash), contentType)
if err != nil {
_ = pr.CloseWithError(err) // 确保 gzip goroutine 不会悬挂
gzErr := <-gzipDone // 安全等待 gzip goroutine 完成
@@ -530,6 +552,7 @@ func (s *BackupService) CreateBackup(ctx context.Context, triggeredBy string, ex
<-gzipDone // 确保 gzip goroutine 已退出
record.SizeBytes = sizeBytes
+ record.SHA256 = hex.EncodeToString(compressedHash.Sum(nil))
record.Status = "completed"
record.FinishedAt = time.Now().Format(time.RFC3339)
if err := s.saveRecord(ctx, record); err != nil {
@@ -681,7 +704,8 @@ func (s *BackupService) executeBackup(record *BackupRecord, objectStore BackupOb
}()
contentType := "application/gzip"
- sizeBytes, err := objectStore.Upload(ctx, record.S3Key, pr, contentType)
+ compressedHash := sha256.New()
+ sizeBytes, err := objectStore.Upload(ctx, record.S3Key, io.TeeReader(pr, compressedHash), contentType)
if err != nil {
_ = pr.CloseWithError(err) // 确保 gzip goroutine 不会悬挂
gzErr := <-gzipDone // 安全等待 gzip goroutine 完成
@@ -699,6 +723,7 @@ func (s *BackupService) executeBackup(record *BackupRecord, objectStore BackupOb
<-gzipDone // 确保 gzip goroutine 已退出
record.SizeBytes = sizeBytes
+ record.SHA256 = hex.EncodeToString(compressedHash.Sum(nil))
record.Status = "completed"
record.Progress = ""
record.FinishedAt = time.Now().Format(time.RFC3339)
@@ -746,8 +771,14 @@ func (s *BackupService) RestoreBackup(ctx context.Context, backupID string) erro
}
defer func() { _ = body.Close() }()
- // 流式解压 gzip -> psql(不将全部数据加载到内存)
- gzReader, err := gzip.NewReader(body)
+ verified, err := stageVerifiedBackupObject(body, record)
+ if err != nil {
+ return err
+ }
+ defer closeAndRemoveVerifiedBackup(verified)
+
+ // 校验完成后再解压并启动 psql,避免在发现篡改前执行任何 SQL。
+ gzReader, err := gzip.NewReader(verified)
if err != nil {
return fmt.Errorf("gzip reader: %w", err)
}
@@ -844,7 +875,16 @@ func (s *BackupService) executeRestore(record *BackupRecord, objectStore BackupO
}
defer func() { _ = body.Close() }()
- gzReader, err := gzip.NewReader(body)
+ verified, err := stageVerifiedBackupObject(body, record)
+ if err != nil {
+ record.RestoreStatus = "failed"
+ record.RestoreError = err.Error()
+ _ = s.saveRecord(context.Background(), record)
+ return
+ }
+ defer closeAndRemoveVerifiedBackup(verified)
+
+ gzReader, err := gzip.NewReader(verified)
if err != nil {
record.RestoreStatus = "failed"
record.RestoreError = fmt.Sprintf("gzip reader: %v", err)
@@ -867,6 +907,50 @@ func (s *BackupService) executeRestore(record *BackupRecord, objectStore BackupO
}
}
+func stageVerifiedBackupObject(body io.Reader, record *BackupRecord) (*os.File, error) {
+ if record == nil || record.SizeBytes <= 0 || record.SizeBytes == math.MaxInt64 || strings.TrimSpace(record.SHA256) == "" {
+ return nil, ErrBackupIntegrityMetadataMissing
+ }
+ expectedHash, err := hex.DecodeString(strings.TrimSpace(record.SHA256))
+ if err != nil || len(expectedHash) != sha256.Size {
+ return nil, ErrBackupIntegrityMetadataMissing
+ }
+
+ tmp, err := os.CreateTemp("", "sub2api-verified-backup-*.sql.gz")
+ if err != nil {
+ return nil, fmt.Errorf("create verified backup staging file: %w", err)
+ }
+ cleanup := func() {
+ _ = tmp.Close()
+ _ = os.Remove(tmp.Name())
+ }
+
+ hasher := sha256.New()
+ written, err := io.Copy(io.MultiWriter(tmp, hasher), io.LimitReader(body, record.SizeBytes+1))
+ if err != nil {
+ cleanup()
+ return nil, fmt.Errorf("stage backup for integrity verification: %w", err)
+ }
+ if written != record.SizeBytes || subtle.ConstantTimeCompare(hasher.Sum(nil), expectedHash) != 1 {
+ cleanup()
+ return nil, ErrBackupIntegrityMismatch
+ }
+ if _, err := tmp.Seek(0, io.SeekStart); err != nil {
+ cleanup()
+ return nil, fmt.Errorf("rewind verified backup: %w", err)
+ }
+ return tmp, nil
+}
+
+func closeAndRemoveVerifiedBackup(file *os.File) {
+ if file == nil {
+ return
+ }
+ name := file.Name()
+ _ = file.Close()
+ _ = os.Remove(name)
+}
+
// ─── 备份记录管理 ───
func (s *BackupService) ListBackups(ctx context.Context) ([]BackupRecord, error) {
@@ -969,13 +1053,11 @@ func (s *BackupService) loadS3Config(ctx context.Context) (*BackupS3Config, erro
}
// 解密 SecretAccessKey
if cfg.SecretAccessKey != "" {
- decrypted, err := s.encryptor.Decrypt(cfg.SecretAccessKey)
+ decrypted, err := DecryptForSecretDomain(s.encryptor, SecretDomainBackupS3, cfg.SecretAccessKey)
if err != nil {
- // 兼容未加密的旧数据:如果解密失败,保持原值
- logger.LegacyPrintf("service.backup", "[Backup] S3 SecretAccessKey 解密失败(可能是旧的未加密数据): %v", err)
- } else {
- cfg.SecretAccessKey = decrypted
+ return nil, fmt.Errorf("decrypt S3 secret after security migration: %w", err)
}
+ cfg.SecretAccessKey = decrypted
}
return &cfg, nil
}
diff --git a/backend/internal/service/backup_service_test.go b/backend/internal/service/backup_service_test.go
index b308e6d09d8..7099e3981b1 100644
--- a/backend/internal/service/backup_service_test.go
+++ b/backend/internal/service/backup_service_test.go
@@ -4,7 +4,9 @@ package service
import (
"bytes"
+ "compress/gzip"
"context"
+ "crypto/sha256"
"encoding/json"
"fmt"
"io"
@@ -108,6 +110,18 @@ func (e *plainEncryptor) Decrypt(ciphertext string) (string, error) {
return ciphertext, fmt.Errorf("not encrypted")
}
+func (e *plainEncryptor) EncryptForDomain(domain, plaintext string) (string, error) {
+ return "DOMAIN:" + domain + ":" + plaintext, nil
+}
+
+func (e *plainEncryptor) DecryptForDomain(domain, ciphertext string) (string, error) {
+ prefix := "DOMAIN:" + domain + ":"
+ if !strings.HasPrefix(ciphertext, prefix) {
+ return "", fmt.Errorf("ciphertext domain mismatch")
+ }
+ return strings.TrimPrefix(ciphertext, prefix), nil
+}
+
type mockDumper struct {
dumpData []byte
dumpErr error
@@ -223,7 +237,7 @@ func seedS3Config(t *testing.T, repo *mockSettingRepo) {
cfg := BackupS3Config{
Bucket: "test-bucket",
AccessKeyID: "AKID",
- SecretAccessKey: "ENC:secret123",
+ SecretAccessKey: "DOMAIN:" + SecretDomainBackupS3 + ":secret123",
Prefix: "backups",
}
data, _ := json.Marshal(cfg)
@@ -249,7 +263,7 @@ func TestBackupService_S3ConfigEncryption(t *testing.T) {
raw, _ := repo.GetValue(context.Background(), settingKeyBackupS3Config)
var stored BackupS3Config
require.NoError(t, json.Unmarshal([]byte(raw), &stored))
- require.Equal(t, "ENC:my-secret", stored.SecretAccessKey)
+ require.Equal(t, "DOMAIN:"+SecretDomainBackupS3+":my-secret", stored.SecretAccessKey)
// 通过 GetS3Config 获取应该脱敏
cfg, err := svc.GetS3Config(context.Background())
@@ -263,6 +277,18 @@ func TestBackupService_S3ConfigEncryption(t *testing.T) {
require.Equal(t, "my-secret", internal.SecretAccessKey)
}
+func TestBackupServiceRejectsLegacyUnboundS3SecretAfterStartupMigration(t *testing.T) {
+ repo := newMockSettingRepo()
+ legacy := BackupS3Config{Bucket: "bucket", AccessKeyID: "access", SecretAccessKey: "ENC:legacy-secret"}
+ raw, err := json.Marshal(legacy)
+ require.NoError(t, err)
+ require.NoError(t, repo.Set(context.Background(), settingKeyBackupS3Config, string(raw)))
+ svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
+
+ _, err = svc.loadS3Config(context.Background())
+ require.Error(t, err)
+}
+
func TestBackupService_S3ConfigKeepExistingSecret(t *testing.T) {
repo := newMockSettingRepo()
svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
@@ -281,6 +307,11 @@ func TestBackupService_S3ConfigKeepExistingSecret(t *testing.T) {
AccessKeyID: "AKID-NEW",
})
require.NoError(t, err)
+ raw, err := repo.GetValue(context.Background(), settingKeyBackupS3Config)
+ require.NoError(t, err)
+ var stored BackupS3Config
+ require.NoError(t, json.Unmarshal([]byte(raw), &stored))
+ require.Equal(t, "DOMAIN:"+SecretDomainBackupS3+":original-secret", stored.SecretAccessKey)
internal, err := svc.loadS3Config(context.Background())
require.NoError(t, err)
@@ -345,6 +376,7 @@ func TestBackupService_CreateBackup_Streaming(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "completed", record.Status)
require.Greater(t, record.SizeBytes, int64(0))
+ require.Len(t, record.SHA256, sha256.Size*2)
require.NotEmpty(t, record.S3Key)
// 验证 S3 上确实有文件
@@ -414,6 +446,77 @@ func TestBackupService_RestoreBackup_Streaming(t *testing.T) {
require.Equal(t, dumpContent, string(dumper.restored))
}
+func TestBackupService_RestoreBackupRejectsTamperedObjectBeforeDatabaseRestore(t *testing.T) {
+ repo := newMockSettingRepo()
+ seedS3Config(t, repo)
+ dumper := &mockDumper{dumpData: []byte("SELECT 1;")}
+ store := newMockObjectStore()
+ svc := newTestBackupService(repo, dumper, store)
+ record, err := svc.CreateBackup(context.Background(), "manual", 14)
+ require.NoError(t, err)
+
+ var tampered bytes.Buffer
+ zw := gzip.NewWriter(&tampered)
+ _, err = zw.Write([]byte("DROP TABLE users;"))
+ require.NoError(t, err)
+ require.NoError(t, zw.Close())
+ store.mu.Lock()
+ store.objects[record.S3Key] = tampered.Bytes()
+ store.mu.Unlock()
+
+ err = svc.RestoreBackup(context.Background(), record.ID)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "integrity")
+ require.Empty(t, dumper.restored)
+}
+
+func TestBackupService_RestoreBackupRejectsLegacyRecordWithoutIntegrityMetadata(t *testing.T) {
+ repo := newMockSettingRepo()
+ seedS3Config(t, repo)
+ dumper := &mockDumper{dumpData: []byte("SELECT 1;")}
+ store := newMockObjectStore()
+ svc := newTestBackupService(repo, dumper, store)
+ record, err := svc.CreateBackup(context.Background(), "manual", 14)
+ require.NoError(t, err)
+ record.SHA256 = ""
+ require.NoError(t, svc.saveRecord(context.Background(), record))
+
+ err = svc.RestoreBackup(context.Background(), record.ID)
+
+ require.ErrorIs(t, err, ErrBackupIntegrityMetadataMissing)
+ require.Empty(t, dumper.restored)
+}
+
+func TestBackupService_StartRestoreRecordsTamperedObjectFailure(t *testing.T) {
+ repo := newMockSettingRepo()
+ seedS3Config(t, repo)
+ dumper := &mockDumper{dumpData: []byte("SELECT 1;")}
+ store := newMockObjectStore()
+ svc := newTestBackupService(repo, dumper, store)
+ record, err := svc.CreateBackup(context.Background(), "manual", 14)
+ require.NoError(t, err)
+
+ var tampered bytes.Buffer
+ zw := gzip.NewWriter(&tampered)
+ _, err = zw.Write([]byte("DROP TABLE users;"))
+ require.NoError(t, err)
+ require.NoError(t, zw.Close())
+ store.mu.Lock()
+ store.objects[record.S3Key] = tampered.Bytes()
+ store.mu.Unlock()
+
+ _, err = svc.StartRestore(context.Background(), record.ID)
+ require.NoError(t, err)
+ svc.wg.Wait()
+ final, err := svc.GetBackupRecord(context.Background(), record.ID)
+
+ require.NoError(t, err)
+ require.Equal(t, "failed", final.RestoreStatus)
+ require.Contains(t, final.RestoreError, "integrity")
+ require.Empty(t, dumper.restored)
+}
+
func TestBackupService_RestoreBackup_NotCompleted(t *testing.T) {
repo := newMockSettingRepo()
seedS3Config(t, repo)
@@ -510,6 +613,72 @@ func TestBackupService_TestS3Connection(t *testing.T) {
require.NoError(t, err)
}
+func TestBackupService_TestS3ConnectionRequiresSecretForCredentialIdentityChange(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ endpoint string
+ accessKeyID string
+ }{
+ {name: "endpoint", endpoint: "https://attacker.example.com", accessKeyID: "ak"},
+ {name: "access key", endpoint: "https://trusted.example.com", accessKeyID: "other-ak"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ repo := newMockSettingRepo()
+ store := newMockObjectStore()
+ called := false
+ factory := func(_ context.Context, _ *BackupS3Config) (BackupObjectStore, error) {
+ called = true
+ return store, nil
+ }
+ svc := NewBackupService(repo, &config.Config{}, &plainEncryptor{}, factory, &mockDumper{})
+ _, err := svc.UpdateS3Config(context.Background(), BackupS3Config{
+ Endpoint: "https://trusted.example.com",
+ Bucket: "test",
+ AccessKeyID: "ak",
+ SecretAccessKey: "saved-secret",
+ })
+ require.NoError(t, err)
+
+ err = svc.TestS3Connection(context.Background(), BackupS3Config{
+ Endpoint: tc.endpoint,
+ Bucket: "test",
+ AccessKeyID: tc.accessKeyID,
+ })
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "secret_access_key must be provided")
+ require.False(t, called)
+ })
+ }
+}
+
+func TestBackupService_TestS3ConnectionReusesSecretForSameCredentialIdentity(t *testing.T) {
+ repo := newMockSettingRepo()
+ store := newMockObjectStore()
+ var testedSecret string
+ factory := func(_ context.Context, cfg *BackupS3Config) (BackupObjectStore, error) {
+ testedSecret = cfg.SecretAccessKey
+ return store, nil
+ }
+ svc := NewBackupService(repo, &config.Config{}, &plainEncryptor{}, factory, &mockDumper{})
+ _, err := svc.UpdateS3Config(context.Background(), BackupS3Config{
+ Endpoint: "https://trusted.example.com",
+ Bucket: "test",
+ AccessKeyID: "ak",
+ SecretAccessKey: "saved-secret",
+ })
+ require.NoError(t, err)
+
+ err = svc.TestS3Connection(context.Background(), BackupS3Config{
+ Endpoint: "https://trusted.example.com",
+ Bucket: "other-bucket",
+ AccessKeyID: "ak",
+ })
+
+ require.NoError(t, err)
+ require.Equal(t, "saved-secret", testedSecret)
+}
+
func TestBackupService_TestS3Connection_Incomplete(t *testing.T) {
repo := newMockSettingRepo()
svc := newTestBackupService(repo, &mockDumper{}, newMockObjectStore())
diff --git a/backend/internal/service/bedrock_account_validation_test.go b/backend/internal/service/bedrock_account_validation_test.go
new file mode 100644
index 00000000000..f64b221d18c
--- /dev/null
+++ b/backend/internal/service/bedrock_account_validation_test.go
@@ -0,0 +1,45 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestAdminCreateAccountRejectsBedrockAuthorityRegionBeforeRepositoryUse(t *testing.T) {
+ svc := &adminServiceImpl{}
+ account, err := svc.CreateAccount(context.Background(), &CreateAccountInput{
+ Name: "bad-bedrock",
+ Platform: PlatformAnthropic,
+ Type: AccountTypeBedrock,
+ Credentials: map[string]any{"aws_region": "x@attacker.example/"},
+ SkipDefaultGroupBind: true,
+ })
+ require.Error(t, err)
+ require.Nil(t, account)
+}
+
+func TestAccountServiceCreateRejectsBedrockAuthorityRegionBeforeRepositoryUse(t *testing.T) {
+ svc := NewAccountService(nil, nil)
+ account, err := svc.Create(context.Background(), CreateAccountRequest{
+ Name: "bad-bedrock",
+ Platform: PlatformAnthropic,
+ Type: AccountTypeBedrock,
+ Credentials: map[string]any{"aws_region": "us-east-1@attacker.example"},
+ })
+ require.Error(t, err)
+ require.Nil(t, account)
+}
+
+func TestAdminBulkUpdateRejectsBedrockAuthorityRegionBeforeRepositoryUse(t *testing.T) {
+ svc := &adminServiceImpl{}
+ result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
+ AccountIDs: []int64{1},
+ Credentials: map[string]any{"aws_region": "us-east-1.attacker.example"},
+ })
+ require.Error(t, err)
+ require.Nil(t, result)
+}
diff --git a/backend/internal/service/bedrock_request.go b/backend/internal/service/bedrock_request.go
index 2160c13cc86..abba50b58c6 100644
--- a/backend/internal/service/bedrock_request.go
+++ b/backend/internal/service/bedrock_request.go
@@ -15,7 +15,41 @@ import (
const defaultBedrockRegion = "us-east-1"
-var bedrockCrossRegionPrefixes = []string{"us.", "eu.", "apac.", "jp.", "au.", "us-gov.", "global."}
+var (
+ bedrockCrossRegionPrefixes = []string{"us.", "eu.", "apac.", "jp.", "au.", "us-gov.", "global."}
+ bedrockRegionPattern = regexp.MustCompile(`^[a-z][a-z0-9]{1,7}(?:-[a-z0-9]+)+-[0-9]+$`)
+)
+
+func normalizeBedrockRegion(region string) (string, error) {
+ region = strings.TrimSpace(region)
+ if region == "" {
+ return defaultBedrockRegion, nil
+ }
+ if len(region) > 63 || !bedrockRegionPattern.MatchString(region) {
+ return "", fmt.Errorf("invalid aws_region")
+ }
+ return region, nil
+}
+
+func validateBedrockAccountCredentials(accountType string, credentials map[string]any) error {
+ if accountType != AccountTypeBedrock || credentials == nil {
+ return nil
+ }
+ rawRegion, exists := credentials["aws_region"]
+ if !exists || rawRegion == nil {
+ return nil
+ }
+ region, ok := rawRegion.(string)
+ if !ok {
+ return fmt.Errorf("aws_region must be a string")
+ }
+ normalized, err := normalizeBedrockRegion(region)
+ if err != nil {
+ return err
+ }
+ credentials["aws_region"] = normalized
+ return nil
+}
// BedrockCrossRegionPrefix 根据 AWS Region 返回 Bedrock 跨区域推理的模型 ID 前缀
// 参考: https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
@@ -159,18 +193,19 @@ func ResolveBedrockModelID(account *Account, requestedModel string) (string, boo
// BuildBedrockURL 构建 Bedrock InvokeModel 的 URL
// stream=true 时使用 invoke-with-response-stream 端点
// modelID 中的特殊字符会被 URL 编码(与 litellm 的 urllib.parse.quote(safe="") 对齐)
-func BuildBedrockURL(region, modelID string, stream bool) string {
- if region == "" {
- region = defaultBedrockRegion
+func BuildBedrockURL(region, modelID string, stream bool) (string, error) {
+ region, err := normalizeBedrockRegion(region)
+ if err != nil {
+ return "", err
}
encodedModelID := url.PathEscape(modelID)
// url.PathEscape 不编码冒号(RFC 允许 path 中出现 ":"),
// 但 AWS Bedrock 期望模型 ID 中的冒号被编码为 %3A
encodedModelID = strings.ReplaceAll(encodedModelID, ":", "%3A")
if stream {
- return fmt.Sprintf("https://bedrock-runtime.%s.amazonaws.com/model/%s/invoke-with-response-stream", region, encodedModelID)
+ return fmt.Sprintf("https://bedrock-runtime.%s.amazonaws.com/model/%s/invoke-with-response-stream", region, encodedModelID), nil
}
- return fmt.Sprintf("https://bedrock-runtime.%s.amazonaws.com/model/%s/invoke", region, encodedModelID)
+ return fmt.Sprintf("https://bedrock-runtime.%s.amazonaws.com/model/%s/invoke", region, encodedModelID), nil
}
// PrepareBedrockRequestBody 处理请求体以适配 Bedrock API
diff --git a/backend/internal/service/bedrock_request_test.go b/backend/internal/service/bedrock_request_test.go
index 361cafb427d..4e341755347 100644
--- a/backend/internal/service/bedrock_request_test.go
+++ b/backend/internal/service/bedrock_request_test.go
@@ -419,6 +419,29 @@ func TestBedrockCrossRegionPrefix(t *testing.T) {
}
}
+func TestValidateBedrockAccountCredentials(t *testing.T) {
+ t.Run("normalizes a valid region", func(t *testing.T) {
+ credentials := map[string]any{"aws_region": " eu-west-1 "}
+ require.NoError(t, validateBedrockAccountCredentials(AccountTypeBedrock, credentials))
+ assert.Equal(t, "eu-west-1", credentials["aws_region"])
+ })
+
+ t.Run("rejects authority characters", func(t *testing.T) {
+ credentials := map[string]any{"aws_region": "x@attacker.example/"}
+ require.Error(t, validateBedrockAccountCredentials(AccountTypeBedrock, credentials))
+ })
+
+ t.Run("rejects non-string region", func(t *testing.T) {
+ credentials := map[string]any{"aws_region": 123}
+ require.Error(t, validateBedrockAccountCredentials(AccountTypeBedrock, credentials))
+ })
+
+ t.Run("ignores unrelated account types", func(t *testing.T) {
+ credentials := map[string]any{"aws_region": "not-a-region"}
+ require.NoError(t, validateBedrockAccountCredentials(AccountTypeAPIKey, credentials))
+ })
+}
+
func TestResolveBedrockModelID(t *testing.T) {
t.Run("default alias resolves and adjusts region", func(t *testing.T) {
account := &Account{
diff --git a/backend/internal/service/bedrock_signer.go b/backend/internal/service/bedrock_signer.go
index e7000b4dc8a..8b34c0c378a 100644
--- a/backend/internal/service/bedrock_signer.go
+++ b/backend/internal/service/bedrock_signer.go
@@ -43,8 +43,9 @@ func NewBedrockSignerFromAccount(account *Account) (*BedrockSigner, error) {
return nil, fmt.Errorf("aws_secret_access_key not found in credentials")
}
region := account.GetCredential("aws_region")
- if region == "" {
- region = defaultBedrockRegion
+ region, err := normalizeBedrockRegion(region)
+ if err != nil {
+ return nil, err
}
sessionToken := account.GetCredential("aws_session_token") // 可选
diff --git a/backend/internal/service/bedrock_signer_test.go b/backend/internal/service/bedrock_signer_test.go
index 641e9341777..203f240c5ed 100644
--- a/backend/internal/service/bedrock_signer_test.go
+++ b/backend/internal/service/bedrock_signer_test.go
@@ -23,6 +23,22 @@ func TestNewBedrockSignerFromAccount_DefaultRegion(t *testing.T) {
assert.Equal(t, defaultBedrockRegion, signer.region)
}
+func TestNewBedrockSignerFromAccount_RejectsInvalidRegion(t *testing.T) {
+ account := &Account{
+ Platform: PlatformAnthropic,
+ Type: AccountTypeBedrock,
+ Credentials: map[string]any{
+ "aws_access_key_id": "test-akid",
+ "aws_secret_access_key": "test-secret",
+ "aws_region": "x@attacker.example/",
+ },
+ }
+
+ signer, err := NewBedrockSignerFromAccount(account)
+ require.Error(t, err)
+ assert.Nil(t, signer)
+}
+
func TestFilterBetaTokens(t *testing.T) {
tokens := []string{"interleaved-thinking-2025-05-14", "tool-search-tool-2025-10-19"}
filterSet := map[string]struct{}{
diff --git a/backend/internal/service/bedrock_stream.go b/backend/internal/service/bedrock_stream.go
index 98196d27ec8..571b691e0a3 100644
--- a/backend/internal/service/bedrock_stream.go
+++ b/backend/internal/service/bedrock_stream.go
@@ -241,6 +241,8 @@ type bedrockEventStreamDecoder struct {
reader *bufio.Reader
}
+const bedrockEventStreamMaxFrameBytes uint32 = 16 * 1024 * 1024
+
func newBedrockEventStreamDecoder(r io.Reader) *bedrockEventStreamDecoder {
return &bedrockEventStreamDecoder{
reader: bufio.NewReaderSize(r, 64*1024),
@@ -268,6 +270,12 @@ func (d *bedrockEventStreamDecoder) Decode() ([]byte, error) {
if totalLength < 16 { // minimum: 12 prelude + 4 message_crc
return nil, fmt.Errorf("invalid eventstream frame: total_length=%d", totalLength)
}
+ if totalLength > bedrockEventStreamMaxFrameBytes {
+ return nil, fmt.Errorf("invalid eventstream frame: total_length=%d exceeds limit=%d", totalLength, bedrockEventStreamMaxFrameBytes)
+ }
+ if headersLength > totalLength-16 {
+ return nil, fmt.Errorf("invalid eventstream frame: headers_length=%d exceeds frame content=%d", headersLength, totalLength-16)
+ }
// 读取 headers + payload + message_crc
remaining := int(totalLength) - 12
diff --git a/backend/internal/service/bedrock_stream_test.go b/backend/internal/service/bedrock_stream_test.go
index 3d0661379a2..95291e81533 100644
--- a/backend/internal/service/bedrock_stream_test.go
+++ b/backend/internal/service/bedrock_stream_test.go
@@ -241,21 +241,66 @@ func TestBedrockEventStreamDecoder(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "prelude CRC mismatch")
})
+
+ t.Run("oversized frame is rejected before allocation", func(t *testing.T) {
+ var prelude bytes.Buffer
+ _ = binary.Write(&prelude, binary.BigEndian, uint32(16*1024*1024+1))
+ _ = binary.Write(&prelude, binary.BigEndian, uint32(0))
+ frame := append([]byte(nil), prelude.Bytes()...)
+ frame = binary.BigEndian.AppendUint32(frame, crc32.Checksum(frame, crc32IeeeTab))
+
+ decoder := newBedrockEventStreamDecoder(bytes.NewReader(frame))
+ _, err := decoder.Decode()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "exceeds limit")
+ })
+
+ t.Run("headers length beyond frame is rejected without panic", func(t *testing.T) {
+ var prelude bytes.Buffer
+ _ = binary.Write(&prelude, binary.BigEndian, uint32(16))
+ _ = binary.Write(&prelude, binary.BigEndian, uint32(5))
+ frame := append([]byte(nil), prelude.Bytes()...)
+ frame = binary.BigEndian.AppendUint32(frame, crc32.Checksum(frame, crc32IeeeTab))
+ frame = binary.BigEndian.AppendUint32(frame, crc32.Checksum(frame, crc32IeeeTab))
+
+ decoder := newBedrockEventStreamDecoder(bytes.NewReader(frame))
+ _, err := decoder.Decode()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "headers_length")
+ })
}
func TestBuildBedrockURL(t *testing.T) {
t.Run("stream URL with colon in model ID", func(t *testing.T) {
- url := BuildBedrockURL("us-east-1", "us.anthropic.claude-opus-4-5-20251101-v1:0", true)
+ url, err := BuildBedrockURL("us-east-1", "us.anthropic.claude-opus-4-5-20251101-v1:0", true)
+ require.NoError(t, err)
assert.Equal(t, "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-opus-4-5-20251101-v1%3A0/invoke-with-response-stream", url)
})
t.Run("non-stream URL with colon in model ID", func(t *testing.T) {
- url := BuildBedrockURL("eu-west-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", false)
+ url, err := BuildBedrockURL("eu-west-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", false)
+ require.NoError(t, err)
assert.Equal(t, "https://bedrock-runtime.eu-west-1.amazonaws.com/model/eu.anthropic.claude-sonnet-4-5-20250929-v1%3A0/invoke", url)
})
t.Run("model ID without colon", func(t *testing.T) {
- url := BuildBedrockURL("us-east-1", "us.anthropic.claude-sonnet-4-6", true)
+ url, err := BuildBedrockURL("us-east-1", "us.anthropic.claude-sonnet-4-6", true)
+ require.NoError(t, err)
assert.Equal(t, "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-sonnet-4-6/invoke-with-response-stream", url)
})
+
+ for _, region := range []string{
+ "us-east-1@attacker.example",
+ "x@attacker.example/",
+ "us-east-1.attacker.example",
+ "us-east-1:443",
+ "US-EAST-1",
+ "us-east-1%2fattacker.example",
+ } {
+ t.Run("reject authority injection "+region, func(t *testing.T) {
+ builtURL, err := BuildBedrockURL(region, "anthropic.claude-sonnet-v1:0", false)
+ require.Error(t, err)
+ assert.Empty(t, builtURL)
+ })
+ }
}
diff --git a/backend/internal/service/billing_cache_service.go b/backend/internal/service/billing_cache_service.go
index 050db55b91f..c66a72b37b2 100644
--- a/backend/internal/service/billing_cache_service.go
+++ b/backend/internal/service/billing_cache_service.go
@@ -3,7 +3,9 @@ package service
import (
"context"
"fmt"
+ "net/http"
"strconv"
+ "strings"
"sync"
"sync/atomic"
"time"
@@ -18,13 +20,24 @@ import (
// 注:ErrInsufficientBalance在redeem_service.go中定义
// 注:ErrDailyLimitExceeded/ErrWeeklyLimitExceeded/ErrMonthlyLimitExceeded在subscription_service.go中定义
var (
- ErrSubscriptionInvalid = infraerrors.Forbidden("SUBSCRIPTION_INVALID", "subscription is invalid or expired")
- ErrBillingServiceUnavailable = infraerrors.ServiceUnavailable("BILLING_SERVICE_ERROR", "Billing service temporarily unavailable. Please retry later.")
+ ErrSubscriptionInvalid = infraerrors.Forbidden("SUBSCRIPTION_INVALID", "subscription is invalid or expired")
+ ErrBillingServiceUnavailable = infraerrors.ServiceUnavailable("BILLING_SERVICE_ERROR", "Billing service temporarily unavailable. Please retry later.")
+ ErrTrialPaymentBindingRequired = infraerrors.New(
+ http.StatusPaymentRequired,
+ "TRIAL_PAYMENT_BINDING_REQUIRED",
+ "trial usage reached the free threshold; please bind a payment account or make a small recharge to continue",
+ )
// RPM 超限错误。gateway_handler 负责映射为 HTTP 429。
ErrGroupRPMExceeded = infraerrors.TooManyRequests("GROUP_RPM_EXCEEDED", "group requests-per-minute limit exceeded")
ErrUserRPMExceeded = infraerrors.TooManyRequests("USER_RPM_EXCEEDED", "user requests-per-minute limit exceeded")
)
+const (
+ trialBonusGroupID int64 = 17
+ trialBonusGroupName = "paid-trial-bonus"
+ trialBonusPaymentBindingThresholdUSD float64 = 3
+)
+
// subscriptionCacheData 订阅缓存数据结构(内部使用)
type subscriptionCacheData struct {
Status string
@@ -385,6 +398,21 @@ func (s *BillingCacheService) InvalidateUserBalance(ctx context.Context, userID
return nil
}
+// invalidateBillingCacheAfterCommit completes invalidation before a successful
+// mutation response is returned. The database is already authoritative, so a
+// cache outage is logged rather than misreported to the caller as a failed
+// financial mutation.
+func invalidateBillingCacheAfterCommit(ctx context.Context, operation string, invalidate func(context.Context) error) {
+ if invalidate == nil {
+ return
+ }
+ cacheCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cacheWriteTimeout)
+ defer cancel()
+ if err := invalidate(cacheCtx); err != nil {
+ logger.LegacyPrintf("service.billing_cache", "Warning: post-commit cache invalidation failed (%s): %v", operation, err)
+ }
+}
+
// ============================================
// 订阅缓存方法
// ============================================
@@ -671,11 +699,19 @@ func (s *BillingCacheService) CheckBillingEligibility(ctx context.Context, user
return ErrBillingServiceUnavailable
}
- // 判断计费模式
- isSubscriptionMode := group != nil && group.IsSubscriptionType() && subscription != nil
+ // 判断计费模式。effectiveGroup 在用户切 group 到 plan_groups 链内时是 sub 主 group,
+ // 避免老 bug:limits 检查跑到 standard group(limits=0)"无限"过关又静默扣 user.balance。
+ // 见 docs/plans/2026-05-16-wallet-v4-group-switch-billing-fix.md。
+ isSubscriptionMode, effectiveGroup := EffectiveBillingContext(group, subscription)
if isSubscriptionMode {
- if err := s.checkSubscriptionEligibility(ctx, user.ID, group, subscription); err != nil {
+ // v4 钱包模式优先:subscription.wallet_balance_usd != NULL → 走钱包预检,
+ // 跳过 group 维度的 daily/weekly/monthly cap 校验(钱包共享,不绑 group)。
+ if subscription.IsWalletMode() {
+ if err := s.checkWalletEligibility(subscription); err != nil {
+ return err
+ }
+ } else if err := s.checkSubscriptionEligibility(ctx, user, effectiveGroup, subscription); err != nil {
return err
}
} else {
@@ -804,15 +840,38 @@ func (s *BillingCacheService) checkBalanceEligibility(ctx context.Context, userI
return nil
}
+// checkWalletEligibility 钱包模式 (v4) 预检:余额 ≤ 0 直接 402,避免 race
+// 让请求服务后才发现钱包扣不动。直接读 subscription.WalletBalanceUSD(auth
+// snapshot 已带最新值),不查 Redis 也不进 circuit breaker。
+//
+// 注:与 v3 不同,钱包模式不查 group 维度的 daily/weekly/monthly cap —— 钱包
+// 是跨 group 共享的一笔额度,cap 由 wallet_balance_usd 自身承担。
+func (s *BillingCacheService) checkWalletEligibility(subscription *UserSubscription) error {
+ if subscription == nil || subscription.WalletBalanceUSD == nil {
+ // 调用方已保证 IsWalletMode(),但 defensive:理论不可达。
+ return nil
+ }
+ bal := *subscription.WalletBalanceUSD
+ if bal <= 0 {
+ // 注:insufficient_total 在 handler.billingErrorDetails 统一 +1(HTTP
+ // 边界做单一计数源,避免重复)。
+ return ErrWalletInsufficient
+ }
+ if bal <= walletBalanceLowThresholdUSD {
+ recordWalletBalanceLow()
+ }
+ return nil
+}
+
// checkSubscriptionEligibility 检查订阅模式资格
-func (s *BillingCacheService) checkSubscriptionEligibility(ctx context.Context, userID int64, group *Group, subscription *UserSubscription) error {
+func (s *BillingCacheService) checkSubscriptionEligibility(ctx context.Context, user *User, group *Group, subscription *UserSubscription) error {
// 获取订阅缓存数据
- subData, err := s.GetSubscriptionStatus(ctx, userID, group.ID)
+ subData, err := s.GetSubscriptionStatus(ctx, user.ID, group.ID)
if err != nil {
if s.circuitBreaker != nil {
s.circuitBreaker.OnFailure(err)
}
- logger.LegacyPrintf("service.billing_cache", "ALERT: billing subscription check failed for user %d group %d: %v", userID, group.ID, err)
+ logger.LegacyPrintf("service.billing_cache", "ALERT: billing subscription check failed for user %d group %d: %v", user.ID, group.ID, err)
return ErrBillingServiceUnavailable.WithCause(err)
}
if s.circuitBreaker != nil {
@@ -829,6 +888,10 @@ func (s *BillingCacheService) checkSubscriptionEligibility(ctx context.Context,
return ErrSubscriptionInvalid
}
+ if err := checkTrialBonusPaymentBinding(user, group, subData); err != nil {
+ return err
+ }
+
// 检查限额(使用传入的Group限额配置)
if group.HasDailyLimit() && subData.DailyUsage >= *group.DailyLimitUSD {
return ErrDailyLimitExceeded
@@ -845,6 +908,52 @@ func (s *BillingCacheService) checkSubscriptionEligibility(ctx context.Context,
return nil
}
+func checkTrialBonusPaymentBinding(user *User, group *Group, subData *subscriptionCacheData) error {
+ if user == nil || group == nil || subData == nil || !isTrialBonusGroup(group) {
+ return nil
+ }
+ if !strings.EqualFold(user.Role, RoleUser) {
+ return nil
+ }
+ if user.TotalRecharged > 0 {
+ return nil
+ }
+
+ used := maxSubscriptionUsageUSD(subData)
+ if used < trialBonusPaymentBindingThresholdUSD {
+ return nil
+ }
+ return ErrTrialPaymentBindingRequired.WithMetadata(map[string]string{
+ "threshold_usd": fmt.Sprintf("%.2f", trialBonusPaymentBindingThresholdUSD),
+ "used_usd": fmt.Sprintf("%.4f", used),
+ "action": "/purchase?trial_bind=1",
+ })
+}
+
+func isTrialBonusGroup(group *Group) bool {
+ if group == nil {
+ return false
+ }
+ if group.ID == trialBonusGroupID {
+ return true
+ }
+ return strings.EqualFold(strings.TrimSpace(group.Name), trialBonusGroupName)
+}
+
+func maxSubscriptionUsageUSD(subData *subscriptionCacheData) float64 {
+ if subData == nil {
+ return 0
+ }
+ used := subData.DailyUsage
+ if subData.WeeklyUsage > used {
+ used = subData.WeeklyUsage
+ }
+ if subData.MonthlyUsage > used {
+ used = subData.MonthlyUsage
+ }
+ return used
+}
+
type billingCircuitBreakerState int
const (
diff --git a/backend/internal/service/billing_cache_service_test.go b/backend/internal/service/billing_cache_service_test.go
index 849e24b8145..b3c5e3149ed 100644
--- a/backend/internal/service/billing_cache_service_test.go
+++ b/backend/internal/service/billing_cache_service_test.go
@@ -12,8 +12,9 @@ import (
)
type billingCacheWorkerStub struct {
- balanceUpdates int64
- subscriptionUpdates int64
+ balanceUpdates int64
+ subscriptionUpdates int64
+ subscriptionInvalidations int64
}
func (b *billingCacheWorkerStub) GetUserBalance(ctx context.Context, userID int64) (float64, error) {
@@ -49,6 +50,7 @@ func (b *billingCacheWorkerStub) UpdateSubscriptionUsage(ctx context.Context, us
}
func (b *billingCacheWorkerStub) InvalidateSubscriptionCache(ctx context.Context, userID, groupID int64) error {
+ atomic.AddInt64(&b.subscriptionInvalidations, 1)
return nil
}
@@ -90,6 +92,38 @@ func TestBillingCacheServiceQueueHighLoad(t *testing.T) {
}, 2*time.Second, 10*time.Millisecond)
}
+func TestInvalidateBillingCacheAfterCommitWaitsForInvalidation(t *testing.T) {
+ started := make(chan struct{})
+ release := make(chan struct{})
+ done := make(chan struct{})
+
+ go func() {
+ invalidateBillingCacheAfterCommit(context.Background(), "test", func(context.Context) error {
+ close(started)
+ <-release
+ return nil
+ })
+ close(done)
+ }()
+
+ select {
+ case <-started:
+ case <-time.After(time.Second):
+ t.Fatal("cache invalidation did not start")
+ }
+ select {
+ case <-done:
+ t.Fatal("helper returned before cache invalidation completed")
+ default:
+ }
+ close(release)
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("helper did not return after cache invalidation completed")
+ }
+}
+
func TestBillingCacheServiceEnqueueAfterStopReturnsFalse(t *testing.T) {
cache := &billingCacheWorkerStub{}
svc := NewBillingCacheService(cache, nil, nil, nil, nil, nil, &config.Config{})
diff --git a/backend/internal/service/billing_cache_trial_payment_test.go b/backend/internal/service/billing_cache_trial_payment_test.go
new file mode 100644
index 00000000000..b9c8ba6ed39
--- /dev/null
+++ b/backend/internal/service/billing_cache_trial_payment_test.go
@@ -0,0 +1,89 @@
+//go:build unit
+
+package service
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestCheckTrialBonusPaymentBinding(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ user *User
+ group *Group
+ subData *subscriptionCacheData
+ wantError bool
+ }{
+ {
+ name: "non trial group passes",
+ user: &User{ID: 1, Role: RoleUser},
+ group: &Group{ID: 1, Name: "standard"},
+ subData: &subscriptionCacheData{MonthlyUsage: 10},
+ },
+ {
+ name: "trial group below threshold passes",
+ user: &User{ID: 1, Role: RoleUser},
+ group: &Group{ID: trialBonusGroupID},
+ subData: &subscriptionCacheData{MonthlyUsage: trialBonusPaymentBindingThresholdUSD - 0.01},
+ },
+ {
+ name: "trial group at threshold requires payment binding",
+ user: &User{ID: 1, Role: RoleUser},
+ group: &Group{ID: trialBonusGroupID},
+ subData: &subscriptionCacheData{MonthlyUsage: trialBonusPaymentBindingThresholdUSD},
+ wantError: true,
+ },
+ {
+ name: "trial group by name requires payment binding",
+ user: &User{ID: 1, Role: RoleUser},
+ group: &Group{ID: 99, Name: trialBonusGroupName},
+ subData: &subscriptionCacheData{WeeklyUsage: trialBonusPaymentBindingThresholdUSD},
+ wantError: true,
+ },
+ {
+ name: "positive recharge is accepted as binding proof",
+ user: &User{ID: 1, Role: RoleUser, TotalRecharged: 0.01},
+ group: &Group{ID: trialBonusGroupID},
+ subData: &subscriptionCacheData{MonthlyUsage: trialBonusPaymentBindingThresholdUSD},
+ },
+ {
+ name: "admin role passes",
+ user: &User{ID: 1, Role: RoleAdmin},
+ group: &Group{ID: trialBonusGroupID},
+ subData: &subscriptionCacheData{MonthlyUsage: trialBonusPaymentBindingThresholdUSD},
+ },
+ }
+
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ err := checkTrialBonusPaymentBinding(tt.user, tt.group, tt.subData)
+ if tt.wantError {
+ if !errors.Is(err, ErrTrialPaymentBindingRequired) {
+ t.Fatalf("expected ErrTrialPaymentBindingRequired, got %v", err)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("expected nil err, got %v", err)
+ }
+ })
+ }
+}
+
+func TestMaxSubscriptionUsageUSD(t *testing.T) {
+ t.Parallel()
+
+ got := maxSubscriptionUsageUSD(&subscriptionCacheData{
+ DailyUsage: 1,
+ WeeklyUsage: 2,
+ MonthlyUsage: 3,
+ })
+ if got != 3 {
+ t.Fatalf("expected monthly max usage 3, got %v", got)
+ }
+}
diff --git a/backend/internal/service/billing_cache_wallet_eligibility_test.go b/backend/internal/service/billing_cache_wallet_eligibility_test.go
new file mode 100644
index 00000000000..baceaab90e1
--- /dev/null
+++ b/backend/internal/service/billing_cache_wallet_eligibility_test.go
@@ -0,0 +1,47 @@
+//go:build unit
+
+package service
+
+import (
+ "errors"
+ "testing"
+)
+
+// TestCheckWalletEligibility v4 钱包模式预检:余额 > 0 放行,<=0 直接
+// ErrWalletInsufficient(gateway_handler 映射 HTTP 402)。
+func TestCheckWalletEligibility(t *testing.T) {
+ t.Parallel()
+
+ pos := 1.5
+ zero := 0.0
+ neg := -0.01
+
+ tests := []struct {
+ name string
+ sub *UserSubscription
+ wantErr error
+ }{
+ {name: "nil subscription is no-op", sub: nil, wantErr: nil},
+ {name: "non-wallet subscription is no-op", sub: &UserSubscription{ID: 1}, wantErr: nil},
+ {name: "positive balance passes", sub: &UserSubscription{ID: 1, WalletBalanceUSD: &pos}, wantErr: nil},
+ {name: "zero balance is 402", sub: &UserSubscription{ID: 1, WalletBalanceUSD: &zero}, wantErr: ErrWalletInsufficient},
+ {name: "negative balance is 402", sub: &UserSubscription{ID: 1, WalletBalanceUSD: &neg}, wantErr: ErrWalletInsufficient},
+ }
+
+ s := &BillingCacheService{}
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ err := s.checkWalletEligibility(tt.sub)
+ if tt.wantErr == nil {
+ if err != nil {
+ t.Fatalf("expected nil err, got %v", err)
+ }
+ return
+ }
+ if !errors.Is(err, tt.wantErr) {
+ t.Fatalf("expected %v, got %v", tt.wantErr, err)
+ }
+ })
+ }
+}
diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go
index 392b3e0ba9f..3a9e968da3a 100644
--- a/backend/internal/service/billing_service.go
+++ b/backend/internal/service/billing_service.go
@@ -2,6 +2,7 @@ package service
import (
"context"
+ "errors"
"fmt"
"log"
@@ -203,6 +204,28 @@ func (s *BillingService) initFallbackPricing() {
SupportsCacheBreakdown: false,
}
+ s.fallbackPrices["gpt-5.6-sol"] = &ModelPricing{
+ InputPricePerToken: 5e-6,
+ OutputPricePerToken: 30e-6,
+ CacheCreationPricePerToken: 6.25e-6,
+ CacheReadPricePerToken: 0.5e-6,
+ SupportsCacheBreakdown: false,
+ }
+ s.fallbackPrices["gpt-5.6-terra"] = &ModelPricing{
+ InputPricePerToken: 2.5e-6,
+ OutputPricePerToken: 15e-6,
+ CacheCreationPricePerToken: 3.125e-6,
+ CacheReadPricePerToken: 0.25e-6,
+ SupportsCacheBreakdown: false,
+ }
+ s.fallbackPrices["gpt-5.6-luna"] = &ModelPricing{
+ InputPricePerToken: 1e-6,
+ OutputPricePerToken: 6e-6,
+ CacheCreationPricePerToken: 1.25e-6,
+ CacheReadPricePerToken: 0.1e-6,
+ SupportsCacheBreakdown: false,
+ }
+
// OpenAI GPT-5.4(业务指定价格)
s.fallbackPrices["gpt-5.4"] = &ModelPricing{
InputPricePerToken: 2.5e-6, // $2.5 per MTok
@@ -226,6 +249,12 @@ func (s *BillingService) initFallbackPricing() {
CacheReadPricePerToken: 7.5e-8,
SupportsCacheBreakdown: false,
}
+ s.fallbackPrices["gpt-5.4-nano"] = &ModelPricing{
+ InputPricePerToken: 2e-7,
+ OutputPricePerToken: 1.25e-6,
+ CacheReadPricePerToken: 2e-8,
+ SupportsCacheBreakdown: false,
+ }
// OpenAI GPT-5.2(本地兜底)
s.fallbackPrices["gpt-5.2"] = &ModelPricing{
InputPricePerToken: 1.75e-6,
@@ -288,13 +317,16 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
}
// OpenAI 仅匹配已知 GPT-5/Codex 族,避免未知 OpenAI 型号误计价。
- if strings.Contains(modelLower, "gpt-5") || strings.Contains(modelLower, "codex") {
- normalized := normalizeCodexModel(modelLower)
+ if normalized := normalizeKnownOpenAICodexModel(modelLower); normalized != "" {
switch normalized {
+ case "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna":
+ return s.fallbackPrices[normalized]
case "gpt-5.5":
return s.fallbackPrices["gpt-5.5"]
case "gpt-5.4-mini":
return s.fallbackPrices["gpt-5.4-mini"]
+ case "gpt-5.4-nano":
+ return s.fallbackPrices["gpt-5.4-nano"]
case "gpt-5.4":
return s.fallbackPrices["gpt-5.4"]
case "gpt-5.2":
@@ -309,6 +341,13 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
// GetModelPricing 获取模型价格配置
func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) {
+ pricing, _, err := s.GetModelPricingWithSource(model)
+ return pricing, err
+}
+
+// GetModelPricingWithSource resolves pricing and preserves whether the
+// effective schedule came from the LiteLLM snapshot or a built-in fallback.
+func (s *BillingService) GetModelPricingWithSource(model string) (*ModelPricing, string, error) {
// 标准化模型名称(转小写)
model = strings.ToLower(model)
@@ -322,6 +361,12 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) {
price5m := litellmPricing.CacheCreationInputTokenCost
price1h := litellmPricing.CacheCreationInputTokenCostAbove1hr
enableBreakdown := price1h > 0 && price1h > price5m
+ source := PricingSourceLiteLLM
+ if normalized, family := classifyOpenAIGPT56PreviewModel(model); family && normalized != "" {
+ // PricingService policy-locks GPT-5.6 to its built-in tier table;
+ // dynamic/local LiteLLM data cannot override these values.
+ source = PricingSourceBuiltinGPT56
+ }
return s.applyModelSpecificPricingPolicy(model, &ModelPricing{
InputPricePerToken: litellmPricing.InputCostPerToken,
InputPricePerTokenPriority: litellmPricing.InputCostPerTokenPriority,
@@ -337,7 +382,7 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) {
LongContextInputMultiplier: litellmPricing.LongContextInputCostMultiplier,
LongContextOutputMultiplier: litellmPricing.LongContextOutputCostMultiplier,
ImageOutputPricePerToken: litellmPricing.OutputCostPerImageToken,
- }), nil
+ }), source, nil
}
}
@@ -345,10 +390,15 @@ func (s *BillingService) GetModelPricing(model string) (*ModelPricing, error) {
fallback := s.getFallbackPricing(model)
if fallback != nil {
log.Printf("[Billing] Using fallback pricing for model: %s", model)
- return s.applyModelSpecificPricingPolicy(model, fallback), nil
+ cloned := *fallback
+ source := PricingSourceBuiltinFallback
+ if normalized, family := classifyOpenAIGPT56PreviewModel(model); family && normalized != "" {
+ source = PricingSourceBuiltinGPT56
+ }
+ return s.applyModelSpecificPricingPolicy(model, &cloned), source, nil
}
- return nil, fmt.Errorf("pricing not found for model: %s", model)
+ return nil, "", fmt.Errorf("pricing not found for model: %s", model)
}
// GetModelPricingWithChannel 获取模型定价,渠道配置的价格覆盖默认值
@@ -449,6 +499,9 @@ func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input Cos
}
pricing = s.applyModelSpecificPricingPolicy(input.Model, pricing)
+ if err := validateTokenPricingForUsage(pricing, input.Tokens, input.ServiceTier); err != nil {
+ return nil, fmt.Errorf("invalid pricing for used token dimension: %w", err)
+ }
// 长上下文定价仅在无区间定价时应用(区间定价已包含上下文分层)
applyLongCtx := len(resolved.Intervals) == 0
@@ -456,6 +509,51 @@ func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input Cos
return s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongCtx), nil
}
+func validateTokenPricingForUsage(pricing *ModelPricing, tokens UsageTokens, serviceTier string) error {
+ if pricing == nil {
+ return errors.New("pricing is nil")
+ }
+ inputPrice := pricing.InputPricePerToken
+ outputPrice := pricing.OutputPricePerToken
+ cacheReadPrice := pricing.CacheReadPricePerToken
+ if usePriorityServiceTierPricing(serviceTier, pricing) {
+ if pricing.InputPricePerTokenPriority > 0 {
+ inputPrice = pricing.InputPricePerTokenPriority
+ }
+ if pricing.OutputPricePerTokenPriority > 0 {
+ outputPrice = pricing.OutputPricePerTokenPriority
+ }
+ if pricing.CacheReadPricePerTokenPriority > 0 {
+ cacheReadPrice = pricing.CacheReadPricePerTokenPriority
+ }
+ }
+ if tokens.InputTokens > 0 && !positiveFinitePrice(inputPrice) {
+ return errors.New("input tokens have no positive price")
+ }
+ textOutputTokens := tokens.OutputTokens - tokens.ImageOutputTokens
+ if textOutputTokens > 0 && !positiveFinitePrice(outputPrice) {
+ return errors.New("output tokens have no positive price")
+ }
+ if tokens.ImageOutputTokens > 0 && !positiveFinitePrice(pricing.ImageOutputPricePerToken) && !positiveFinitePrice(outputPrice) {
+ return errors.New("image output tokens have no positive price")
+ }
+ if tokens.CacheReadTokens > 0 && !positiveFinitePrice(cacheReadPrice) {
+ return errors.New("cache read tokens have no positive price")
+ }
+ useCacheBreakdown := pricing.SupportsCacheBreakdown && (pricing.CacheCreation5mPrice > 0 || pricing.CacheCreation1hPrice > 0)
+ if useCacheBreakdown && (tokens.CacheCreationTokens > 0 || tokens.CacheCreation5mTokens > 0 || tokens.CacheCreation1hTokens > 0) {
+ if tokens.CacheCreation1hTokens > 0 && !positiveFinitePrice(pricing.CacheCreation1hPrice) {
+ return errors.New("1h cache creation tokens have no positive price")
+ }
+ if (tokens.CacheCreation5mTokens > 0 || (tokens.CacheCreationTokens > 0 && tokens.CacheCreation5mTokens == 0 && tokens.CacheCreation1hTokens == 0)) && !positiveFinitePrice(pricing.CacheCreation5mPrice) {
+ return errors.New("5m cache creation tokens have no positive price")
+ }
+ } else if tokens.CacheCreationTokens > 0 && !positiveFinitePrice(pricing.CacheCreationPricePerToken) {
+ return errors.New("cache creation tokens have no positive price")
+ }
+ return nil
+}
+
// computeTokenBreakdown 是 token 计费的核心逻辑,由 calculateTokenCost 和 calculateCostInternal 共用。
// applyLongCtx 控制是否检查长上下文定价(区间定价已自含上下文分层,不需要额外应用)。
func (s *BillingService) computeTokenBreakdown(
@@ -636,13 +734,10 @@ func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens
}
func isOpenAIGPT54Model(model string) bool {
- trimmed := strings.TrimSpace(strings.ToLower(model))
- // 仅当模型字符串实际属于 GPT-5/Codex 族时才做归一判定,避免 normalizeCodexModel
- // 的默认兜底把非 OpenAI 模型(claude-*、gemini-*、gpt-4o)误识别为 gpt-5.4。
- if !strings.Contains(trimmed, "gpt-5") && !strings.Contains(trimmed, "codex") {
- return false
- }
- normalized := normalizeCodexModel(trimmed)
+ // 仅当模型字符串实际属于已知 GPT-5/Codex 族时才做归一判定,避免
+ // normalizeCodexModel 的默认兜底把非 OpenAI 模型(claude-*、gemini-*、gpt-4o)
+ // 误识别为 gpt-5.4。
+ normalized := normalizeKnownOpenAICodexModel(model)
return normalized == "gpt-5.4" || normalized == "gpt-5.5"
}
diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go
index 222abd6990d..9a2700e1259 100644
--- a/backend/internal/service/billing_service_test.go
+++ b/backend/internal/service/billing_service_test.go
@@ -137,6 +137,100 @@ func TestGetModelPricing_OpenAIGPT54Fallback(t *testing.T) {
require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12)
}
+func TestBillingServiceGetModelPricing_GPT56ExactFallbacks(t *testing.T) {
+ svc := newTestBillingService()
+ tests := []struct {
+ model string
+ input float64
+ output float64
+ cacheWrite float64
+ cacheRead float64
+ }{
+ {model: "gpt-5.6-sol", input: 5e-6, output: 30e-6, cacheWrite: 6.25e-6, cacheRead: 0.5e-6},
+ {model: "gpt-5.6-terra", input: 2.5e-6, output: 15e-6, cacheWrite: 3.125e-6, cacheRead: 0.25e-6},
+ {model: "gpt-5.6-luna", input: 1e-6, output: 6e-6, cacheWrite: 1.25e-6, cacheRead: 0.1e-6},
+ }
+ for _, tt := range tests {
+ t.Run(tt.model, func(t *testing.T) {
+ pricing, err := svc.GetModelPricing(tt.model)
+ require.NoError(t, err)
+ require.InDelta(t, tt.input, pricing.InputPricePerToken, 1e-12)
+ require.InDelta(t, tt.output, pricing.OutputPricePerToken, 1e-12)
+ require.InDelta(t, tt.cacheWrite, pricing.CacheCreationPricePerToken, 1e-12)
+ require.InDelta(t, tt.cacheRead, pricing.CacheReadPricePerToken, 1e-12)
+ })
+ }
+
+ pricing, err := svc.GetModelPricing("gpt-5.6-unknown")
+ require.Error(t, err)
+ require.Nil(t, pricing)
+}
+
+func TestBillingServiceGetModelPricing_GPT56ChannelOverrideDoesNotPolluteFallback(t *testing.T) {
+ tests := []struct {
+ model string
+ input float64
+ output float64
+ cacheWrite float64
+ cacheRead float64
+ }{
+ {model: "gpt-5.6-sol", input: 5e-6, output: 30e-6, cacheWrite: 6.25e-6, cacheRead: 0.5e-6},
+ {model: "gpt-5.6-terra", input: 2.5e-6, output: 15e-6, cacheWrite: 3.125e-6, cacheRead: 0.25e-6},
+ {model: "gpt-5.6-luna", input: 1e-6, output: 6e-6, cacheWrite: 1.25e-6, cacheRead: 0.1e-6},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.model, func(t *testing.T) {
+ svc := newTestBillingService()
+ overridden, err := svc.GetModelPricingWithChannel(tt.model, &ChannelModelPricing{
+ InputPrice: testPtrFloat64(101),
+ OutputPrice: testPtrFloat64(102),
+ CacheWritePrice: testPtrFloat64(103),
+ CacheReadPrice: testPtrFloat64(104),
+ })
+ require.NoError(t, err)
+ require.InDelta(t, 101.0, overridden.InputPricePerToken, 1e-12)
+
+ fresh, err := svc.GetModelPricing(tt.model)
+ require.NoError(t, err)
+ require.NotSame(t, overridden, fresh)
+ require.InDelta(t, tt.input, fresh.InputPricePerToken, 1e-12)
+ require.InDelta(t, tt.output, fresh.OutputPricePerToken, 1e-12)
+ require.InDelta(t, tt.cacheWrite, fresh.CacheCreationPricePerToken, 1e-12)
+ require.InDelta(t, tt.cacheRead, fresh.CacheReadPricePerToken, 1e-12)
+ })
+ }
+}
+
+func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) {
+ svc := newTestBillingService()
+
+ tests := []struct {
+ model string
+ inputPrice float64
+ outputPrice float64
+ cacheRead float64
+ longContext int
+ }{
+ {model: "gpt5.5", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 272000},
+ {model: "openai/gpt5.4", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 272000},
+ {model: "gpt5.4-mini", inputPrice: 7.5e-7, outputPrice: 4.5e-6, cacheRead: 7.5e-8, longContext: 0},
+ {model: "gpt5.3codexspark", inputPrice: 1.5e-6, outputPrice: 12e-6, cacheRead: 0.15e-6, longContext: 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.model, func(t *testing.T) {
+ pricing, err := svc.GetModelPricing(tt.model)
+ require.NoError(t, err)
+ require.NotNil(t, pricing)
+ require.InDelta(t, tt.inputPrice, pricing.InputPricePerToken, 1e-12)
+ require.InDelta(t, tt.outputPrice, pricing.OutputPricePerToken, 1e-12)
+ require.InDelta(t, tt.cacheRead, pricing.CacheReadPricePerToken, 1e-12)
+ require.Equal(t, tt.longContext, pricing.LongContextInputThreshold)
+ })
+ }
+}
+
func TestGetModelPricing_OpenAIGPT54MiniFallback(t *testing.T) {
svc := newTestBillingService()
diff --git a/backend/internal/service/channel_monitor_checker.go b/backend/internal/service/channel_monitor_checker.go
index 33570629d78..ad8bdde50ea 100644
--- a/backend/internal/service/channel_monitor_checker.go
+++ b/backend/internal/service/channel_monitor_checker.go
@@ -40,7 +40,7 @@ func newSSRFSafeHTTPClient(timeout time.Duration) *http.Client {
// CheckOptions 承载一次检测的自定义入参。
// 所有字段都是可选(零值即等价于"用默认行为")。
type CheckOptions struct {
- // ExtraHeaders 用户自定义 HTTP 头(merge 到 adapter 默认 headers,用户优先)。
+ // ExtraHeaders 用户自定义 HTTP 头(仅允许覆盖非鉴权、非客户端自管 headers)。
ExtraHeaders map[string]string
// BodyOverrideMode: off | merge | replace
BodyOverrideMode string
@@ -249,7 +249,7 @@ func callProvider(ctx context.Context, provider, endpoint, apiKey, model, prompt
}
// mergeHeaders 把用户自定义 headers 合并到 adapter 默认 headers 上。
-// 用户值覆盖默认;命中黑名单(hop-by-hop / 由 http.Client 自管的)的 key 静默丢弃。
+// 用户值仅覆盖非敏感默认值;鉴权、hop-by-hop、客户端自管 key 会被静默丢弃。
func mergeHeaders(base map[string]string, opts *CheckOptions) map[string]string {
if opts == nil || len(opts.ExtraHeaders) == 0 {
return base
diff --git a/backend/internal/service/channel_monitor_secret_payload.go b/backend/internal/service/channel_monitor_secret_payload.go
new file mode 100644
index 00000000000..3199e7e2054
--- /dev/null
+++ b/backend/internal/service/channel_monitor_secret_payload.go
@@ -0,0 +1,166 @@
+package service
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+)
+
+// ChannelMonitorSecretEnvelopeKey is the only top-level key persisted in the
+// JSONB request-customization fields. Keeping the marker outside the encrypted
+// payload lets the startup migration distinguish legacy plaintext rows from
+// domain-bound ciphertext without relying on secret-looking field names.
+const ChannelMonitorSecretEnvelopeKey = "__sub2api_channel_monitor_secret_v1__"
+
+const (
+ channelMonitorSecretPayloadVersion = 1
+ channelMonitorHeadersPayloadKind = "extra_headers"
+ channelMonitorBodyPayloadKind = "body_override"
+)
+
+type channelMonitorSecretPayload struct {
+ Version int `json:"version"`
+ Kind string `json:"kind"`
+ Headers map[string]string `json:"headers,omitempty"`
+ Body map[string]any `json:"body,omitempty"`
+}
+
+// SealChannelMonitorExtraHeaders encrypts the complete header map. Individual
+// values must never be persisted alongside the envelope in plaintext.
+func SealChannelMonitorExtraHeaders(encryptor SecretEncryptor, headers map[string]string) (map[string]string, error) {
+ if err := validateExtraHeaders(headers); err != nil {
+ return nil, err
+ }
+ payload := channelMonitorSecretPayload{
+ Version: channelMonitorSecretPayloadVersion,
+ Kind: channelMonitorHeadersPayloadKind,
+ Headers: emptyHeadersIfNil(headers),
+ }
+ ciphertext, err := sealChannelMonitorPayload(encryptor, payload)
+ if err != nil {
+ return nil, fmt.Errorf("encrypt channel monitor extra headers: %w", err)
+ }
+ return map[string]string{ChannelMonitorSecretEnvelopeKey: ciphertext}, nil
+}
+
+// OpenChannelMonitorExtraHeaders strictly accepts the marker-only encrypted
+// representation. Legacy plaintext, malformed envelopes, and wrong-domain
+// ciphertext all fail closed.
+func OpenChannelMonitorExtraHeaders(encryptor SecretEncryptor, stored map[string]string) (map[string]string, error) {
+ if len(stored) != 1 {
+ return nil, errors.New("channel monitor extra headers are not an encrypted envelope")
+ }
+ ciphertext, ok := stored[ChannelMonitorSecretEnvelopeKey]
+ if !ok || ciphertext == "" {
+ return nil, errors.New("channel monitor extra headers envelope is malformed")
+ }
+ payload, err := openChannelMonitorPayload(encryptor, ciphertext, channelMonitorHeadersPayloadKind)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt channel monitor extra headers: %w", err)
+ }
+ if payload.Headers == nil {
+ payload.Headers = map[string]string{}
+ }
+ if err := validateExtraHeaders(payload.Headers); err != nil {
+ return nil, fmt.Errorf("validate decrypted channel monitor extra headers: %w", err)
+ }
+ return payload.Headers, nil
+}
+
+// SealChannelMonitorBodyOverride encrypts a non-nil complete body map. A SQL
+// NULL body contains no secret and remains nil so existing off-mode semantics
+// are preserved.
+func SealChannelMonitorBodyOverride(encryptor SecretEncryptor, body map[string]any) (map[string]any, error) {
+ if body == nil {
+ return nil, nil
+ }
+ if err := validateChannelMonitorBodyOverride(body); err != nil {
+ return nil, err
+ }
+ payload := channelMonitorSecretPayload{
+ Version: channelMonitorSecretPayloadVersion,
+ Kind: channelMonitorBodyPayloadKind,
+ Body: body,
+ }
+ ciphertext, err := sealChannelMonitorPayload(encryptor, payload)
+ if err != nil {
+ return nil, fmt.Errorf("encrypt channel monitor body override: %w", err)
+ }
+ return map[string]any{ChannelMonitorSecretEnvelopeKey: ciphertext}, nil
+}
+
+// OpenChannelMonitorBodyOverride is the strict inverse of
+// SealChannelMonitorBodyOverride. nil is the only unencrypted representation
+// accepted because it carries no payload.
+func OpenChannelMonitorBodyOverride(encryptor SecretEncryptor, stored map[string]any) (map[string]any, error) {
+ if stored == nil {
+ return nil, nil
+ }
+ if len(stored) != 1 {
+ return nil, errors.New("channel monitor body override is not an encrypted envelope")
+ }
+ raw, ok := stored[ChannelMonitorSecretEnvelopeKey]
+ if !ok {
+ return nil, errors.New("channel monitor body override envelope is malformed")
+ }
+ ciphertext, ok := raw.(string)
+ if !ok || ciphertext == "" {
+ return nil, errors.New("channel monitor body override ciphertext is malformed")
+ }
+ payload, err := openChannelMonitorPayload(encryptor, ciphertext, channelMonitorBodyPayloadKind)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt channel monitor body override: %w", err)
+ }
+ if payload.Body == nil {
+ payload.Body = map[string]any{}
+ }
+ if err := validateChannelMonitorBodyOverride(payload.Body); err != nil {
+ return nil, fmt.Errorf("validate decrypted channel monitor body override: %w", err)
+ }
+ return payload.Body, nil
+}
+
+// HasChannelMonitorExtraHeadersEnvelopeMarker reports marker presence, not
+// validity. The migration uses this to ensure malformed marker-shaped rows are
+// rejected instead of being re-encrypted as if they were plaintext.
+func HasChannelMonitorExtraHeadersEnvelopeMarker(headers map[string]string) bool {
+ _, ok := headers[ChannelMonitorSecretEnvelopeKey]
+ return ok
+}
+
+// HasChannelMonitorBodyOverrideEnvelopeMarker is the body-map counterpart of
+// HasChannelMonitorExtraHeadersEnvelopeMarker.
+func HasChannelMonitorBodyOverrideEnvelopeMarker(body map[string]any) bool {
+ _, ok := body[ChannelMonitorSecretEnvelopeKey]
+ return ok
+}
+
+func sealChannelMonitorPayload(encryptor SecretEncryptor, payload channelMonitorSecretPayload) (string, error) {
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return "", err
+ }
+ return EncryptForSecretDomain(encryptor, SecretDomainChannelMonitor, string(raw))
+}
+
+func openChannelMonitorPayload(encryptor SecretEncryptor, ciphertext, wantKind string) (channelMonitorSecretPayload, error) {
+ plain, err := DecryptForSecretDomain(encryptor, SecretDomainChannelMonitor, ciphertext)
+ if err != nil {
+ return channelMonitorSecretPayload{}, err
+ }
+ var payload channelMonitorSecretPayload
+ if err := json.Unmarshal([]byte(plain), &payload); err != nil {
+ return channelMonitorSecretPayload{}, err
+ }
+ if payload.Version != channelMonitorSecretPayloadVersion || payload.Kind != wantKind {
+ return channelMonitorSecretPayload{}, errors.New("channel monitor secret payload type mismatch")
+ }
+ return payload, nil
+}
+
+func validateChannelMonitorBodyEnvelopeKey(body map[string]any) error {
+ if _, exists := body[ChannelMonitorSecretEnvelopeKey]; exists {
+ return ErrChannelMonitorTemplateBodyReservedKey
+ }
+ return nil
+}
diff --git a/backend/internal/service/channel_monitor_secret_payload_test.go b/backend/internal/service/channel_monitor_secret_payload_test.go
new file mode 100644
index 00000000000..d7d7cec29de
--- /dev/null
+++ b/backend/internal/service/channel_monitor_secret_payload_test.go
@@ -0,0 +1,354 @@
+package service
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+type channelMonitorPayloadTestEncryptor struct{}
+
+func (channelMonitorPayloadTestEncryptor) Encrypt(plaintext string) (string, error) {
+ return "legacy:" + base64.RawURLEncoding.EncodeToString([]byte(plaintext)), nil
+}
+
+func (channelMonitorPayloadTestEncryptor) Decrypt(ciphertext string) (string, error) {
+ if !strings.HasPrefix(ciphertext, "legacy:") {
+ return "", errors.New("invalid legacy ciphertext")
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(ciphertext, "legacy:"))
+ return string(raw), err
+}
+
+func (channelMonitorPayloadTestEncryptor) EncryptForDomain(domain, plaintext string) (string, error) {
+ raw := domain + "\x00" + plaintext
+ return "domain:" + base64.RawURLEncoding.EncodeToString([]byte(raw)), nil
+}
+
+func (channelMonitorPayloadTestEncryptor) DecryptForDomain(domain, ciphertext string) (string, error) {
+ if !strings.HasPrefix(ciphertext, "domain:") {
+ return "", errors.New("invalid domain ciphertext")
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(ciphertext, "domain:"))
+ if err != nil {
+ return "", err
+ }
+ prefix := domain + "\x00"
+ if !strings.HasPrefix(string(raw), prefix) {
+ return "", errors.New("secret domain mismatch")
+ }
+ return strings.TrimPrefix(string(raw), prefix), nil
+}
+
+func TestChannelMonitorSecretPayloadRoundTripAndStrictFailures(t *testing.T) {
+ encryptor := channelMonitorPayloadTestEncryptor{}
+ headers := map[string]string{"User-Agent": "monitor-test", "anthropic-beta": "feature"}
+ body := map[string]any{"metadata": map[string]any{"token": "body-secret"}, "temperature": 0.25}
+
+ sealedHeaders, err := SealChannelMonitorExtraHeaders(encryptor, headers)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(sealedHeaders) != 1 || sealedHeaders[ChannelMonitorSecretEnvelopeKey] == "" {
+ t.Fatalf("sealed headers = %#v", sealedHeaders)
+ }
+ openedHeaders, err := OpenChannelMonitorExtraHeaders(encryptor, sealedHeaders)
+ if err != nil || !reflect.DeepEqual(openedHeaders, headers) {
+ t.Fatalf("opened headers = %#v, err=%v", openedHeaders, err)
+ }
+
+ sealedBody, err := SealChannelMonitorBodyOverride(encryptor, body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ openedBody, err := OpenChannelMonitorBodyOverride(encryptor, sealedBody)
+ if err != nil || !reflect.DeepEqual(openedBody, body) {
+ t.Fatalf("opened body = %#v, err=%v", openedBody, err)
+ }
+
+ if _, err := OpenChannelMonitorExtraHeaders(encryptor, headers); err == nil {
+ t.Fatal("plaintext headers were accepted")
+ }
+ malformedHeaders := map[string]string{
+ ChannelMonitorSecretEnvelopeKey: sealedHeaders[ChannelMonitorSecretEnvelopeKey],
+ "User-Agent": "plaintext-sibling",
+ }
+ if _, err := OpenChannelMonitorExtraHeaders(encryptor, malformedHeaders); err == nil {
+ t.Fatal("marker envelope with plaintext sibling was accepted")
+ }
+ wrongDomain, err := encryptor.EncryptForDomain(SecretDomainSettingSecret, `{"version":1,"kind":"extra_headers","headers":{}}`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := OpenChannelMonitorExtraHeaders(encryptor, map[string]string{ChannelMonitorSecretEnvelopeKey: wrongDomain}); err == nil {
+ t.Fatal("wrong-domain ciphertext was accepted")
+ }
+ if _, err := OpenChannelMonitorBodyOverride(encryptor, map[string]any{ChannelMonitorSecretEnvelopeKey: sealedHeaders[ChannelMonitorSecretEnvelopeKey]}); err == nil {
+ t.Fatal("header payload was accepted as body payload")
+ }
+ if got, err := OpenChannelMonitorBodyOverride(encryptor, nil); err != nil || got != nil {
+ t.Fatalf("nil body = %#v, err=%v", got, err)
+ }
+}
+
+func TestChannelMonitorCredentialHeadersAndEnvelopeMarkerAreForbidden(t *testing.T) {
+ for _, name := range []string{
+ "Authorization", "proxy-authorization", "Cookie", "Set-Cookie",
+ "x-api-key", "API-Key", "X-Goog-Api-Key", "x-auth-token",
+ "X-Amz-Security-Token", "X-Client-Secret", "X-Request-Signature",
+ "Private-Token", "Ocp-Apim-Subscription-Key", "X-Password",
+ ChannelMonitorSecretEnvelopeKey,
+ } {
+ t.Run(name, func(t *testing.T) {
+ if err := validateExtraHeaders(map[string]string{name: "attacker-value"}); err == nil {
+ t.Fatalf("credential-shaped header %q was accepted", name)
+ }
+ })
+ }
+ if err := validateExtraHeaders(map[string]string{"User-Agent": "sub2api-monitor/1", "anthropic-beta": "interleaved-thinking-2025-05-14"}); err != nil {
+ t.Fatalf("ordinary headers rejected: %v", err)
+ }
+ for name, value := range map[string]string{
+ "User-Agent": "claude-cli/2.1.92 (external, cli)",
+ "X-App": "cli",
+ "anthropic-beta": "interleaved-thinking-2025-05-14,claude-code-20250219",
+ "Anthropic-Dangerous-Direct-Browser-Access": "true",
+ } {
+ if err := validateExtraHeaders(map[string]string{name: value}); err == nil {
+ t.Fatalf("official-client attribution header %q was accepted", name)
+ }
+ }
+ if err := validateBodyModeParams(MonitorBodyOverrideModeMerge, map[string]any{
+ "system": "You are Claude Code, Anthropic's official CLI for Claude.",
+ }); err == nil {
+ t.Fatal("official-client attribution body was accepted")
+ }
+ if err := validateBodyModeParams(MonitorBodyOverrideModeReplace, map[string]any{ChannelMonitorSecretEnvelopeKey: "collision"}); err == nil {
+ t.Fatal("reserved body envelope marker was accepted")
+ }
+
+ base := map[string]string{"Authorization": "Bearer real", "x-api-key": "real-key"}
+ got := mergeHeaders(base, &CheckOptions{ExtraHeaders: map[string]string{
+ "authorization": "Bearer attacker",
+ "X-API-Key": "attacker-key",
+ "User-Agent": "sub2api-monitor/1",
+ }})
+ if got["Authorization"] != "Bearer real" || got["x-api-key"] != "real-key" {
+ t.Fatalf("adapter credentials were overridden: %#v", got)
+ }
+ if got["User-Agent"] != "sub2api-monitor/1" {
+ t.Fatalf("ordinary header was not merged: %#v", got)
+ }
+}
+
+type channelMonitorPayloadRepoSpy struct {
+ ChannelMonitorRepository
+ created *ChannelMonitor
+ updated *ChannelMonitor
+ stored *ChannelMonitor
+}
+
+func (r *channelMonitorPayloadRepoSpy) Create(_ context.Context, m *ChannelMonitor) error {
+ r.created = cloneChannelMonitorPayloadTest(m)
+ m.ID = 41
+ r.created.ID = m.ID
+ return nil
+}
+
+func (r *channelMonitorPayloadRepoSpy) GetByID(_ context.Context, _ int64) (*ChannelMonitor, error) {
+ return cloneChannelMonitorPayloadTest(r.stored), nil
+}
+
+func (r *channelMonitorPayloadRepoSpy) Update(_ context.Context, m *ChannelMonitor) error {
+ r.updated = cloneChannelMonitorPayloadTest(m)
+ return nil
+}
+
+func TestChannelMonitorServicePersistsEncryptedPayloadAndReturnsPlaintext(t *testing.T) {
+ encryptor := channelMonitorPayloadTestEncryptor{}
+ repo := &channelMonitorPayloadRepoSpy{}
+ svc := NewChannelMonitorService(repo, encryptor)
+ wantHeaders := map[string]string{"User-Agent": "monitor-client"}
+ wantBody := map[string]any{"metadata": map[string]any{"token": "body-secret"}}
+
+ created, err := svc.Create(context.Background(), ChannelMonitorCreateParams{
+ Name: "encrypted-monitor",
+ Provider: MonitorProviderOpenAI,
+ Endpoint: "https://api.example.test",
+ APIKey: "api-secret",
+ PrimaryModel: "gpt-test",
+ Enabled: true,
+ IntervalSeconds: 60,
+ CreatedBy: 1,
+ ExtraHeaders: wantHeaders,
+ BodyOverrideMode: MonitorBodyOverrideModeReplace,
+ BodyOverride: wantBody,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if repo.created == nil || !HasChannelMonitorExtraHeadersEnvelopeMarker(repo.created.ExtraHeaders) || !HasChannelMonitorBodyOverrideEnvelopeMarker(repo.created.BodyOverride) {
+ t.Fatalf("repository received plaintext payload: %#v", repo.created)
+ }
+ if strings.Contains(repo.created.ExtraHeaders[ChannelMonitorSecretEnvelopeKey], "monitor-client") || strings.Contains(repo.created.BodyOverride[ChannelMonitorSecretEnvelopeKey].(string), "body-secret") {
+ t.Fatal("repository payload ciphertext contains plaintext secret")
+ }
+ if created.APIKey != "api-secret" || !reflect.DeepEqual(created.ExtraHeaders, wantHeaders) || !reflect.DeepEqual(created.BodyOverride, wantBody) {
+ t.Fatalf("create response = %#v", created)
+ }
+
+ repo.stored = repo.created
+ got, err := svc.Get(context.Background(), 41)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.APIKey != "api-secret" || !reflect.DeepEqual(got.ExtraHeaders, wantHeaders) || !reflect.DeepEqual(got.BodyOverride, wantBody) {
+ t.Fatalf("get response = %#v", got)
+ }
+ newName := "updated-monitor"
+ repo.stored = repo.created
+ updated, err := svc.Update(context.Background(), 41, ChannelMonitorUpdateParams{Name: &newName})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if repo.updated == nil || !HasChannelMonitorExtraHeadersEnvelopeMarker(repo.updated.ExtraHeaders) || !HasChannelMonitorBodyOverrideEnvelopeMarker(repo.updated.BodyOverride) {
+ t.Fatalf("repository received plaintext update: %#v", repo.updated)
+ }
+ if updated.Name != newName || updated.APIKey != "api-secret" || !reflect.DeepEqual(updated.ExtraHeaders, wantHeaders) || !reflect.DeepEqual(updated.BodyOverride, wantBody) {
+ t.Fatalf("update response = %#v", updated)
+ }
+
+ repo.stored = cloneChannelMonitorPayloadTest(repo.created)
+ repo.stored.ExtraHeaders = map[string]string{"User-Agent": "legacy-plaintext"}
+ if _, err := svc.Get(context.Background(), 41); err == nil {
+ t.Fatal("service accepted a plaintext stored payload")
+ }
+}
+
+type channelMonitorTemplatePayloadRepoSpy struct {
+ ChannelMonitorRequestTemplateRepository
+ created *ChannelMonitorRequestTemplate
+ updated *ChannelMonitorRequestTemplate
+ stored *ChannelMonitorRequestTemplate
+ applyCalls int
+}
+
+func (r *channelMonitorTemplatePayloadRepoSpy) Create(_ context.Context, t *ChannelMonitorRequestTemplate) error {
+ r.created = cloneChannelMonitorTemplatePayloadTest(t)
+ t.ID = 72
+ r.created.ID = t.ID
+ return nil
+}
+
+func (r *channelMonitorTemplatePayloadRepoSpy) GetByID(_ context.Context, _ int64) (*ChannelMonitorRequestTemplate, error) {
+ return cloneChannelMonitorTemplatePayloadTest(r.stored), nil
+}
+
+func (r *channelMonitorTemplatePayloadRepoSpy) Update(_ context.Context, t *ChannelMonitorRequestTemplate) error {
+ r.updated = cloneChannelMonitorTemplatePayloadTest(t)
+ return nil
+}
+
+func (r *channelMonitorTemplatePayloadRepoSpy) ApplyToMonitors(_ context.Context, _ int64, _ []int64) (int64, error) {
+ r.applyCalls++
+ return 1, nil
+}
+
+func TestChannelMonitorTemplateServiceEncryptsAndBlocksPlaintextFanout(t *testing.T) {
+ encryptor := channelMonitorPayloadTestEncryptor{}
+ repo := &channelMonitorTemplatePayloadRepoSpy{}
+ svc := NewChannelMonitorRequestTemplateService(repo, encryptor)
+ wantHeaders := map[string]string{"User-Agent": "template-client"}
+ wantBody := map[string]any{"metadata": map[string]any{"token": "template-secret"}}
+
+ created, err := svc.Create(context.Background(), ChannelMonitorRequestTemplateCreateParams{
+ Name: "encrypted-template",
+ Provider: MonitorProviderOpenAI,
+ ExtraHeaders: wantHeaders,
+ BodyOverrideMode: MonitorBodyOverrideModeReplace,
+ BodyOverride: wantBody,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if repo.created == nil || !HasChannelMonitorExtraHeadersEnvelopeMarker(repo.created.ExtraHeaders) || !HasChannelMonitorBodyOverrideEnvelopeMarker(repo.created.BodyOverride) {
+ t.Fatalf("repository received plaintext template: %#v", repo.created)
+ }
+ if !reflect.DeepEqual(created.ExtraHeaders, wantHeaders) || !reflect.DeepEqual(created.BodyOverride, wantBody) {
+ t.Fatalf("template response = %#v", created)
+ }
+ description := "updated description"
+ repo.stored = repo.created
+ updated, err := svc.Update(context.Background(), 72, ChannelMonitorRequestTemplateUpdateParams{Description: &description})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if repo.updated == nil || !HasChannelMonitorExtraHeadersEnvelopeMarker(repo.updated.ExtraHeaders) || !HasChannelMonitorBodyOverrideEnvelopeMarker(repo.updated.BodyOverride) {
+ t.Fatalf("repository received plaintext template update: %#v", repo.updated)
+ }
+ if updated.Description != description || !reflect.DeepEqual(updated.ExtraHeaders, wantHeaders) || !reflect.DeepEqual(updated.BodyOverride, wantBody) {
+ t.Fatalf("template update response = %#v", updated)
+ }
+
+ repo.stored = repo.created
+ if _, err := svc.ApplyToMonitors(context.Background(), 72, []int64{41}); err != nil {
+ t.Fatal(err)
+ }
+ if repo.applyCalls != 1 {
+ t.Fatalf("apply calls = %d", repo.applyCalls)
+ }
+
+ repo.stored = cloneChannelMonitorTemplatePayloadTest(repo.created)
+ repo.stored.BodyOverride = map[string]any{"token": "legacy-plaintext"}
+ if _, err := svc.ApplyToMonitors(context.Background(), 72, []int64{41}); err == nil {
+ t.Fatal("plaintext template was fanned out")
+ }
+ if repo.applyCalls != 1 {
+ t.Fatalf("apply ran after payload validation failed: calls=%d", repo.applyCalls)
+ }
+}
+
+func cloneChannelMonitorPayloadTest(m *ChannelMonitor) *ChannelMonitor {
+ if m == nil {
+ return nil
+ }
+ clone := *m
+ clone.ExtraHeaders = cloneStringMap(m.ExtraHeaders)
+ clone.BodyOverride = cloneAnyMap(m.BodyOverride)
+ return &clone
+}
+
+func cloneChannelMonitorTemplatePayloadTest(t *ChannelMonitorRequestTemplate) *ChannelMonitorRequestTemplate {
+ if t == nil {
+ return nil
+ }
+ clone := *t
+ clone.ExtraHeaders = cloneStringMap(t.ExtraHeaders)
+ clone.BodyOverride = cloneAnyMap(t.BodyOverride)
+ return &clone
+}
+
+func cloneStringMap(input map[string]string) map[string]string {
+ if input == nil {
+ return nil
+ }
+ out := make(map[string]string, len(input))
+ for key, value := range input {
+ out[key] = value
+ }
+ return out
+}
+
+func cloneAnyMap(input map[string]any) map[string]any {
+ if input == nil {
+ return nil
+ }
+ out := make(map[string]any, len(input))
+ for key, value := range input {
+ out[key] = value
+ }
+ return out
+}
diff --git a/backend/internal/service/channel_monitor_service.go b/backend/internal/service/channel_monitor_service.go
index 7050e141894..97ca9f4ef42 100644
--- a/backend/internal/service/channel_monitor_service.go
+++ b/backend/internal/service/channel_monitor_service.go
@@ -88,6 +88,9 @@ func (s *ChannelMonitorService) List(ctx context.Context, params ChannelMonitorL
}
for _, it := range items {
s.decryptInPlace(it)
+ if err := s.openRequestPayloadInPlace(it); err != nil {
+ return nil, 0, err
+ }
}
return items, total, nil
}
@@ -99,6 +102,9 @@ func (s *ChannelMonitorService) Get(ctx context.Context, id int64) (*ChannelMoni
return nil, err
}
s.decryptInPlace(m)
+ if err := s.openRequestPayloadInPlace(m); err != nil {
+ return nil, err
+ }
return m, nil
}
@@ -113,7 +119,7 @@ func (s *ChannelMonitorService) Create(ctx context.Context, p ChannelMonitorCrea
if err := validateExtraHeaders(p.ExtraHeaders); err != nil {
return nil, err
}
- encrypted, err := s.encryptor.Encrypt(p.APIKey)
+ encrypted, err := EncryptForSecretDomain(s.encryptor, SecretDomainChannelMonitor, p.APIKey)
if err != nil {
return nil, fmt.Errorf("encrypt api key: %w", err)
}
@@ -133,12 +139,17 @@ func (s *ChannelMonitorService) Create(ctx context.Context, p ChannelMonitorCrea
BodyOverrideMode: defaultBodyMode(p.BodyOverrideMode),
BodyOverride: p.BodyOverride,
}
+ plainHeaders, plainBody := m.ExtraHeaders, m.BodyOverride
+ if err := s.sealRequestPayloadInPlace(m); err != nil {
+ return nil, err
+ }
if err := s.repo.Create(ctx, m); err != nil {
return nil, fmt.Errorf("create channel monitor: %w", err)
}
// 不再调 s.Get 重走解密链:已知刚加密的明文,直接构造响应。
// 这样可避免 SecretEncryptor 解密失败时 APIKey 被静默清空的问题(见 Fix 4)。
m.APIKey = strings.TrimSpace(p.APIKey)
+ m.ExtraHeaders, m.BodyOverride = plainHeaders, plainBody
if s.scheduler != nil {
s.scheduler.Schedule(m)
}
@@ -171,6 +182,9 @@ func (s *ChannelMonitorService) Update(ctx context.Context, id int64, p ChannelM
if err != nil {
return nil, err
}
+ if err := s.openRequestPayloadInPlace(existing); err != nil {
+ return nil, err
+ }
if err := applyMonitorUpdate(existing, p); err != nil {
return nil, err
}
@@ -180,9 +194,14 @@ func (s *ChannelMonitorService) Update(ctx context.Context, id int64, p ChannelM
return nil, err
}
+ plainHeaders, plainBody := existing.ExtraHeaders, existing.BodyOverride
+ if err := s.sealRequestPayloadInPlace(existing); err != nil {
+ return nil, err
+ }
if err := s.repo.Update(ctx, existing); err != nil {
return nil, fmt.Errorf("update channel monitor: %w", err)
}
+ existing.ExtraHeaders, existing.BodyOverride = plainHeaders, plainBody
// 不再调 s.Get 重走解密链:避免二次解密带来的"密文被静默清空"风险(与 Create 一致)。
if apiKeyUpdated {
@@ -207,7 +226,7 @@ func (s *ChannelMonitorService) applyAPIKeyUpdate(existing *ChannelMonitor, raw
return "", false, nil
}
plain = strings.TrimSpace(*raw)
- encrypted, encErr := s.encryptor.Encrypt(plain)
+ encrypted, encErr := EncryptForSecretDomain(s.encryptor, SecretDomainChannelMonitor, plain)
if encErr != nil {
return "", false, fmt.Errorf("encrypt api key: %w", encErr)
}
@@ -336,6 +355,9 @@ func (s *ChannelMonitorService) ListEnabledMonitors(ctx context.Context) ([]*Cha
}
for _, m := range all {
s.decryptInPlace(m)
+ if err := s.openRequestPayloadInPlace(m); err != nil {
+ return nil, err
+ }
}
return all, nil
}
@@ -452,7 +474,7 @@ func (s *ChannelMonitorService) decryptInPlace(m *ChannelMonitor) {
if m == nil || m.APIKey == "" {
return
}
- plain, err := s.encryptor.Decrypt(m.APIKey)
+ plain, err := DecryptForSecretDomain(s.encryptor, SecretDomainChannelMonitor, m.APIKey)
if err != nil {
slog.Warn("channel_monitor: decrypt api key failed",
"monitor_id", m.ID, "error", err)
@@ -463,6 +485,35 @@ func (s *ChannelMonitorService) decryptInPlace(m *ChannelMonitor) {
m.APIKey = plain
}
+func (s *ChannelMonitorService) sealRequestPayloadInPlace(m *ChannelMonitor) error {
+ headers, err := SealChannelMonitorExtraHeaders(s.encryptor, m.ExtraHeaders)
+ if err != nil {
+ return err
+ }
+ body, err := SealChannelMonitorBodyOverride(s.encryptor, m.BodyOverride)
+ if err != nil {
+ return err
+ }
+ m.ExtraHeaders, m.BodyOverride = headers, body
+ return nil
+}
+
+func (s *ChannelMonitorService) openRequestPayloadInPlace(m *ChannelMonitor) error {
+ if m == nil {
+ return nil
+ }
+ headers, err := OpenChannelMonitorExtraHeaders(s.encryptor, m.ExtraHeaders)
+ if err != nil {
+ return fmt.Errorf("open channel monitor %d extra headers: %w", m.ID, err)
+ }
+ body, err := OpenChannelMonitorBodyOverride(s.encryptor, m.BodyOverride)
+ if err != nil {
+ return fmt.Errorf("open channel monitor %d body override: %w", m.ID, err)
+ }
+ m.ExtraHeaders, m.BodyOverride = headers, body
+ return nil
+}
+
// applyMonitorUpdate 把 update params 中非 nil 的字段应用到 existing 上。
// APIKey 字段在调用方单独处理(涉及加密)。
//
@@ -524,6 +575,9 @@ func applyMonitorAdvancedUpdate(existing *ChannelMonitor, p ChannelMonitorUpdate
newBody := existing.BodyOverride
if p.BodyOverrideMode != nil {
newMode = *p.BodyOverrideMode
+ if newMode == MonitorBodyOverrideModeOff {
+ newBody = nil
+ }
}
if p.BodyOverride != nil {
newBody = *p.BodyOverride
diff --git a/backend/internal/service/channel_monitor_template_service.go b/backend/internal/service/channel_monitor_template_service.go
index 8d2e8173f64..0efaad1bdf6 100644
--- a/backend/internal/service/channel_monitor_template_service.go
+++ b/backend/internal/service/channel_monitor_template_service.go
@@ -2,6 +2,7 @@ package service
import (
"context"
+ "encoding/json"
"fmt"
"regexp"
"strings"
@@ -14,7 +15,7 @@ type ChannelMonitorRequestTemplateRepository interface {
Update(ctx context.Context, t *ChannelMonitorRequestTemplate) error
Delete(ctx context.Context, id int64) error
List(ctx context.Context, params ChannelMonitorRequestTemplateListParams) ([]*ChannelMonitorRequestTemplate, error)
- // ApplyToMonitors 把模板当前的 extra_headers / body_override_mode / body_override
+ // ApplyToMonitors 把模板当前已加密的 extra_headers / body_override 快照和 mode
// 批量覆盖到指定 monitorIDs 的监控上(同时还要求这些监控当前 template_id = id,
// 防止误覆盖未关联的监控)。monitorIDs 必须非空;空列表直接返回 0 不写库。
// 返回被覆盖的监控数量。
@@ -36,12 +37,13 @@ type AssociatedMonitorBrief struct {
// ChannelMonitorRequestTemplateService 模板管理 service。
type ChannelMonitorRequestTemplateService struct {
- repo ChannelMonitorRequestTemplateRepository
+ repo ChannelMonitorRequestTemplateRepository
+ encryptor SecretEncryptor
}
// NewChannelMonitorRequestTemplateService 创建模板 service。
-func NewChannelMonitorRequestTemplateService(repo ChannelMonitorRequestTemplateRepository) *ChannelMonitorRequestTemplateService {
- return &ChannelMonitorRequestTemplateService{repo: repo}
+func NewChannelMonitorRequestTemplateService(repo ChannelMonitorRequestTemplateRepository, encryptor SecretEncryptor) *ChannelMonitorRequestTemplateService {
+ return &ChannelMonitorRequestTemplateService{repo: repo, encryptor: encryptor}
}
// ---------- CRUD ----------
@@ -53,12 +55,28 @@ func (s *ChannelMonitorRequestTemplateService) List(ctx context.Context, params
return nil, err
}
}
- return s.repo.List(ctx, params)
+ items, err := s.repo.List(ctx, params)
+ if err != nil {
+ return nil, err
+ }
+ for _, item := range items {
+ if err := s.openRequestPayloadInPlace(item); err != nil {
+ return nil, err
+ }
+ }
+ return items, nil
}
// Get 返回单个模板。
func (s *ChannelMonitorRequestTemplateService) Get(ctx context.Context, id int64) (*ChannelMonitorRequestTemplate, error) {
- return s.repo.GetByID(ctx, id)
+ t, err := s.repo.GetByID(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.openRequestPayloadInPlace(t); err != nil {
+ return nil, err
+ }
+ return t, nil
}
// Create 创建模板(会校验 headers 黑名单和 body 模式匹配)。
@@ -74,9 +92,14 @@ func (s *ChannelMonitorRequestTemplateService) Create(ctx context.Context, p Cha
BodyOverrideMode: defaultBodyMode(p.BodyOverrideMode),
BodyOverride: p.BodyOverride,
}
+ plainHeaders, plainBody := t.ExtraHeaders, t.BodyOverride
+ if err := s.sealRequestPayloadInPlace(t); err != nil {
+ return nil, err
+ }
if err := s.repo.Create(ctx, t); err != nil {
return nil, fmt.Errorf("create template: %w", err)
}
+ t.ExtraHeaders, t.BodyOverride = plainHeaders, plainBody
return t, nil
}
@@ -86,12 +109,20 @@ func (s *ChannelMonitorRequestTemplateService) Update(ctx context.Context, id in
if err != nil {
return nil, err
}
+ if err := s.openRequestPayloadInPlace(existing); err != nil {
+ return nil, err
+ }
if err := applyTemplateUpdate(existing, p); err != nil {
return nil, err
}
+ plainHeaders, plainBody := existing.ExtraHeaders, existing.BodyOverride
+ if err := s.sealRequestPayloadInPlace(existing); err != nil {
+ return nil, err
+ }
if err := s.repo.Update(ctx, existing); err != nil {
return nil, fmt.Errorf("update template: %w", err)
}
+ existing.ExtraHeaders, existing.BodyOverride = plainHeaders, plainBody
return existing, nil
}
@@ -107,7 +138,7 @@ func (s *ChannelMonitorRequestTemplateService) Delete(ctx context.Context, id in
// monitorIDs 必须非空且每个 id 都必须当前 template_id = id;不满足条件的会被 SQL WHERE 过滤掉。
// 返回实际被覆盖的监控数。
func (s *ChannelMonitorRequestTemplateService) ApplyToMonitors(ctx context.Context, id int64, monitorIDs []int64) (int64, error) {
- if _, err := s.repo.GetByID(ctx, id); err != nil {
+ if _, err := s.Get(ctx, id); err != nil {
return 0, err
}
if len(monitorIDs) == 0 {
@@ -176,6 +207,9 @@ func applyTemplateUpdate(existing *ChannelMonitorRequestTemplate, p ChannelMonit
newBody := existing.BodyOverride
if p.BodyOverrideMode != nil {
newMode = *p.BodyOverrideMode
+ if newMode == MonitorBodyOverrideModeOff {
+ newBody = nil
+ }
}
if p.BodyOverride != nil {
newBody = *p.BodyOverride
@@ -190,6 +224,9 @@ func applyTemplateUpdate(existing *ChannelMonitorRequestTemplate, p ChannelMonit
// validateBodyModeParams 校验 body_override_mode 合法,且 merge/replace 模式下 body_override 非空。
func validateBodyModeParams(mode string, body map[string]any) error {
+ if err := validateChannelMonitorBodyOverride(body); err != nil {
+ return err
+ }
switch mode {
case "", MonitorBodyOverrideModeOff:
return nil
@@ -209,27 +246,109 @@ var headerNameRegex = regexp.MustCompile(`^[A-Za-z0-9!#$%&'*+\-.^_` + "`" + `|~]
// forbiddenHeaderNames hop-by-hop + HTTP 客户端自管的 header;禁止用户覆盖,
// 否则会让 Go http.Client 行为异常(双重 Content-Length、连接复用错乱等)。
var forbiddenHeaderNames = map[string]bool{
- "host": true,
- "content-length": true,
- "content-encoding": true,
- "transfer-encoding": true,
- "connection": true,
+ "host": true,
+ "content-length": true,
+ "content-encoding": true,
+ "transfer-encoding": true,
+ "connection": true,
+ "authorization": true,
+ "proxy-authorization": true,
+ "cookie": true,
+ "set-cookie": true,
+ "x-api-key": true,
+ "api-key": true,
+ "x-goog-api-key": true,
+ "x-auth-token": true,
+ ChannelMonitorSecretEnvelopeKey: true,
+}
+
+var forbiddenCredentialHeaderFragments = []string{
+ "authorization",
+ "apikey",
+ "authtoken",
+ "accesstoken",
+ "securitytoken",
+ "credential",
+ "clientsecret",
+ "privatekey",
+ "signature",
+ "cookie",
+ "token",
+ "password",
}
// IsForbiddenHeaderName 对外暴露,checker 运行时也会再过滤一次做兜底。
func IsForbiddenHeaderName(name string) bool {
- return forbiddenHeaderNames[strings.ToLower(strings.TrimSpace(name))]
+ normalized := strings.ToLower(strings.TrimSpace(name))
+ if forbiddenHeaderNames[normalized] {
+ return true
+ }
+ parts := strings.FieldsFunc(normalized, func(r rune) bool { return r == '-' || r == '_' || r == '.' })
+ if len(parts) > 0 && parts[len(parts)-1] == "key" {
+ return true
+ }
+ compact := strings.NewReplacer("-", "", "_", "", ".", "").Replace(normalized)
+ for _, fragment := range forbiddenCredentialHeaderFragments {
+ if strings.Contains(compact, fragment) {
+ return true
+ }
+ }
+ return false
}
// validateExtraHeaders 校验 header 名字格式 + 黑名单。保存时就拒绝非法 header,早失败。
func validateExtraHeaders(h map[string]string) error {
- for k := range h {
+ for k, v := range h {
if !headerNameRegex.MatchString(k) {
return ErrChannelMonitorTemplateHeaderInvalidName
}
if IsForbiddenHeaderName(k) {
return ErrChannelMonitorTemplateHeaderForbidden
}
+ if claimsOfficialProviderClientHeader(k, v) {
+ return ErrChannelMonitorTemplateOfficialClientAttribution
+ }
+ }
+ return nil
+}
+
+func claimsOfficialProviderClientHeader(name, value string) bool {
+ name = strings.ToLower(strings.TrimSpace(name))
+ value = strings.ToLower(strings.TrimSpace(value))
+ switch name {
+ case "user-agent":
+ return strings.HasPrefix(value, "claude-cli/")
+ case "x-app":
+ return value == "cli"
+ case "anthropic-beta":
+ return strings.Contains(value, "claude-code-") || strings.Contains(value, "oauth-")
+ case "anthropic-dangerous-direct-browser-access":
+ return value == "true"
+ default:
+ return false
+ }
+}
+
+func validateChannelMonitorBodyOverride(body map[string]any) error {
+ if err := validateChannelMonitorBodyEnvelopeKey(body); err != nil {
+ return err
+ }
+ if len(body) == 0 {
+ return nil
+ }
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return fmt.Errorf("marshal channel monitor body for validation: %w", err)
+ }
+ normalized := strings.ToLower(string(raw))
+ for _, marker := range []string{
+ "anthropic's official cli for claude",
+ `"cc_entrypoint"`,
+ "",
+ } {
+ if strings.Contains(normalized, marker) {
+ return ErrChannelMonitorTemplateOfficialClientAttribution
+ }
}
return nil
}
@@ -249,3 +368,32 @@ func defaultBodyMode(mode string) string {
}
return mode
}
+
+func (s *ChannelMonitorRequestTemplateService) sealRequestPayloadInPlace(t *ChannelMonitorRequestTemplate) error {
+ headers, err := SealChannelMonitorExtraHeaders(s.encryptor, t.ExtraHeaders)
+ if err != nil {
+ return err
+ }
+ body, err := SealChannelMonitorBodyOverride(s.encryptor, t.BodyOverride)
+ if err != nil {
+ return err
+ }
+ t.ExtraHeaders, t.BodyOverride = headers, body
+ return nil
+}
+
+func (s *ChannelMonitorRequestTemplateService) openRequestPayloadInPlace(t *ChannelMonitorRequestTemplate) error {
+ if t == nil {
+ return nil
+ }
+ headers, err := OpenChannelMonitorExtraHeaders(s.encryptor, t.ExtraHeaders)
+ if err != nil {
+ return fmt.Errorf("open channel monitor template %d extra headers: %w", t.ID, err)
+ }
+ body, err := OpenChannelMonitorBodyOverride(s.encryptor, t.BodyOverride)
+ if err != nil {
+ return fmt.Errorf("open channel monitor template %d body override: %w", t.ID, err)
+ }
+ t.ExtraHeaders, t.BodyOverride = headers, body
+ return nil
+}
diff --git a/backend/internal/service/channel_monitor_template_types.go b/backend/internal/service/channel_monitor_template_types.go
index e5bf7568443..bab77af4393 100644
--- a/backend/internal/service/channel_monitor_template_types.go
+++ b/backend/internal/service/channel_monitor_template_types.go
@@ -62,12 +62,18 @@ var (
ErrChannelMonitorTemplateBodyRequired = infraerrors.BadRequest(
"CHANNEL_MONITOR_TEMPLATE_BODY_REQUIRED", "body_override is required when body_override_mode is merge or replace",
)
+ ErrChannelMonitorTemplateBodyReservedKey = infraerrors.BadRequest(
+ "CHANNEL_MONITOR_TEMPLATE_BODY_RESERVED_KEY", "body_override contains a reserved top-level key",
+ )
ErrChannelMonitorTemplateHeaderForbidden = infraerrors.BadRequest(
- "CHANNEL_MONITOR_TEMPLATE_HEADER_FORBIDDEN", "header name is forbidden (hop-by-hop or computed by HTTP client)",
+ "CHANNEL_MONITOR_TEMPLATE_HEADER_FORBIDDEN", "header name is forbidden (credential, hop-by-hop, or computed by HTTP client)",
)
ErrChannelMonitorTemplateHeaderInvalidName = infraerrors.BadRequest(
"CHANNEL_MONITOR_TEMPLATE_HEADER_INVALID_NAME", "header name contains invalid characters",
)
+ ErrChannelMonitorTemplateOfficialClientAttribution = infraerrors.BadRequest(
+ "CHANNEL_MONITOR_TEMPLATE_OFFICIAL_CLIENT_ATTRIBUTION", "monitor customization cannot claim an official provider client identity",
+ )
ErrChannelMonitorTemplateProviderMismatch = infraerrors.BadRequest(
"CHANNEL_MONITOR_TEMPLATE_PROVIDER_MISMATCH", "monitor provider does not match template provider",
)
diff --git a/backend/internal/service/channel_monitor_types.go b/backend/internal/service/channel_monitor_types.go
index b797a89b7c7..fd1f38308da 100644
--- a/backend/internal/service/channel_monitor_types.go
+++ b/backend/internal/service/channel_monitor_types.go
@@ -34,7 +34,7 @@ type ChannelMonitor struct {
// 请求自定义快照(来自模板拷贝 or 用户手填,运行时直接读取)
TemplateID *int64 // 仅用于 UI 分组 + 一键应用,运行时不用
- ExtraHeaders map[string]string // 与 adapter 默认 headers 合并,用户优先
+ ExtraHeaders map[string]string // 与 adapter 默认 headers 合并;鉴权 headers 不允许覆盖
BodyOverrideMode string // off / merge / replace
BodyOverride map[string]any // 仅 mode != off 时使用
diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go
index 4e08df4a569..7cc16a5c9dc 100644
--- a/backend/internal/service/channel_service.go
+++ b/backend/internal/service/channel_service.go
@@ -88,8 +88,9 @@ type channelCache struct {
groupPlatform map[int64]string // groupID → platform
// 冷路径(CRUD 操作)
- byID map[int64]*Channel
- loadedAt time.Time
+ byID map[int64]*Channel
+ loadedAt time.Time
+ loadFailed bool
}
// ChannelMappingResult 渠道映射查找结果
@@ -97,6 +98,7 @@ type ChannelMappingResult struct {
MappedModel string // 映射后的模型名(无映射时等于原始模型名)
ChannelID int64 // 渠道 ID(0 = 无渠道关联)
Mapped bool // 是否发生了映射
+ MappingExact bool // true when the configured source was an exact model slug
BillingModelSource string // 计费模型来源("requested" / "upstream" / "channel_mapped")
}
@@ -259,6 +261,7 @@ func expandMappingToCache(cache *channelCache, ch *Channel, gid int64, platform
func (s *ChannelService) storeErrorCache() {
errorCache := newEmptyChannelCache()
errorCache.loadedAt = time.Now().Add(-(channelCacheTTL - channelErrorTTL))
+ errorCache.loadFailed = true
s.cache.Store(errorCache)
}
@@ -379,36 +382,46 @@ func (c *channelCache) matchWildcardMapping(groupID int64, platform, modelLower
// lookupPricingAcrossPlatforms 在分组平台内查找模型定价。
// 各平台严格独立,只在本平台内查找(先精确匹配,再通配符)。
func lookupPricingAcrossPlatforms(cache *channelCache, groupID int64, groupPlatform, modelLower string) *ChannelModelPricing {
+ pricing, _ := lookupPricingAcrossPlatformsWithExact(cache, groupID, groupPlatform, modelLower)
+ return pricing
+}
+
+func lookupPricingAcrossPlatformsWithExact(cache *channelCache, groupID int64, groupPlatform, modelLower string) (*ChannelModelPricing, bool) {
for _, p := range matchingPlatforms(groupPlatform) {
key := channelModelKey{groupID: groupID, platform: p, model: modelLower}
if pricing, ok := cache.pricingByGroupModel[key]; ok {
- return pricing
+ return pricing, true
}
}
// 精确查找全部失败,依次尝试通配符匹配
for _, p := range matchingPlatforms(groupPlatform) {
if pricing := cache.matchWildcard(groupID, p, modelLower); pricing != nil {
- return pricing
+ return pricing, false
}
}
- return nil
+ return nil, false
}
// lookupMappingAcrossPlatforms 在分组平台内查找模型映射。
// 逻辑与 lookupPricingAcrossPlatforms 相同:先精确查找,再通配符。
func lookupMappingAcrossPlatforms(cache *channelCache, groupID int64, groupPlatform, modelLower string) string {
+ mapped, _ := lookupMappingAcrossPlatformsWithExact(cache, groupID, groupPlatform, modelLower)
+ return mapped
+}
+
+func lookupMappingAcrossPlatformsWithExact(cache *channelCache, groupID int64, groupPlatform, modelLower string) (string, bool) {
for _, p := range matchingPlatforms(groupPlatform) {
key := channelModelKey{groupID: groupID, platform: p, model: modelLower}
if mapped, ok := cache.mappingByGroupModel[key]; ok {
- return mapped
+ return mapped, true
}
}
for _, p := range matchingPlatforms(groupPlatform) {
if mapped := cache.matchWildcardMapping(groupID, p, modelLower); mapped != "" {
- return mapped
+ return mapped, false
}
}
- return ""
+ return "", false
}
// GetChannelForGroup 获取分组关联的渠道(热路径 O(1))
@@ -449,6 +462,9 @@ func (s *ChannelService) lookupGroupChannel(ctx context.Context, groupID int64)
if err != nil {
return nil, err
}
+ if cache.loadFailed {
+ return nil, fmt.Errorf("channel cache unavailable")
+ }
ch, ok := cache.channelByGroupID[groupID]
if !ok || !ch.IsActive() {
return nil, nil
@@ -463,23 +479,40 @@ func (s *ChannelService) lookupGroupChannel(ctx context.Context, groupID int64)
// GetChannelModelPricing 获取指定分组+模型的渠道定价(热路径 O(1))。
// 各平台严格独立,只在本平台内查找定价。
func (s *ChannelService) GetChannelModelPricing(ctx context.Context, groupID int64, model string) *ChannelModelPricing {
+ pricing, _ := s.GetChannelModelPricingMatch(ctx, groupID, model)
+ return pricing
+}
+
+// GetChannelModelPricingMatch returns a cloned price plus exact-match provenance.
+func (s *ChannelService) GetChannelModelPricingMatch(ctx context.Context, groupID int64, model string) (*ChannelModelPricing, bool) {
+ if s == nil {
+ return nil, false
+ }
+ // Unit tests and immutable snapshots may provide a preloaded cache without
+ // a repository. Accept that valid hot-path state, while keeping a zero-value
+ // service from attempting to build a cache through a nil repository.
+ if s.repo == nil {
+ if cached, ok := s.cache.Load().(*channelCache); !ok || cached == nil {
+ return nil, false
+ }
+ }
lk, err := s.lookupGroupChannel(ctx, groupID)
if err != nil {
slog.Warn("failed to load channel cache", "group_id", groupID, "error", err)
- return nil
+ return nil, false
}
if lk == nil {
- return nil
+ return nil, false
}
modelLower := strings.ToLower(model)
- pricing := lookupPricingAcrossPlatforms(lk.cache, groupID, lk.platform, modelLower)
+ pricing, exact := lookupPricingAcrossPlatformsWithExact(lk.cache, groupID, lk.platform, modelLower)
if pricing == nil {
- return nil
+ return nil, false
}
cp := pricing.Clone()
- return &cp
+ return &cp, exact
}
// ResolveChannelMapping 解析渠道级模型映射(热路径 O(1))
@@ -502,6 +535,7 @@ func (s *ChannelService) IsModelRestricted(ctx context.Context, groupID int64, m
lk, err := s.lookupGroupChannel(ctx, groupID)
if err != nil {
slog.Warn("failed to load channel cache for model restriction check", "group_id", groupID, "error", err)
+ return true
}
if lk == nil {
return false
@@ -535,9 +569,10 @@ func resolveMapping(lk *channelLookup, groupID int64, model string) ChannelMappi
}
modelLower := strings.ToLower(model)
- if mapped := lookupMappingAcrossPlatforms(lk.cache, groupID, lk.platform, modelLower); mapped != "" {
+ if mapped, exact := lookupMappingAcrossPlatformsWithExact(lk.cache, groupID, lk.platform, modelLower); mapped != "" {
result.MappedModel = mapped
result.Mapped = true
+ result.MappingExact = exact
}
return result
diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go
index e737a21125b..31416c9aadb 100644
--- a/backend/internal/service/channel_service_test.go
+++ b/backend/internal/service/channel_service_test.go
@@ -1067,6 +1067,21 @@ func TestIsModelRestricted_CaseInsensitive(t *testing.T) {
require.False(t, restricted)
}
+func TestIsModelRestricted_CacheBuildFailureFailsClosedDuringErrorTTL(t *testing.T) {
+ var listCalls int
+ repo := &mockChannelRepository{
+ listAllFn: func(_ context.Context) ([]Channel, error) {
+ listCalls++
+ return nil, errors.New("database unavailable")
+ },
+ }
+ svc := newTestChannelService(repo)
+
+ require.True(t, svc.IsModelRestricted(context.Background(), 10, "claude-opus-4"))
+ require.True(t, svc.IsModelRestricted(context.Background(), 10, "claude-opus-4"))
+ require.Equal(t, 1, listCalls, "the short error cache should avoid a retry storm without failing open")
+}
+
// --- 4.5 ResolveChannelMappingAndRestrict ---
// 注意:模型限制检查已移至调度阶段(GatewayService.checkChannelPricingRestriction),
// ResolveChannelMappingAndRestrict 仅做映射,restricted 始终为 false。
diff --git a/backend/internal/service/channel_test.go b/backend/internal/service/channel_test.go
index 164861fb93d..4624b7f2949 100644
--- a/backend/internal/service/channel_test.go
+++ b/backend/internal/service/channel_test.go
@@ -481,8 +481,6 @@ func TestSupportedModels_WildcardExpandedFromPricing(t *testing.T) {
require.NotContains(t, m.Name, "*")
}
}
-
-
func TestSupportedModels_MissingPricingKeepsNilPricing(t *testing.T) {
ch := &Channel{
ModelMapping: map[string]map[string]string{
diff --git a/backend/internal/service/chat_bridge_service.go b/backend/internal/service/chat_bridge_service.go
new file mode 100644
index 00000000000..cfa2adb0d10
--- /dev/null
+++ b/backend/internal/service/chat_bridge_service.go
@@ -0,0 +1,180 @@
+package service
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+)
+
+const defaultChatBridgeCodeTTL = 2 * time.Minute
+
+var ErrChatBridgeCodeInvalid = infraerrors.Unauthorized("CHAT_BRIDGE_CODE_INVALID", "invalid or expired chat login code")
+
+type chatBridgeUserReader interface {
+ GetByID(ctx context.Context, id int64) (*User, error)
+}
+
+type chatBridgeTokenIssuer interface {
+ GenerateToken(user *User) (string, error)
+ GetAccessTokenExpiresIn() int
+}
+
+type authServiceChatBridgeUserReader struct {
+ authService *AuthService
+}
+
+func (r authServiceChatBridgeUserReader) GetByID(ctx context.Context, id int64) (*User, error) {
+ if r.authService == nil || r.authService.userRepo == nil {
+ return nil, ErrServiceUnavailable
+ }
+ return r.authService.userRepo.GetByID(ctx, id)
+}
+
+type ChatBridgeLoginCode struct {
+ Code string
+ ExpiresAt time.Time
+ ExpiresIn int
+}
+
+type ChatBridgeExchangeResult struct {
+ AccessToken string
+ ExpiresIn int
+ TokenType string
+ User *User
+}
+
+type chatBridgeCodeRecord struct {
+ expiresAt time.Time
+ userID int64
+}
+
+type ChatBridgeService struct {
+ mu sync.Mutex
+ codes map[string]chatBridgeCodeRecord
+ ttl time.Duration
+ tokenIssuer chatBridgeTokenIssuer
+ userReader chatBridgeUserReader
+}
+
+func NewChatBridgeService(authService *AuthService) *ChatBridgeService {
+ if authService == nil {
+ return nil
+ }
+ return newChatBridgeService(authServiceChatBridgeUserReader{authService: authService}, authService, defaultChatBridgeCodeTTL)
+}
+
+func newChatBridgeServiceForTest(userReader chatBridgeUserReader, tokenIssuer chatBridgeTokenIssuer, ttl time.Duration) *ChatBridgeService {
+ return newChatBridgeService(userReader, tokenIssuer, ttl)
+}
+
+func newChatBridgeService(userReader chatBridgeUserReader, tokenIssuer chatBridgeTokenIssuer, ttl time.Duration) *ChatBridgeService {
+ return &ChatBridgeService{
+ codes: make(map[string]chatBridgeCodeRecord),
+ ttl: ttl,
+ tokenIssuer: tokenIssuer,
+ userReader: userReader,
+ }
+}
+
+func (s *ChatBridgeService) CreateLoginCode(ctx context.Context, userID int64) (*ChatBridgeLoginCode, error) {
+ if s == nil || s.userReader == nil {
+ return nil, ErrServiceUnavailable
+ }
+ user, err := s.userReader.GetByID(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ if !user.IsActive() {
+ return nil, ErrUserNotActive
+ }
+
+ code, err := randomURLSafeToken(32)
+ if err != nil {
+ return nil, fmt.Errorf("generate chat bridge code: %w", err)
+ }
+
+ now := time.Now()
+ expiresAt := now.Add(s.ttl)
+ s.mu.Lock()
+ s.cleanupExpiredLocked(now)
+ s.codes[hashChatBridgeCode(code)] = chatBridgeCodeRecord{userID: userID, expiresAt: expiresAt}
+ s.mu.Unlock()
+
+ return &ChatBridgeLoginCode{
+ Code: code,
+ ExpiresAt: expiresAt,
+ ExpiresIn: int(s.ttl.Seconds()),
+ }, nil
+}
+
+func (s *ChatBridgeService) ExchangeLoginCode(ctx context.Context, code string) (*ChatBridgeExchangeResult, error) {
+ code = strings.TrimSpace(code)
+ if s == nil || s.userReader == nil || s.tokenIssuer == nil || code == "" {
+ return nil, ErrChatBridgeCodeInvalid
+ }
+
+ now := time.Now()
+ key := hashChatBridgeCode(code)
+
+ s.mu.Lock()
+ record, ok := s.codes[key]
+ delete(s.codes, key)
+ s.cleanupExpiredLocked(now)
+ s.mu.Unlock()
+
+ if !ok || !record.expiresAt.After(now) {
+ return nil, ErrChatBridgeCodeInvalid
+ }
+
+ user, err := s.userReader.GetByID(ctx, record.userID)
+ if err != nil {
+ return nil, err
+ }
+ if !user.IsActive() {
+ return nil, ErrUserNotActive
+ }
+
+ token, err := s.tokenIssuer.GenerateToken(user)
+ if err != nil {
+ return nil, err
+ }
+
+ return &ChatBridgeExchangeResult{
+ AccessToken: token,
+ ExpiresIn: s.tokenIssuer.GetAccessTokenExpiresIn(),
+ TokenType: "Bearer",
+ User: user,
+ }, nil
+}
+
+func (s *ChatBridgeService) cleanupExpiredLocked(now time.Time) {
+ for key, record := range s.codes {
+ if !record.expiresAt.After(now) {
+ delete(s.codes, key)
+ }
+ }
+}
+
+func hashChatBridgeCode(code string) string {
+ sum := sha256.Sum256([]byte(code))
+ return hex.EncodeToString(sum[:])
+}
+
+func randomURLSafeToken(byteLength int) (string, error) {
+ if byteLength <= 0 {
+ byteLength = 32
+ }
+ buf := make([]byte, byteLength)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(buf), nil
+}
diff --git a/backend/internal/service/chat_bridge_service_test.go b/backend/internal/service/chat_bridge_service_test.go
new file mode 100644
index 00000000000..07fd6e1ce73
--- /dev/null
+++ b/backend/internal/service/chat_bridge_service_test.go
@@ -0,0 +1,110 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+type chatBridgeUserReaderStub struct {
+ users map[int64]*User
+}
+
+func (s *chatBridgeUserReaderStub) GetByID(_ context.Context, id int64) (*User, error) {
+ user, ok := s.users[id]
+ if !ok {
+ return nil, ErrUserNotFound
+ }
+ return user, nil
+}
+
+type chatBridgeTokenIssuerStub struct {
+ token string
+ expiresIn int
+ err error
+ userIDs []int64
+}
+
+func (s *chatBridgeTokenIssuerStub) GenerateToken(user *User) (string, error) {
+ if s.err != nil {
+ return "", s.err
+ }
+ s.userIDs = append(s.userIDs, user.ID)
+ return s.token, nil
+}
+
+func (s *chatBridgeTokenIssuerStub) GetAccessTokenExpiresIn() int {
+ return s.expiresIn
+}
+
+func TestChatBridgeServiceCreateAndExchangeLoginCodeOnce(t *testing.T) {
+ userReader := &chatBridgeUserReaderStub{
+ users: map[int64]*User{
+ 42: {ID: 42, Email: "user@example.com", Role: RoleUser, Status: StatusActive},
+ },
+ }
+ tokenIssuer := &chatBridgeTokenIssuerStub{token: "user-jwt", expiresIn: 3600}
+ svc := newChatBridgeServiceForTest(userReader, tokenIssuer, time.Minute)
+
+ code, err := svc.CreateLoginCode(context.Background(), 42)
+ require.NoError(t, err)
+ require.NotEmpty(t, code.Code)
+ require.Equal(t, 60, code.ExpiresIn)
+
+ exchanged, err := svc.ExchangeLoginCode(context.Background(), code.Code)
+ require.NoError(t, err)
+ require.Equal(t, "user-jwt", exchanged.AccessToken)
+ require.Equal(t, "Bearer", exchanged.TokenType)
+ require.Equal(t, 3600, exchanged.ExpiresIn)
+ require.Equal(t, int64(42), exchanged.User.ID)
+ require.Equal(t, []int64{42}, tokenIssuer.userIDs)
+
+ _, err = svc.ExchangeLoginCode(context.Background(), code.Code)
+ require.ErrorIs(t, err, ErrChatBridgeCodeInvalid)
+}
+
+func TestChatBridgeServiceRejectsInactiveUser(t *testing.T) {
+ userReader := &chatBridgeUserReaderStub{
+ users: map[int64]*User{
+ 7: {ID: 7, Email: "disabled@example.com", Role: RoleUser, Status: StatusDisabled},
+ },
+ }
+ svc := newChatBridgeServiceForTest(userReader, &chatBridgeTokenIssuerStub{}, time.Minute)
+
+ _, err := svc.CreateLoginCode(context.Background(), 7)
+ require.ErrorIs(t, err, ErrUserNotActive)
+}
+
+func TestChatBridgeServiceRejectsExpiredCode(t *testing.T) {
+ userReader := &chatBridgeUserReaderStub{
+ users: map[int64]*User{
+ 42: {ID: 42, Email: "user@example.com", Role: RoleUser, Status: StatusActive},
+ },
+ }
+ svc := newChatBridgeServiceForTest(userReader, &chatBridgeTokenIssuerStub{token: "user-jwt", expiresIn: 3600}, -time.Second)
+
+ code, err := svc.CreateLoginCode(context.Background(), 42)
+ require.NoError(t, err)
+
+ _, err = svc.ExchangeLoginCode(context.Background(), code.Code)
+ require.ErrorIs(t, err, ErrChatBridgeCodeInvalid)
+}
+
+func TestChatBridgeServiceReturnsTokenIssuerError(t *testing.T) {
+ userReader := &chatBridgeUserReaderStub{
+ users: map[int64]*User{
+ 42: {ID: 42, Email: "user@example.com", Role: RoleUser, Status: StatusActive},
+ },
+ }
+ tokenErr := errors.New("sign failed")
+ svc := newChatBridgeServiceForTest(userReader, &chatBridgeTokenIssuerStub{err: tokenErr}, time.Minute)
+
+ code, err := svc.CreateLoginCode(context.Background(), 42)
+ require.NoError(t, err)
+
+ _, err = svc.ExchangeLoginCode(context.Background(), code.Code)
+ require.ErrorIs(t, err, tokenErr)
+}
diff --git a/backend/internal/service/claude_code_validator.go b/backend/internal/service/claude_code_validator.go
index 4e8ced67954..b3168b034f2 100644
--- a/backend/internal/service/claude_code_validator.go
+++ b/backend/internal/service/claude_code_validator.go
@@ -10,8 +10,9 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
)
-// ClaudeCodeValidator 验证请求是否来自 Claude Code 客户端
-// 完全学习自 claude-relay-service 项目的验证逻辑
+// ClaudeCodeValidator classifies Claude Code-shaped requests for routing and
+// UX compatibility. Every signal it reads is downstream-controlled, so this is
+// not provenance authentication and must never unlock provider credentials.
type ClaudeCodeValidator struct{}
var (
@@ -52,8 +53,7 @@ func NewClaudeCodeValidator() *ClaudeCodeValidator {
return &ClaudeCodeValidator{}
}
-// Validate 验证请求是否来自 Claude Code CLI
-// 采用与 claude-relay-service 完全一致的验证策略:
+// Validate applies the compatibility-shape heuristic:
//
// Step 1: User-Agent 检查 (必需) - 必须是 claude-cli/x.x.x
// Step 2: 对于非 messages 路径,只要 UA 匹配就通过
@@ -80,7 +80,7 @@ func (v *ClaudeCodeValidator) Validate(r *http.Request, body map[string]any) boo
// Step 3: 检查 max_tokens=1 + haiku 探测请求绕过
// 这类请求用于 Claude Code 验证 API 连通性,不携带 system prompt
if isMaxTokensOneHaiku, ok := IsMaxTokensOneHaikuRequestFromContext(r.Context()); ok && isMaxTokensOneHaiku {
- return true // 绕过 system prompt 检查,UA 已在 Step 1 验证
+ return true // routing heuristic only; not an authentication result
}
// Step 4: messages 路径,进行严格验证
diff --git a/backend/internal/service/claude_token_provider.go b/backend/internal/service/claude_token_provider.go
index d70379c1f45..bcee7a66dba 100644
--- a/backend/internal/service/claude_token_provider.go
+++ b/backend/internal/service/claude_token_provider.go
@@ -68,8 +68,16 @@ func (p *ClaudeTokenProvider) GetAccessToken(ctx context.Context, account *Accou
// 1) Try cache first.
if p.tokenCache != nil {
if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" {
- slog.Debug("claude_token_cache_hit", "account_id", account.ID)
- return token, nil
+ latestAccount, cacheHitIsCurrent, validationErr := validateOAuthTokenCacheHit(ctx, account, p.accountRepo)
+ if validationErr != nil {
+ return "", validationErr
+ }
+ if cacheHitIsCurrent {
+ slog.Debug("claude_token_cache_hit", "account_id", account.ID)
+ return token, nil
+ }
+ account = latestAccount
+ cacheKey = ClaudeTokenCacheKey(account)
} else if err != nil {
slog.Warn("claude_token_cache_get_failed", "account_id", account.ID, "error", err)
}
@@ -94,8 +102,17 @@ func (p *ClaudeTokenProvider) GetAccessToken(ctx context.Context, account *Accou
if p.refreshPolicy.OnLockHeld == ProviderLockHeldWaitForCache && p.tokenCache != nil {
time.Sleep(claudeLockWaitTime)
if token, cacheErr := p.tokenCache.GetAccessToken(ctx, cacheKey); cacheErr == nil && strings.TrimSpace(token) != "" {
- slog.Debug("claude_token_cache_hit_after_wait", "account_id", account.ID)
- return token, nil
+ latestAccount, cacheHitIsCurrent, validationErr := validateOAuthTokenCacheHit(ctx, account, p.accountRepo)
+ if validationErr != nil {
+ return "", validationErr
+ }
+ if cacheHitIsCurrent {
+ slog.Debug("claude_token_cache_hit_after_wait", "account_id", account.ID)
+ return token, nil
+ }
+ account = latestAccount
+ cacheKey = ClaudeTokenCacheKey(account)
+ expiresAt = account.GetCredentialAsTime("expires_at")
}
}
} else {
@@ -104,16 +121,25 @@ func (p *ClaudeTokenProvider) GetAccessToken(ctx context.Context, account *Accou
}
} else if needsRefresh && p.tokenCache != nil {
// Backward-compatible test path when refreshAPI is not injected.
- locked, lockErr := p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
+ locked, ownershipToken, lockErr := p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
if lockErr == nil && locked {
- defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey) }()
+ defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey, ownershipToken) }()
} else if lockErr != nil {
slog.Warn("claude_token_lock_failed", "account_id", account.ID, "error", lockErr)
} else {
time.Sleep(claudeLockWaitTime)
if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" {
- slog.Debug("claude_token_cache_hit_after_wait", "account_id", account.ID)
- return token, nil
+ latestAccount, cacheHitIsCurrent, validationErr := validateOAuthTokenCacheHit(ctx, account, p.accountRepo)
+ if validationErr != nil {
+ return "", validationErr
+ }
+ if cacheHitIsCurrent {
+ slog.Debug("claude_token_cache_hit_after_wait", "account_id", account.ID)
+ return token, nil
+ }
+ account = latestAccount
+ cacheKey = ClaudeTokenCacheKey(account)
+ expiresAt = account.GetCredentialAsTime("expires_at")
}
}
}
@@ -125,8 +151,10 @@ func (p *ClaudeTokenProvider) GetAccessToken(ctx context.Context, account *Accou
// 3) Populate cache with TTL.
if p.tokenCache != nil {
- latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
- if isStale && latestAccount != nil {
+ latestAccount, isStale, versionErr := CheckTokenVersion(ctx, account, p.accountRepo)
+ if versionErr != nil {
+ slog.Warn("claude_token_version_check_unavailable", "account_id", account.ID, "error", versionErr)
+ } else if isStale && latestAccount != nil {
slog.Debug("claude_token_version_stale_use_latest", "account_id", account.ID)
accessToken = latestAccount.GetCredential("access_token")
if strings.TrimSpace(accessToken) == "" {
diff --git a/backend/internal/service/claude_token_provider_test.go b/backend/internal/service/claude_token_provider_test.go
index d4a4a14a364..8b584869eaf 100644
--- a/backend/internal/service/claude_token_provider_test.go
+++ b/backend/internal/service/claude_token_provider_test.go
@@ -68,18 +68,21 @@ func (s *claudeTokenCacheStub) DeleteAccessToken(ctx context.Context, cacheKey s
return nil
}
-func (s *claudeTokenCacheStub) AcquireRefreshLock(ctx context.Context, cacheKey string, ttl time.Duration) (bool, error) {
+func (s *claudeTokenCacheStub) AcquireRefreshLock(ctx context.Context, cacheKey string, ttl time.Duration) (bool, string, error) {
atomic.AddInt32(&s.lockCalled, 1)
if s.lockErr != nil {
- return false, s.lockErr
+ return false, "", s.lockErr
}
if s.simulateLockRace {
- return false, nil
+ return false, "", nil
}
- return s.lockAcquired, nil
+ if !s.lockAcquired {
+ return false, "", nil
+ }
+ return true, "claude-test-owner", nil
}
-func (s *claudeTokenCacheStub) ReleaseRefreshLock(ctx context.Context, cacheKey string) error {
+func (s *claudeTokenCacheStub) ReleaseRefreshLock(ctx context.Context, cacheKey string, ownershipToken string) error {
atomic.AddInt32(&s.unlockCalled, 1)
return s.releaseLockErr
}
@@ -154,9 +157,9 @@ func (p *testClaudeTokenProvider) GetAccessToken(ctx context.Context, account *A
needsRefresh := expiresAt == nil || time.Until(*expiresAt) <= claudeTokenRefreshSkew
refreshFailed := false
if needsRefresh && p.tokenCache != nil {
- locked, err := p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
+ locked, ownershipToken, err := p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
if err == nil && locked {
- defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey) }()
+ defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey, ownershipToken) }()
// Check cache again after acquiring lock
if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && token != "" {
@@ -242,7 +245,7 @@ func TestClaudeTokenProvider_CacheHit(t *testing.T) {
cacheKey := ClaudeTokenCacheKey(account)
cache.tokens[cacheKey] = "cached-token"
- provider := NewClaudeTokenProvider(nil, cache, nil)
+ provider := NewClaudeTokenProvider(&mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}, cache, nil)
token, err := provider.GetAccessToken(context.Background(), account)
require.NoError(t, err)
@@ -265,7 +268,9 @@ func TestClaudeTokenProvider_CacheMiss_FromCredentials(t *testing.T) {
},
}
- provider := NewClaudeTokenProvider(nil, cache, nil)
+ provider := NewClaudeTokenProvider(&mockAccountRepoForGemini{
+ accountsByID: map[int64]*Account{account.ID: account},
+ }, cache, nil)
token, err := provider.GetAccessToken(context.Background(), account)
require.NoError(t, err)
@@ -439,7 +444,7 @@ func TestClaudeTokenProvider_CacheGetError(t *testing.T) {
},
}
- provider := NewClaudeTokenProvider(nil, cache, nil)
+ provider := NewClaudeTokenProvider(&mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}, cache, nil)
// Should gracefully degrade and return from credentials
token, err := provider.GetAccessToken(context.Background(), account)
@@ -462,7 +467,7 @@ func TestClaudeTokenProvider_CacheSetError(t *testing.T) {
},
}
- provider := NewClaudeTokenProvider(nil, cache, nil)
+ provider := NewClaudeTokenProvider(&mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}, cache, nil)
// Should still work even if cache set fails
token, err := provider.GetAccessToken(context.Background(), account)
@@ -586,7 +591,9 @@ func TestClaudeTokenProvider_TTLCalculation(t *testing.T) {
},
}
- provider := NewClaudeTokenProvider(nil, cache, nil)
+ provider := NewClaudeTokenProvider(&mockAccountRepoForGemini{
+ accountsByID: map[int64]*Account{account.ID: account},
+ }, cache, nil)
_, err := provider.GetAccessToken(context.Background(), account)
require.NoError(t, err)
@@ -794,7 +801,7 @@ func TestClaudeTokenProvider_Real_LockFailedWait(t *testing.T) {
cache.mu.Unlock()
}()
- provider := NewClaudeTokenProvider(nil, cache, nil)
+ provider := NewClaudeTokenProvider(&mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}, cache, nil)
token, err := provider.GetAccessToken(context.Background(), account)
require.NoError(t, err)
require.NotEmpty(t, token)
@@ -825,7 +832,7 @@ func TestClaudeTokenProvider_Real_CacheHitAfterWait(t *testing.T) {
cache.mu.Unlock()
}()
- provider := NewClaudeTokenProvider(nil, cache, nil)
+ provider := NewClaudeTokenProvider(&mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}, cache, nil)
token, err := provider.GetAccessToken(context.Background(), account)
require.NoError(t, err)
require.NotEmpty(t, token)
diff --git a/backend/internal/service/codex_image_generation_bridge.go b/backend/internal/service/codex_image_generation_bridge.go
new file mode 100644
index 00000000000..c7a894a7929
--- /dev/null
+++ b/backend/internal/service/codex_image_generation_bridge.go
@@ -0,0 +1,64 @@
+package service
+
+import "strings"
+
+const featureKeyCodexImageGenerationBridge = "codex_image_generation_bridge"
+
+func boolOverridePtr(v bool) *bool {
+ return &v
+}
+
+func boolOverrideFromMap(values map[string]any, keys ...string) *bool {
+ if values == nil {
+ return nil
+ }
+ for _, key := range keys {
+ if v, ok := values[key].(bool); ok {
+ return boolOverridePtr(v)
+ }
+ }
+ return nil
+}
+
+func platformBoolOverride(values map[string]any, key string, platform string) *bool {
+ if values == nil {
+ return nil
+ }
+ if v, ok := values[key].(bool); ok {
+ return boolOverridePtr(v)
+ }
+ raw, ok := values[key].(map[string]any)
+ if !ok {
+ return nil
+ }
+ platform = strings.TrimSpace(platform)
+ if platform == "" {
+ return nil
+ }
+ if v, ok := raw[platform].(bool); ok {
+ return boolOverridePtr(v)
+ }
+ return nil
+}
+
+// CodexImageGenerationBridgeOverride returns the channel-level override for Codex
+// image_generation bridge injection. Nil means follow the global/account policy.
+func (c *Channel) CodexImageGenerationBridgeOverride(platform string) *bool {
+ if c == nil {
+ return nil
+ }
+ return platformBoolOverride(c.FeaturesConfig, featureKeyCodexImageGenerationBridge, platform)
+}
+
+// CodexImageGenerationBridgeOverride returns the account-level override for Codex
+// image_generation bridge injection. Nil means follow the channel/global policy.
+func (a *Account) CodexImageGenerationBridgeOverride() *bool {
+ if a == nil || a.Platform != PlatformOpenAI || a.Extra == nil {
+ return nil
+ }
+ if override := boolOverrideFromMap(a.Extra, featureKeyCodexImageGenerationBridge, "codex_image_generation_bridge_enabled"); override != nil {
+ return override
+ }
+ openaiConfig, _ := a.Extra[PlatformOpenAI].(map[string]any)
+ return boolOverrideFromMap(openaiConfig, featureKeyCodexImageGenerationBridge, "codex_image_generation_bridge_enabled")
+}
diff --git a/backend/internal/service/content_moderation.go b/backend/internal/service/content_moderation.go
new file mode 100644
index 00000000000..1a29087c8c6
--- /dev/null
+++ b/backend/internal/service/content_moderation.go
@@ -0,0 +1,2597 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/url"
+ "sort"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+)
+
+const (
+ ContentModerationModeOff = "off"
+ ContentModerationModeObserve = "observe"
+ ContentModerationModePreBlock = "pre_block"
+
+ contentModerationAPIKeysModeAppend = "append"
+ contentModerationAPIKeysModeReplace = "replace"
+
+ ContentModerationActionAllow = "allow"
+ ContentModerationActionBlock = "block"
+ ContentModerationActionHashBlock = "hash_block"
+ ContentModerationActionError = "error"
+
+ ContentModerationProtocolAnthropicMessages = "anthropic_messages"
+ ContentModerationProtocolOpenAIResponses = "openai_responses"
+ ContentModerationProtocolOpenAIChat = "openai_chat_completions"
+ ContentModerationProtocolGemini = "gemini"
+ ContentModerationProtocolOpenAIImages = "openai_images"
+
+ ContentModerationStageInput = "input"
+ ContentModerationStageOutput = "output"
+
+ defaultContentModerationBaseURL = "https://api.openai.com"
+ defaultContentModerationModel = "omni-moderation-latest"
+ defaultContentModerationTimeoutMS = 3000
+ maxContentModerationTimeoutMS = 30000
+ maxModerationInputRunes = 12000
+ maxModerationExcerptRunes = 240
+
+ defaultContentModerationWorkerCount = 4
+ maxContentModerationWorkerCount = 32
+ defaultContentModerationQueueSize = 1024
+ maxContentModerationQueueSize = 4096
+ defaultContentModerationBanThreshold = 10
+ defaultContentModerationViolationWindowHours = 720
+ defaultContentModerationBlockHTTPStatus = http.StatusForbidden
+ defaultContentModerationBlockMessage = "内容审计命中风险规则,请调整输入后重试"
+ defaultContentModerationUnavailableMessage = "Image safety review is temporarily unavailable"
+ defaultContentModerationRetryCount = 2
+ maxContentModerationRetryCount = 5
+ defaultContentModerationHitRetentionDays = 180
+ defaultContentModerationNonHitRetentionDays = 3
+ maxContentModerationRetentionDays = 3650
+ maxContentModerationNonHitRetentionDays = 3
+ contentModerationKeyRateLimitFreezeDuration = time.Minute
+ contentModerationKeyAuthFreezeDuration = 10 * time.Minute
+ contentModerationKeyHTTPErrorFreezeDuration = 10 * time.Second
+ maxContentModerationInputImages = 1
+ maxContentModerationTestImages = maxContentModerationInputImages
+ maxContentModerationTestImageBytes = 8 * 1024 * 1024
+ maxContentModerationTestImageDataURLBytes = 12 * 1024 * 1024
+
+ contentModerationCleanupInterval = 24 * time.Hour
+ contentModerationCleanupTimeout = 30 * time.Minute
+ contentModerationCleanupDelay = 5 * time.Minute
+)
+
+var contentModerationCategoryOrder = []string{
+ "harassment",
+ "harassment/threatening",
+ "hate",
+ "hate/threatening",
+ "illicit",
+ "illicit/violent",
+ "self-harm",
+ "self-harm/intent",
+ "self-harm/instructions",
+ "sexual",
+ "sexual/minors",
+ "violence",
+ "violence/graphic",
+}
+
+func ContentModerationDefaultThresholds() map[string]float64 {
+ return map[string]float64{
+ "harassment": 0.98,
+ "harassment/threatening": 0.90,
+ "hate": 0.65,
+ "hate/threatening": 0.65,
+ "illicit": 0.95,
+ "illicit/violent": 0.95,
+ "self-harm": 0.65,
+ "self-harm/intent": 0.85,
+ "self-harm/instructions": 0.65,
+ "sexual": 0.65,
+ "sexual/minors": 0.65,
+ "violence": 0.95,
+ "violence/graphic": 0.95,
+ }
+}
+
+func ContentModerationCategories() []string {
+ out := make([]string, len(contentModerationCategoryOrder))
+ copy(out, contentModerationCategoryOrder)
+ return out
+}
+
+type ContentModerationConfig struct {
+ Enabled bool `json:"enabled"`
+ Mode string `json:"mode"`
+ BaseURL string `json:"base_url"`
+ Model string `json:"model"`
+ APIKey string `json:"api_key,omitempty"`
+ APIKeys []string `json:"api_keys,omitempty"`
+ EncryptedAPIKeys []string `json:"api_keys_encrypted,omitempty"`
+ TimeoutMS int `json:"timeout_ms"`
+ SampleRate int `json:"sample_rate"`
+ AllGroups bool `json:"all_groups"`
+ GroupIDs []int64 `json:"group_ids"`
+ RecordNonHits bool `json:"record_non_hits"`
+ Thresholds map[string]float64 `json:"thresholds"`
+ WorkerCount int `json:"worker_count"`
+ QueueSize int `json:"queue_size"`
+ BlockStatus int `json:"block_status"`
+ BlockMessage string `json:"block_message"`
+ EmailOnHit bool `json:"email_on_hit"`
+ AutoBanEnabled bool `json:"auto_ban_enabled"`
+ BanThreshold int `json:"ban_threshold"`
+ ViolationWindowHours int `json:"violation_window_hours"`
+ RetryCount int `json:"retry_count"`
+ HitRetentionDays int `json:"hit_retention_days"`
+ NonHitRetentionDays int `json:"non_hit_retention_days"`
+ PreHashCheckEnabled bool `json:"pre_hash_check_enabled"`
+}
+
+type ContentModerationConfigView struct {
+ Enabled bool `json:"enabled"`
+ Mode string `json:"mode"`
+ BaseURL string `json:"base_url"`
+ Model string `json:"model"`
+ APIKeyConfigured bool `json:"api_key_configured"`
+ APIKeyMasked string `json:"api_key_masked"`
+ APIKeyCount int `json:"api_key_count"`
+ APIKeyMasks []string `json:"api_key_masks"`
+ APIKeyStatuses []ContentModerationAPIKeyStatus `json:"api_key_statuses"`
+ TimeoutMS int `json:"timeout_ms"`
+ SampleRate int `json:"sample_rate"`
+ AllGroups bool `json:"all_groups"`
+ GroupIDs []int64 `json:"group_ids"`
+ RecordNonHits bool `json:"record_non_hits"`
+ WorkerCount int `json:"worker_count"`
+ QueueSize int `json:"queue_size"`
+ BlockStatus int `json:"block_status"`
+ BlockMessage string `json:"block_message"`
+ EmailOnHit bool `json:"email_on_hit"`
+ AutoBanEnabled bool `json:"auto_ban_enabled"`
+ BanThreshold int `json:"ban_threshold"`
+ ViolationWindowHours int `json:"violation_window_hours"`
+ RetryCount int `json:"retry_count"`
+ HitRetentionDays int `json:"hit_retention_days"`
+ NonHitRetentionDays int `json:"non_hit_retention_days"`
+ PreHashCheckEnabled bool `json:"pre_hash_check_enabled"`
+}
+
+type ContentModerationAPIKeyStatus struct {
+ Index int `json:"index"`
+ KeyHash string `json:"key_hash"`
+ Masked string `json:"masked"`
+ Status string `json:"status"`
+ FailureCount int `json:"failure_count"`
+ SuccessCount int64 `json:"success_count"`
+ LastError string `json:"last_error"`
+ LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
+ FrozenUntil *time.Time `json:"frozen_until,omitempty"`
+ LastLatencyMS int `json:"last_latency_ms"`
+ LastHTTPStatus int `json:"last_http_status"`
+ LastTested bool `json:"last_tested"`
+ Configured bool `json:"configured"`
+}
+
+type TestContentModerationAPIKeysInput struct {
+ APIKeys []string `json:"api_keys"`
+ BaseURL string `json:"base_url"`
+ Model string `json:"model"`
+ TimeoutMS int `json:"timeout_ms"`
+ Prompt string `json:"prompt"`
+ Images []string `json:"images"`
+}
+
+type TestContentModerationAPIKeysResult struct {
+ Items []ContentModerationAPIKeyStatus `json:"items"`
+ AuditResult *ContentModerationTestAuditResult `json:"audit_result,omitempty"`
+ ImageCount int `json:"image_count"`
+}
+
+type ContentModerationTestAuditResult struct {
+ Flagged bool `json:"flagged"`
+ HighestCategory string `json:"highest_category"`
+ HighestScore float64 `json:"highest_score"`
+ CompositeScore float64 `json:"composite_score"`
+ CategoryScores map[string]float64 `json:"category_scores"`
+ Thresholds map[string]float64 `json:"thresholds"`
+}
+
+type UpdateContentModerationConfigInput struct {
+ Enabled *bool `json:"enabled"`
+ Mode *string `json:"mode"`
+ BaseURL *string `json:"base_url"`
+ Model *string `json:"model"`
+ APIKey *string `json:"api_key"`
+ APIKeys *[]string `json:"api_keys"`
+ APIKeysMode string `json:"api_keys_mode"`
+ DeleteAPIKeyHashes *[]string `json:"delete_api_key_hashes"`
+ ClearAPIKey bool `json:"clear_api_key"`
+ TimeoutMS *int `json:"timeout_ms"`
+ SampleRate *int `json:"sample_rate"`
+ AllGroups *bool `json:"all_groups"`
+ GroupIDs *[]int64 `json:"group_ids"`
+ RecordNonHits *bool `json:"record_non_hits"`
+ WorkerCount *int `json:"worker_count"`
+ QueueSize *int `json:"queue_size"`
+ BlockStatus *int `json:"block_status"`
+ BlockMessage *string `json:"block_message"`
+ EmailOnHit *bool `json:"email_on_hit"`
+ AutoBanEnabled *bool `json:"auto_ban_enabled"`
+ BanThreshold *int `json:"ban_threshold"`
+ ViolationWindowHours *int `json:"violation_window_hours"`
+ RetryCount *int `json:"retry_count"`
+ HitRetentionDays *int `json:"hit_retention_days"`
+ NonHitRetentionDays *int `json:"non_hit_retention_days"`
+ PreHashCheckEnabled *bool `json:"pre_hash_check_enabled"`
+}
+
+type ContentModerationCheckInput struct {
+ RequestID string
+ UserID int64
+ UserEmail string
+ APIKeyID int64
+ APIKeyName string
+ GroupID *int64
+ GroupName string
+ Endpoint string
+ Provider string
+ Model string
+ Protocol string
+ Body []byte
+ Stage string
+ FailClosed bool
+ FailClosedStatusCode int
+ FailClosedMessage string
+ FailClosedError string
+ PolicyRule string
+ UpstreamRequestID string
+ SafetyIdentifier string
+ InputHash string
+ OutputHashes []string
+}
+
+type ContentModerationInput struct {
+ Text string
+ Images []string
+}
+
+func (in *ContentModerationInput) Normalize() {
+ if in == nil {
+ return
+ }
+ in.Text = trimRunes(normalizeContentModerationText(in.Text), maxModerationInputRunes)
+ in.Images = normalizeModerationImages(in.Images)
+}
+
+func (in ContentModerationInput) IsEmpty() bool {
+ return strings.TrimSpace(in.Text) == "" && len(in.Images) == 0
+}
+
+func (in ContentModerationInput) ModerationInput() any {
+ images := limitContentModerationImages(in.Images)
+ if len(images) == 0 {
+ return in.Text
+ }
+ parts := make([]moderationAPIInputPart, 0, len(images)+1)
+ if strings.TrimSpace(in.Text) != "" {
+ parts = append(parts, moderationAPIInputPart{Type: "text", Text: in.Text})
+ }
+ for _, image := range images {
+ parts = append(parts, moderationAPIInputPart{
+ Type: "image_url",
+ ImageURL: &moderationAPIImageURLRef{URL: image},
+ })
+ }
+ return parts
+}
+
+func (in ContentModerationInput) ExcerptText() string {
+ return in.Text
+}
+
+func (in ContentModerationInput) Hash() string {
+ h := sha256.New()
+ _, _ = h.Write([]byte("text:"))
+ _, _ = h.Write([]byte(in.Text))
+ for _, image := range in.Images {
+ imageHash := sha256.Sum256([]byte(image))
+ _, _ = h.Write([]byte("\nimage:"))
+ _, _ = h.Write([]byte(hex.EncodeToString(imageHash[:])))
+ }
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+type ContentModerationDecision struct {
+ Allowed bool `json:"allowed"`
+ Blocked bool `json:"blocked"`
+ Flagged bool `json:"flagged"`
+ Message string `json:"message"`
+ StatusCode int `json:"status_code"`
+ InputHash string `json:"input_hash,omitempty"`
+ HighestCategory string `json:"highest_category"`
+ HighestScore float64 `json:"highest_score"`
+ CategoryScores map[string]float64 `json:"category_scores"`
+ Action string `json:"action"`
+ PolicyRule string `json:"policy_rule,omitempty"`
+}
+
+type ContentModerationLog struct {
+ ID int64 `json:"id"`
+ RequestID string `json:"request_id"`
+ UserID *int64 `json:"user_id,omitempty"`
+ UserEmail string `json:"user_email"`
+ APIKeyID *int64 `json:"api_key_id,omitempty"`
+ APIKeyName string `json:"api_key_name"`
+ GroupID *int64 `json:"group_id,omitempty"`
+ GroupName string `json:"group_name"`
+ Endpoint string `json:"endpoint"`
+ Provider string `json:"provider"`
+ Model string `json:"model"`
+ Stage string `json:"stage"`
+ Mode string `json:"mode"`
+ Action string `json:"action"`
+ Flagged bool `json:"flagged"`
+ HighestCategory string `json:"highest_category"`
+ HighestScore float64 `json:"highest_score"`
+ CategoryScores map[string]float64 `json:"category_scores"`
+ CategoryFlags map[string]bool `json:"category_flags"`
+ CategoryAppliedInputTypes map[string][]string `json:"category_applied_input_types"`
+ ThresholdSnapshot map[string]float64 `json:"threshold_snapshot"`
+ InputHash string `json:"input_hash"`
+ OutputHashes []string `json:"output_hashes"`
+ PolicyRule string `json:"policy_rule"`
+ UpstreamRequestID string `json:"upstream_request_id"`
+ SafetyIdentifier string `json:"safety_identifier"`
+ InputExcerpt string `json:"input_excerpt"`
+ UpstreamLatencyMS *int `json:"upstream_latency_ms,omitempty"`
+ Error string `json:"error"`
+ ViolationCount int `json:"violation_count"`
+ AutoBanned bool `json:"auto_banned"`
+ EmailSent bool `json:"email_sent"`
+ UserStatus string `json:"user_status"`
+ QueueDelayMS *int `json:"queue_delay_ms,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+type ContentModerationLogFilter struct {
+ Pagination pagination.PaginationParams
+ Result string
+ GroupID *int64
+ Endpoint string
+ Search string
+ From *time.Time
+ To *time.Time
+}
+
+type ContentModerationCleanupResult struct {
+ DeletedHit int64 `json:"deleted_hit"`
+ DeletedNonHit int64 `json:"deleted_non_hit"`
+ FinishedAt time.Time `json:"finished_at"`
+}
+
+type ContentModerationRuntimeStatus struct {
+ Enabled bool `json:"enabled"`
+ RiskControlEnabled bool `json:"risk_control_enabled"`
+ Mode string `json:"mode"`
+ WorkerCount int `json:"worker_count"`
+ MaxWorkers int `json:"max_workers"`
+ ActiveWorkers int `json:"active_workers"`
+ IdleWorkers int `json:"idle_workers"`
+ QueueSize int `json:"queue_size"`
+ QueueLength int `json:"queue_length"`
+ QueueUsagePercent float64 `json:"queue_usage_percent"`
+ Enqueued int64 `json:"enqueued"`
+ Dropped int64 `json:"dropped"`
+ Processed int64 `json:"processed"`
+ Errors int64 `json:"errors"`
+ APIKeyStatuses []ContentModerationAPIKeyStatus `json:"api_key_statuses"`
+ FlaggedHashCount int64 `json:"flagged_hash_count"`
+ LastCleanupAt *time.Time `json:"last_cleanup_at,omitempty"`
+ LastCleanupDeletedHit int64 `json:"last_cleanup_deleted_hit"`
+ LastCleanupDeletedNonHit int64 `json:"last_cleanup_deleted_non_hit"`
+}
+
+type ContentModerationUnbanUserResult struct {
+ UserID int64 `json:"user_id"`
+ Status string `json:"status"`
+}
+
+type ContentModerationDeleteHashResult struct {
+ InputHash string `json:"input_hash"`
+ Deleted bool `json:"deleted"`
+}
+
+type ContentModerationClearHashesResult struct {
+ Deleted int64 `json:"deleted"`
+}
+
+type ContentModerationRepository interface {
+ CreateLog(ctx context.Context, log *ContentModerationLog) error
+ ListLogs(ctx context.Context, filter ContentModerationLogFilter) ([]ContentModerationLog, *pagination.PaginationResult, error)
+ CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time) (int, error)
+ CleanupExpiredLogs(ctx context.Context, hitBefore time.Time, nonHitBefore time.Time) (*ContentModerationCleanupResult, error)
+}
+
+type ContentModerationHashCache interface {
+ RecordFlaggedInputHash(ctx context.Context, inputHash string) error
+ HasFlaggedInputHash(ctx context.Context, inputHash string) (bool, error)
+ DeleteFlaggedInputHash(ctx context.Context, inputHash string) (bool, error)
+ ClearFlaggedInputHashes(ctx context.Context) (int64, error)
+ CountFlaggedInputHashes(ctx context.Context) (int64, error)
+}
+
+type ContentModerationService struct {
+ settingRepo SettingRepository
+ repo ContentModerationRepository
+ hashCache ContentModerationHashCache
+ groupRepo GroupRepository
+ userRepo UserRepository
+ authCacheInvalidator APIKeyAuthCacheInvalidator
+ abuseRiskRecorder HFCAbuseRiskRecorder
+ emailService *EmailService
+ secretEncryptor SecretEncryptor
+ httpClient *http.Client
+ asyncQueue chan contentModerationTask
+ workerCount int
+ apiKeyCursor atomic.Uint64
+ asyncActive atomic.Int64
+ asyncEnqueued atomic.Int64
+ asyncDropped atomic.Int64
+ asyncProcessed atomic.Int64
+ asyncErrors atomic.Int64
+ lastCleanupUnix atomic.Int64
+ lastCleanupDeletedHit atomic.Int64
+ lastCleanupDeletedNonHit atomic.Int64
+ keyHealthMu sync.Mutex
+ keyHealth map[string]*contentModerationKeyHealth
+}
+
+type contentModerationTask struct {
+ input ContentModerationCheckInput
+ content ContentModerationInput
+ inputHash string
+ enqueuedAt time.Time
+}
+
+type contentModerationKeyHealth struct {
+ Hash string
+ Masked string
+ FailureCount int
+ SuccessCount int64
+ LastError string
+ LastCheckedAt time.Time
+ FrozenUntil time.Time
+ LastLatencyMS int
+ LastHTTPStatus int
+ LastTested bool
+}
+
+func NewContentModerationService(
+ settingRepo SettingRepository,
+ repo ContentModerationRepository,
+ hashCache ContentModerationHashCache,
+ groupRepo GroupRepository,
+ userRepo UserRepository,
+ authCacheInvalidator APIKeyAuthCacheInvalidator,
+ emailService *EmailService,
+) *ContentModerationService {
+ return newContentModerationService(settingRepo, repo, hashCache, groupRepo, userRepo, authCacheInvalidator, emailService, nil)
+}
+
+func NewContentModerationServiceWithEncryptor(
+ settingRepo SettingRepository,
+ repo ContentModerationRepository,
+ hashCache ContentModerationHashCache,
+ groupRepo GroupRepository,
+ userRepo UserRepository,
+ authCacheInvalidator APIKeyAuthCacheInvalidator,
+ emailService *EmailService,
+ secretEncryptor SecretEncryptor,
+) *ContentModerationService {
+ return newContentModerationService(settingRepo, repo, hashCache, groupRepo, userRepo, authCacheInvalidator, emailService, secretEncryptor)
+}
+
+func newContentModerationService(
+ settingRepo SettingRepository,
+ repo ContentModerationRepository,
+ hashCache ContentModerationHashCache,
+ groupRepo GroupRepository,
+ userRepo UserRepository,
+ authCacheInvalidator APIKeyAuthCacheInvalidator,
+ emailService *EmailService,
+ secretEncryptor SecretEncryptor,
+) *ContentModerationService {
+ svc := &ContentModerationService{
+ settingRepo: settingRepo,
+ repo: repo,
+ hashCache: hashCache,
+ groupRepo: groupRepo,
+ userRepo: userRepo,
+ authCacheInvalidator: authCacheInvalidator,
+ emailService: emailService,
+ secretEncryptor: secretEncryptor,
+ httpClient: contentModerationHTTPClient(nil),
+ workerCount: maxContentModerationWorkerCount,
+ asyncQueue: make(chan contentModerationTask, maxContentModerationQueueSize),
+ keyHealth: make(map[string]*contentModerationKeyHealth),
+ }
+ if settingRepo != nil && repo != nil {
+ for i := 0; i < svc.workerCount; i++ {
+ go svc.worker(i)
+ }
+ go svc.cleanupWorker()
+ }
+ return svc
+}
+
+// SetSecretEncryptor is retained for tests and manual construction. Production
+// wiring uses NewContentModerationServiceWithEncryptor so workers never observe
+// an uninitialized encryptor.
+func (s *ContentModerationService) SetSecretEncryptor(encryptor SecretEncryptor) {
+ if s != nil {
+ s.secretEncryptor = encryptor
+ }
+}
+
+func (s *ContentModerationService) SetHFCAbuseRiskRecorder(recorder HFCAbuseRiskRecorder) {
+ if s == nil {
+ return
+ }
+ s.abuseRiskRecorder = recorder
+}
+
+func (s *ContentModerationService) GetConfig(ctx context.Context) (*ContentModerationConfigView, error) {
+ cfg, err := s.loadConfig(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return s.configView(cfg), nil
+}
+
+func (s *ContentModerationService) UpdateConfig(ctx context.Context, input UpdateContentModerationConfigInput) (*ContentModerationConfigView, error) {
+ cfg, err := s.loadConfig(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if input.BaseURL != nil &&
+ contentModerationBaseURLIdentity(*input.BaseURL) != contentModerationBaseURLIdentity(cfg.BaseURL) &&
+ len(cfg.apiKeys()) > 0 &&
+ !contentModerationConfigReplacesOrClearsAPIKeys(input) {
+ return nil, infraerrors.BadRequest(
+ "CONTENT_MODERATION_API_KEY_REENTRY_REQUIRED",
+ "replace or clear API keys when the moderation base URL changes",
+ )
+ }
+ if input.Enabled != nil {
+ cfg.Enabled = *input.Enabled
+ }
+ if input.Mode != nil {
+ cfg.Mode = strings.TrimSpace(*input.Mode)
+ }
+ if input.BaseURL != nil {
+ cfg.BaseURL = strings.TrimSpace(*input.BaseURL)
+ }
+ if input.Model != nil {
+ cfg.Model = strings.TrimSpace(*input.Model)
+ }
+ if input.TimeoutMS != nil {
+ cfg.TimeoutMS = *input.TimeoutMS
+ }
+ if input.SampleRate != nil {
+ cfg.SampleRate = *input.SampleRate
+ }
+ if input.WorkerCount != nil {
+ cfg.WorkerCount = *input.WorkerCount
+ }
+ if input.QueueSize != nil {
+ cfg.QueueSize = *input.QueueSize
+ }
+ if input.BlockStatus != nil {
+ cfg.BlockStatus = *input.BlockStatus
+ }
+ if input.BlockMessage != nil {
+ cfg.BlockMessage = strings.TrimSpace(*input.BlockMessage)
+ }
+ if input.EmailOnHit != nil {
+ cfg.EmailOnHit = *input.EmailOnHit
+ }
+ if input.AutoBanEnabled != nil {
+ cfg.AutoBanEnabled = *input.AutoBanEnabled
+ }
+ if input.BanThreshold != nil {
+ cfg.BanThreshold = *input.BanThreshold
+ }
+ if input.ViolationWindowHours != nil {
+ cfg.ViolationWindowHours = *input.ViolationWindowHours
+ }
+ if input.RetryCount != nil {
+ cfg.RetryCount = *input.RetryCount
+ }
+ if input.HitRetentionDays != nil {
+ cfg.HitRetentionDays = *input.HitRetentionDays
+ }
+ if input.NonHitRetentionDays != nil {
+ cfg.NonHitRetentionDays = *input.NonHitRetentionDays
+ }
+ if input.PreHashCheckEnabled != nil {
+ cfg.PreHashCheckEnabled = *input.PreHashCheckEnabled
+ }
+ if input.AllGroups != nil {
+ cfg.AllGroups = *input.AllGroups
+ }
+ if input.GroupIDs != nil {
+ cfg.GroupIDs = normalizeInt64IDs(*input.GroupIDs)
+ }
+ if input.RecordNonHits != nil {
+ cfg.RecordNonHits = *input.RecordNonHits
+ }
+ if input.ClearAPIKey {
+ cfg.APIKey = ""
+ cfg.APIKeys = []string{}
+ } else {
+ apiKeysMode := normalizeContentModerationAPIKeysMode(input.APIKeysMode)
+ if input.DeleteAPIKeyHashes != nil && apiKeysMode != contentModerationAPIKeysModeReplace {
+ cfg.APIKeys = deleteModerationAPIKeysByHash(cfg.apiKeys(), *input.DeleteAPIKeyHashes)
+ cfg.APIKey = ""
+ }
+ if input.APIKeys != nil {
+ if apiKeysMode == contentModerationAPIKeysModeReplace {
+ cfg.APIKeys = normalizeModerationAPIKeys(*input.APIKeys)
+ } else {
+ cfg.APIKeys = normalizeModerationAPIKeys(append(cfg.apiKeys(), *input.APIKeys...))
+ }
+ cfg.APIKey = ""
+ }
+ if input.APIKey != nil && strings.TrimSpace(*input.APIKey) != "" {
+ cfg.APIKeys = normalizeModerationAPIKeys(append(cfg.APIKeys, *input.APIKey))
+ cfg.APIKey = ""
+ }
+ }
+ if err := s.validateConfig(ctx, cfg); err != nil {
+ return nil, err
+ }
+ cfg.normalize()
+ if err := s.saveConfig(ctx, cfg); err != nil {
+ return nil, err
+ }
+ return s.configView(cfg), nil
+}
+
+func (s *ContentModerationService) TestAPIKeys(ctx context.Context, input TestContentModerationAPIKeysInput) (*TestContentModerationAPIKeysResult, error) {
+ cfg, err := s.loadConfig(ctx)
+ if err != nil {
+ return nil, err
+ }
+ keys := normalizeModerationAPIKeys(input.APIKeys)
+ configured := false
+ if len(keys) == 0 {
+ if strings.TrimSpace(input.BaseURL) != "" &&
+ contentModerationBaseURLIdentity(input.BaseURL) != contentModerationBaseURLIdentity(cfg.BaseURL) &&
+ len(cfg.apiKeys()) > 0 {
+ return nil, infraerrors.BadRequest(
+ "CONTENT_MODERATION_API_KEY_REENTRY_REQUIRED",
+ "explicit API keys are required when testing a different moderation base URL",
+ )
+ }
+ keys = cfg.apiKeys()
+ configured = true
+ }
+ if strings.TrimSpace(input.BaseURL) != "" {
+ cfg.BaseURL = input.BaseURL
+ }
+ if strings.TrimSpace(input.Model) != "" {
+ cfg.Model = input.Model
+ }
+ if input.TimeoutMS > 0 {
+ cfg.TimeoutMS = input.TimeoutMS
+ }
+ cfg.normalize()
+ if err := s.validateConfig(ctx, cfg); err != nil {
+ return nil, err
+ }
+ testInput, imageCount, err := buildModerationTestInput(input.Prompt, input.Images)
+ if err != nil {
+ return nil, err
+ }
+ auditOnly := contentModerationTestHasAuditInput(input.Prompt, input.Images)
+ if configured && auditOnly {
+ key, ok := s.nextUsableAPIKey(cfg)
+ if !ok {
+ return &TestContentModerationAPIKeysResult{
+ Items: s.apiKeyStatuses(keys),
+ ImageCount: imageCount,
+ }, nil
+ }
+ keys = []string{key}
+ }
+ if len(keys) == 0 {
+ return &TestContentModerationAPIKeysResult{Items: []ContentModerationAPIKeyStatus{}, ImageCount: imageCount}, nil
+ }
+ items := make([]ContentModerationAPIKeyStatus, 0, len(keys))
+ var auditResult *ContentModerationTestAuditResult
+ for idx, key := range keys {
+ start := time.Now()
+ httpStatus := 0
+ result, err := s.callModerationOnceWithInput(ctx, cfg, key, testInput, &httpStatus)
+ latency := int(time.Since(start).Milliseconds())
+ keyHash := moderationAPIKeyHash(key)
+ if err != nil {
+ s.markAPIKeyError(key, err.Error(), latency, httpStatus)
+ } else {
+ s.markAPIKeySuccess(key, latency, httpStatus)
+ if auditResult == nil {
+ auditResult = buildContentModerationTestAuditResult(result, cfg.Thresholds)
+ }
+ }
+ status := s.apiKeyStatusForHash(idx, keyHash, maskSecretTail(key), configured)
+ status.LastTested = true
+ items = append(items, status)
+ }
+ return &TestContentModerationAPIKeysResult{Items: items, AuditResult: auditResult, ImageCount: imageCount}, nil
+}
+
+func contentModerationBaseURLIdentity(value string) string {
+ return strings.TrimRight(strings.TrimSpace(value), "/")
+}
+
+func contentModerationConfigReplacesOrClearsAPIKeys(input UpdateContentModerationConfigInput) bool {
+ if input.ClearAPIKey {
+ return true
+ }
+ return input.APIKeys != nil && normalizeContentModerationAPIKeysMode(input.APIKeysMode) == contentModerationAPIKeysModeReplace
+}
+
+func (s *ContentModerationService) Check(ctx context.Context, input ContentModerationCheckInput) (*ContentModerationDecision, error) {
+ allow := &ContentModerationDecision{Allowed: true, Action: ContentModerationActionAllow}
+ if s == nil || s.settingRepo == nil || s.repo == nil {
+ slog.Info("content_moderation.skip_unavailable",
+ "user_id", input.UserID,
+ "api_key_id", input.APIKeyID,
+ "group_id", contentModerationLogGroupID(input.GroupID),
+ "endpoint", input.Endpoint,
+ "protocol", input.Protocol)
+ if input.FailClosed {
+ return buildContentModerationFailClosedDecision(input, nil, ContentModerationActionError, "moderation_service_unavailable", http.StatusServiceUnavailable, defaultContentModerationUnavailableMessage, "content moderation service unavailable"), nil
+ }
+ return allow, nil
+ }
+ if !s.isRiskControlEnabled(ctx) {
+ slog.Info("content_moderation.skip_feature_disabled",
+ "user_id", input.UserID,
+ "api_key_id", input.APIKeyID,
+ "group_id", contentModerationLogGroupID(input.GroupID),
+ "endpoint", input.Endpoint,
+ "protocol", input.Protocol)
+ if input.FailClosed {
+ return s.recordFailClosedDecision(ctx, input, nil, ContentModerationActionError, "risk_control_disabled", http.StatusServiceUnavailable, defaultContentModerationUnavailableMessage, "risk control is disabled")
+ }
+ return allow, nil
+ }
+ cfg, err := s.loadConfig(ctx)
+ if err != nil {
+ slog.Warn("content_moderation.skip_config_load_failed",
+ "user_id", input.UserID,
+ "api_key_id", input.APIKeyID,
+ "group_id", contentModerationLogGroupID(input.GroupID),
+ "endpoint", input.Endpoint,
+ "protocol", input.Protocol,
+ "error", err)
+ if input.FailClosed {
+ return s.recordFailClosedDecision(ctx, input, nil, ContentModerationActionError, "moderation_config_load_failed", http.StatusServiceUnavailable, defaultContentModerationUnavailableMessage, err.Error())
+ }
+ return allow, nil
+ }
+ inScope := cfg.includesGroup(input.GroupID)
+ slog.Info("content_moderation.config_loaded",
+ "user_id", input.UserID,
+ "api_key_id", input.APIKeyID,
+ "group_id", contentModerationLogGroupID(input.GroupID),
+ "group_name", input.GroupName,
+ "endpoint", input.Endpoint,
+ "provider", input.Provider,
+ "protocol", input.Protocol,
+ "model", input.Model,
+ "enabled", cfg.Enabled,
+ "mode", cfg.Mode,
+ "all_groups", cfg.AllGroups,
+ "configured_group_ids", cfg.GroupIDs,
+ "in_scope", inScope,
+ "sample_rate", cfg.SampleRate,
+ "api_key_count", len(cfg.apiKeys()),
+ "pre_hash_check_enabled", cfg.PreHashCheckEnabled,
+ "record_non_hits", cfg.RecordNonHits)
+ if !cfg.Enabled {
+ slog.Info("content_moderation.skip_config_disabled",
+ "user_id", input.UserID,
+ "api_key_id", input.APIKeyID,
+ "group_id", contentModerationLogGroupID(input.GroupID),
+ "endpoint", input.Endpoint,
+ "protocol", input.Protocol)
+ if input.FailClosed {
+ return s.recordFailClosedDecision(ctx, input, cfg, ContentModerationActionError, "moderation_config_disabled", http.StatusServiceUnavailable, defaultContentModerationUnavailableMessage, "content moderation config is disabled")
+ }
+ return allow, nil
+ }
+ if cfg.Mode == ContentModerationModeOff {
+ slog.Info("content_moderation.skip_mode_off",
+ "user_id", input.UserID,
+ "api_key_id", input.APIKeyID,
+ "group_id", contentModerationLogGroupID(input.GroupID),
+ "endpoint", input.Endpoint,
+ "protocol", input.Protocol)
+ if input.FailClosed {
+ return s.recordFailClosedDecision(ctx, input, cfg, ContentModerationActionError, "moderation_mode_off", http.StatusServiceUnavailable, defaultContentModerationUnavailableMessage, "content moderation mode is off")
+ }
+ return allow, nil
+ }
+ if !inScope {
+ slog.Info("content_moderation.skip_group_out_of_scope",
+ "user_id", input.UserID,
+ "api_key_id", input.APIKeyID,
+ "group_id", contentModerationLogGroupID(input.GroupID),
+ "group_name", input.GroupName,
+ "endpoint", input.Endpoint,
+ "protocol", input.Protocol,
+ "all_groups", cfg.AllGroups,
+ "configured_group_ids", cfg.GroupIDs)
+ if input.FailClosed {
+ return s.recordFailClosedDecision(ctx, input, cfg, ContentModerationActionError, "moderation_group_out_of_scope", http.StatusServiceUnavailable, defaultContentModerationUnavailableMessage, "content moderation group is out of scope")
+ }
+ return allow, nil
+ }
+ content := ExtractContentModerationInput(input.Protocol, input.Body)
+ if content.IsEmpty() {
+ slog.Info("content_moderation.skip_empty_input",
+ "user_id", input.UserID,
+ "api_key_id", input.APIKeyID,
+ "group_id", contentModerationLogGroupID(input.GroupID),
+ "endpoint", input.Endpoint,
+ "protocol", input.Protocol,
+ "body_bytes", len(input.Body))
+ if input.FailClosed {
+ status := input.FailClosedStatusCode
+ message := input.FailClosedMessage
+ errText := input.FailClosedError
+ if status == 0 {
+ status = http.StatusForbidden
+ }
+ if strings.TrimSpace(message) == "" {
+ message = cfg.BlockMessage
+ }
+ if strings.TrimSpace(errText) == "" {
+ errText = "content moderation input is empty"
+ }
+ return s.recordFailClosedDecision(ctx, input, cfg, ContentModerationActionError, "moderation_empty_input", status, message, errText)
+ }
+ return allow, nil
+ }
+ if len(content.Images) > maxContentModerationInputImages {
+ return s.recordFailClosedDecision(
+ ctx,
+ input,
+ cfg,
+ ContentModerationActionError,
+ "moderation_too_many_images",
+ http.StatusBadRequest,
+ fmt.Sprintf("Content moderation supports at most %d image per request", maxContentModerationInputImages),
+ "content moderation input exceeds the supported image count",
+ )
+ }
+ content.Normalize()
+ hashText := content.Hash()
+ input.InputHash = hashText
+ slog.Info("content_moderation.input_extracted",
+ "user_id", input.UserID,
+ "api_key_id", input.APIKeyID,
+ "group_id", contentModerationLogGroupID(input.GroupID),
+ "endpoint", input.Endpoint,
+ "protocol", input.Protocol,
+ "text_runes", len([]rune(content.Text)),
+ "image_count", len(content.Images))
+ if cfg.PreHashCheckEnabled && s.hashCache != nil {
+ matched, err := s.hashCache.HasFlaggedInputHash(ctx, hashText)
+ if err != nil {
+ slog.Warn("content_moderation.hash_check_failed", "user_id", input.UserID, "endpoint", input.Endpoint, "error", err)
+ }
+ if matched {
+ slog.Info("content_moderation.hash_block",
+ "user_id", input.UserID,
+ "api_key_id", input.APIKeyID,
+ "group_id", contentModerationLogGroupID(input.GroupID),
+ "endpoint", input.Endpoint,
+ "protocol", input.Protocol,
+ "input_hash", hashText)
+ message := cfg.BlockMessage
+ if message != "" {
+ message = fmt.Sprintf("%s(hash: %s)", message, hashText)
+ }
+ return &ContentModerationDecision{
+ Allowed: false,
+ Blocked: true,
+ Flagged: true,
+ Message: message,
+ StatusCode: cfg.BlockStatus,
+ InputHash: hashText,
+ Action: ContentModerationActionHashBlock,
+ PolicyRule: contentModerationPolicyRule(input.PolicyRule, "moderation_hash_block"),
+ }, nil
+ }
+ }
+ if !input.FailClosed && !cfg.shouldSample(hashText) {
+ slog.Info("content_moderation.skip_sample_rate",
+ "user_id", input.UserID,
+ "api_key_id", input.APIKeyID,
+ "group_id", contentModerationLogGroupID(input.GroupID),
+ "endpoint", input.Endpoint,
+ "protocol", input.Protocol,
+ "sample_rate", cfg.SampleRate)
+ return allow, nil
+ }
+ if len(cfg.apiKeys()) == 0 {
+ slog.Warn("content_moderation.skip_no_audit_api_keys",
+ "user_id", input.UserID,
+ "api_key_id", input.APIKeyID,
+ "group_id", contentModerationLogGroupID(input.GroupID),
+ "endpoint", input.Endpoint,
+ "protocol", input.Protocol)
+ if input.FailClosed {
+ return s.recordFailClosedDecision(ctx, input, cfg, ContentModerationActionError, "moderation_no_audit_api_keys", http.StatusServiceUnavailable, defaultContentModerationUnavailableMessage, "no moderation api key available")
+ }
+ return allow, nil
+ }
+ if !input.FailClosed && cfg.Mode == ContentModerationModeObserve && len(content.Images) == 0 {
+ slog.Info("content_moderation.enqueue_observe",
+ "user_id", input.UserID,
+ "api_key_id", input.APIKeyID,
+ "group_id", contentModerationLogGroupID(input.GroupID),
+ "endpoint", input.Endpoint,
+ "protocol", input.Protocol,
+ "queue_len", len(s.asyncQueue))
+ s.enqueueAsync(input, cfg, content, hashText)
+ return allow, nil
+ }
+
+ return s.checkSync(ctx, input, cfg, content, hashText, nil, true), nil
+}
+
+func (s *ContentModerationService) RecordPolicyBlock(ctx context.Context, input ContentModerationCheckInput, message string, statusCode int) *ContentModerationDecision {
+ cfg := defaultContentModerationConfig()
+ if s != nil && s.settingRepo != nil {
+ if loaded, err := s.loadConfig(ctx); err == nil && loaded != nil {
+ cfg = loaded
+ }
+ }
+ if statusCode == 0 {
+ statusCode = http.StatusForbidden
+ }
+ if strings.TrimSpace(message) == "" {
+ message = cfg.BlockMessage
+ }
+ content := ExtractContentModerationInput(input.Protocol, input.Body)
+ content.Normalize()
+ input.InputHash = content.Hash()
+ rule := contentModerationPolicyRule(input.PolicyRule, "local_policy_block")
+ if s != nil && s.repo != nil {
+ log := s.buildLog(input, cfg, ContentModerationActionBlock, true, "", 0, nil, content.ExcerptText(), nil, nil, "")
+ log.InputHash = input.InputHash
+ log.PolicyRule = rule
+ s.applyFlaggedSideEffects(ctx, cfg, log)
+ _ = s.repo.CreateLog(ctx, log)
+ }
+ return &ContentModerationDecision{
+ Allowed: false,
+ Blocked: true,
+ Flagged: true,
+ Message: message,
+ StatusCode: statusCode,
+ InputHash: input.InputHash,
+ Action: ContentModerationActionBlock,
+ PolicyRule: rule,
+ }
+}
+
+func (s *ContentModerationService) recordFailClosedDecision(ctx context.Context, input ContentModerationCheckInput, cfg *ContentModerationConfig, action string, fallbackRule string, statusCode int, message string, errText string) (*ContentModerationDecision, error) {
+ if cfg == nil {
+ cfg = defaultContentModerationConfig()
+ }
+ if input.FailClosedStatusCode > 0 {
+ statusCode = input.FailClosedStatusCode
+ }
+ if strings.TrimSpace(input.FailClosedMessage) != "" {
+ message = strings.TrimSpace(input.FailClosedMessage)
+ }
+ if strings.TrimSpace(input.FailClosedError) != "" {
+ errText = strings.TrimSpace(input.FailClosedError)
+ }
+ if strings.TrimSpace(input.FailClosedError) == "" && strings.TrimSpace(fallbackRule) != "" {
+ input.PolicyRule = fallbackRule
+ }
+ if s != nil && s.repo != nil {
+ log := s.buildLog(input, cfg, action, false, "", 0, nil, "", nil, nil, errText)
+ _ = s.repo.CreateLog(ctx, log)
+ }
+ return buildContentModerationFailClosedDecision(input, cfg, action, fallbackRule, statusCode, message, errText), nil
+}
+
+func (s *ContentModerationService) checkSync(ctx context.Context, input ContentModerationCheckInput, cfg *ContentModerationConfig, content ContentModerationInput, hashText string, queueDelay *int, allowBlock bool) *ContentModerationDecision {
+ allow := &ContentModerationDecision{Allowed: true, Action: ContentModerationActionAllow}
+ start := time.Now()
+ result, err := s.callModeration(ctx, cfg, content.ModerationInput())
+ latency := int(time.Since(start).Milliseconds())
+ if err != nil {
+ slog.Warn("content_moderation.audit_api_failed",
+ "user_id", input.UserID,
+ "api_key_id", input.APIKeyID,
+ "group_id", contentModerationLogGroupID(input.GroupID),
+ "endpoint", input.Endpoint,
+ "protocol", input.Protocol,
+ "mode", cfg.Mode,
+ "allow_block", allowBlock,
+ "queue_delay_ms", queueDelay,
+ "latency_ms", latency,
+ "error", err)
+ if queueDelay != nil {
+ s.asyncErrors.Add(1)
+ }
+ if cfg.RecordNonHits || input.FailClosed {
+ log := s.buildLog(input, cfg, ContentModerationActionError, false, "", 0, nil, content.ExcerptText(), &latency, queueDelay, err.Error())
+ log.InputHash = hashText
+ if input.FailClosed {
+ log.PolicyRule = "moderation_api_failed"
+ }
+ _ = s.repo.CreateLog(ctx, log)
+ }
+ if input.FailClosed {
+ return buildContentModerationFailClosedDecision(input, cfg, ContentModerationActionError, "moderation_api_failed", http.StatusServiceUnavailable, defaultContentModerationUnavailableMessage, err.Error())
+ }
+ return allow
+ }
+
+ flagged, highestCategory, highestScore := evaluateModerationResult(result, cfg.Thresholds)
+ action := ContentModerationActionAllow
+ blocked := false
+ if allowBlock && flagged && (cfg.Mode == ContentModerationModePreBlock || input.FailClosed) {
+ action = ContentModerationActionBlock
+ blocked = true
+ }
+ slog.Info("content_moderation.audit_result",
+ "user_id", input.UserID,
+ "api_key_id", input.APIKeyID,
+ "group_id", contentModerationLogGroupID(input.GroupID),
+ "group_name", input.GroupName,
+ "endpoint", input.Endpoint,
+ "protocol", input.Protocol,
+ "mode", cfg.Mode,
+ "allow_block", allowBlock,
+ "flagged", flagged,
+ "blocked", blocked,
+ "action", action,
+ "highest_category", highestCategory,
+ "highest_score", highestScore,
+ "latency_ms", latency,
+ "queue_delay_ms", queueDelay)
+ if flagged || cfg.RecordNonHits {
+ log := s.buildLog(input, cfg, action, flagged, highestCategory, highestScore, result.CategoryScores, content.ExcerptText(), &latency, queueDelay, "")
+ log.InputHash = hashText
+ log.CategoryFlags = cloneBoolMap(result.Categories)
+ log.CategoryAppliedInputTypes = cloneStringSliceMap(result.CategoryAppliedInputTypes)
+ if flagged && s.hashCache != nil {
+ if err := s.hashCache.RecordFlaggedInputHash(ctx, hashText); err != nil {
+ slog.Warn("content_moderation.record_hash_failed", "user_id", input.UserID, "endpoint", input.Endpoint, "error", err)
+ }
+ }
+ autoBanJustApplied := s.applyFlaggedSideEffects(ctx, cfg, log)
+ _ = s.repo.CreateLog(ctx, log)
+ if autoBanJustApplied {
+ s.notifyContentModerationAutoBanTelegramAlert(cfg, log)
+ }
+ }
+ if blocked {
+ return &ContentModerationDecision{
+ Allowed: false,
+ Blocked: true,
+ Flagged: true,
+ Message: cfg.BlockMessage,
+ StatusCode: cfg.BlockStatus,
+ HighestCategory: highestCategory,
+ HighestScore: highestScore,
+ CategoryScores: result.CategoryScores,
+ Action: action,
+ PolicyRule: contentModerationPolicyRule(input.PolicyRule, "moderation_flagged_"+contentModerationStage(input.Stage)),
+ }
+ }
+ return &ContentModerationDecision{
+ Allowed: true,
+ Flagged: flagged,
+ Message: "",
+ HighestCategory: highestCategory,
+ HighestScore: highestScore,
+ CategoryScores: result.CategoryScores,
+ Action: action,
+ }
+}
+
+func (s *ContentModerationService) enqueueAsync(input ContentModerationCheckInput, cfg *ContentModerationConfig, content ContentModerationInput, hashText string) {
+ if s == nil || s.asyncQueue == nil {
+ return
+ }
+ queueSize := defaultContentModerationQueueSize
+ if cfg != nil && cfg.QueueSize > 0 {
+ queueSize = cfg.QueueSize
+ }
+ if len(s.asyncQueue) >= queueSize {
+ slog.Warn("content_moderation.async_queue_full", "user_id", input.UserID, "endpoint", input.Endpoint, "queue_size", queueSize)
+ s.asyncDropped.Add(1)
+ return
+ }
+ // The worker uses the normalized content and immutable hash below; retaining
+ // the complete request body would duplicate attacker-controlled queue memory.
+ input.Body = nil
+ task := contentModerationTask{
+ input: input,
+ content: content,
+ inputHash: hashText,
+ enqueuedAt: time.Now(),
+ }
+ select {
+ case s.asyncQueue <- task:
+ s.asyncEnqueued.Add(1)
+ default:
+ slog.Warn("content_moderation.async_queue_full", "user_id", input.UserID, "endpoint", input.Endpoint)
+ s.asyncDropped.Add(1)
+ }
+}
+
+func (s *ContentModerationService) worker(id int) {
+ for {
+ ctx, cancel := context.WithTimeout(context.Background(), maxContentModerationTimeoutMS*time.Millisecond+10*time.Second)
+ cfg, err := s.loadConfig(ctx)
+ if err != nil || !cfg.Enabled || cfg.Mode == ContentModerationModeOff || len(cfg.apiKeys()) == 0 || id >= cfg.WorkerCount {
+ cancel()
+ time.Sleep(time.Second)
+ continue
+ }
+ task, ok := s.dequeueAsyncTask(ctx, time.Second)
+ if !ok {
+ cancel()
+ continue
+ }
+ func() {
+ defer cancel()
+ defer func() {
+ if r := recover(); r != nil {
+ slog.Error("content_moderation.worker_panic", "worker_id", id, "recover", r)
+ }
+ }()
+ if !cfg.includesGroup(task.input.GroupID) {
+ return
+ }
+ s.asyncActive.Add(1)
+ defer s.asyncActive.Add(-1)
+ queueDelay := int(time.Since(task.enqueuedAt).Milliseconds())
+ _ = s.checkSync(ctx, task.input, cfg, task.content, task.inputHash, &queueDelay, false)
+ s.asyncProcessed.Add(1)
+ }()
+ }
+}
+
+func (s *ContentModerationService) dequeueAsyncTask(ctx context.Context, idleWait time.Duration) (contentModerationTask, bool) {
+ var zero contentModerationTask
+ if s == nil || s.asyncQueue == nil {
+ return zero, false
+ }
+ if idleWait <= 0 {
+ idleWait = time.Second
+ }
+ timer := time.NewTimer(idleWait)
+ defer timer.Stop()
+ select {
+ case task, ok := <-s.asyncQueue:
+ return task, ok
+ case <-ctx.Done():
+ return zero, false
+ case <-timer.C:
+ return zero, false
+ }
+}
+
+func (s *ContentModerationService) ListLogs(ctx context.Context, filter ContentModerationLogFilter) ([]ContentModerationLog, *pagination.PaginationResult, error) {
+ if filter.Pagination.Page <= 0 {
+ filter.Pagination.Page = 1
+ }
+ if filter.Pagination.PageSize <= 0 {
+ filter.Pagination.PageSize = 20
+ }
+ if filter.Pagination.PageSize > 100 {
+ filter.Pagination.PageSize = 100
+ }
+ if filter.Pagination.SortOrder == "" {
+ filter.Pagination.SortOrder = pagination.SortOrderDesc
+ }
+ return s.repo.ListLogs(ctx, filter)
+}
+
+func (s *ContentModerationService) UnbanUser(ctx context.Context, userID int64) (*ContentModerationUnbanUserResult, error) {
+ if s == nil || s.userRepo == nil {
+ return nil, infraerrors.InternalServer("CONTENT_MODERATION_USER_REPOSITORY_UNAVAILABLE", "用户仓储不可用")
+ }
+ if userID <= 0 {
+ return nil, infraerrors.BadRequest("INVALID_USER_ID", "用户 ID 无效")
+ }
+ user, err := s.userRepo.GetByID(ctx, userID)
+ if err != nil {
+ if errors.Is(err, ErrUserNotFound) {
+ return nil, infraerrors.NotFound("USER_NOT_FOUND", "用户不存在")
+ }
+ return nil, fmt.Errorf("get content moderation unban user: %w", err)
+ }
+ if user.Status != StatusActive {
+ user.Status = StatusActive
+ if err := s.userRepo.Update(ctx, user); err != nil {
+ return nil, fmt.Errorf("update content moderation unban user: %w", err)
+ }
+ }
+ if s.authCacheInvalidator != nil {
+ s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
+ }
+ return &ContentModerationUnbanUserResult{
+ UserID: userID,
+ Status: StatusActive,
+ }, nil
+}
+
+func (s *ContentModerationService) DeleteFlaggedInputHash(ctx context.Context, inputHash string) (*ContentModerationDeleteHashResult, error) {
+ inputHash = normalizeContentModerationHash(inputHash)
+ if inputHash == "" {
+ return nil, infraerrors.BadRequest("INVALID_CONTENT_MODERATION_HASH", "风险输入哈希无效")
+ }
+ if s == nil || s.hashCache == nil {
+ return nil, infraerrors.InternalServer("CONTENT_MODERATION_HASH_CACHE_UNAVAILABLE", "内容审计哈希缓存不可用")
+ }
+ deleted, err := s.hashCache.DeleteFlaggedInputHash(ctx, inputHash)
+ if err != nil {
+ return nil, fmt.Errorf("delete content moderation flagged hash: %w", err)
+ }
+ return &ContentModerationDeleteHashResult{
+ InputHash: inputHash,
+ Deleted: deleted,
+ }, nil
+}
+
+func (s *ContentModerationService) ClearFlaggedInputHashes(ctx context.Context) (*ContentModerationClearHashesResult, error) {
+ if s == nil || s.hashCache == nil {
+ return nil, infraerrors.InternalServer("CONTENT_MODERATION_HASH_CACHE_UNAVAILABLE", "内容审计哈希缓存不可用")
+ }
+ deleted, err := s.hashCache.ClearFlaggedInputHashes(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("clear content moderation flagged hashes: %w", err)
+ }
+ return &ContentModerationClearHashesResult{Deleted: deleted}, nil
+}
+
+func (s *ContentModerationService) GetStatus(ctx context.Context) (*ContentModerationRuntimeStatus, error) {
+ if s == nil {
+ return &ContentModerationRuntimeStatus{}, nil
+ }
+ cfg, err := s.loadConfig(ctx)
+ if err != nil {
+ return nil, err
+ }
+ riskEnabled := s.isRiskControlEnabled(ctx)
+ active := int(s.asyncActive.Load())
+ if active < 0 {
+ active = 0
+ }
+ if active > cfg.WorkerCount {
+ active = cfg.WorkerCount
+ }
+ queueLength := 0
+ if s.asyncQueue != nil {
+ queueLength = len(s.asyncQueue)
+ }
+ queueUsage := 0.0
+ if cfg.QueueSize > 0 {
+ queueUsage = float64(queueLength) * 100 / float64(cfg.QueueSize)
+ }
+ var flaggedHashCount int64
+ if s.hashCache != nil {
+ if n, err := s.hashCache.CountFlaggedInputHashes(ctx); err == nil {
+ flaggedHashCount = n
+ } else {
+ slog.Warn("content_moderation.hash_count_failed", "error", err)
+ }
+ }
+ var lastCleanupAt *time.Time
+ if unix := s.lastCleanupUnix.Load(); unix > 0 {
+ t := time.Unix(unix, 0)
+ lastCleanupAt = &t
+ }
+ return &ContentModerationRuntimeStatus{
+ Enabled: cfg.Enabled,
+ RiskControlEnabled: riskEnabled,
+ Mode: cfg.Mode,
+ WorkerCount: cfg.WorkerCount,
+ MaxWorkers: maxContentModerationWorkerCount,
+ ActiveWorkers: active,
+ IdleWorkers: cfg.WorkerCount - active,
+ QueueSize: cfg.QueueSize,
+ QueueLength: queueLength,
+ QueueUsagePercent: queueUsage,
+ Enqueued: s.asyncEnqueued.Load(),
+ Dropped: s.asyncDropped.Load(),
+ Processed: s.asyncProcessed.Load(),
+ Errors: s.asyncErrors.Load(),
+ APIKeyStatuses: s.apiKeyStatuses(cfg.apiKeys()),
+ FlaggedHashCount: flaggedHashCount,
+ LastCleanupAt: lastCleanupAt,
+ LastCleanupDeletedHit: s.lastCleanupDeletedHit.Load(),
+ LastCleanupDeletedNonHit: s.lastCleanupDeletedNonHit.Load(),
+ }, nil
+}
+
+func (s *ContentModerationService) cleanupWorker() {
+ timer := time.NewTimer(contentModerationCleanupDelay)
+ defer timer.Stop()
+ for {
+ <-timer.C
+ s.runCleanupOnce()
+ timer.Reset(contentModerationCleanupInterval)
+ }
+}
+
+func (s *ContentModerationService) runCleanupOnce() {
+ if s == nil || s.repo == nil || s.settingRepo == nil {
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), contentModerationCleanupTimeout)
+ defer cancel()
+ cfg, err := s.loadConfig(ctx)
+ if err != nil {
+ slog.Warn("content_moderation.cleanup_load_config_failed", "error", err)
+ return
+ }
+ now := time.Now()
+ hitBefore := now.AddDate(0, 0, -cfg.HitRetentionDays)
+ nonHitBefore := now.AddDate(0, 0, -cfg.NonHitRetentionDays)
+ result, err := s.repo.CleanupExpiredLogs(ctx, hitBefore, nonHitBefore)
+ if err != nil {
+ slog.Warn("content_moderation.cleanup_failed", "error", err)
+ return
+ }
+ if result == nil {
+ return
+ }
+ s.lastCleanupUnix.Store(result.FinishedAt.Unix())
+ s.lastCleanupDeletedHit.Store(result.DeletedHit)
+ s.lastCleanupDeletedNonHit.Store(result.DeletedNonHit)
+}
+
+func (s *ContentModerationService) loadConfig(ctx context.Context) (*ContentModerationConfig, error) {
+ cfg := defaultContentModerationConfig()
+ raw, err := s.settingRepo.GetValue(ctx, SettingKeyContentModerationConfig)
+ if err != nil {
+ if errors.Is(err, ErrSettingNotFound) {
+ cfg.normalize()
+ return cfg, nil
+ }
+ return nil, fmt.Errorf("get content moderation config: %w", err)
+ }
+ if strings.TrimSpace(raw) == "" {
+ cfg.normalize()
+ return cfg, nil
+ }
+ if err := json.Unmarshal([]byte(raw), cfg); err != nil {
+ return nil, infraerrors.BadRequest("INVALID_CONTENT_MODERATION_CONFIG", "内容审计配置不是有效 JSON")
+ }
+ legacyKeys := normalizeModerationAPIKeys(append(append([]string{}, cfg.APIKeys...), cfg.APIKey))
+ if len(legacyKeys) > 0 {
+ return nil, fmt.Errorf("content moderation plaintext API keys remain after security migration")
+ }
+ if len(cfg.EncryptedAPIKeys) > 0 {
+ if s.secretEncryptor == nil {
+ return nil, fmt.Errorf("content moderation secret encryptor is not configured")
+ }
+ keys := make([]string, 0, len(cfg.EncryptedAPIKeys))
+ for _, ciphertext := range cfg.EncryptedAPIKeys {
+ plaintext, err := DecryptForSecretDomain(s.secretEncryptor, SecretDomainContentModeration, ciphertext)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt content moderation api key: %w", err)
+ }
+ keys = append(keys, plaintext)
+ }
+ decryptedKeys := normalizeModerationAPIKeys(keys)
+ cfg.APIKey = ""
+ cfg.APIKeys = decryptedKeys
+ cfg.EncryptedAPIKeys = nil
+ }
+ cfg.normalize()
+ return cfg, nil
+}
+
+func (s *ContentModerationService) saveConfig(ctx context.Context, cfg *ContentModerationConfig) error {
+ stored := *cfg
+ stored.APIKey = ""
+ // Persist only ciphertext. loadConfig still accepts the legacy plaintext
+ // fields and immediately rewrites them, but mixed old/new binaries are not a
+ // safe deployment topology after this version starts saving configuration.
+ stored.APIKeys = nil
+ stored.EncryptedAPIKeys = nil
+ for _, key := range cfg.apiKeys() {
+ if s.secretEncryptor == nil {
+ return fmt.Errorf("content moderation secret encryptor is not configured")
+ }
+ ciphertext, err := EncryptForSecretDomain(s.secretEncryptor, SecretDomainContentModeration, key)
+ if err != nil {
+ return fmt.Errorf("encrypt content moderation api key: %w", err)
+ }
+ stored.EncryptedAPIKeys = append(stored.EncryptedAPIKeys, ciphertext)
+ }
+ raw, err := json.Marshal(&stored)
+ if err != nil {
+ return fmt.Errorf("marshal content moderation config: %w", err)
+ }
+ if err := s.settingRepo.Set(ctx, SettingKeyContentModerationConfig, string(raw)); err != nil {
+ return fmt.Errorf("save content moderation config: %w", err)
+ }
+ return nil
+}
+
+func equalModerationAPIKeys(left, right []string) bool {
+ left = normalizeModerationAPIKeys(left)
+ right = normalizeModerationAPIKeys(right)
+ if len(left) != len(right) {
+ return false
+ }
+ for i := range left {
+ if left[i] != right[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func (s *ContentModerationService) isRiskControlEnabled(ctx context.Context) bool {
+ raw, err := s.settingRepo.GetValue(ctx, SettingKeyRiskControlEnabled)
+ if err != nil {
+ return false
+ }
+ return raw == "true"
+}
+
+func (s *ContentModerationService) validateConfig(ctx context.Context, cfg *ContentModerationConfig) error {
+ if cfg == nil {
+ return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_CONFIG", "内容审计配置不能为空")
+ }
+ cfg.normalize()
+ switch cfg.Mode {
+ case ContentModerationModeOff, ContentModerationModeObserve, ContentModerationModePreBlock:
+ default:
+ return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_MODE", "内容审计模式无效")
+ }
+ if err := validateContentModerationBaseURL(cfg.BaseURL); err != nil {
+ return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_BASE_URL", "OpenAI Base URL 无效")
+ }
+ if cfg.BlockStatus < 400 || cfg.BlockStatus > 599 {
+ return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_BLOCK_STATUS", "拦截 HTTP 状态码必须在 400-599 之间")
+ }
+ if !cfg.AllGroups && len(cfg.GroupIDs) > 0 && s.groupRepo != nil {
+ for _, groupID := range cfg.GroupIDs {
+ if _, err := s.groupRepo.GetByIDLite(ctx, groupID); err != nil {
+ return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_GROUP", fmt.Sprintf("审计分组不存在: %d", groupID))
+ }
+ }
+ }
+ return nil
+}
+
+func validateContentModerationBaseURL(raw string) error {
+ parsed, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil || !parsed.IsAbs() || parsed.Host == "" {
+ return fmt.Errorf("absolute moderation base URL is required")
+ }
+ if parsed.User != nil || parsed.Fragment != "" {
+ return fmt.Errorf("moderation base URL cannot contain credentials or a fragment")
+ }
+ scheme := strings.ToLower(parsed.Scheme)
+ if scheme == "https" {
+ return nil
+ }
+ if scheme != "http" {
+ return fmt.Errorf("moderation base URL must use HTTPS")
+ }
+ hostname := strings.ToLower(parsed.Hostname())
+ if hostname == "localhost" {
+ return nil
+ }
+ ip := net.ParseIP(hostname)
+ if ip != nil && ip.IsLoopback() {
+ return nil
+ }
+ return fmt.Errorf("non-loopback moderation base URL must use HTTPS")
+}
+
+func contentModerationHTTPClient(base *http.Client) *http.Client {
+ client := *http.DefaultClient
+ if base != nil {
+ client = *base
+ }
+ client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
+ return http.ErrUseLastResponse
+ }
+ return &client
+}
+
+func (s *ContentModerationService) callModeration(ctx context.Context, cfg *ContentModerationConfig, input any) (*moderationAPIResult, error) {
+ attempts := cfg.RetryCount + 1
+ if attempts <= 0 {
+ attempts = 1
+ }
+ if attempts > maxContentModerationRetryCount+1 {
+ attempts = maxContentModerationRetryCount + 1
+ }
+ var lastErr error
+ for attempt := 0; attempt < attempts; attempt++ {
+ key, ok := s.nextUsableAPIKey(cfg)
+ if !ok {
+ lastErr = errors.New("no moderation api key available")
+ break
+ }
+ start := time.Now()
+ httpStatus := 0
+ result, err := s.callModerationOnceWithInput(ctx, cfg, key, input, &httpStatus)
+ latency := int(time.Since(start).Milliseconds())
+ if err == nil {
+ s.markAPIKeySuccess(key, latency, httpStatus)
+ return result, nil
+ }
+ s.markAPIKeyError(key, err.Error(), latency, httpStatus)
+ lastErr = err
+ if httpStatus == http.StatusBadRequest {
+ break
+ }
+ if attempt == attempts-1 {
+ break
+ }
+ wait := time.Duration(100*(attempt+1)) * time.Millisecond
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-time.After(wait):
+ }
+ }
+ return nil, lastErr
+}
+
+func (s *ContentModerationService) callModerationOnceWithInput(ctx context.Context, cfg *ContentModerationConfig, apiKey string, input any, httpStatus *int) (*moderationAPIResult, error) {
+ if cfg == nil {
+ return nil, errors.New("content moderation config is missing")
+ }
+ if err := validateContentModerationBaseURL(cfg.BaseURL); err != nil {
+ return nil, fmt.Errorf("invalid content moderation base URL: %w", err)
+ }
+ base := strings.TrimRight(cfg.BaseURL, "/")
+ endpoint, err := url.JoinPath(base, "/v1/moderations")
+ if err != nil {
+ return nil, err
+ }
+ payload := moderationAPIRequest{
+ Model: cfg.Model,
+ Input: input,
+ }
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+ timeout := time.Duration(cfg.TimeoutMS) * time.Millisecond
+ reqCtx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, endpoint, bytes.NewReader(raw))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+apiKey)
+ req.Header.Set("Content-Type", "application/json")
+
+ client := contentModerationHTTPClient(s.httpClient)
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if httpStatus != nil {
+ *httpStatus = resp.StatusCode
+ }
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return nil, fmt.Errorf("moderation api status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+ var out moderationAPIResponse
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return nil, err
+ }
+ if len(out.Results) == 0 {
+ return nil, errors.New("moderation api returned empty results")
+ }
+ return &out.Results[0], nil
+}
+
+func (s *ContentModerationService) buildLog(input ContentModerationCheckInput, cfg *ContentModerationConfig, action string, flagged bool, highestCategory string, highestScore float64, scores map[string]float64, text string, latency *int, queueDelay *int, errText string) *ContentModerationLog {
+ var userID *int64
+ if input.UserID > 0 {
+ userID = &input.UserID
+ }
+ var apiKeyID *int64
+ if input.APIKeyID > 0 {
+ apiKeyID = &input.APIKeyID
+ }
+ return &ContentModerationLog{
+ RequestID: input.RequestID,
+ UserID: userID,
+ UserEmail: input.UserEmail,
+ APIKeyID: apiKeyID,
+ APIKeyName: input.APIKeyName,
+ GroupID: cloneInt64Ptr(input.GroupID),
+ GroupName: input.GroupName,
+ Endpoint: input.Endpoint,
+ Provider: input.Provider,
+ Model: input.Model,
+ Stage: contentModerationStage(input.Stage),
+ Mode: cfg.Mode,
+ Action: action,
+ Flagged: flagged,
+ HighestCategory: highestCategory,
+ HighestScore: highestScore,
+ CategoryScores: cloneFloatMap(scores),
+ ThresholdSnapshot: cloneFloatMap(cfg.Thresholds),
+ InputHash: normalizeContentModerationHash(input.InputHash),
+ OutputHashes: cloneModerationStringSlice(input.OutputHashes),
+ PolicyRule: contentModerationPolicyRule(input.PolicyRule, contentModerationDefaultPolicyRule(action, flagged, contentModerationStage(input.Stage))),
+ UpstreamRequestID: strings.TrimSpace(input.UpstreamRequestID),
+ SafetyIdentifier: strings.TrimSpace(input.SafetyIdentifier),
+ InputExcerpt: trimRunes(redactContentModerationSecrets(text), maxModerationExcerptRunes),
+ UpstreamLatencyMS: latency,
+ QueueDelayMS: queueDelay,
+ Error: errText,
+ }
+}
+
+func (s *ContentModerationService) applyFlaggedSideEffects(ctx context.Context, cfg *ContentModerationConfig, log *ContentModerationLog) bool {
+ if s == nil || cfg == nil || log == nil || !log.Flagged || log.UserID == nil || *log.UserID <= 0 {
+ return false
+ }
+ count := 1
+ if s.repo != nil && cfg.ViolationWindowHours > 0 {
+ since := time.Now().Add(-time.Duration(cfg.ViolationWindowHours) * time.Hour)
+ if n, err := s.repo.CountFlaggedByUserSince(ctx, *log.UserID, since); err == nil {
+ count = n + 1
+ }
+ }
+ log.ViolationCount = count
+ autoBanJustApplied := false
+ if cfg.AutoBanEnabled && cfg.BanThreshold > 0 && count >= cfg.BanThreshold && s.userRepo != nil {
+ user, err := s.userRepo.GetByID(ctx, *log.UserID)
+ if err != nil {
+ slog.Warn("content_moderation.ban_get_user_failed", "user_id", *log.UserID, "error", err)
+ return false
+ }
+ if user.Status != StatusDisabled {
+ user.Status = StatusDisabled
+ if err := s.userRepo.Update(ctx, user); err != nil {
+ slog.Warn("content_moderation.ban_update_user_failed", "user_id", *log.UserID, "error", err)
+ return false
+ }
+ if s.authCacheInvalidator != nil {
+ s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, *log.UserID)
+ }
+ autoBanJustApplied = true
+ }
+ log.AutoBanned = true
+ }
+
+ if s.emailService == nil || strings.TrimSpace(log.UserEmail) == "" {
+ return autoBanJustApplied
+ }
+ emailSent := false
+ if cfg.EmailOnHit {
+ if err := s.sendViolationEmail(ctx, cfg, log); err != nil {
+ slog.Warn("content_moderation.email_failed", "user_id", *log.UserID, "email", log.UserEmail, "error", err)
+ } else {
+ emailSent = true
+ }
+ }
+ if autoBanJustApplied {
+ if err := s.sendAccountDisabledEmail(ctx, cfg, log); err != nil {
+ slog.Warn("content_moderation.ban_email_failed", "user_id", *log.UserID, "email", log.UserEmail, "error", err)
+ } else {
+ emailSent = true
+ }
+ }
+ log.EmailSent = emailSent
+ return autoBanJustApplied
+}
+
+func (s *ContentModerationService) notifyContentModerationAutoBanTelegramAlert(cfg *ContentModerationConfig, log *ContentModerationLog) {
+ if s == nil || s.settingRepo == nil || cfg == nil || log == nil {
+ return
+ }
+ userID := int64(0)
+ if log.UserID != nil {
+ userID = *log.UserID
+ }
+ apiKeyID := int64(0)
+ if log.APIKeyID != nil {
+ apiKeyID = *log.APIKeyID
+ }
+ groupID := int64(0)
+ if log.GroupID != nil {
+ groupID = *log.GroupID
+ }
+ evidence := []string{
+ fmt.Sprintf("violation_count=%d", log.ViolationCount),
+ fmt.Sprintf("ban_threshold=%d", cfg.BanThreshold),
+ "highest_category=" + log.HighestCategory,
+ fmt.Sprintf("highest_score=%.4f", log.HighestScore),
+ "endpoint=" + log.Endpoint,
+ "model=" + log.Model,
+ }
+ alert := HFCAbuseRiskTelegramAlert{
+ Source: "content_moderation_auto_ban",
+ Severity: HFCAbuseRiskSeverityCritical,
+ Summary: "内容审计累计命中阈值并触发自动封禁",
+ UserID: userID,
+ UserEmail: log.UserEmail,
+ APIKeyID: apiKeyID,
+ APIKeyName: log.APIKeyName,
+ GroupID: groupID,
+ GroupName: log.GroupName,
+ RiskScore: log.ViolationCount,
+ Evidence: evidence,
+ Action: "account disabled by existing content moderation auto-ban; review evidence before further action",
+ OccurredAt: time.Now(),
+ }
+ if s.abuseRiskRecorder == nil {
+ DispatchHFCAbuseRiskTelegramAlert(s.settingRepo, alert)
+ return
+ }
+ recorder := s.abuseRiskRecorder
+ settingRepo := s.settingRepo
+ go func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ var contentModerationLogID *int64
+ if log.ID > 0 {
+ contentModerationLogID = &log.ID
+ }
+ event, err := recorder.RecordEvent(ctx, &HFCAbuseRiskEvent{
+ Source: HFCAbuseRiskSourceContentModerationAutoBan,
+ Severity: HFCAbuseRiskSeverityCritical,
+ Status: HFCAbuseRiskStatusOpen,
+ UserID: log.UserID,
+ UserEmail: log.UserEmail,
+ APIKeyID: log.APIKeyID,
+ APIKeyName: log.APIKeyName,
+ GroupID: log.GroupID,
+ GroupName: log.GroupName,
+ RiskScore: log.ViolationCount,
+ ContentModerationLogID: contentModerationLogID,
+ Summary: alert.Summary,
+ Evidence: []HFCAbuseRiskEvidence{
+ hfcRiskEvidence("violation_count", "窗口累计命中", log.ViolationCount),
+ hfcRiskEvidence("ban_threshold", "自动封禁阈值", cfg.BanThreshold),
+ hfcRiskEvidence("highest_category", "最高分类", log.HighestCategory),
+ hfcRiskEvidence("highest_score", "最高分", fmt.Sprintf("%.4f", log.HighestScore)),
+ hfcRiskEvidence("endpoint", "端点", log.Endpoint),
+ hfcRiskEvidence("model", "模型", log.Model),
+ },
+ })
+ if err != nil {
+ logHFCAbuseRiskRecordFailure(HFCAbuseRiskSourceContentModerationAutoBan, userID, apiKeyID, err)
+ }
+ if event != nil && event.ID > 0 {
+ alert.EventID = event.ID
+ }
+ DispatchHFCAbuseRiskTelegramAlert(settingRepo, alert)
+ }()
+}
+
+func (s *ContentModerationService) sendViolationEmail(ctx context.Context, cfg *ContentModerationConfig, log *ContentModerationLog) error {
+ siteName := s.siteName(ctx)
+ subject := fmt.Sprintf("[%s] 账户风控提醒 / Risk Control Notice", sanitizeEmailHeader(siteName))
+ body := buildContentModerationViolationEmailBody(siteName, log, cfg)
+ return s.emailService.SendEmail(ctx, log.UserEmail, subject, body)
+}
+
+func (s *ContentModerationService) sendAccountDisabledEmail(ctx context.Context, cfg *ContentModerationConfig, log *ContentModerationLog) error {
+ siteName := s.siteName(ctx)
+ subject := fmt.Sprintf("[%s] 账户已被禁用 / Account Disabled", sanitizeEmailHeader(siteName))
+ body := buildContentModerationAccountDisabledEmailBody(siteName, log, cfg)
+ return s.emailService.SendEmail(ctx, log.UserEmail, subject, body)
+}
+
+func (s *ContentModerationService) siteName(ctx context.Context) string {
+ if s == nil || s.settingRepo == nil {
+ return "Sub2API"
+ }
+ name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName)
+ if err != nil || strings.TrimSpace(name) == "" {
+ return "Sub2API"
+ }
+ return strings.TrimSpace(name)
+}
+
+func defaultContentModerationConfig() *ContentModerationConfig {
+ return &ContentModerationConfig{
+ Enabled: false,
+ Mode: ContentModerationModePreBlock,
+ BaseURL: defaultContentModerationBaseURL,
+ Model: defaultContentModerationModel,
+ TimeoutMS: defaultContentModerationTimeoutMS,
+ SampleRate: 100,
+ AllGroups: true,
+ GroupIDs: []int64{},
+ RecordNonHits: false,
+ Thresholds: ContentModerationDefaultThresholds(),
+ WorkerCount: defaultContentModerationWorkerCount,
+ QueueSize: defaultContentModerationQueueSize,
+ BlockStatus: defaultContentModerationBlockHTTPStatus,
+ BlockMessage: defaultContentModerationBlockMessage,
+ EmailOnHit: true,
+ AutoBanEnabled: true,
+ BanThreshold: defaultContentModerationBanThreshold,
+ ViolationWindowHours: defaultContentModerationViolationWindowHours,
+ RetryCount: defaultContentModerationRetryCount,
+ HitRetentionDays: defaultContentModerationHitRetentionDays,
+ NonHitRetentionDays: defaultContentModerationNonHitRetentionDays,
+ PreHashCheckEnabled: false,
+ }
+}
+
+func (cfg *ContentModerationConfig) normalize() {
+ if cfg.APIKey != "" {
+ cfg.APIKeys = normalizeModerationAPIKeys(append(cfg.APIKeys, cfg.APIKey))
+ cfg.APIKey = ""
+ } else {
+ cfg.APIKeys = normalizeModerationAPIKeys(cfg.APIKeys)
+ }
+ if cfg.Mode == "" {
+ cfg.Mode = ContentModerationModePreBlock
+ }
+ if cfg.BaseURL == "" {
+ cfg.BaseURL = defaultContentModerationBaseURL
+ }
+ cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
+ if cfg.Model == "" {
+ cfg.Model = defaultContentModerationModel
+ }
+ cfg.Model = strings.TrimSpace(cfg.Model)
+ if cfg.TimeoutMS <= 0 {
+ cfg.TimeoutMS = defaultContentModerationTimeoutMS
+ }
+ if cfg.TimeoutMS > maxContentModerationTimeoutMS {
+ cfg.TimeoutMS = maxContentModerationTimeoutMS
+ }
+ if cfg.SampleRate < 0 {
+ cfg.SampleRate = 0
+ }
+ if cfg.SampleRate > 100 {
+ cfg.SampleRate = 100
+ }
+ if cfg.WorkerCount <= 0 {
+ cfg.WorkerCount = defaultContentModerationWorkerCount
+ }
+ if cfg.WorkerCount > maxContentModerationWorkerCount {
+ cfg.WorkerCount = maxContentModerationWorkerCount
+ }
+ if cfg.QueueSize <= 0 {
+ cfg.QueueSize = defaultContentModerationQueueSize
+ }
+ if cfg.QueueSize > maxContentModerationQueueSize {
+ cfg.QueueSize = maxContentModerationQueueSize
+ }
+ if strings.TrimSpace(cfg.BlockMessage) == "" {
+ cfg.BlockMessage = defaultContentModerationBlockMessage
+ }
+ cfg.BlockMessage = strings.TrimSpace(cfg.BlockMessage)
+ if cfg.BlockStatus <= 0 {
+ cfg.BlockStatus = defaultContentModerationBlockHTTPStatus
+ }
+ if cfg.BanThreshold <= 0 {
+ cfg.BanThreshold = defaultContentModerationBanThreshold
+ }
+ if cfg.ViolationWindowHours <= 0 {
+ cfg.ViolationWindowHours = defaultContentModerationViolationWindowHours
+ }
+ if cfg.RetryCount < 0 {
+ cfg.RetryCount = 0
+ }
+ if cfg.RetryCount > maxContentModerationRetryCount {
+ cfg.RetryCount = maxContentModerationRetryCount
+ }
+ if cfg.HitRetentionDays <= 0 {
+ cfg.HitRetentionDays = defaultContentModerationHitRetentionDays
+ }
+ if cfg.HitRetentionDays > maxContentModerationRetentionDays {
+ cfg.HitRetentionDays = maxContentModerationRetentionDays
+ }
+ if cfg.NonHitRetentionDays <= 0 {
+ cfg.NonHitRetentionDays = defaultContentModerationNonHitRetentionDays
+ }
+ if cfg.NonHitRetentionDays > maxContentModerationNonHitRetentionDays {
+ cfg.NonHitRetentionDays = maxContentModerationNonHitRetentionDays
+ }
+ cfg.GroupIDs = normalizeInt64IDs(cfg.GroupIDs)
+ cfg.Thresholds = mergeContentModerationThresholds(ContentModerationDefaultThresholds(), cfg.Thresholds)
+}
+
+func (cfg *ContentModerationConfig) includesGroup(groupID *int64) bool {
+ if cfg.AllGroups {
+ return true
+ }
+ if groupID == nil {
+ return false
+ }
+ for _, id := range cfg.GroupIDs {
+ if id == *groupID {
+ return true
+ }
+ }
+ return false
+}
+
+func contentModerationLogGroupID(groupID *int64) int64 {
+ if groupID == nil {
+ return 0
+ }
+ return *groupID
+}
+
+func (cfg *ContentModerationConfig) shouldSample(hashText string) bool {
+ if cfg.SampleRate >= 100 {
+ return true
+ }
+ if cfg.SampleRate <= 0 {
+ return false
+ }
+ raw, err := hex.DecodeString(hashText)
+ if err != nil || len(raw) < 2 {
+ return true
+ }
+ return int(binary.BigEndian.Uint16(raw[:2])%100) < cfg.SampleRate
+}
+
+func (cfg *ContentModerationConfig) apiKeys() []string {
+ if cfg == nil {
+ return nil
+ }
+ return normalizeModerationAPIKeys(cfg.APIKeys)
+}
+
+func (s *ContentModerationService) nextUsableAPIKey(cfg *ContentModerationConfig) (string, bool) {
+ keys := cfg.apiKeys()
+ if len(keys) == 0 {
+ return "", false
+ }
+ now := time.Now()
+ for i := 0; i < len(keys); i++ {
+ idx := int(s.apiKeyCursor.Add(1)-1) % len(keys)
+ key := keys[idx]
+ if !s.isAPIKeyFrozen(key, now) {
+ return key, true
+ }
+ }
+ return "", false
+}
+
+func (s *ContentModerationService) isAPIKeyFrozen(key string, now time.Time) bool {
+ hash := moderationAPIKeyHash(key)
+ if hash == "" || s == nil {
+ return false
+ }
+ s.keyHealthMu.Lock()
+ defer s.keyHealthMu.Unlock()
+ state := s.keyHealth[hash]
+ return state != nil && state.FrozenUntil.After(now)
+}
+
+func (s *ContentModerationService) markAPIKeySuccess(key string, latencyMS int, httpStatus int) {
+ hash := moderationAPIKeyHash(key)
+ if hash == "" || s == nil {
+ return
+ }
+ s.keyHealthMu.Lock()
+ defer s.keyHealthMu.Unlock()
+ state := s.ensureAPIKeyHealthLocked(hash, maskSecretTail(key))
+ state.FailureCount = 0
+ state.SuccessCount++
+ state.LastError = ""
+ state.LastCheckedAt = time.Now()
+ state.FrozenUntil = time.Time{}
+ state.LastLatencyMS = latencyMS
+ state.LastHTTPStatus = httpStatus
+ state.LastTested = true
+}
+
+func (s *ContentModerationService) markAPIKeyError(key string, errText string, latencyMS int, httpStatus int) {
+ hash := moderationAPIKeyHash(key)
+ if hash == "" || s == nil {
+ return
+ }
+ s.keyHealthMu.Lock()
+ defer s.keyHealthMu.Unlock()
+ state := s.ensureAPIKeyHealthLocked(hash, maskSecretTail(key))
+ if contentModerationFreezeDurationForHTTPStatus(httpStatus) > 0 {
+ state.FailureCount++
+ }
+ state.LastError = trimRunes(errText, 180)
+ state.LastCheckedAt = time.Now()
+ state.LastLatencyMS = latencyMS
+ state.LastHTTPStatus = httpStatus
+ state.LastTested = true
+ if freezeDuration := contentModerationFreezeDurationForHTTPStatus(httpStatus); freezeDuration > 0 {
+ state.FrozenUntil = time.Now().Add(freezeDuration)
+ }
+}
+
+func contentModerationFreezeDurationForHTTPStatus(httpStatus int) time.Duration {
+ switch httpStatus {
+ case 0, http.StatusBadRequest:
+ return 0
+ case http.StatusUnauthorized, http.StatusForbidden:
+ return contentModerationKeyAuthFreezeDuration
+ case http.StatusTooManyRequests, 529:
+ return contentModerationKeyRateLimitFreezeDuration
+ default:
+ return contentModerationKeyHTTPErrorFreezeDuration
+ }
+}
+
+func (s *ContentModerationService) ensureAPIKeyHealthLocked(hash string, masked string) *contentModerationKeyHealth {
+ if s.keyHealth == nil {
+ s.keyHealth = make(map[string]*contentModerationKeyHealth)
+ }
+ state := s.keyHealth[hash]
+ if state == nil {
+ state = &contentModerationKeyHealth{Hash: hash}
+ s.keyHealth[hash] = state
+ }
+ if strings.TrimSpace(masked) != "" {
+ state.Masked = masked
+ }
+ return state
+}
+
+func (s *ContentModerationService) configView(cfg *ContentModerationConfig) *ContentModerationConfigView {
+ keys := cfg.apiKeys()
+ masks := make([]string, 0, len(keys))
+ for _, key := range keys {
+ masks = append(masks, maskSecretTail(key))
+ }
+ apiKeyMasked := ""
+ if len(masks) > 0 {
+ apiKeyMasked = masks[0]
+ }
+ return &ContentModerationConfigView{
+ Enabled: cfg.Enabled,
+ Mode: cfg.Mode,
+ BaseURL: cfg.BaseURL,
+ Model: cfg.Model,
+ APIKeyConfigured: len(keys) > 0,
+ APIKeyMasked: apiKeyMasked,
+ APIKeyCount: len(keys),
+ APIKeyMasks: masks,
+ APIKeyStatuses: s.apiKeyStatuses(keys),
+ TimeoutMS: cfg.TimeoutMS,
+ SampleRate: cfg.SampleRate,
+ AllGroups: cfg.AllGroups,
+ GroupIDs: append([]int64(nil), cfg.GroupIDs...),
+ RecordNonHits: cfg.RecordNonHits,
+ WorkerCount: cfg.WorkerCount,
+ QueueSize: cfg.QueueSize,
+ BlockStatus: cfg.BlockStatus,
+ BlockMessage: cfg.BlockMessage,
+ EmailOnHit: cfg.EmailOnHit,
+ AutoBanEnabled: cfg.AutoBanEnabled,
+ BanThreshold: cfg.BanThreshold,
+ ViolationWindowHours: cfg.ViolationWindowHours,
+ RetryCount: cfg.RetryCount,
+ HitRetentionDays: cfg.HitRetentionDays,
+ NonHitRetentionDays: cfg.NonHitRetentionDays,
+ PreHashCheckEnabled: cfg.PreHashCheckEnabled,
+ }
+}
+
+func (s *ContentModerationService) apiKeyStatuses(keys []string) []ContentModerationAPIKeyStatus {
+ out := make([]ContentModerationAPIKeyStatus, 0, len(keys))
+ for idx, key := range keys {
+ out = append(out, s.apiKeyStatusForHash(idx, moderationAPIKeyHash(key), maskSecretTail(key), true))
+ }
+ return out
+}
+
+func (s *ContentModerationService) apiKeyStatusForHash(index int, hash string, masked string, configured bool) ContentModerationAPIKeyStatus {
+ status := ContentModerationAPIKeyStatus{
+ Index: index,
+ KeyHash: hash,
+ Masked: masked,
+ Status: "unknown",
+ Configured: configured,
+ }
+ if hash == "" || s == nil {
+ return status
+ }
+ now := time.Now()
+ s.keyHealthMu.Lock()
+ defer s.keyHealthMu.Unlock()
+ state := s.keyHealth[hash]
+ if state == nil {
+ return status
+ }
+ status.FailureCount = state.FailureCount
+ status.SuccessCount = state.SuccessCount
+ status.LastError = state.LastError
+ status.LastLatencyMS = state.LastLatencyMS
+ status.LastHTTPStatus = state.LastHTTPStatus
+ status.LastTested = state.LastTested
+ if !state.LastCheckedAt.IsZero() {
+ t := state.LastCheckedAt
+ status.LastCheckedAt = &t
+ }
+ if state.FrozenUntil.After(now) {
+ t := state.FrozenUntil
+ status.FrozenUntil = &t
+ status.Status = "frozen"
+ return status
+ }
+ if state.LastError != "" {
+ status.Status = "error"
+ return status
+ }
+ if state.SuccessCount > 0 || state.LastTested {
+ status.Status = "ok"
+ }
+ return status
+}
+
+func moderationAPIKeyHash(key string) string {
+ key = strings.TrimSpace(key)
+ if key == "" {
+ return ""
+ }
+ sum := sha256.Sum256([]byte(key))
+ return hex.EncodeToString(sum[:])
+}
+
+func buildModerationTestInput(prompt string, images []string) (any, int, error) {
+ prompt = trimRunes(normalizeContentModerationText(prompt), maxModerationInputRunes)
+ normalizedImages := make([]string, 0, len(images))
+ for _, image := range images {
+ image = strings.TrimSpace(image)
+ if image == "" {
+ continue
+ }
+ if len(normalizedImages) >= maxContentModerationTestImages {
+ return nil, 0, infraerrors.BadRequest("TOO_MANY_MODERATION_TEST_IMAGES", fmt.Sprintf("最多上传 %d 张测试图片", maxContentModerationTestImages))
+ }
+ if err := validateModerationTestImageDataURL(image); err != nil {
+ return nil, 0, err
+ }
+ normalizedImages = append(normalizedImages, image)
+ }
+ if prompt == "" && len(normalizedImages) == 0 {
+ return "hello", 0, nil
+ }
+ if len(normalizedImages) == 0 {
+ return prompt, 0, nil
+ }
+ parts := make([]moderationAPIInputPart, 0, len(normalizedImages)+1)
+ if prompt != "" {
+ parts = append(parts, moderationAPIInputPart{Type: "text", Text: prompt})
+ }
+ for _, image := range normalizedImages {
+ parts = append(parts, moderationAPIInputPart{
+ Type: "image_url",
+ ImageURL: &moderationAPIImageURLRef{URL: image},
+ })
+ }
+ return parts, len(normalizedImages), nil
+}
+
+func contentModerationTestHasAuditInput(prompt string, images []string) bool {
+ if normalizeContentModerationText(prompt) != "" {
+ return true
+ }
+ for _, image := range images {
+ if strings.TrimSpace(image) != "" {
+ return true
+ }
+ }
+ return false
+}
+
+func validateModerationTestImageDataURL(value string) error {
+ if len(value) > maxContentModerationTestImageDataURLBytes {
+ return infraerrors.BadRequest("MODERATION_TEST_IMAGE_TOO_LARGE", "测试图片不能超过 8MB")
+ }
+ if !strings.HasPrefix(value, "data:image/") {
+ return infraerrors.BadRequest("INVALID_MODERATION_TEST_IMAGE", "测试图片必须是 data:image/* base64")
+ }
+ parts := strings.SplitN(value, ",", 2)
+ if len(parts) != 2 || !strings.Contains(parts[0], ";base64") {
+ return infraerrors.BadRequest("INVALID_MODERATION_TEST_IMAGE", "测试图片必须是 base64 data URL")
+ }
+ raw, err := base64.StdEncoding.DecodeString(parts[1])
+ if err != nil {
+ return infraerrors.BadRequest("INVALID_MODERATION_TEST_IMAGE", "测试图片 base64 无效")
+ }
+ if len(raw) > maxContentModerationTestImageBytes {
+ return infraerrors.BadRequest("MODERATION_TEST_IMAGE_TOO_LARGE", "测试图片不能超过 8MB")
+ }
+ return nil
+}
+
+func buildContentModerationTestAuditResult(result *moderationAPIResult, thresholds map[string]float64) *ContentModerationTestAuditResult {
+ if result == nil {
+ return nil
+ }
+ scores := make(map[string]float64, len(result.CategoryScores))
+ for category, score := range result.CategoryScores {
+ scores[category] = score
+ }
+ thresholdSnapshot := mergeContentModerationThresholds(ContentModerationDefaultThresholds(), thresholds)
+ flagged, highestCategory, highestScore := evaluateModerationResult(result, thresholdSnapshot)
+ compositeScore := highestScore
+ return &ContentModerationTestAuditResult{
+ Flagged: flagged,
+ HighestCategory: highestCategory,
+ HighestScore: highestScore,
+ CompositeScore: compositeScore,
+ CategoryScores: scores,
+ Thresholds: thresholdSnapshot,
+ }
+}
+
+type moderationAPIRequest struct {
+ Model string `json:"model"`
+ Input any `json:"input"`
+}
+
+type moderationAPIInputPart struct {
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ ImageURL *moderationAPIImageURLRef `json:"image_url,omitempty"`
+}
+
+type moderationAPIImageURLRef struct {
+ URL string `json:"url"`
+}
+
+type moderationAPIResponse struct {
+ Results []moderationAPIResult `json:"results"`
+}
+
+type moderationAPIResult struct {
+ Flagged bool `json:"flagged"`
+ Categories map[string]bool `json:"categories"`
+ CategoryScores map[string]float64 `json:"category_scores"`
+ CategoryAppliedInputTypes map[string][]string `json:"category_applied_input_types"`
+}
+
+func evaluateModerationResult(result *moderationAPIResult, thresholds map[string]float64) (bool, string, float64) {
+ if result == nil {
+ return false, "", 0
+ }
+ flagged, highestCategory, highestScore := evaluateModerationScores(result.CategoryScores, thresholds)
+ for _, category := range contentModerationCategoryOrder {
+ if result.Categories[category] {
+ flagged = true
+ if score, ok := result.CategoryScores[category]; ok {
+ if score > highestScore || highestCategory == "" {
+ highestCategory = category
+ highestScore = score
+ }
+ } else if highestCategory == "" {
+ highestCategory = category
+ highestScore = 1
+ }
+ }
+ }
+ for category, categoryFlagged := range result.Categories {
+ if !categoryFlagged {
+ continue
+ }
+ flagged = true
+ if score, ok := result.CategoryScores[category]; ok {
+ if score > highestScore || highestCategory == "" {
+ highestCategory = category
+ highestScore = score
+ }
+ } else if highestCategory == "" {
+ highestCategory = category
+ highestScore = 1
+ }
+ }
+ return flagged, highestCategory, highestScore
+}
+
+func evaluateModerationScores(scores map[string]float64, thresholds map[string]float64) (bool, string, float64) {
+ flagged := false
+ highestCategory := ""
+ highestScore := 0.0
+ for _, category := range contentModerationCategoryOrder {
+ score := scores[category]
+ if score > highestScore || highestCategory == "" {
+ highestScore = score
+ highestCategory = category
+ }
+ if score >= thresholds[category] {
+ flagged = true
+ }
+ }
+ for category, score := range scores {
+ if score > highestScore || highestCategory == "" {
+ highestScore = score
+ highestCategory = category
+ }
+ }
+ return flagged, highestCategory, highestScore
+}
+
+func buildContentModerationFailClosedDecision(input ContentModerationCheckInput, cfg *ContentModerationConfig, action string, fallbackRule string, statusCode int, message string, errText string) *ContentModerationDecision {
+ if cfg == nil {
+ cfg = defaultContentModerationConfig()
+ }
+ if statusCode == 0 {
+ statusCode = http.StatusServiceUnavailable
+ }
+ if strings.TrimSpace(message) == "" {
+ if statusCode >= 500 {
+ message = defaultContentModerationUnavailableMessage
+ } else {
+ message = cfg.BlockMessage
+ }
+ }
+ rule := input.PolicyRule
+ if strings.TrimSpace(input.FailClosedError) == "" && strings.TrimSpace(fallbackRule) != "" {
+ rule = fallbackRule
+ }
+ return &ContentModerationDecision{
+ Allowed: false,
+ Blocked: true,
+ Flagged: false,
+ Message: strings.TrimSpace(message),
+ StatusCode: statusCode,
+ InputHash: normalizeContentModerationHash(input.InputHash),
+ Action: action,
+ PolicyRule: contentModerationPolicyRule(rule, fallbackRule),
+ }
+}
+
+func contentModerationStage(stage string) string {
+ switch strings.ToLower(strings.TrimSpace(stage)) {
+ case ContentModerationStageOutput:
+ return ContentModerationStageOutput
+ default:
+ return ContentModerationStageInput
+ }
+}
+
+func contentModerationPolicyRule(rule string, fallback string) string {
+ rule = strings.TrimSpace(rule)
+ if rule != "" {
+ return rule
+ }
+ return strings.TrimSpace(fallback)
+}
+
+func contentModerationDefaultPolicyRule(action string, flagged bool, stage string) string {
+ stage = contentModerationStage(stage)
+ switch {
+ case action == ContentModerationActionError:
+ return "moderation_error_" + stage
+ case action == ContentModerationActionHashBlock:
+ return "moderation_hash_block"
+ case flagged:
+ return "moderation_flagged_" + stage
+ default:
+ return ""
+ }
+}
+
+func mergeContentModerationThresholds(base map[string]float64, override map[string]float64) map[string]float64 {
+ out := cloneFloatMap(base)
+ if out == nil {
+ out = map[string]float64{}
+ }
+ for _, category := range contentModerationCategoryOrder {
+ if v, ok := override[category]; ok {
+ if v < 0 {
+ v = 0
+ }
+ if v > 1 {
+ v = 1
+ }
+ out[category] = v
+ }
+ }
+ return out
+}
+
+func normalizeInt64IDs(ids []int64) []int64 {
+ if len(ids) == 0 {
+ return []int64{}
+ }
+ seen := make(map[int64]struct{}, len(ids))
+ out := make([]int64, 0, len(ids))
+ for _, id := range ids {
+ if id <= 0 {
+ continue
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ out = append(out, id)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
+ return out
+}
+
+func normalizeModerationAPIKeys(keys []string) []string {
+ if len(keys) == 0 {
+ return []string{}
+ }
+ seen := make(map[string]struct{}, len(keys))
+ out := make([]string, 0, len(keys))
+ for _, key := range keys {
+ key = strings.TrimSpace(key)
+ if key == "" {
+ continue
+ }
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ out = append(out, key)
+ }
+ return out
+}
+
+func deleteModerationAPIKeysByHash(keys []string, hashes []string) []string {
+ keys = normalizeModerationAPIKeys(keys)
+ deleteHashes := make(map[string]struct{}, len(hashes))
+ for _, hash := range hashes {
+ hash = normalizeContentModerationHash(hash)
+ if hash != "" {
+ deleteHashes[hash] = struct{}{}
+ }
+ }
+ if len(deleteHashes) == 0 {
+ return keys
+ }
+ out := make([]string, 0, len(keys))
+ for _, key := range keys {
+ if _, ok := deleteHashes[moderationAPIKeyHash(key)]; ok {
+ continue
+ }
+ out = append(out, key)
+ }
+ return out
+}
+
+func normalizeContentModerationAPIKeysMode(mode string) string {
+ switch strings.ToLower(strings.TrimSpace(mode)) {
+ case contentModerationAPIKeysModeReplace:
+ return contentModerationAPIKeysModeReplace
+ default:
+ return contentModerationAPIKeysModeAppend
+ }
+}
+
+func normalizeContentModerationHash(inputHash string) string {
+ inputHash = strings.ToLower(strings.TrimSpace(inputHash))
+ if len(inputHash) != sha256.Size*2 {
+ return ""
+ }
+ if _, err := hex.DecodeString(inputHash); err != nil {
+ return ""
+ }
+ return inputHash
+}
+
+func cloneFloatMap(in map[string]float64) map[string]float64 {
+ if in == nil {
+ return map[string]float64{}
+ }
+ out := make(map[string]float64, len(in))
+ for k, v := range in {
+ out[k] = v
+ }
+ return out
+}
+
+func cloneBoolMap(in map[string]bool) map[string]bool {
+ if in == nil {
+ return map[string]bool{}
+ }
+ out := make(map[string]bool, len(in))
+ for k, v := range in {
+ out[k] = v
+ }
+ return out
+}
+
+func cloneStringSliceMap(in map[string][]string) map[string][]string {
+ if in == nil {
+ return map[string][]string{}
+ }
+ out := make(map[string][]string, len(in))
+ for k, values := range in {
+ out[k] = cloneModerationStringSlice(values)
+ }
+ return out
+}
+
+func cloneModerationStringSlice(in []string) []string {
+ if len(in) == 0 {
+ return []string{}
+ }
+ out := make([]string, len(in))
+ copy(out, in)
+ return out
+}
+
+func cloneInt64Ptr(in *int64) *int64 {
+ if in == nil {
+ return nil
+ }
+ v := *in
+ return &v
+}
+
+func trimRunes(text string, max int) string {
+ if max <= 0 {
+ return ""
+ }
+ runes := []rune(text)
+ if len(runes) <= max {
+ return text
+ }
+ return string(runes[:max])
+}
+
+func maskSecretTail(secret string) string {
+ secret = strings.TrimSpace(secret)
+ if secret == "" {
+ return ""
+ }
+ if len(secret) <= 4 {
+ return "****"
+ }
+ return strings.Repeat("*", 8) + secret[len(secret)-4:]
+}
diff --git a/backend/internal/service/content_moderation_email.go b/backend/internal/service/content_moderation_email.go
new file mode 100644
index 00000000000..e462ff88f0f
--- /dev/null
+++ b/backend/internal/service/content_moderation_email.go
@@ -0,0 +1,117 @@
+package service
+
+import (
+ "fmt"
+ "html"
+ "strings"
+ "time"
+)
+
+func buildContentModerationViolationEmailBody(siteName string, log *ContentModerationLog, cfg *ContentModerationConfig) string {
+ if log == nil {
+ return ""
+ }
+ userName := strings.TrimSpace(log.UserEmail)
+ if userName == "" && log.UserID != nil {
+ userName = fmt.Sprintf("UID %d", *log.UserID)
+ }
+ threshold := cfg.BanThreshold
+ if threshold <= 0 {
+ threshold = defaultContentModerationBanThreshold
+ }
+ statusBlock := ""
+ if log.AutoBanned {
+ statusBlock = `账户当前处于封禁状态,所有 API 请求将被拒绝
`
+ }
+ return fmt.Sprintf(`
+
+
+
+
+
+
Risk Control / 风控提醒
+
账户触发内容审计规则
+
尊敬的用户 %s ,您的 API 请求在内容审计中触发平台风控策略。详情如下。
+
+
触发详情
+
+ 触发时间 %s
+ 触发来源 内容审核
+ 所属分组 %s
+ 命中类别 %s / %.3f
+ 累计触发次数 %d 次(阈值 %d)
+
+
+ %s
+
此邮件由 %s 自动发送,请勿回复。
+
+
+
+`,
+ html.EscapeString(userName),
+ html.EscapeString(time.Now().Format("2006-01-02 15:04:05")),
+ html.EscapeString(defaultContentModerationString(log.GroupName, "-")),
+ html.EscapeString(defaultContentModerationString(log.HighestCategory, "-")),
+ log.HighestScore,
+ log.ViolationCount,
+ threshold,
+ statusBlock,
+ html.EscapeString(siteName),
+ )
+}
+
+func buildContentModerationAccountDisabledEmailBody(siteName string, log *ContentModerationLog, cfg *ContentModerationConfig) string {
+ if log == nil {
+ return ""
+ }
+ userName := strings.TrimSpace(log.UserEmail)
+ if userName == "" && log.UserID != nil {
+ userName = fmt.Sprintf("UID %d", *log.UserID)
+ }
+ threshold := cfg.BanThreshold
+ if threshold <= 0 {
+ threshold = defaultContentModerationBanThreshold
+ }
+ return fmt.Sprintf(`
+
+
+
+
+
+
Risk Control / 账户封禁
+
账户已被自动禁用
+
尊敬的用户 %s ,您的账户在计数周期内多次触发平台风控策略,系统已自动禁用该账户。详情如下。
+
+
封禁详情
+
+ 封禁时间 %s
+ 触发来源 内容审核
+ 所属分组 %s
+ 命中类别 %s / %.3f
+ 累计触发次数 %d 次(阈值 %d)
+
+
+
账户当前处于封禁状态,所有 API 请求将被拒绝
+
如需申诉或恢复账号,请联系平台管理员处理。
+
此邮件由 %s 自动发送,请勿回复。
+
+
+
+`,
+ html.EscapeString(userName),
+ html.EscapeString(time.Now().Format("2006-01-02 15:04:05")),
+ html.EscapeString(defaultContentModerationString(log.GroupName, "-")),
+ html.EscapeString(defaultContentModerationString(log.HighestCategory, "-")),
+ log.HighestScore,
+ log.ViolationCount,
+ threshold,
+ html.EscapeString(siteName),
+ )
+}
+
+func defaultContentModerationString(value string, fallback string) string {
+ if strings.TrimSpace(value) == "" {
+ return fallback
+ }
+ return strings.TrimSpace(value)
+}
diff --git a/backend/internal/service/content_moderation_input.go b/backend/internal/service/content_moderation_input.go
new file mode 100644
index 00000000000..3f7b524a3b1
--- /dev/null
+++ b/backend/internal/service/content_moderation_input.go
@@ -0,0 +1,288 @@
+package service
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/tidwall/gjson"
+)
+
+func ExtractContentModerationText(protocol string, body []byte) string {
+ return ExtractContentModerationInput(protocol, body).Text
+}
+
+func ExtractContentModerationInput(protocol string, body []byte) ContentModerationInput {
+ if len(body) == 0 || !gjson.ValidBytes(body) {
+ return ContentModerationInput{}
+ }
+ var parts []string
+ var images []string
+ switch protocol {
+ case ContentModerationProtocolAnthropicMessages:
+ collectAnthropicUserMessages(gjson.GetBytes(body, "messages"), &parts, &images)
+ case ContentModerationProtocolOpenAIChat:
+ collectRoleMessages(gjson.GetBytes(body, "messages"), "user", &parts, &images)
+ case ContentModerationProtocolOpenAIResponses:
+ collectResponsesInput(gjson.GetBytes(body, "input"), &parts, &images)
+ case ContentModerationProtocolGemini:
+ collectGeminiContents(gjson.GetBytes(body, "contents"), &parts, &images)
+ case ContentModerationProtocolOpenAIImages:
+ addModerationText(&parts, gjson.GetBytes(body, "prompt").String())
+ collectContentValue(gjson.GetBytes(body, "images"), &parts, &images)
+ default:
+ collectResponsesInput(gjson.GetBytes(body, "input"), &parts, &images)
+ collectRoleMessages(gjson.GetBytes(body, "messages"), "user", &parts, &images)
+ collectGeminiContents(gjson.GetBytes(body, "contents"), &parts, &images)
+ }
+ out := ContentModerationInput{
+ Text: normalizeContentModerationText(strings.Join(parts, "\n")),
+ Images: normalizeModerationImages(images),
+ }
+ out.Normalize()
+ return out
+}
+
+func collectRoleMessages(messages gjson.Result, role string, parts *[]string, images *[]string) {
+ if !messages.IsArray() {
+ return
+ }
+ messages.ForEach(func(_, msg gjson.Result) bool {
+ if strings.ToLower(strings.TrimSpace(msg.Get("role").String())) == role {
+ var candidate []string
+ var candidateImages []string
+ collectContentValue(msg.Get("content"), &candidate, &candidateImages)
+ if normalizeContentModerationText(strings.Join(candidate, "\n")) != "" || len(candidateImages) > 0 {
+ *parts = append(*parts, candidate...)
+ *images = append(*images, candidateImages...)
+ }
+ }
+ return true
+ })
+}
+
+func collectAnthropicUserMessages(messages gjson.Result, parts *[]string, images *[]string) {
+ if !messages.IsArray() {
+ return
+ }
+ messages.ForEach(func(_, msg gjson.Result) bool {
+ if strings.ToLower(strings.TrimSpace(msg.Get("role").String())) == "user" {
+ var candidate []string
+ var candidateImages []string
+ collectAnthropicUserContentValue(msg.Get("content"), &candidate, &candidateImages)
+ if normalizeContentModerationText(strings.Join(candidate, "\n")) != "" || len(candidateImages) > 0 {
+ *parts = append(*parts, candidate...)
+ *images = append(*images, candidateImages...)
+ }
+ }
+ return true
+ })
+}
+
+func collectAnthropicUserContentValue(value gjson.Result, parts *[]string, images *[]string) {
+ switch {
+ case !value.Exists():
+ return
+ case value.Type == gjson.String:
+ addModerationText(parts, value.String())
+ case value.IsArray():
+ value.ForEach(func(_, item gjson.Result) bool {
+ collectAnthropicUserContentValue(item, parts, images)
+ return true
+ })
+ case value.IsObject():
+ typ := strings.ToLower(strings.TrimSpace(value.Get("type").String()))
+ switch typ {
+ case "", "text", "input_text", "message":
+ if value.Get("text").Exists() {
+ addModerationText(parts, value.Get("text").String())
+ }
+ if value.Get("content").Exists() {
+ collectAnthropicUserContentValue(value.Get("content"), parts, images)
+ }
+ case "image_url", "input_image", "image":
+ collectContentValue(value, parts, images)
+ }
+ }
+}
+func collectResponsesInput(input gjson.Result, parts *[]string, images *[]string) {
+ switch {
+ case !input.Exists():
+ return
+ case input.Type == gjson.String:
+ addModerationText(parts, input.String())
+ case input.IsArray():
+ input.ForEach(func(_, item gjson.Result) bool {
+ if isResponsesUserTextItem(item) {
+ collectContentValue(item.Get("content"), parts, images)
+ if item.Get("type").String() == "input_text" || item.Get("text").Exists() {
+ collectContentValue(item, parts, images)
+ }
+ }
+ return true
+ })
+ case input.IsObject():
+ if isResponsesUserTextItem(input) {
+ collectContentValue(input.Get("content"), parts, images)
+ if input.Get("type").String() == "input_text" || input.Get("text").Exists() {
+ collectContentValue(input, parts, images)
+ }
+ }
+ }
+}
+
+func isResponsesUserTextItem(item gjson.Result) bool {
+ role := strings.ToLower(strings.TrimSpace(item.Get("role").String()))
+ if role == "user" {
+ return responseItemHasModerationText(item)
+ }
+ if role != "" {
+ return false
+ }
+ return responseItemHasModerationText(item)
+}
+
+func responseItemHasModerationText(item gjson.Result) bool {
+ var parts []string
+ var images []string
+ collectContentValue(item.Get("content"), &parts, &images)
+ if item.Get("type").String() == "input_text" || item.Get("text").Exists() {
+ collectContentValue(item, &parts, &images)
+ }
+ return normalizeContentModerationText(strings.Join(parts, "\n")) != "" || len(images) > 0
+}
+
+func collectGeminiContents(contents gjson.Result, parts *[]string, images *[]string) {
+ if !contents.IsArray() {
+ return
+ }
+ contents.ForEach(func(_, content gjson.Result) bool {
+ role := strings.ToLower(strings.TrimSpace(content.Get("role").String()))
+ if role == "" || role == "user" {
+ var candidate []string
+ var candidateImages []string
+ if arr := content.Get("parts"); arr.IsArray() {
+ arr.ForEach(func(_, part gjson.Result) bool {
+ addModerationText(&candidate, part.Get("text").String())
+ addGeminiModerationImage(&candidateImages, part)
+ return true
+ })
+ }
+ if normalizeContentModerationText(strings.Join(candidate, "\n")) != "" || len(candidateImages) > 0 {
+ *parts = append(*parts, candidate...)
+ *images = append(*images, candidateImages...)
+ }
+ }
+ return true
+ })
+}
+
+func collectContentValue(value gjson.Result, parts *[]string, images *[]string) {
+ switch {
+ case !value.Exists():
+ return
+ case value.Type == gjson.String:
+ addModerationText(parts, value.String())
+ case value.IsArray():
+ value.ForEach(func(_, item gjson.Result) bool {
+ collectContentValue(item, parts, images)
+ return true
+ })
+ case value.IsObject():
+ typ := strings.ToLower(strings.TrimSpace(value.Get("type").String()))
+ addModerationImage(images, value.Get("image_url.url").String())
+ addModerationImage(images, value.Get("image_url").String())
+ addModerationImage(images, value.Get("url").String())
+ addModerationImageData(images, value.Get("source.media_type").String(), value.Get("source.data").String())
+ addModerationImageData(images, value.Get("source.mediaType").String(), value.Get("source.data").String())
+ addModerationImageData(images, value.Get("media_type").String(), value.Get("data").String())
+ addModerationImageData(images, value.Get("mime_type").String(), value.Get("data").String())
+ addModerationImageData(images, value.Get("mimeType").String(), value.Get("data").String())
+ addModerationImage(images, value.Get("source.data").String())
+ addModerationImage(images, value.Get("data").String())
+ addModerationImage(images, value.Get("base64").String())
+ switch typ {
+ case "", "text", "input_text", "message":
+ if value.Get("text").Exists() {
+ addModerationText(parts, value.Get("text").String())
+ }
+ if value.Get("content").Exists() {
+ collectContentValue(value.Get("content"), parts, images)
+ }
+ case "image_url", "input_image", "image":
+ }
+ }
+}
+
+func addGeminiModerationImage(images *[]string, part gjson.Result) {
+ if inlineData := part.Get("inline_data"); inlineData.IsObject() {
+ mimeType := strings.TrimSpace(inlineData.Get("mime_type").String())
+ data := strings.TrimSpace(inlineData.Get("data").String())
+ if mimeType != "" && data != "" {
+ addModerationImage(images, fmt.Sprintf("data:%s;base64,%s", mimeType, data))
+ }
+ }
+ if inlineData := part.Get("inlineData"); inlineData.IsObject() {
+ mimeType := strings.TrimSpace(inlineData.Get("mimeType").String())
+ data := strings.TrimSpace(inlineData.Get("data").String())
+ if mimeType != "" && data != "" {
+ addModerationImage(images, fmt.Sprintf("data:%s;base64,%s", mimeType, data))
+ }
+ }
+ addModerationImage(images, part.Get("file_data.file_uri").String())
+ addModerationImage(images, part.Get("fileData.fileUri").String())
+}
+
+func addModerationImageData(images *[]string, mimeType string, data string) {
+ mimeType = strings.TrimSpace(mimeType)
+ data = strings.TrimSpace(data)
+ if mimeType == "" || data == "" {
+ return
+ }
+ addModerationImage(images, fmt.Sprintf("data:%s;base64,%s", mimeType, data))
+}
+
+func addModerationImage(images *[]string, image string) {
+ image = strings.TrimSpace(image)
+ if image == "" {
+ return
+ }
+ if strings.HasPrefix(image, "data:") || strings.HasPrefix(image, "http://") || strings.HasPrefix(image, "https://") {
+ *images = append(*images, image)
+ }
+}
+
+func normalizeModerationImages(images []string) []string {
+ out := make([]string, 0, len(images))
+ seen := make(map[string]struct{}, len(images))
+ for _, image := range images {
+ image = strings.TrimSpace(image)
+ if image == "" {
+ continue
+ }
+ if _, ok := seen[image]; ok {
+ continue
+ }
+ seen[image] = struct{}{}
+ out = append(out, image)
+ }
+ return out
+}
+
+func limitContentModerationImages(images []string) []string {
+ if len(images) <= maxContentModerationInputImages {
+ return images
+ }
+ return images[:maxContentModerationInputImages]
+}
+
+func addModerationText(parts *[]string, text string) {
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return
+ }
+ *parts = append(*parts, text)
+}
+
+func normalizeContentModerationText(text string) string {
+ return strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
+}
diff --git a/backend/internal/service/content_moderation_redact.go b/backend/internal/service/content_moderation_redact.go
new file mode 100644
index 00000000000..473c8178190
--- /dev/null
+++ b/backend/internal/service/content_moderation_redact.go
@@ -0,0 +1,37 @@
+package service
+
+import (
+ "regexp"
+ "strings"
+)
+
+var contentModerationSecretPatterns = []*regexp.Regexp{
+ regexp.MustCompile(`(?i)\bhttps?://[^\s"'<>,。;、]+`),
+ regexp.MustCompile(`(?i)\b((?:api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|token|session|cookie|set[_-]?cookie|authorization|bearer|password|passwd|pwd|secret|client[_-]?secret|private[_-]?key)\s*[:=]\s*)(["']?)[^"'\s,;,。;、]{6,}`),
+ regexp.MustCompile(`(?i)\b(Bearer\s+)[A-Za-z0-9._~+/=-]{12,}`),
+ regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b`),
+ regexp.MustCompile(`(?i)\b(?:sk|sk-proj|sk-ant|sess|rk|pk|ak|api|key|token|secret)[_-][A-Za-z0-9._~+/=-]{12,}\b`),
+ regexp.MustCompile(`\b[0-9a-fA-F]{32,}\b`),
+ regexp.MustCompile(`\b[A-Za-z0-9_-]{48,}\b`),
+ regexp.MustCompile(`\b[A-Za-z0-9+/]{48,}={0,2}\b`),
+ regexp.MustCompile(`\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b`),
+}
+
+func redactContentModerationSecrets(text string) string {
+ text = strings.TrimSpace(text)
+ if text == "" {
+ return ""
+ }
+ out := text
+ for idx, pattern := range contentModerationSecretPatterns {
+ switch idx {
+ case 1:
+ out = pattern.ReplaceAllString(out, `${1}${2}[已脱敏]`)
+ case 2:
+ out = pattern.ReplaceAllString(out, `${1}[已脱敏]`)
+ default:
+ out = pattern.ReplaceAllString(out, `[已脱敏]`)
+ }
+ }
+ return out
+}
diff --git a/backend/internal/service/content_moderation_test.go b/backend/internal/service/content_moderation_test.go
new file mode 100644
index 00000000000..c521f357ef8
--- /dev/null
+++ b/backend/internal/service/content_moderation_test.go
@@ -0,0 +1,1464 @@
+package service
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+ "github.com/stretchr/testify/require"
+)
+
+type contentModerationTestSettingRepo struct {
+ values map[string]string
+}
+
+func (r *contentModerationTestSettingRepo) Get(ctx context.Context, key string) (*Setting, error) {
+ if value, ok := r.values[key]; ok {
+ return &Setting{Key: key, Value: value}, nil
+ }
+ return nil, ErrSettingNotFound
+}
+
+func (r *contentModerationTestSettingRepo) GetValue(ctx context.Context, key string) (string, error) {
+ if value, ok := r.values[key]; ok {
+ return value, nil
+ }
+ return "", ErrSettingNotFound
+}
+
+func (r *contentModerationTestSettingRepo) Set(ctx context.Context, key, value string) error {
+ if r.values == nil {
+ r.values = map[string]string{}
+ }
+ r.values[key] = value
+ return nil
+}
+
+func (r *contentModerationTestSettingRepo) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
+ out := map[string]string{}
+ for _, key := range keys {
+ if value, ok := r.values[key]; ok {
+ out[key] = value
+ }
+ }
+ return out, nil
+}
+
+func (r *contentModerationTestSettingRepo) SetMultiple(ctx context.Context, settings map[string]string) error {
+ if r.values == nil {
+ r.values = map[string]string{}
+ }
+ for key, value := range settings {
+ r.values[key] = value
+ }
+ return nil
+}
+
+func (r *contentModerationTestSettingRepo) GetAll(ctx context.Context) (map[string]string, error) {
+ out := make(map[string]string, len(r.values))
+ for key, value := range r.values {
+ out[key] = value
+ }
+ return out, nil
+}
+
+func (r *contentModerationTestSettingRepo) Delete(ctx context.Context, key string) error {
+ delete(r.values, key)
+ return nil
+}
+
+type contentModerationTestRepo struct {
+ logs []ContentModerationLog
+}
+
+func (r *contentModerationTestRepo) CreateLog(ctx context.Context, log *ContentModerationLog) error {
+ if log != nil {
+ r.logs = append(r.logs, *log)
+ }
+ return nil
+}
+
+func (r *contentModerationTestRepo) ListLogs(ctx context.Context, filter ContentModerationLogFilter) ([]ContentModerationLog, *pagination.PaginationResult, error) {
+ return nil, nil, nil
+}
+
+func (r *contentModerationTestRepo) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time) (int, error) {
+ return 0, nil
+}
+
+func (r *contentModerationTestRepo) CleanupExpiredLogs(ctx context.Context, hitBefore time.Time, nonHitBefore time.Time) (*ContentModerationCleanupResult, error) {
+ return &ContentModerationCleanupResult{}, nil
+}
+
+type contentModerationTestHashCache struct {
+ hashes map[string]struct{}
+ recorded []string
+ checked []string
+ deleted []string
+ hasResult bool
+ hasResultUsed bool
+}
+
+type contentModerationTestUserRepo struct {
+ user *User
+ updated []User
+}
+
+func (r *contentModerationTestUserRepo) Create(ctx context.Context, user *User) error {
+ panic("unexpected Create call")
+}
+
+func (r *contentModerationTestUserRepo) GetByID(ctx context.Context, id int64) (*User, error) {
+ if r.user == nil {
+ return nil, ErrUserNotFound
+ }
+ clone := *r.user
+ return &clone, nil
+}
+
+func (r *contentModerationTestUserRepo) GetByEmail(ctx context.Context, email string) (*User, error) {
+ panic("unexpected GetByEmail call")
+}
+
+func (r *contentModerationTestUserRepo) GetFirstAdmin(ctx context.Context) (*User, error) {
+ panic("unexpected GetFirstAdmin call")
+}
+
+func (r *contentModerationTestUserRepo) Update(ctx context.Context, user *User) error {
+ if user == nil {
+ return nil
+ }
+ clone := *user
+ r.updated = append(r.updated, clone)
+ r.user = &clone
+ return nil
+}
+
+func (r *contentModerationTestUserRepo) Delete(ctx context.Context, id int64) error {
+ panic("unexpected Delete call")
+}
+
+func (r *contentModerationTestUserRepo) GetUserAvatar(ctx context.Context, userID int64) (*UserAvatar, error) {
+ panic("unexpected GetUserAvatar call")
+}
+
+func (r *contentModerationTestUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error) {
+ panic("unexpected UpsertUserAvatar call")
+}
+
+func (r *contentModerationTestUserRepo) DeleteUserAvatar(ctx context.Context, userID int64) error {
+ panic("unexpected DeleteUserAvatar call")
+}
+
+func (r *contentModerationTestUserRepo) List(ctx context.Context, params pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) {
+ panic("unexpected List call")
+}
+
+func (r *contentModerationTestUserRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UserListFilters) ([]User, *pagination.PaginationResult, error) {
+ panic("unexpected ListWithFilters call")
+}
+
+func (r *contentModerationTestUserRepo) GetLatestUsedAtByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*time.Time, error) {
+ panic("unexpected GetLatestUsedAtByUserIDs call")
+}
+
+func (r *contentModerationTestUserRepo) GetLatestUsedAtByUserID(ctx context.Context, userID int64) (*time.Time, error) {
+ panic("unexpected GetLatestUsedAtByUserID call")
+}
+
+func (r *contentModerationTestUserRepo) UpdateUserLastActiveAt(ctx context.Context, userID int64, activeAt time.Time) error {
+ panic("unexpected UpdateUserLastActiveAt call")
+}
+
+func (r *contentModerationTestUserRepo) UpdateBalance(ctx context.Context, id int64, amount float64) error {
+ panic("unexpected UpdateBalance call")
+}
+
+func (r *contentModerationTestUserRepo) DeductBalance(ctx context.Context, id int64, amount float64) error {
+ panic("unexpected DeductBalance call")
+}
+
+func (r *contentModerationTestUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount int) error {
+ panic("unexpected UpdateConcurrency call")
+}
+
+func (r *contentModerationTestUserRepo) BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error) {
+ panic("unexpected BatchSetConcurrency call")
+}
+
+func (r *contentModerationTestUserRepo) BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) {
+ panic("unexpected BatchAddConcurrency call")
+}
+
+func (r *contentModerationTestUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
+ panic("unexpected ExistsByEmail call")
+}
+
+func (r *contentModerationTestUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) {
+ panic("unexpected RemoveGroupFromAllowedGroups call")
+}
+
+func (r *contentModerationTestUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
+ panic("unexpected AddGroupToAllowedGroups call")
+}
+
+func (r *contentModerationTestUserRepo) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error {
+ panic("unexpected RemoveGroupFromUserAllowedGroups call")
+}
+
+func (r *contentModerationTestUserRepo) ListUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error) {
+ panic("unexpected ListUserAuthIdentities call")
+}
+
+func (r *contentModerationTestUserRepo) UnbindUserAuthProvider(ctx context.Context, userID int64, provider string) error {
+ panic("unexpected UnbindUserAuthProvider call")
+}
+
+func (r *contentModerationTestUserRepo) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error {
+ panic("unexpected UpdateTotpSecret call")
+}
+
+func (r *contentModerationTestUserRepo) EnableTotp(ctx context.Context, userID int64) error {
+ panic("unexpected EnableTotp call")
+}
+
+func (r *contentModerationTestUserRepo) DisableTotp(ctx context.Context, userID int64) error {
+ panic("unexpected DisableTotp call")
+}
+
+type contentModerationTestAuthCacheInvalidator struct {
+ userIDs []int64
+}
+
+func (i *contentModerationTestAuthCacheInvalidator) InvalidateAuthCacheByKey(ctx context.Context, key string) {
+}
+
+func (i *contentModerationTestAuthCacheInvalidator) InvalidateAuthCacheByUserID(ctx context.Context, userID int64) {
+ i.userIDs = append(i.userIDs, userID)
+}
+
+func (i *contentModerationTestAuthCacheInvalidator) InvalidateAuthCacheByGroupID(ctx context.Context, groupID int64) {
+}
+
+func (c *contentModerationTestHashCache) RecordFlaggedInputHash(ctx context.Context, inputHash string) error {
+ if c.hashes == nil {
+ c.hashes = map[string]struct{}{}
+ }
+ c.hashes[inputHash] = struct{}{}
+ c.recorded = append(c.recorded, inputHash)
+ return nil
+}
+
+func (c *contentModerationTestHashCache) HasFlaggedInputHash(ctx context.Context, inputHash string) (bool, error) {
+ c.checked = append(c.checked, inputHash)
+ if c.hasResultUsed {
+ return c.hasResult, nil
+ }
+ _, ok := c.hashes[inputHash]
+ return ok, nil
+}
+
+func (c *contentModerationTestHashCache) DeleteFlaggedInputHash(ctx context.Context, inputHash string) (bool, error) {
+ c.deleted = append(c.deleted, inputHash)
+ if c.hashes == nil {
+ return false, nil
+ }
+ if _, ok := c.hashes[inputHash]; !ok {
+ return false, nil
+ }
+ delete(c.hashes, inputHash)
+ return true, nil
+}
+
+func (c *contentModerationTestHashCache) ClearFlaggedInputHashes(ctx context.Context) (int64, error) {
+ deleted := int64(len(c.hashes))
+ c.hashes = map[string]struct{}{}
+ return deleted, nil
+}
+
+func (c *contentModerationTestHashCache) CountFlaggedInputHashes(ctx context.Context) (int64, error) {
+ return int64(len(c.hashes)), nil
+}
+
+func TestBuildContentModerationLog_RedactsInputExcerpt(t *testing.T) {
+ svc := &ContentModerationService{}
+ cfg := defaultContentModerationConfig()
+ input := ContentModerationCheckInput{
+ RequestID: "req-1",
+ Endpoint: "/v1/chat/completions",
+ Provider: "openai",
+ }
+
+ log := svc.buildLog(input, cfg, ContentModerationActionAllow, true, "sexual", 0.8, map[string]float64{"sexual": 0.8}, "hello sk-proj-1234567890abcdef", nil, nil, "")
+
+ require.NotContains(t, log.InputExcerpt, "sk-proj-1234567890abcdef")
+ require.Contains(t, log.InputExcerpt, "[已脱敏]")
+}
+
+func TestRedactContentModerationSecrets_LongHexAndTokens(t *testing.T) {
+ input := "你哈市多大事cf5bbdc4cd508f3aaf0d2070d529d4a4ac29099f8ecc357f696df28e1df91554 token=abc123456789xyz Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signaturepart https://example.com/private/path?token=abc123"
+
+ out := redactContentModerationSecrets(input)
+
+ require.NotContains(t, out, "cf5bbdc4cd508f3aaf0d2070d529d4a4ac29099f8ecc357f696df28e1df91554")
+ require.NotContains(t, out, "abc123456789xyz")
+ require.NotContains(t, out, "eyJhbGciOiJIUzI1NiJ9")
+ require.NotContains(t, out, "https://example.com/private/path")
+ require.Contains(t, out, "[已脱敏]")
+}
+
+func TestContentModerationConfigNormalize_NonHitRetentionMaxThreeDays(t *testing.T) {
+ cfg := defaultContentModerationConfig()
+ cfg.NonHitRetentionDays = 30
+
+ cfg.normalize()
+
+ require.Equal(t, 3, cfg.NonHitRetentionDays)
+}
+
+func TestContentModerationConfigRequiresHTTPSExceptLoopback(t *testing.T) {
+ svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil)
+
+ remoteHTTP := defaultContentModerationConfig()
+ remoteHTTP.BaseURL = "http://moderation.example.com"
+ if err := svc.validateConfig(context.Background(), remoteHTTP); err == nil {
+ t.Fatal("non-loopback moderation endpoint must require HTTPS")
+ }
+
+ loopbackHTTP := defaultContentModerationConfig()
+ loopbackHTTP.BaseURL = "http://127.0.0.1:8080"
+ if err := svc.validateConfig(context.Background(), loopbackHTTP); err != nil {
+ t.Fatalf("loopback HTTP endpoint should remain available for local tests: %v", err)
+ }
+}
+
+func TestContentModerationDoesNotForwardAuthorizationAcrossRedirect(t *testing.T) {
+ forwarded := make(chan string, 1)
+ target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ forwarded <- r.Header.Get("Authorization")
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer target.Close()
+
+ source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, target.URL+"/v1/moderations", http.StatusTemporaryRedirect)
+ }))
+ defer source.Close()
+
+ svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil)
+ cfg := defaultContentModerationConfig()
+ cfg.BaseURL = source.URL
+ cfg.APIKeys = []string{"sk-must-not-cross-redirect"}
+ cfg.RetryCount = 0
+ _, err := svc.callModeration(context.Background(), cfg, "hello")
+ require.Error(t, err)
+ select {
+ case auth := <-forwarded:
+ t.Fatalf("redirect target received Authorization header %q", auth)
+ case <-time.After(100 * time.Millisecond):
+ }
+}
+
+func TestContentModerationUpdateConfig_AppendsAndDeletesAPIKeys(t *testing.T) {
+ cfg := defaultContentModerationConfig()
+ cfg.APIKeys = []string{"sk-old-a", "sk-old-b"}
+ rawCfg := mustMarshalStoredContentModerationConfig(t, cfg)
+
+ repo := &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyContentModerationConfig: rawCfg,
+ }}
+ svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil)
+ svc.SetSecretEncryptor(contentModerationTestEncryptor{})
+ deleteHashes := []string{moderationAPIKeyHash("sk-old-a")}
+ addKeys := []string{"sk-new-c", "sk-old-b"}
+
+ view, err := svc.UpdateConfig(context.Background(), UpdateContentModerationConfigInput{
+ APIKeys: &addKeys,
+ DeleteAPIKeyHashes: &deleteHashes,
+ })
+
+ require.NoError(t, err)
+ require.Equal(t, 2, view.APIKeyCount)
+ require.Equal(t, []string{maskSecretTail("sk-old-b"), maskSecretTail("sk-new-c")}, view.APIKeyMasks)
+
+ saved, err := svc.loadConfig(context.Background())
+ require.NoError(t, err)
+ require.Equal(t, []string{"sk-old-b", "sk-new-c"}, saved.apiKeys())
+ require.NotContains(t, repo.values[SettingKeyContentModerationConfig], "sk-new-c")
+ require.Contains(t, repo.values[SettingKeyContentModerationConfig], "api_keys_encrypted")
+}
+
+type contentModerationTestEncryptor struct{}
+
+func (contentModerationTestEncryptor) Encrypt(plaintext string) (string, error) {
+ return "encrypted:" + base64.RawStdEncoding.EncodeToString([]byte(plaintext)), nil
+}
+
+func (contentModerationTestEncryptor) Decrypt(ciphertext string) (string, error) {
+ if !strings.HasPrefix(ciphertext, "encrypted:") {
+ return "", fmt.Errorf("invalid ciphertext")
+ }
+ decoded, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(ciphertext, "encrypted:"))
+ return string(decoded), err
+}
+
+func (contentModerationTestEncryptor) EncryptForDomain(domain, plaintext string) (string, error) {
+ return "domain:" + domain + ":" + base64.RawStdEncoding.EncodeToString([]byte(plaintext)), nil
+}
+
+func (contentModerationTestEncryptor) DecryptForDomain(domain, ciphertext string) (string, error) {
+ prefix := "domain:" + domain + ":"
+ if !strings.HasPrefix(ciphertext, prefix) {
+ return "", fmt.Errorf("ciphertext domain mismatch")
+ }
+ decoded, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(ciphertext, prefix))
+ return string(decoded), err
+}
+
+func mustMarshalStoredContentModerationConfig(t *testing.T, cfg *ContentModerationConfig) string {
+ t.Helper()
+ stored := *cfg
+ keys := normalizeModerationAPIKeys(append(append([]string{}, stored.APIKeys...), stored.APIKey))
+ stored.APIKey = ""
+ stored.APIKeys = nil
+ stored.EncryptedAPIKeys = nil
+ for _, key := range keys {
+ ciphertext, err := (contentModerationTestEncryptor{}).EncryptForDomain(SecretDomainContentModeration, key)
+ require.NoError(t, err)
+ stored.EncryptedAPIKeys = append(stored.EncryptedAPIKeys, ciphertext)
+ }
+ raw, err := json.Marshal(&stored)
+ require.NoError(t, err)
+ return string(raw)
+}
+
+func TestContentModerationConfigRejectsLegacyPlaintextAfterStartupMigration(t *testing.T) {
+ cfg := defaultContentModerationConfig()
+ cfg.APIKeys = []string{"sk-legacy-secret"}
+ rawCfg, err := json.Marshal(cfg)
+ require.NoError(t, err)
+ repo := &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyContentModerationConfig: string(rawCfg),
+ }}
+ svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil)
+ svc.SetSecretEncryptor(contentModerationTestEncryptor{})
+
+ _, err = svc.GetConfig(context.Background())
+ require.Error(t, err)
+ require.Contains(t, repo.values[SettingKeyContentModerationConfig], "sk-legacy-secret")
+}
+
+func TestContentModerationUpdateConfig_ReplacesAPIKeysWhenRequested(t *testing.T) {
+ cfg := defaultContentModerationConfig()
+ cfg.APIKeys = []string{"sk-old-a", "sk-old-b"}
+ rawCfg := mustMarshalStoredContentModerationConfig(t, cfg)
+
+ repo := &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyContentModerationConfig: rawCfg,
+ }}
+ svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil)
+ svc.SetSecretEncryptor(contentModerationTestEncryptor{})
+ deleteHashes := []string{moderationAPIKeyHash("sk-old-a")}
+ replaceKeys := []string{"sk-new-only"}
+ newBaseURL := "https://other.example.com"
+
+ view, err := svc.UpdateConfig(context.Background(), UpdateContentModerationConfigInput{
+ BaseURL: &newBaseURL,
+ APIKeys: &replaceKeys,
+ APIKeysMode: contentModerationAPIKeysModeReplace,
+ DeleteAPIKeyHashes: &deleteHashes,
+ })
+
+ require.NoError(t, err)
+ require.Equal(t, 1, view.APIKeyCount)
+ require.Equal(t, []string{maskSecretTail("sk-new-only")}, view.APIKeyMasks)
+
+ saved, err := svc.loadConfig(context.Background())
+ require.NoError(t, err)
+ require.Equal(t, newBaseURL, saved.BaseURL)
+ require.Equal(t, []string{"sk-new-only"}, saved.apiKeys())
+ require.NotContains(t, repo.values[SettingKeyContentModerationConfig], "sk-new-only")
+ require.Contains(t, repo.values[SettingKeyContentModerationConfig], "api_keys_encrypted")
+}
+
+func TestContentModerationUpdateConfig_RequiresKeyReplacementWhenBaseURLChanges(t *testing.T) {
+ cfg := defaultContentModerationConfig()
+ cfg.BaseURL = "https://trusted.example.com"
+ cfg.APIKeys = []string{"sk-saved"}
+ rawCfg := mustMarshalStoredContentModerationConfig(t, cfg)
+ repo := &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyContentModerationConfig: rawCfg,
+ }}
+ svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil)
+ svc.SetSecretEncryptor(contentModerationTestEncryptor{})
+ attackerURL := "https://attacker.example.com"
+
+ _, err := svc.UpdateConfig(context.Background(), UpdateContentModerationConfigInput{BaseURL: &attackerURL})
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "replace or clear API keys")
+ saved, loadErr := svc.loadConfig(context.Background())
+ require.NoError(t, loadErr)
+ require.Equal(t, "https://trusted.example.com", saved.BaseURL)
+ require.Equal(t, []string{"sk-saved"}, saved.apiKeys())
+}
+
+func TestContentModerationTestAPIKeys_RequiresExplicitKeysWhenBaseURLChanges(t *testing.T) {
+ called := false
+ target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ called = true
+ _ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{}}})
+ }))
+ defer target.Close()
+ cfg := defaultContentModerationConfig()
+ cfg.BaseURL = "https://trusted.example.com"
+ cfg.APIKeys = []string{"sk-saved"}
+ rawCfg := mustMarshalStoredContentModerationConfig(t, cfg)
+ svc := NewContentModerationService(
+ &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyContentModerationConfig: rawCfg,
+ }},
+ nil, nil, nil, nil, nil, nil,
+ )
+ svc.SetSecretEncryptor(contentModerationTestEncryptor{})
+
+ _, err := svc.TestAPIKeys(context.Background(), TestContentModerationAPIKeysInput{
+ BaseURL: target.URL,
+ Prompt: "hello",
+ })
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "explicit API keys")
+ require.False(t, called)
+}
+
+func TestContentModerationConfigRejectsPlaintextEncryptedMismatch(t *testing.T) {
+ ciphertext, err := (contentModerationTestEncryptor{}).Encrypt("sk-encrypted")
+ require.NoError(t, err)
+ cfg := defaultContentModerationConfig()
+ cfg.APIKeys = []string{"sk-plaintext-different"}
+ cfg.EncryptedAPIKeys = []string{ciphertext}
+ rawCfg, err := json.Marshal(cfg)
+ require.NoError(t, err)
+
+ repo := &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyContentModerationConfig: string(rawCfg),
+ }}
+ svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil)
+ svc.SetSecretEncryptor(contentModerationTestEncryptor{})
+
+ _, err = svc.loadConfig(context.Background())
+ require.Error(t, err)
+}
+
+func TestContentModerationRejectsCiphertextRelocatedFromTOTPDomain(t *testing.T) {
+ encryptor := contentModerationTestEncryptor{}
+ relocated, err := encryptor.EncryptForDomain(SecretDomainTOTP, "JBSWY3DPEHPK3PXP")
+ require.NoError(t, err)
+ cfg := defaultContentModerationConfig()
+ cfg.EncryptedAPIKeys = []string{relocated}
+ raw, err := json.Marshal(cfg)
+ require.NoError(t, err)
+ svc := NewContentModerationService(
+ &contentModerationTestSettingRepo{values: map[string]string{SettingKeyContentModerationConfig: string(raw)}},
+ nil, nil, nil, nil, nil, nil,
+ )
+ svc.SetSecretEncryptor(encryptor)
+
+ _, err = svc.loadConfig(context.Background())
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "domain mismatch")
+}
+
+func TestExtractContentModerationInput_AnthropicIncludesAllUserMessagesAndImages(t *testing.T) {
+ body := []byte(`{
+ "messages": [
+ {"role":"user","content":"old"},
+ {"role":"assistant","content":"ok"},
+ {"role":"user","content":[
+ {"type":"text","text":"检查这张图"},
+ {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}}
+ ]}
+ ]
+ }`)
+
+ input := ExtractContentModerationInput(ContentModerationProtocolAnthropicMessages, body)
+ require.Equal(t, "old 检查这张图", input.Text)
+ require.Equal(t, []string{"data:image/png;base64,aGVsbG8="}, input.Images)
+
+ log := (&ContentModerationService{}).buildLog(ContentModerationCheckInput{}, defaultContentModerationConfig(), ContentModerationActionAllow, false, "", 0, nil, input.ExcerptText(), nil, nil, "")
+ require.Equal(t, "old 检查这张图", log.InputExcerpt)
+ require.NotContains(t, log.InputExcerpt, "aGVsbG8=")
+}
+
+func TestExtractContentModerationInput_GeminiIncludesAllUserContents(t *testing.T) {
+ body := []byte(`{
+ "contents": [
+ {"role":"user","parts":[{"text":"harmful earlier turn"}]},
+ {"role":"model","parts":[{"text":"assistant"}]},
+ {"role":"user","parts":[{"text":"benign final turn"}]}
+ ]
+ }`)
+
+ input := ExtractContentModerationInput(ContentModerationProtocolGemini, body)
+
+ require.Equal(t, "harmful earlier turn benign final turn", input.Text)
+}
+
+func TestExtractContentModerationInput_AnthropicKeepsAllUserTextIncludingSystemReminderMarkers(t *testing.T) {
+ body := []byte(`{
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": "工具说明 "},
+ {"type": "text", "text": "Ainder>\n\n"},
+ {"type": "text", "text": "hid", "cache_control": {"type": "ephemeral"}}
+ ]
+ }
+ ]
+ }`)
+
+ input := ExtractContentModerationInput(ContentModerationProtocolAnthropicMessages, body)
+
+ require.Equal(t, "工具说明 Ainder> hid", input.Text)
+ require.Empty(t, input.Images)
+}
+
+func TestExtractContentModerationInput_OpenAIUserMarkerCannotSuppressText(t *testing.T) {
+ body := []byte(`{
+ "messages": [
+ {"role":"user","content":"harmful attacker controlled"}
+ ]
+ }`)
+
+ input := ExtractContentModerationInput(ContentModerationProtocolOpenAIChat, body)
+
+ require.Equal(t, "harmful attacker controlled", input.Text)
+}
+
+func TestExtractContentModerationInput_OpenAIChatIncludesAllUserMessages(t *testing.T) {
+ body := []byte(`{
+ "model":"gpt-5.5",
+ "messages":[
+ {"role":"system","content":"system prompt"},
+ {"role":"user","content":"old user"},
+ {"role":"assistant","content":"ok"},
+ {"role":"user","content":[{"type":"text","text":"latest user"},{"type":"image_url","image_url":{"url":"https://example.com/a.png"}}]}
+ ]
+ }`)
+
+ input := ExtractContentModerationInput(ContentModerationProtocolOpenAIChat, body)
+
+ require.Equal(t, "old user latest user", input.Text)
+ require.Equal(t, []string{"https://example.com/a.png"}, input.Images)
+ require.NotContains(t, input.Text, "system prompt")
+}
+
+func TestExtractContentModerationInput_OpenAIImagesIncludesPromptAndImages(t *testing.T) {
+ body := []byte(`{
+ "prompt":"replace background",
+ "images":[
+ {"image_url":"https://example.com/source.png"},
+ {"image_url":"data:image/png;base64,aGVsbG8="}
+ ]
+ }`)
+
+ input := ExtractContentModerationInput(ContentModerationProtocolOpenAIImages, body)
+
+ require.Equal(t, "replace background", input.Text)
+ require.Equal(t, []string{"https://example.com/source.png", "data:image/png;base64,aGVsbG8="}, input.Images)
+}
+
+func TestContentModerationInput_NormalizeKeepsImagesAndModerationInputCapsDeterministically(t *testing.T) {
+ images := []string{
+ "data:image/png;base64,Zmlyc3Q=",
+ "data:image/png;base64,c2Vjb25k",
+ }
+ input := ContentModerationInput{
+ Text: "check image",
+ Images: append([]string(nil), images...),
+ }
+ input.Normalize()
+
+ require.Equal(t, images, input.Images)
+
+ parts, ok := input.ModerationInput().([]moderationAPIInputPart)
+ require.True(t, ok)
+ require.Len(t, parts, 2)
+ require.Equal(t, "text", parts[0].Type)
+ require.Equal(t, "image_url", parts[1].Type)
+ require.NotNil(t, parts[1].ImageURL)
+ require.Equal(t, images[0], parts[1].ImageURL.URL)
+}
+
+func TestContentModerationCheck_MultipleImagesFailClosedBeforeSampling(t *testing.T) {
+ cfg := defaultContentModerationConfig()
+ cfg.Enabled = true
+ cfg.Mode = ContentModerationModePreBlock
+ cfg.AllGroups = true
+ rawCfg := mustMarshalStoredContentModerationConfig(t, cfg)
+ svc := NewContentModerationService(
+ &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyRiskControlEnabled: "true",
+ SettingKeyContentModerationConfig: rawCfg,
+ }},
+ &contentModerationTestRepo{},
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ )
+ body := []byte(`{
+ "messages": [{
+ "role":"user",
+ "content":[
+ {"type":"text","text":"check both"},
+ {"type":"image_url","image_url":{"url":"https://example.com/a.png"}},
+ {"type":"image_url","image_url":{"url":"https://example.com/b.png"}}
+ ]
+ }]
+ }`)
+
+ decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
+ Protocol: ContentModerationProtocolOpenAIChat,
+ Body: body,
+ })
+
+ require.NoError(t, err)
+ require.True(t, decision.Blocked)
+ require.Equal(t, http.StatusBadRequest, decision.StatusCode)
+ require.Equal(t, "moderation_too_many_images", decision.PolicyRule)
+}
+
+func TestContentModerationEnqueueAsyncDoesNotRetainOriginalBody(t *testing.T) {
+ svc := &ContentModerationService{asyncQueue: make(chan contentModerationTask, 1)}
+ body := []byte(`{"messages":[{"role":"user","content":"large original body"}]}`)
+ svc.enqueueAsync(
+ ContentModerationCheckInput{UserID: 1, Body: body},
+ &ContentModerationConfig{QueueSize: 1},
+ ContentModerationInput{Text: "large original body"},
+ "hash",
+ )
+
+ task := <-svc.asyncQueue
+
+ require.Nil(t, task.input.Body)
+ require.Equal(t, "large original body", task.content.Text)
+}
+
+func TestContentModerationCheck_ObserveImageRunsSynchronouslyOutsideQueue(t *testing.T) {
+ called := false
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ called = true
+ _ = json.NewEncoder(w).Encode(moderationAPIResponse{
+ Results: []moderationAPIResult{{CategoryScores: map[string]float64{"sexual": 0.01}}},
+ })
+ }))
+ defer server.Close()
+ cfg := defaultContentModerationConfig()
+ cfg.Enabled = true
+ cfg.Mode = ContentModerationModeObserve
+ cfg.AllGroups = true
+ cfg.BaseURL = server.URL
+ cfg.APIKeys = []string{"sk-test"}
+ rawCfg := mustMarshalStoredContentModerationConfig(t, cfg)
+ svc := &ContentModerationService{
+ settingRepo: &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyRiskControlEnabled: "true",
+ SettingKeyContentModerationConfig: rawCfg,
+ }},
+ repo: &contentModerationTestRepo{},
+ secretEncryptor: contentModerationTestEncryptor{},
+ httpClient: contentModerationHTTPClient(nil),
+ asyncQueue: make(chan contentModerationTask, 1),
+ keyHealth: make(map[string]*contentModerationKeyHealth),
+ }
+ body := []byte(`{
+ "messages":[{"role":"user","content":[
+ {"type":"text","text":"check image"},
+ {"type":"image_url","image_url":{"url":"https://example.com/a.png"}}
+ ]}]
+ }`)
+
+ decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
+ Protocol: ContentModerationProtocolOpenAIChat,
+ Body: body,
+ })
+
+ require.NoError(t, err)
+ require.True(t, decision.Allowed)
+ require.True(t, called)
+ require.Empty(t, svc.asyncQueue)
+}
+
+func TestBuildModerationTestInputRejectsMultipleImages(t *testing.T) {
+ _, _, err := buildModerationTestInput("check image", []string{
+ "data:image/png;base64,Zmlyc3Q=",
+ "data:image/png;base64,c2Vjb25k",
+ })
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "最多上传 1 张测试图片")
+}
+
+func TestExtractContentModerationInput_OpenAIResponsesCodexPayloadIncludesAllUserMessages(t *testing.T) {
+ body := []byte(`{
+ "model":"gpt-5.5",
+ "instructions":"instructions.....",
+ "input":[
+ {"type":"message","role":"developer","content":[{"type":"input_text","text":"developer permissions sk-proj-1234567890abcdef"}]},
+ {"type":"message","role":"user","content":[{"type":"input_text","text":"first user prompt"}]},
+ {"type":"message","role":"user","content":[{"type":"input_text","text":"last user prompt"}]}
+ ],
+ "prompt_cache_key":"cache-key"
+ }`)
+
+ input := ExtractContentModerationInput(ContentModerationProtocolOpenAIResponses, body)
+
+ require.Equal(t, "first user prompt last user prompt", input.Text)
+ require.Empty(t, input.Images)
+ require.NotContains(t, input.Text, "developer permissions")
+}
+
+func TestContentModerationCheck_OpenAIResponsesRecordsNonHitForCodexPayload(t *testing.T) {
+ var moderationRequest moderationAPIRequest
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/v1/moderations", r.URL.Path)
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&moderationRequest))
+ _ = json.NewEncoder(w).Encode(moderationAPIResponse{
+ Results: []moderationAPIResult{{
+ CategoryScores: map[string]float64{"sexual": 0.01},
+ }},
+ })
+ }))
+ defer server.Close()
+
+ cfg := defaultContentModerationConfig()
+ cfg.Enabled = true
+ cfg.Mode = ContentModerationModePreBlock
+ cfg.BaseURL = server.URL
+ cfg.APIKeys = []string{"sk-test"}
+ cfg.RecordNonHits = true
+ rawCfg := mustMarshalStoredContentModerationConfig(t, cfg)
+
+ repo := &contentModerationTestRepo{}
+ svc := NewContentModerationService(
+ &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyRiskControlEnabled: "true",
+ SettingKeyContentModerationConfig: rawCfg,
+ }},
+ repo,
+ &contentModerationTestHashCache{},
+ nil,
+ nil,
+ nil,
+ nil,
+ )
+ svc.SetSecretEncryptor(contentModerationTestEncryptor{})
+
+ body := []byte(`{
+ "model":"gpt-5.5",
+ "input":[
+ {"type":"message","role":"developer","content":[{"type":"input_text","text":"developer instructions should not be audited"}]},
+ {"type":"message","role":"user","content":[{"type":"input_text","text":"first user prompt"}]},
+ {"type":"message","role":"user","content":[{"type":"input_text","text":"last user prompt"}]}
+ ]
+ }`)
+ decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
+ UserID: 1001,
+ Endpoint: "/responses",
+ Provider: "openai",
+ Model: "gpt-5.5",
+ Protocol: ContentModerationProtocolOpenAIResponses,
+ Body: body,
+ })
+
+ require.NoError(t, err)
+ require.False(t, decision.Blocked)
+ require.Len(t, repo.logs, 1)
+ require.False(t, repo.logs[0].Flagged)
+ require.Equal(t, ContentModerationActionAllow, repo.logs[0].Action)
+ require.Equal(t, "/responses", repo.logs[0].Endpoint)
+ require.Equal(t, "first user prompt last user prompt", repo.logs[0].InputExcerpt)
+ require.Equal(t, "first user prompt last user prompt", moderationRequest.Input)
+}
+
+func TestContentModerationCheck_PreBlockBlocksCodexResponsesAllUserInput(t *testing.T) {
+ var moderationRequest moderationAPIRequest
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/v1/moderations", r.URL.Path)
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&moderationRequest))
+ _ = json.NewEncoder(w).Encode(moderationAPIResponse{
+ Results: []moderationAPIResult{{
+ CategoryScores: map[string]float64{"sexual": 0.9},
+ }},
+ })
+ }))
+ defer server.Close()
+
+ cfg := defaultContentModerationConfig()
+ cfg.Enabled = true
+ cfg.Mode = ContentModerationModePreBlock
+ cfg.BaseURL = server.URL
+ cfg.APIKeys = []string{"sk-test"}
+ cfg.BlockStatus = http.StatusUnavailableForLegalReasons
+ cfg.BlockMessage = "内容审计测试阻断"
+ rawCfg := mustMarshalStoredContentModerationConfig(t, cfg)
+
+ repo := &contentModerationTestRepo{}
+ svc := NewContentModerationService(
+ &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyRiskControlEnabled: "true",
+ SettingKeyContentModerationConfig: rawCfg,
+ }},
+ repo,
+ &contentModerationTestHashCache{},
+ nil,
+ nil,
+ nil,
+ nil,
+ )
+ svc.SetSecretEncryptor(contentModerationTestEncryptor{})
+
+ body := []byte(`{
+ "model":"gpt-5.5",
+ "instructions":"instructions.....",
+ "input":[
+ {"type":"message","role":"developer","content":[{"type":"input_text","text":"developer instructions should not be audited"}]},
+ {"type":"message","role":"user","content":[{"type":"input_text","text":"environment context"}]},
+ {"type":"message","role":"user","content":[{"type":"input_text","text":"latest blocked prompt"}]}
+ ]
+ }`)
+ decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
+ UserID: 1001,
+ Endpoint: "/responses",
+ Provider: "openai",
+ Model: "gpt-5.5",
+ Protocol: ContentModerationProtocolOpenAIResponses,
+ Body: body,
+ })
+
+ require.NoError(t, err)
+ require.True(t, decision.Blocked)
+ require.Equal(t, ContentModerationActionBlock, decision.Action)
+ require.Equal(t, http.StatusUnavailableForLegalReasons, decision.StatusCode)
+ require.Equal(t, "内容审计测试阻断", decision.Message)
+ require.Len(t, repo.logs, 1)
+ require.True(t, repo.logs[0].Flagged)
+ require.Equal(t, ContentModerationActionBlock, repo.logs[0].Action)
+ require.Equal(t, ContentModerationModePreBlock, repo.logs[0].Mode)
+ require.Equal(t, "environment context latest blocked prompt", repo.logs[0].InputExcerpt)
+ require.Equal(t, "environment context latest blocked prompt", moderationRequest.Input)
+}
+
+func TestContentModerationCheck_ImageFailClosedBlocksWhenAuditKeysMissing(t *testing.T) {
+ cfg := defaultContentModerationConfig()
+ cfg.Enabled = true
+ cfg.Mode = ContentModerationModePreBlock
+ cfg.APIKeys = nil
+ rawCfg := mustMarshalStoredContentModerationConfig(t, cfg)
+
+ repo := &contentModerationTestRepo{}
+ svc := NewContentModerationService(
+ &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyRiskControlEnabled: "true",
+ SettingKeyContentModerationConfig: rawCfg,
+ }},
+ repo,
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ )
+ svc.SetSecretEncryptor(contentModerationTestEncryptor{})
+
+ decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
+ UserID: 1001,
+ Endpoint: "/v1/images/generations",
+ Model: "gpt-image-2",
+ Protocol: ContentModerationProtocolOpenAIImages,
+ Body: []byte(`{"prompt":"draw a cat"}`),
+ Stage: ContentModerationStageInput,
+ FailClosed: true,
+ PolicyRule: "moderation_flagged_input",
+ })
+
+ require.NoError(t, err)
+ require.True(t, decision.Blocked)
+ require.Equal(t, ContentModerationActionError, decision.Action)
+ require.Equal(t, http.StatusServiceUnavailable, decision.StatusCode)
+ require.Len(t, repo.logs, 1)
+ require.Equal(t, ContentModerationActionError, repo.logs[0].Action)
+ require.Equal(t, ContentModerationStageInput, repo.logs[0].Stage)
+ require.Equal(t, "moderation_no_audit_api_keys", repo.logs[0].PolicyRule)
+ require.NotEmpty(t, repo.logs[0].InputHash)
+}
+
+func TestContentModerationCheck_NonImageStillAllowsWhenAuditKeysMissing(t *testing.T) {
+ cfg := defaultContentModerationConfig()
+ cfg.Enabled = true
+ cfg.Mode = ContentModerationModePreBlock
+ cfg.APIKeys = nil
+ rawCfg := mustMarshalStoredContentModerationConfig(t, cfg)
+
+ svc := NewContentModerationService(
+ &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyRiskControlEnabled: "true",
+ SettingKeyContentModerationConfig: rawCfg,
+ }},
+ &contentModerationTestRepo{},
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ )
+ svc.SetSecretEncryptor(contentModerationTestEncryptor{})
+
+ decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
+ UserID: 1001,
+ Endpoint: "/v1/responses",
+ Model: "gpt-5.5",
+ Protocol: ContentModerationProtocolOpenAIResponses,
+ Body: []byte(`{"input":[{"role":"user","content":[{"type":"input_text","text":"hello"}]}]}`),
+ })
+
+ require.NoError(t, err)
+ require.True(t, decision.Allowed)
+ require.False(t, decision.Blocked)
+}
+
+func TestContentModerationCheck_ImageFailClosedBlocksOnCategoryFlags(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ require.Equal(t, "/v1/moderations", r.URL.Path)
+ _ = json.NewEncoder(w).Encode(moderationAPIResponse{
+ Results: []moderationAPIResult{{
+ Categories: map[string]bool{"sexual/minors": true},
+ CategoryScores: map[string]float64{"sexual/minors": 0.2},
+ CategoryAppliedInputTypes: map[string][]string{"sexual/minors": []string{"image"}},
+ }},
+ })
+ }))
+ defer server.Close()
+
+ cfg := defaultContentModerationConfig()
+ cfg.Enabled = true
+ cfg.Mode = ContentModerationModeObserve
+ cfg.BaseURL = server.URL
+ cfg.APIKeys = []string{"sk-test"}
+ rawCfg := mustMarshalStoredContentModerationConfig(t, cfg)
+
+ repo := &contentModerationTestRepo{}
+ svc := NewContentModerationService(
+ &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyRiskControlEnabled: "true",
+ SettingKeyContentModerationConfig: rawCfg,
+ }},
+ repo,
+ &contentModerationTestHashCache{},
+ nil,
+ nil,
+ nil,
+ nil,
+ )
+ svc.SetSecretEncryptor(contentModerationTestEncryptor{})
+
+ decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
+ UserID: 1001,
+ Endpoint: "/v1/images/generations",
+ Model: "gpt-image-2",
+ Protocol: ContentModerationProtocolOpenAIImages,
+ Body: []byte(`{"prompt":"draw","images":[{"image_url":"data:image/png;base64,aGVsbG8="}]}`),
+ Stage: ContentModerationStageOutput,
+ FailClosed: true,
+ PolicyRule: "moderation_flagged_output",
+ })
+
+ require.NoError(t, err)
+ require.True(t, decision.Blocked)
+ require.Equal(t, ContentModerationActionBlock, decision.Action)
+ require.Equal(t, "sexual/minors", decision.HighestCategory)
+ require.Len(t, repo.logs, 1)
+ require.True(t, repo.logs[0].CategoryFlags["sexual/minors"])
+ require.Equal(t, []string{"image"}, repo.logs[0].CategoryAppliedInputTypes["sexual/minors"])
+ require.Equal(t, ContentModerationStageOutput, repo.logs[0].Stage)
+}
+
+func TestBuildContentModerationTestAuditResult_UsesConfiguredThresholdsOnly(t *testing.T) {
+ result := buildContentModerationTestAuditResult(&moderationAPIResult{
+ Flagged: true,
+ CategoryScores: map[string]float64{
+ "harassment": 0.65,
+ },
+ }, nil)
+
+ require.NotNil(t, result)
+ require.False(t, result.Flagged)
+ require.Equal(t, "harassment", result.HighestCategory)
+ require.Equal(t, 0.65, result.HighestScore)
+ require.Equal(t, 0.65, result.CompositeScore)
+ require.Equal(t, 0.98, result.Thresholds["harassment"])
+}
+
+func TestContentModerationCallModeration_400DoesNotFreezeAPIKey(t *testing.T) {
+ requestCount := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requestCount++
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":{"message":"Number of images (5) exceeds maximum of 1","type":"invalid_request_error","param":"input","code":"too_many_images"}}`))
+ }))
+ defer server.Close()
+
+ cfg := defaultContentModerationConfig()
+ cfg.BaseURL = server.URL
+ cfg.APIKeys = []string{"sk-test"}
+ cfg.RetryCount = 5
+ svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil)
+
+ _, err := svc.callModeration(context.Background(), cfg, "hello")
+
+ require.Error(t, err)
+ require.Equal(t, 1, requestCount)
+ status := svc.apiKeyStatusForHash(0, moderationAPIKeyHash("sk-test"), maskSecretTail("sk-test"), true)
+ require.Equal(t, "error", status.Status)
+ require.Equal(t, http.StatusBadRequest, status.LastHTTPStatus)
+ require.Zero(t, status.FailureCount)
+ require.Nil(t, status.FrozenUntil)
+}
+
+func TestContentModerationCallModeration_FreezesByHTTPStatus(t *testing.T) {
+ tests := []struct {
+ name string
+ statusCode int
+ minFreeze time.Duration
+ maxFreeze time.Duration
+ }{
+ {name: "401 freezes ten minutes", statusCode: http.StatusUnauthorized, minFreeze: 9*time.Minute + 55*time.Second, maxFreeze: 10*time.Minute + time.Second},
+ {name: "403 freezes ten minutes", statusCode: http.StatusForbidden, minFreeze: 9*time.Minute + 55*time.Second, maxFreeze: 10*time.Minute + time.Second},
+ {name: "429 freezes one minute", statusCode: http.StatusTooManyRequests, minFreeze: 55 * time.Second, maxFreeze: time.Minute + time.Second},
+ {name: "529 freezes one minute", statusCode: 529, minFreeze: 55 * time.Second, maxFreeze: time.Minute + time.Second},
+ {name: "500 freezes ten seconds", statusCode: http.StatusInternalServerError, minFreeze: 5 * time.Second, maxFreeze: 11 * time.Second},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(tt.statusCode)
+ _, _ = w.Write([]byte(`{"error":{"message":"upstream error"}}`))
+ }))
+ defer server.Close()
+
+ cfg := defaultContentModerationConfig()
+ cfg.BaseURL = server.URL
+ cfg.APIKeys = []string{"sk-test"}
+ cfg.RetryCount = 0
+ svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil)
+
+ _, err := svc.callModeration(context.Background(), cfg, "hello")
+
+ require.Error(t, err)
+ status := svc.apiKeyStatusForHash(0, moderationAPIKeyHash("sk-test"), maskSecretTail("sk-test"), true)
+ require.Equal(t, "frozen", status.Status)
+ require.Equal(t, tt.statusCode, status.LastHTTPStatus)
+ require.Equal(t, 1, status.FailureCount)
+ require.NotNil(t, status.FrozenUntil)
+ remaining := time.Until(*status.FrozenUntil)
+ require.GreaterOrEqual(t, remaining, tt.minFreeze)
+ require.LessOrEqual(t, remaining, tt.maxFreeze)
+ })
+ }
+}
+
+func TestContentModerationTestAPIKeys_400DoesNotFreezeAPIKey(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":{"message":"invalid moderation request"}}`))
+ }))
+ defer server.Close()
+
+ svc := NewContentModerationService(
+ &contentModerationTestSettingRepo{values: map[string]string{}},
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ )
+ result, err := svc.TestAPIKeys(context.Background(), TestContentModerationAPIKeysInput{
+ APIKeys: []string{"sk-test"},
+ BaseURL: server.URL,
+ Prompt: "hello",
+ })
+
+ require.NoError(t, err)
+ require.Len(t, result.Items, 1)
+ require.Equal(t, "error", result.Items[0].Status)
+ require.Equal(t, http.StatusBadRequest, result.Items[0].LastHTTPStatus)
+ require.Zero(t, result.Items[0].FailureCount)
+ require.Nil(t, result.Items[0].FrozenUntil)
+}
+
+func TestContentModerationCheck_PreHashUsesRedisHashCache(t *testing.T) {
+ cfg := defaultContentModerationConfig()
+ cfg.Enabled = true
+ cfg.PreHashCheckEnabled = true
+ cfg.APIKeys = []string{"sk-test"}
+ cfg.BlockStatus = http.StatusConflict
+ cfg.BlockMessage = "命中历史风险输入"
+ rawCfg := mustMarshalStoredContentModerationConfig(t, cfg)
+
+ hashCache := &contentModerationTestHashCache{hashes: map[string]struct{}{}}
+ content := ContentModerationInput{Text: "blocked prompt"}
+ content.Normalize()
+ hashCache.hashes[content.Hash()] = struct{}{}
+
+ svc := NewContentModerationService(
+ &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyRiskControlEnabled: "true",
+ SettingKeyContentModerationConfig: rawCfg,
+ }},
+ &contentModerationTestRepo{},
+ hashCache,
+ nil,
+ nil,
+ nil,
+ nil,
+ )
+ svc.SetSecretEncryptor(contentModerationTestEncryptor{})
+
+ decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
+ Protocol: ContentModerationProtocolOpenAIChat,
+ Body: []byte(`{"messages":[{"role":"user","content":"blocked prompt"}]}`),
+ })
+ require.NoError(t, err)
+ require.True(t, decision.Blocked)
+ require.Equal(t, ContentModerationActionHashBlock, decision.Action)
+ require.Equal(t, http.StatusConflict, decision.StatusCode)
+ require.Equal(t, content.Hash(), decision.InputHash)
+ require.Contains(t, decision.Message, "命中历史风险输入")
+ require.Contains(t, decision.Message, content.Hash())
+ require.Len(t, hashCache.checked, 1)
+}
+
+func TestContentModerationCheck_PreBlockFlaggedWritesRedisHashCache(t *testing.T) {
+ requestCount := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requestCount++
+ _ = json.NewEncoder(w).Encode(moderationAPIResponse{
+ Results: []moderationAPIResult{{
+ CategoryScores: map[string]float64{"sexual": 0.9},
+ }},
+ })
+ }))
+ defer server.Close()
+
+ cfg := defaultContentModerationConfig()
+ cfg.Enabled = true
+ cfg.Mode = ContentModerationModePreBlock
+ cfg.PreHashCheckEnabled = true
+ cfg.BaseURL = server.URL
+ cfg.APIKeys = []string{"sk-test"}
+ cfg.BlockStatus = http.StatusConflict
+ cfg.BlockMessage = "命中风险输入"
+ rawCfg := mustMarshalStoredContentModerationConfig(t, cfg)
+
+ repo := &contentModerationTestRepo{}
+ hashCache := &contentModerationTestHashCache{}
+ svc := NewContentModerationService(
+ &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyRiskControlEnabled: "true",
+ SettingKeyContentModerationConfig: rawCfg,
+ }},
+ repo,
+ hashCache,
+ nil,
+ nil,
+ nil,
+ nil,
+ )
+ svc.SetSecretEncryptor(contentModerationTestEncryptor{})
+
+ body := []byte(`{"messages":[{"role":"user","content":"repeat blocked prompt"}]}`)
+ decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
+ Protocol: ContentModerationProtocolOpenAIChat,
+ Body: body,
+ })
+ require.NoError(t, err)
+ require.True(t, decision.Blocked)
+ require.Equal(t, ContentModerationActionBlock, decision.Action)
+ require.Equal(t, 1, requestCount)
+ require.Len(t, hashCache.recorded, 1)
+ require.Len(t, repo.logs, 1)
+
+ decision, err = svc.Check(context.Background(), ContentModerationCheckInput{
+ Protocol: ContentModerationProtocolOpenAIChat,
+ Body: body,
+ })
+ require.NoError(t, err)
+ require.True(t, decision.Blocked)
+ require.Equal(t, ContentModerationActionHashBlock, decision.Action)
+ require.Equal(t, hashCache.recorded[0], decision.InputHash)
+ require.Equal(t, 1, requestCount)
+ require.Len(t, repo.logs, 1)
+}
+
+func TestContentModerationDeleteFlaggedInputHash_NormalizesAndDeletes(t *testing.T) {
+ existingHash := strings.Repeat("a", 64)
+ hashCache := &contentModerationTestHashCache{hashes: map[string]struct{}{
+ existingHash: {},
+ }}
+ svc := &ContentModerationService{hashCache: hashCache}
+
+ result, err := svc.DeleteFlaggedInputHash(context.Background(), strings.ToUpper(existingHash))
+
+ require.NoError(t, err)
+ require.Equal(t, existingHash, result.InputHash)
+ require.True(t, result.Deleted)
+ require.NotContains(t, hashCache.hashes, existingHash)
+ require.Equal(t, []string{existingHash}, hashCache.deleted)
+
+ result, err = svc.DeleteFlaggedInputHash(context.Background(), existingHash)
+
+ require.NoError(t, err)
+ require.Equal(t, existingHash, result.InputHash)
+ require.False(t, result.Deleted)
+}
+
+func TestContentModerationClearFlaggedInputHashesAndStatusCount(t *testing.T) {
+ cfg := defaultContentModerationConfig()
+ cfg.Enabled = true
+ rawCfg := mustMarshalStoredContentModerationConfig(t, cfg)
+
+ hashCache := &contentModerationTestHashCache{hashes: map[string]struct{}{
+ strings.Repeat("a", 64): {},
+ strings.Repeat("b", 64): {},
+ }}
+ svc := &ContentModerationService{
+ settingRepo: &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyRiskControlEnabled: "true",
+ SettingKeyContentModerationConfig: rawCfg,
+ }},
+ hashCache: hashCache,
+ keyHealth: make(map[string]*contentModerationKeyHealth),
+ }
+
+ status, err := svc.GetStatus(context.Background())
+ require.NoError(t, err)
+ require.Equal(t, int64(2), status.FlaggedHashCount)
+
+ result, err := svc.ClearFlaggedInputHashes(context.Background())
+ require.NoError(t, err)
+ require.Equal(t, int64(2), result.Deleted)
+
+ status, err = svc.GetStatus(context.Background())
+ require.NoError(t, err)
+ require.Equal(t, int64(0), status.FlaggedHashCount)
+}
+
+func TestContentModerationCheck_AsyncFlaggedWritesRedisHashCache(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _ = json.NewEncoder(w).Encode(moderationAPIResponse{
+ Results: []moderationAPIResult{{
+ CategoryScores: map[string]float64{"sexual": 0.9},
+ }},
+ })
+ }))
+ defer server.Close()
+
+ cfg := defaultContentModerationConfig()
+ cfg.Enabled = true
+ cfg.Mode = ContentModerationModeObserve
+ cfg.BaseURL = server.URL
+ cfg.APIKeys = []string{"sk-test"}
+ rawCfg := mustMarshalStoredContentModerationConfig(t, cfg)
+
+ repo := &contentModerationTestRepo{}
+ hashCache := &contentModerationTestHashCache{}
+ svc := NewContentModerationService(
+ &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyRiskControlEnabled: "true",
+ SettingKeyContentModerationConfig: rawCfg,
+ }},
+ repo,
+ hashCache,
+ nil,
+ nil,
+ nil,
+ nil,
+ )
+
+ decision := svc.checkSync(context.Background(), ContentModerationCheckInput{
+ Protocol: ContentModerationProtocolOpenAIChat,
+ Body: []byte(`{"messages":[{"role":"user","content":"bad prompt"}]}`),
+ }, cfg, ContentModerationInput{Text: "bad prompt"}, strings.Repeat("b", 64), contentModerationIntPtr(25), false)
+
+ require.False(t, decision.Blocked)
+ require.Len(t, hashCache.recorded, 1)
+ require.Len(t, repo.logs, 1)
+}
+
+func TestBuildContentModerationAccountDisabledEmailBody_ContainsBanDetails(t *testing.T) {
+ userID := int64(1001)
+ cfg := defaultContentModerationConfig()
+ cfg.BanThreshold = 10
+ body := buildContentModerationAccountDisabledEmailBody("Sub2API ", &ContentModerationLog{
+ UserID: &userID,
+ UserEmail: "user@example.com",
+ GroupName: "vip_2",
+ HighestCategory: "sexual",
+ HighestScore: 0.926,
+ ViolationCount: 10,
+ }, cfg)
+
+ require.Contains(t, body, "账户已被自动禁用")
+ require.Contains(t, body, "封禁详情")
+ require.Contains(t, body, "账户当前处于封禁状态,所有 API 请求将被拒绝")
+ require.Contains(t, body, "10 次(阈值 10)")
+ require.Contains(t, body, "sexual / 0.926")
+ require.Contains(t, body, "Sub2API <Admin>")
+}
+
+func TestContentModerationUnbanUser_ActivatesUserAndInvalidatesAuthCache(t *testing.T) {
+ userRepo := &contentModerationTestUserRepo{user: &User{ID: 1001, Email: "user@example.com", Status: StatusDisabled}}
+ invalidator := &contentModerationTestAuthCacheInvalidator{}
+ repo := &contentModerationTestRepo{}
+ svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil)
+
+ result, err := svc.UnbanUser(context.Background(), 1001)
+
+ require.NoError(t, err)
+ require.Equal(t, int64(1001), result.UserID)
+ require.Equal(t, StatusActive, result.Status)
+ require.Len(t, userRepo.updated, 1)
+ require.Equal(t, StatusActive, userRepo.updated[0].Status)
+ require.Equal(t, []int64{1001}, invalidator.userIDs)
+}
+
+func TestContentModerationUnbanUser_ActiveUserOnlyInvalidatesAuthCache(t *testing.T) {
+ userRepo := &contentModerationTestUserRepo{user: &User{ID: 1001, Email: "user@example.com", Status: StatusActive}}
+ invalidator := &contentModerationTestAuthCacheInvalidator{}
+ repo := &contentModerationTestRepo{}
+ svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil)
+
+ result, err := svc.UnbanUser(context.Background(), 1001)
+
+ require.NoError(t, err)
+ require.Equal(t, StatusActive, result.Status)
+ require.Empty(t, userRepo.updated)
+ require.Equal(t, []int64{1001}, invalidator.userIDs)
+}
+
+func contentModerationIntPtr(v int) *int {
+ return &v
+}
diff --git a/backend/internal/service/crs_sync_service.go b/backend/internal/service/crs_sync_service.go
index b69b0639327..42c0b709e29 100644
--- a/backend/internal/service/crs_sync_service.go
+++ b/backend/internal/service/crs_sync_service.go
@@ -198,26 +198,27 @@ func (s *CRSSyncService) fetchCRSExport(ctx context.Context, baseURL, username,
return nil, errors.New("config is not available")
}
normalizedURL := strings.TrimSpace(baseURL)
+ allowedHosts := []string(nil)
if s.cfg.Security.URLAllowlist.Enabled {
- normalized, err := normalizeBaseURL(normalizedURL, s.cfg.Security.URLAllowlist.CRSHosts, s.cfg.Security.URLAllowlist.AllowPrivateHosts)
- if err != nil {
- return nil, err
- }
- normalizedURL = normalized
- } else {
- normalized, err := urlvalidator.ValidateURLFormat(normalizedURL, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
- if err != nil {
- return nil, fmt.Errorf("invalid base_url: %w", err)
- }
- normalizedURL = normalized
+ allowedHosts = s.cfg.Security.URLAllowlist.CRSHosts
+ }
+ normalized, err := normalizeBaseURL(
+ normalizedURL,
+ allowedHosts,
+ s.cfg.Security.URLAllowlist.Enabled,
+ s.cfg.Security.URLAllowlist.AllowPrivateHosts,
+ )
+ if err != nil {
+ return nil, err
}
+ normalizedURL = normalized
if strings.TrimSpace(username) == "" || strings.TrimSpace(password) == "" {
return nil, errors.New("username and password are required")
}
client, err := httpclient.GetClient(httpclient.Options{
Timeout: 20 * time.Second,
- ValidateResolvedIP: s.cfg.Security.URLAllowlist.Enabled,
+ ValidateResolvedIP: true,
AllowPrivateHosts: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
})
if err != nil {
@@ -1119,9 +1120,7 @@ func mapCRSStatus(isActive bool, status string) string {
return "active"
}
-func normalizeBaseURL(raw string, allowlist []string, allowPrivate bool) (string, error) {
- // 当 allowlist 为空时,不强制要求白名单(只进行基本的 URL 和 SSRF 验证)
- requireAllowlist := len(allowlist) > 0
+func normalizeBaseURL(raw string, allowlist []string, requireAllowlist, allowPrivate bool) (string, error) {
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
AllowedHosts: allowlist,
RequireAllowlist: requireAllowlist,
diff --git a/backend/internal/service/crs_sync_ssrf_security_test.go b/backend/internal/service/crs_sync_ssrf_security_test.go
new file mode 100644
index 00000000000..2466a15d7bd
--- /dev/null
+++ b/backend/internal/service/crs_sync_ssrf_security_test.go
@@ -0,0 +1,52 @@
+package service
+
+import (
+ "context"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+)
+
+func TestCRSSyncRejectsPrivateDestinationWhenHostAllowlistDisabled(t *testing.T) {
+ svc := NewCRSSyncService(nil, nil, nil, nil, nil, &config.Config{
+ Security: config.SecurityConfig{
+ URLAllowlist: config.URLAllowlistConfig{
+ Enabled: false,
+ AllowPrivateHosts: false,
+ },
+ },
+ })
+
+ for _, raw := range []string{"https://127.0.0.1:8443", "https://169.254.169.254", "https://localhost"} {
+ if _, err := svc.fetchCRSExport(context.Background(), raw, "admin", "secret"); err == nil {
+ t.Fatalf("private CRS destination %q was accepted", raw)
+ }
+ }
+}
+
+func TestCRSSyncAlwaysRequiresHTTPSForCredentials(t *testing.T) {
+ svc := NewCRSSyncService(nil, nil, nil, nil, nil, &config.Config{
+ Security: config.SecurityConfig{
+ URLAllowlist: config.URLAllowlistConfig{
+ Enabled: false,
+ AllowInsecureHTTP: true,
+ },
+ },
+ })
+
+ if _, err := svc.fetchCRSExport(context.Background(), "http://crs.example.com", "admin", "secret"); err == nil {
+ t.Fatal("CRS credentials were allowed over plaintext HTTP")
+ }
+}
+
+func TestCRSSyncEnabledAllowlistFailsClosedWhenEmpty(t *testing.T) {
+ svc := NewCRSSyncService(nil, nil, nil, nil, nil, &config.Config{
+ Security: config.SecurityConfig{
+ URLAllowlist: config.URLAllowlistConfig{Enabled: true},
+ },
+ })
+
+ if _, err := svc.fetchCRSExport(context.Background(), "https://crs.example.com", "admin", "secret"); err == nil {
+ t.Fatal("enabled CRS allowlist accepted an empty host policy")
+ }
+}
diff --git a/backend/internal/service/cursor_account_isolation.go b/backend/internal/service/cursor_account_isolation.go
new file mode 100644
index 00000000000..7d1eea51357
--- /dev/null
+++ b/backend/internal/service/cursor_account_isolation.go
@@ -0,0 +1,111 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+)
+
+const DefaultCursorTestModel = "cursor-default"
+
+type CursorModel struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ DisplayName string `json:"display_name"`
+ CreatedAt string `json:"created_at"`
+}
+
+var DefaultCursorModels = []CursorModel{
+ {ID: DefaultCursorTestModel, Type: "model", DisplayName: "Cursor Default", CreatedAt: ""},
+ {ID: "cursor-composer-2-fast", Type: "model", DisplayName: "Cursor Composer 2 Fast", CreatedAt: ""},
+ {ID: "cursor-composer-2", Type: "model", DisplayName: "Cursor Composer 2", CreatedAt: ""},
+ {ID: "cursor-gpt-5.5-none", Type: "model", DisplayName: "Cursor GPT-5.5 None", CreatedAt: ""},
+ {ID: "cursor-gpt-5.5-low", Type: "model", DisplayName: "Cursor GPT-5.5 Low", CreatedAt: ""},
+ {ID: "cursor-gpt-5.5-medium", Type: "model", DisplayName: "Cursor GPT-5.5 Medium", CreatedAt: ""},
+ {ID: "cursor-gpt-5.5-high", Type: "model", DisplayName: "Cursor GPT-5.5 High", CreatedAt: ""},
+ {ID: "cursor-gpt-5.5-extra-high", Type: "model", DisplayName: "Cursor GPT-5.5 Extra High", CreatedAt: ""},
+ {ID: "cursor-gpt-5.3-codex", Type: "model", DisplayName: "Cursor GPT-5.3 Codex", CreatedAt: ""},
+ {ID: "cursor-gpt-5.3-codex-high", Type: "model", DisplayName: "Cursor GPT-5.3 Codex High", CreatedAt: ""},
+ {ID: "cursor-gpt-5.3-codex-xhigh", Type: "model", DisplayName: "Cursor GPT-5.3 Codex XHigh", CreatedAt: ""},
+}
+
+func validateCursorAccountType(accountPlatform, accountType string) error {
+ if normalizePlatform(accountPlatform) != PlatformCursor {
+ return nil
+ }
+ switch normalizeAccountType(accountType) {
+ case "", AccountTypeUpstream:
+ return nil
+ default:
+ return fmt.Errorf("cursor accounts must use upstream type")
+ }
+}
+
+func validateCursorAccountGroupIsolation(ctx context.Context, groupRepo GroupRepository, accountPlatform string, groupIDs []int64) error {
+ if len(groupIDs) == 0 {
+ return nil
+ }
+ if groupRepo == nil {
+ return errors.New("group repository not configured")
+ }
+
+ accountPlatform = normalizePlatform(accountPlatform)
+ for _, groupID := range groupIDs {
+ group, err := groupRepo.GetByIDLite(ctx, groupID)
+ if err != nil {
+ return fmt.Errorf("get group %d: %w", groupID, err)
+ }
+ if group == nil {
+ return fmt.Errorf("get group %d: %w", groupID, ErrGroupNotFound)
+ }
+ groupPlatform := normalizePlatform(group.Platform)
+ if !isCursorGroupAssignmentCompatible(accountPlatform, groupPlatform) {
+ return fmt.Errorf(
+ "cursor accounts can only be assigned to cursor groups, and cursor groups only accept cursor accounts: account platform %s, group %d platform %s",
+ accountPlatform,
+ groupID,
+ groupPlatform,
+ )
+ }
+ }
+ return nil
+}
+
+func isCursorGroupAssignmentCompatible(accountPlatform, groupPlatform string) bool {
+ accountPlatform = normalizePlatform(accountPlatform)
+ groupPlatform = normalizePlatform(groupPlatform)
+
+ if accountPlatform == PlatformCursor {
+ return groupPlatform == PlatformCursor
+ }
+ if groupPlatform == PlatformCursor {
+ return accountPlatform == PlatformCursor
+ }
+ return true
+}
+
+func CursorSidecarAccountRef(account *Account) string {
+ if account == nil {
+ return ""
+ }
+ for _, value := range []string{
+ account.GetCredential("sidecar_account_ref"),
+ stringExtra(account.Extra, "sidecar_account_ref"),
+ account.GetCredential("cursor_account_ref"),
+ stringExtra(account.Extra, "cursor_account_ref"),
+ } {
+ if trimmed := strings.TrimSpace(value); trimmed != "" {
+ return trimmed
+ }
+ }
+ return fmt.Sprintf("%d", account.ID)
+}
+
+func stringExtra(extra map[string]any, key string) string {
+ if extra == nil {
+ return ""
+ }
+ value, _ := extra[key].(string)
+ return strings.TrimSpace(value)
+}
diff --git a/backend/internal/service/dashboard_service.go b/backend/internal/service/dashboard_service.go
index 3e059e30695..d6cda062c4e 100644
--- a/backend/internal/service/dashboard_service.go
+++ b/backend/internal/service/dashboard_service.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "strings"
"sync/atomic"
"time"
@@ -141,9 +142,9 @@ func (s *DashboardService) GetModelStatsWithFilters(ctx context.Context, startTi
}
func (s *DashboardService) GetModelStatsWithFiltersBySource(ctx context.Context, startTime, endTime time.Time, userID, apiKeyID, accountID, groupID int64, requestType *int16, stream *bool, billingType *int8, modelSource string) ([]usagestats.ModelStat, error) {
- normalizedSource := usagestats.NormalizeModelSource(modelSource)
- if normalizedSource == usagestats.ModelSourceRequested {
- return s.GetModelStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
+ normalizedSource := usagestats.ModelSourceUpstream
+ if rawSource := strings.TrimSpace(modelSource); rawSource != "" {
+ normalizedSource = usagestats.NormalizeModelSource(rawSource)
}
type modelStatsBySourceRepo interface {
diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go
index bb32540bf93..32843ea7759 100644
--- a/backend/internal/service/domain_constants.go
+++ b/backend/internal/service/domain_constants.go
@@ -29,14 +29,40 @@ const (
AffiliateRebateDurationDaysDefault = 0 // 0 = 永久有效
AffiliateRebateDurationDaysMax = 3650 // ~10 年
AffiliateRebatePerInviteeCapDefault = 0.0 // 0 = 无上限
+
+ // 差异化邀请返利比例(6/7 成本收口定稿:月卡整档零佣金,¥99 砍半,余额卡省到 10%)
+ AffiliateRebateCreditsCardRate = 10.0 // 余额卡(wallet type,plan 11/12/13),15%→10% 省成本
+ AffiliateRebatePackageRate = 5.0 // ¥99 轻量版 plan18(wallet 非余额卡),10%→5%
+ AffiliateRebateSubscriptionRate = 0.0 // 月卡 subscription,10%→0%(不保本 SKU 不给佣金)
+ AffiliateRebateInviteeFirst = 5.0 // 新人首单(被邀请人,仅 wallet 类型:余额卡/¥99;月卡不给)
)
+// affiliateCreditsPlanIDs 是余额卡套餐 ID 集合,这类 wallet 码走余额卡邀请人返利率。
+// plan 11=credits-30, plan 12=credits-100, plan 13=credits-500
+var affiliateCreditsPlanIDs = map[int64]bool{11: true, 12: true, 13: true}
+
+// AffiliateRebateOverrideForAdminAssign decides the admin assignment rebate
+// from the plan type resolved inside the assignment transaction. Database IDs
+// are not business semantics: migrations, restores and ID reuse must not turn a
+// monthly plan into a credits rebate (or vice versa). Manual PlanID=nil wallet
+// assignments keep the existing zero-rebate policy.
+func AffiliateRebateOverrideForAdminAssign(planID *int64, resolvedPlanType string) *float64 {
+ if planID != nil && resolvedPlanType == PlanTypeCredits {
+ rate := AffiliateRebateCreditsCardRate
+ return &rate
+ }
+ rate := AffiliateRebateSubscriptionRate
+ return &rate
+}
+
// Platform constants
const (
PlatformAnthropic = domain.PlatformAnthropic
PlatformOpenAI = domain.PlatformOpenAI
PlatformGemini = domain.PlatformGemini
PlatformAntigravity = domain.PlatformAntigravity
+ PlatformKiro = domain.PlatformKiro
+ PlatformCursor = domain.PlatformCursor
)
// Account type constants
@@ -51,10 +77,12 @@ const (
// Redeem type constants
const (
- RedeemTypeBalance = domain.RedeemTypeBalance
- RedeemTypeConcurrency = domain.RedeemTypeConcurrency
- RedeemTypeSubscription = domain.RedeemTypeSubscription
- RedeemTypeInvitation = domain.RedeemTypeInvitation
+ RedeemTypeBalance = domain.RedeemTypeBalance
+ RedeemTypeConcurrency = domain.RedeemTypeConcurrency
+ RedeemTypeSubscription = domain.RedeemTypeSubscription
+ RedeemTypeInvitation = domain.RedeemTypeInvitation
+ RedeemTypeAffiliateBalance = "affiliate_balance"
+ RedeemTypeWallet = domain.RedeemTypeWallet
)
// PromoCode status constants
@@ -75,6 +103,13 @@ const (
SubscriptionTypeSubscription = domain.SubscriptionTypeSubscription // 订阅模式(按限额控制)
)
+// SubscriptionPlan.plan_type 取值(v4 钱包模式)。
+// migration 153 已落 CHECK 约束 plan_type IN ('subscription','credits')。
+const (
+ PlanTypeSubscription = "subscription" // 月卡:validity_days 控时长,到期冻结余额
+ PlanTypeCredits = "credits" // 额度卡:永久有效(expires_at = MaxExpiresAt 2099),余额烧完为止
+)
+
// Subscription status constants
const (
SubscriptionStatusActive = domain.SubscriptionStatusActive
@@ -106,6 +141,16 @@ const (
SettingKeyAffiliateRebateFreezeHours = "affiliate_rebate_freeze_hours" // 返利冻结期(小时,0=不冻结)
SettingKeyAffiliateRebateDurationDays = "affiliate_rebate_duration_days" // 返利有效期(天,0=永久)
SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限(0=无上限)
+ SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路
+ SettingKeyContentModerationConfig = "content_moderation_config" // 内容审计配置(JSON)
+ SettingKeyHFCTelegramRiskAlertEnabled = "hfc_telegram_risk_alert_enabled" // 是否启用 HFC 风控 Telegram 告警
+ SettingKeyHFCTelegramBotToken = "hfc_telegram_bot_token" // Telegram Bot token(也可用环境变量)
+ SettingKeyHFCTelegramChatID = "hfc_telegram_chat_id" // Telegram 告警目标 chat id
+ SettingKeyHFCTelegramMinSeverity = "hfc_telegram_min_severity" // 最低告警等级:low/medium/high/critical
+ SettingKeyLoginAgreementEnabled = "login_agreement_enabled" // 登录前是否要求同意条款
+ SettingKeyLoginAgreementMode = "login_agreement_mode" // 条款确认展示模式:modal / checkbox
+ SettingKeyLoginAgreementUpdatedAt = "login_agreement_updated_at" // 条款更新日期(展示用)
+ SettingKeyLoginAgreementDocuments = "login_agreement_documents" // 条款文档列表(JSON,Markdown 内容)
// 邮件服务设置
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址
@@ -172,6 +217,18 @@ const (
SettingKeyOIDCConnectUserInfoIDPath = "oidc_connect_userinfo_id_path"
SettingKeyOIDCConnectUserInfoUsernamePath = "oidc_connect_userinfo_username_path"
+ // GitHub / Google 邮箱快捷登录设置
+ SettingKeyGitHubOAuthEnabled = "github_oauth_enabled"
+ SettingKeyGitHubOAuthClientID = "github_oauth_client_id"
+ SettingKeyGitHubOAuthClientSecret = "github_oauth_client_secret"
+ SettingKeyGitHubOAuthRedirectURL = "github_oauth_redirect_url"
+ SettingKeyGitHubOAuthFrontendRedirectURL = "github_oauth_frontend_redirect_url"
+ SettingKeyGoogleOAuthEnabled = "google_oauth_enabled"
+ SettingKeyGoogleOAuthClientID = "google_oauth_client_id"
+ SettingKeyGoogleOAuthClientSecret = "google_oauth_client_secret"
+ SettingKeyGoogleOAuthRedirectURL = "google_oauth_redirect_url"
+ SettingKeyGoogleOAuthFrontendRedirectURL = "google_oauth_frontend_redirect_url"
+
// OEM设置
SettingKeySiteName = "site_name" // 网站名称
SettingKeySiteLogo = "site_logo" // 网站Logo (base64)
@@ -215,6 +272,16 @@ const (
SettingKeyAuthSourceDefaultWeChatSubscriptions = "auth_source_default_wechat_subscriptions"
SettingKeyAuthSourceDefaultWeChatGrantOnSignup = "auth_source_default_wechat_grant_on_signup"
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind = "auth_source_default_wechat_grant_on_first_bind"
+ SettingKeyAuthSourceDefaultGitHubBalance = "auth_source_default_github_balance"
+ SettingKeyAuthSourceDefaultGitHubConcurrency = "auth_source_default_github_concurrency"
+ SettingKeyAuthSourceDefaultGitHubSubscriptions = "auth_source_default_github_subscriptions"
+ SettingKeyAuthSourceDefaultGitHubGrantOnSignup = "auth_source_default_github_grant_on_signup"
+ SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind = "auth_source_default_github_grant_on_first_bind"
+ SettingKeyAuthSourceDefaultGoogleBalance = "auth_source_default_google_balance"
+ SettingKeyAuthSourceDefaultGoogleConcurrency = "auth_source_default_google_concurrency"
+ SettingKeyAuthSourceDefaultGoogleSubscriptions = "auth_source_default_google_subscriptions"
+ SettingKeyAuthSourceDefaultGoogleGrantOnSignup = "auth_source_default_google_grant_on_signup"
+ SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind = "auth_source_default_google_grant_on_first_bind"
SettingKeyForceEmailOnThirdPartySignup = "force_email_on_third_party_signup"
// 管理员 API Key
@@ -286,6 +353,9 @@ const (
// SettingKeyOverloadCooldownSettings stores JSON config for 529 overload cooldown handling.
SettingKeyOverloadCooldownSettings = "overload_cooldown_settings"
+ // SettingKeyRateLimit429CooldownSettings stores JSON config for 429 fallback cooldown handling.
+ SettingKeyRateLimit429CooldownSettings = "rate_limit_429_cooldown_settings"
+
// =========================
// Stream Timeout Handling
// =========================
diff --git a/backend/internal/service/error_passthrough_runtime.go b/backend/internal/service/error_passthrough_runtime.go
index 011c3ce4d54..f7c47cccd25 100644
--- a/backend/internal/service/error_passthrough_runtime.go
+++ b/backend/internal/service/error_passthrough_runtime.go
@@ -1,8 +1,18 @@
package service
-import "github.com/gin-gonic/gin"
+import (
+ "strings"
+
+ "github.com/Wei-Shaw/sub2api/internal/model"
+ "github.com/gin-gonic/gin"
+)
const errorPassthroughServiceContextKey = "error_passthrough_service"
+const maxErrorPassthroughClientMessageRunes = 512
+
+// DefaultErrorPassthroughClientMessage is the safe fallback when a rule does
+// not have an operator-authored client message.
+const DefaultErrorPassthroughClientMessage = "Upstream request failed"
// BindErrorPassthroughService 将错误透传服务绑定到请求上下文,供 service 层在非 failover 场景下复用规则。
func BindErrorPassthroughService(c *gin.Context, svc *ErrorPassthroughService) {
@@ -56,10 +66,7 @@ func applyErrorPassthroughRule(
status = *rule.ResponseCode
}
- errMsg = ExtractUpstreamErrorMessage(responseBody)
- if !rule.PassthroughBody && rule.CustomMessage != nil {
- errMsg = *rule.CustomMessage
- }
+ errMsg = ErrorPassthroughClientMessage(rule, defaultErrMsg)
// 命中 skip_monitoring 时在 context 中标记,供 ops_error_logger 跳过记录。
if rule.SkipMonitoring {
@@ -70,3 +77,25 @@ func applyErrorPassthroughRule(
errType = "upstream_error"
return status, errType, errMsg, true
}
+
+// ErrorPassthroughClientMessage returns only an operator-authored, bounded
+// message. PassthroughBody is retained for schema compatibility but raw
+// upstream bodies are never safe to reflect to tenants.
+func ErrorPassthroughClientMessage(rule *model.ErrorPassthroughRule, defaultMessage string) string {
+ defaultMessage = strings.TrimSpace(defaultMessage)
+ if defaultMessage == "" {
+ defaultMessage = DefaultErrorPassthroughClientMessage
+ }
+ if rule == nil || rule.CustomMessage == nil {
+ return defaultMessage
+ }
+ message := strings.TrimSpace(*rule.CustomMessage)
+ if message == "" {
+ return defaultMessage
+ }
+ runes := []rune(message)
+ if len(runes) > maxErrorPassthroughClientMessageRunes {
+ message = string(runes[:maxErrorPassthroughClientMessageRunes])
+ }
+ return message
+}
diff --git a/backend/internal/service/error_passthrough_runtime_test.go b/backend/internal/service/error_passthrough_runtime_test.go
index 7032d15b950..b73a41394d5 100644
--- a/backend/internal/service/error_passthrough_runtime_test.go
+++ b/backend/internal/service/error_passthrough_runtime_test.go
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/model"
@@ -16,7 +17,6 @@ import (
)
func TestApplyErrorPassthroughRule_NoBoundService(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -36,8 +36,49 @@ func TestApplyErrorPassthroughRule_NoBoundService(t *testing.T) {
assert.Equal(t, "Upstream request failed", errMsg)
}
+func TestApplyErrorPassthroughRule_LegacyRawBodyRuleNeverReflectsUpstreamDetails(t *testing.T) {
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+
+ ruleSvc := &ErrorPassthroughService{}
+ ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{{
+ ID: 1,
+ Name: "legacy-raw-body-rule",
+ Enabled: true,
+ Priority: 1,
+ ErrorCodes: []int{http.StatusInternalServerError},
+ MatchMode: model.MatchModeAny,
+ PassthroughCode: true,
+ PassthroughBody: true,
+ }})
+ BindErrorPassthroughService(c, ruleSvc)
+
+ secretBody := []byte(`{"error":{"message":"database dial tcp 10.0.0.5:5432 password=hunter2 sk-proj-secret"}}`)
+ status, errType, errMsg, matched := applyErrorPassthroughRule(
+ c,
+ PlatformAnthropic,
+ http.StatusInternalServerError,
+ secretBody,
+ http.StatusBadGateway,
+ "upstream_error",
+ "Upstream request failed",
+ )
+
+ require.True(t, matched)
+ assert.Equal(t, http.StatusInternalServerError, status)
+ assert.Equal(t, "upstream_error", errType)
+ assert.Equal(t, "Upstream request failed", errMsg)
+ for _, fragment := range []string{"10.0.0.5", "hunter2", "sk-proj-secret"} {
+ assert.False(t, strings.Contains(errMsg, fragment), "client message leaked %q", fragment)
+ }
+}
+
+func TestErrorPassthroughClientMessage_EmptyDefaultUsesSafeFallback(t *testing.T) {
+ rule := &model.ErrorPassthroughRule{PassthroughBody: true}
+ assert.Equal(t, DefaultErrorPassthroughClientMessage, ErrorPassthroughClientMessage(rule, ""))
+}
+
func TestGatewayHandleErrorResponse_NoRuleKeepsDefault(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -63,7 +104,6 @@ func TestGatewayHandleErrorResponse_NoRuleKeepsDefault(t *testing.T) {
}
func TestOpenAIHandleErrorResponse_NoRuleKeepsDefault(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -89,7 +129,6 @@ func TestOpenAIHandleErrorResponse_NoRuleKeepsDefault(t *testing.T) {
}
func TestGeminiWriteGeminiMappedError_NoRuleKeepsDefault(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -110,7 +149,6 @@ func TestGeminiWriteGeminiMappedError_NoRuleKeepsDefault(t *testing.T) {
}
func TestGatewayHandleErrorResponse_AppliesRuleFor422(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -140,7 +178,6 @@ func TestGatewayHandleErrorResponse_AppliesRuleFor422(t *testing.T) {
}
func TestOpenAIHandleErrorResponse_AppliesRuleFor422(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -170,7 +207,6 @@ func TestOpenAIHandleErrorResponse_AppliesRuleFor422(t *testing.T) {
}
func TestGeminiWriteGeminiMappedError_AppliesRuleFor422(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -195,7 +231,6 @@ func TestGeminiWriteGeminiMappedError_AppliesRuleFor422(t *testing.T) {
}
func TestApplyErrorPassthroughRule_SkipMonitoringSetsContextKey(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -225,7 +260,6 @@ func TestApplyErrorPassthroughRule_SkipMonitoringSetsContextKey(t *testing.T) {
}
func TestApplyErrorPassthroughRule_NoSkipMonitoringDoesNotSetContextKey(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
diff --git a/backend/internal/service/error_passthrough_service_test.go b/backend/internal/service/error_passthrough_service_test.go
index 96ddd6377ed..92295534122 100644
--- a/backend/internal/service/error_passthrough_service_test.go
+++ b/backend/internal/service/error_passthrough_service_test.go
@@ -716,24 +716,26 @@ func TestErrorPassthroughRule_Validate(t *testing.T) {
errorField string
}{
{
- name: "有效规则 - 透传模式(含错误码)",
+ name: "有效规则 - 安全消息模式(含错误码)",
rule: &model.ErrorPassthroughRule{
Name: "Valid Rule",
MatchMode: model.MatchModeAny,
ErrorCodes: []int{422},
PassthroughCode: true,
- PassthroughBody: true,
+ PassthroughBody: false,
+ CustomMessage: testStrPtr("Upstream request failed"),
},
expectError: false,
},
{
- name: "有效规则 - 透传模式(含关键词)",
+ name: "有效规则 - 安全消息模式(含关键词)",
rule: &model.ErrorPassthroughRule{
Name: "Valid Rule",
MatchMode: model.MatchModeAny,
Keywords: []string{"context limit"},
PassthroughCode: true,
- PassthroughBody: true,
+ PassthroughBody: false,
+ CustomMessage: testStrPtr("Upstream request failed"),
},
expectError: false,
},
@@ -814,6 +816,18 @@ func TestErrorPassthroughRule_Validate(t *testing.T) {
expectError: true,
errorField: "response_code",
},
+ {
+ name: "拒绝原始上游正文透传",
+ rule: &model.ErrorPassthroughRule{
+ Name: "Unsafe Body",
+ MatchMode: model.MatchModeAny,
+ ErrorCodes: []int{500},
+ PassthroughCode: true,
+ PassthroughBody: true,
+ },
+ expectError: true,
+ errorField: "passthrough_body",
+ },
{
name: "自定义消息但未提供值",
rule: &model.ErrorPassthroughRule{
@@ -840,6 +854,19 @@ func TestErrorPassthroughRule_Validate(t *testing.T) {
expectError: true,
errorField: "custom_message",
},
+ {
+ name: "自定义消息超过安全上限",
+ rule: &model.ErrorPassthroughRule{
+ Name: "Long Message",
+ MatchMode: model.MatchModeAny,
+ ErrorCodes: []int{422},
+ PassthroughCode: true,
+ PassthroughBody: false,
+ CustomMessage: testStrPtr(strings.Repeat("界", 513)),
+ },
+ expectError: true,
+ errorField: "custom_message",
+ },
}
for _, tt := range tests {
diff --git a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go
index 428231ee03a..93e86353122 100644
--- a/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go
+++ b/backend/internal/service/gateway_anthropic_apikey_passthrough_test.go
@@ -14,7 +14,6 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
@@ -98,19 +97,21 @@ func (w *failWriteResponseWriter) WriteString(_ string) (int, error) {
}
func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamPreservesBodyAndAuthReplacement(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
c.Request.Header.Set("User-Agent", "claude-cli/1.0.0")
+ c.Request.Header.Set("X-App", "cli")
+ c.Request.Header.Set("Anthropic-Dangerous-Direct-Browser-Access", "true")
+ c.Request.Header.Set("X-Claude-Code-Session-Id", "forged-session")
c.Request.Header.Set("Authorization", "Bearer inbound-token")
c.Request.Header.Set("X-Api-Key", "inbound-api-key")
c.Request.Header.Set("X-Goog-Api-Key", "inbound-goog-key")
c.Request.Header.Set("Cookie", "secret=1")
c.Request.Header.Set("Anthropic-Beta", "interleaved-thinking-2025-05-14")
- body := []byte(`{"model":"claude-3-7-sonnet-20250219","stream":true,"system":[{"type":"text","text":"x-anthropic-billing-header keep"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
+ body := []byte(`{"model":"claude-3-7-sonnet-20250219","stream":true,"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.92; cc_entrypoint=cli; cch=00000;"},{"type":"text","text":"Project prompt"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
parsed := &ParsedRequest{
Body: body,
Model: "claude-3-7-sonnet-20250219",
@@ -175,11 +176,17 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamPreservesBodyAnd
require.True(t, result.Stream)
require.Equal(t, "claude-3-haiku-20240307", gjson.GetBytes(upstream.lastBody, "model").String(), "透传模式应应用账号级模型映射")
+ require.NotContains(t, string(upstream.lastBody), "x-anthropic-billing-header")
+ require.Contains(t, string(upstream.lastBody), "Project prompt")
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, "sub2api-gateway/1", getHeaderRaw(upstream.lastReq.Header, "user-agent"))
+ require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "x-app"))
+ require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "anthropic-dangerous-direct-browser-access"))
+ require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "x-claude-code-session-id"))
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 指纹头")
@@ -196,7 +203,6 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamPreservesBodyAnd
}
func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardCountTokensPreservesBody(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -268,7 +274,6 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardCountTokensPreservesBo
// TestGatewayService_AnthropicAPIKeyPassthrough_ModelMappingEdgeCases 覆盖透传模式下模型映射的各种边界情况
func TestGatewayService_AnthropicAPIKeyPassthrough_ModelMappingEdgeCases(t *testing.T) {
- gin.SetMode(gin.TestMode)
tests := []struct {
name string
@@ -425,7 +430,6 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ModelMappingEdgeCases(t *test
// TestGatewayService_AnthropicAPIKeyPassthrough_ModelMappingPreservesOtherFields
// 确保模型映射只替换 model 字段,不影响请求体中的其他字段
func TestGatewayService_AnthropicAPIKeyPassthrough_ModelMappingPreservesOtherFields(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -484,7 +488,6 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ModelMappingPreservesOtherFie
// TestGatewayService_AnthropicAPIKeyPassthrough_EmptyModelSkipsMapping
// 确保空模型名不会触发映射逻辑
func TestGatewayService_AnthropicAPIKeyPassthrough_EmptyModelSkipsMapping(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -534,7 +537,6 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_EmptyModelSkipsMapping(t *tes
}
func TestGatewayService_AnthropicAPIKeyPassthrough_CountTokens404PassthroughNotError(t *testing.T) {
- gin.SetMode(gin.TestMode)
tests := []struct {
name string
@@ -635,7 +637,6 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_CountTokens404PassthroughNotE
}
func TestGatewayService_AnthropicAPIKeyPassthrough_BuildRequestRejectsInvalidBaseURL(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
@@ -662,127 +663,7 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_BuildRequestRejectsInvalidBas
require.Error(t, err)
}
-func TestGatewayService_AnthropicOAuth_NotAffectedByAPIKeyPassthroughToggle(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
-
- svc := &GatewayService{
- cfg: &config.Config{
- Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
- },
- }
- account := &Account{
- Platform: PlatformAnthropic,
- Type: AccountTypeOAuth,
- Extra: map[string]any{
- "anthropic_passthrough": true,
- },
- }
-
- require.False(t, account.IsAnthropicAPIKeyPassthroughEnabled())
-
- 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", getHeaderRaw(req.Header, "authorization"))
- require.Contains(t, getHeaderRaw(req.Header, "anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta")
-}
-
-func TestGatewayService_AnthropicOAuth_ForwardPreservesBillingHeaderSystemBlock(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- tests := []struct {
- name string
- body string
- }{
- {
- name: "system array",
- body: `{"model":"claude-3-5-sonnet-latest","system":[{"type":"text","text":"x-anthropic-billing-header keep"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`,
- },
- {
- name: "system string",
- body: `{"model":"claude-3-5-sonnet-latest","system":"x-anthropic-billing-header keep","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
-
- parsed, err := ParseGatewayRequest([]byte(tt.body), PlatformAnthropic)
- require.NoError(t, err)
-
- upstream := &anthropicHTTPUpstreamRecorder{
- resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{
- "Content-Type": []string{"application/json"},
- "x-request-id": []string{"rid-oauth-preserve"},
- },
- Body: io.NopCloser(strings.NewReader(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-3-5-sonnet-20241022","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":12,"output_tokens":7}}`)),
- },
- }
-
- cfg := &config.Config{
- Gateway: config.GatewayConfig{
- MaxLineSize: defaultMaxLineSize,
- },
- }
- svc := &GatewayService{
- cfg: cfg,
- responseHeaderFilter: compileResponseHeaderFilter(cfg),
- httpUpstream: upstream,
- rateLimitService: &RateLimitService{},
- deferredService: &DeferredService{},
- }
-
- account := &Account{
- ID: 301,
- Name: "anthropic-oauth-preserve",
- Platform: PlatformAnthropic,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- },
- Status: StatusActive,
- Schedulable: true,
- }
-
- result, err := svc.Forward(context.Background(), c, account, parsed)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, upstream.lastReq)
- 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())
- require.True(t, system.IsArray(), "system should be an array")
- arr := system.Array()
- require.Len(t, arr, 2, "system array should have billing block + cc prompt block")
-
- require.Contains(t, arr[0].Get("text").String(), "x-anthropic-billing-header:")
- require.Contains(t, arr[0].Get("text").String(), "cc_version=")
-
- require.Equal(t, claudeCodeSystemPrompt, arr[1].Get("text").String())
- require.Equal(t, "ephemeral", arr[1].Get("cache_control.type").String())
-
- // 原始 system prompt 应迁移至 messages 中
- messages := gjson.GetBytes(upstream.lastBody, "messages")
- require.True(t, messages.IsArray())
- firstMsg := messages.Array()[0]
- require.Equal(t, "user", firstMsg.Get("role").String())
- require.Contains(t, firstMsg.Get("content.0.text").String(), "x-anthropic-billing-header keep")
- })
- }
-}
-
func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingStillCollectsUsageAfterClientDisconnect(t *testing.T) {
- gin.SetMode(gin.TestMode)
// Use a canceled context recorder to simulate client disconnect behavior.
req := httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
@@ -825,7 +706,6 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingStillCollectsUsageAf
}
func TestGatewayService_AnthropicAPIKeyPassthrough_MissingTerminalEventReturnsError(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -858,7 +738,6 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_MissingTerminalEventReturnsEr
}
func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_NonStreamingSuccess(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
@@ -892,7 +771,6 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_NonStreamingSuc
}
func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_InvalidTokenType(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
@@ -915,7 +793,6 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_InvalidTokenTyp
}
func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_UpstreamRequestError(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
@@ -945,7 +822,6 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_UpstreamRequest
}
func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_EmptyResponseBody(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
@@ -1078,7 +954,6 @@ func TestParseClaudeUsageFromResponseBody(t *testing.T) {
}
func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingErrTooLong(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
@@ -1106,7 +981,6 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingErrTooLong(t *testin
}
func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingDataIntervalTimeout(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
@@ -1139,7 +1013,6 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingDataIntervalTimeout(
}
func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingReadError(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
@@ -1168,7 +1041,6 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingReadError(t *testing
}
func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingTimeoutAfterClientDisconnect(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
@@ -1212,7 +1084,6 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingTimeoutAfterClientDi
}
func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingContextCanceled(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
@@ -1241,7 +1112,6 @@ func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingContextCanceled(t *t
}
func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingUpstreamReadErrorAfterClientDisconnect(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
diff --git a/backend/internal/service/gateway_anthropic_oauth_boundary_test.go b/backend/internal/service/gateway_anthropic_oauth_boundary_test.go
new file mode 100644
index 00000000000..87a13f997b7
--- /dev/null
+++ b/backend/internal/service/gateway_anthropic_oauth_boundary_test.go
@@ -0,0 +1,229 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+const anthropicOAuthGatewayDisabledMessage = "disabled for shared gateway inference"
+
+func TestGatewayService_AnthropicOAuthSharedGatewayBoundariesRejectBeforeUpstream(t *testing.T) {
+ tests := []struct {
+ name string
+ call func(*GatewayService, *gin.Context, *Account) error
+ }{
+ {
+ name: "messages",
+ call: func(svc *GatewayService, c *gin.Context, account *Account) error {
+ _, err := svc.Forward(context.Background(), c, account, &ParsedRequest{
+ Body: []byte(`{"model":"claude-sonnet-4","stream":false,"messages":[{"role":"user","content":"hi"}]}`),
+ Model: "claude-sonnet-4",
+ Stream: false,
+ })
+ return err
+ },
+ },
+ {
+ name: "count_tokens",
+ call: func(svc *GatewayService, c *gin.Context, account *Account) error {
+ return svc.ForwardCountTokens(context.Background(), c, account, &ParsedRequest{
+ Body: []byte(`{"model":"claude-sonnet-4","messages":[{"role":"user","content":"hi"}]}`),
+ Model: "claude-sonnet-4",
+ })
+ },
+ },
+ {
+ name: "chat_completions",
+ call: func(svc *GatewayService, c *gin.Context, account *Account) error {
+ _, err := svc.ForwardAsChatCompletions(
+ context.Background(),
+ c,
+ account,
+ []byte(`{"model":"claude-sonnet-4","messages":[{"role":"user","content":"hi"}]}`),
+ &ParsedRequest{},
+ )
+ return err
+ },
+ },
+ {
+ name: "responses",
+ call: func(svc *GatewayService, c *gin.Context, account *Account) error {
+ _, err := svc.ForwardAsResponses(
+ context.Background(),
+ c,
+ account,
+ []byte(`{"model":"claude-sonnet-4","input":"hi"}`),
+ &ParsedRequest{},
+ )
+ return err
+ },
+ },
+ }
+
+ for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
+ for _, tt := range tests {
+ t.Run(accountType+"/"+tt.name, func(t *testing.T) {
+ upstream := &anthropicHTTPUpstreamRecorder{err: errors.New("unexpected upstream call")}
+ svc := newAnthropicOAuthBoundaryTestService(upstream)
+ c, recorder := newAnthropicOAuthBoundaryTestContext(tt.name)
+ account := newAnthropicOAuthBoundaryTestAccount(accountType)
+
+ err := tt.call(svc, c, account)
+
+ require.ErrorContains(t, err, anthropicOAuthGatewayDisabledMessage)
+ require.Nil(t, upstream.lastReq, "policy rejection must happen before any upstream request")
+ if tt.name == "count_tokens" {
+ require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
+ }
+ })
+ }
+ }
+}
+
+func TestGatewayService_AnthropicOAuthRequestBuildersReject(t *testing.T) {
+ account := newAnthropicOAuthBoundaryTestAccount(AccountTypeOAuth)
+ svc := newAnthropicOAuthBoundaryTestService(nil)
+ c, _ := newAnthropicOAuthBoundaryTestContext("builders")
+ body := []byte(`{"model":"claude-sonnet-4","messages":[{"role":"user","content":"hi"}]}`)
+
+ _, err := svc.buildUpstreamRequest(
+ context.Background(), c, account, body, "oauth-token", "oauth", "claude-sonnet-4", false,
+ )
+ require.ErrorContains(t, err, anthropicOAuthGatewayDisabledMessage)
+
+ _, err = svc.buildCountTokensRequest(
+ context.Background(), c, account, body, "oauth-token", "oauth", "claude-sonnet-4",
+ )
+ require.ErrorContains(t, err, anthropicOAuthGatewayDisabledMessage)
+}
+
+func TestGatewayService_AnthropicAPIKeyRemainsEligibleForGatewayInference(t *testing.T) {
+ svc := newAnthropicOAuthBoundaryTestService(nil)
+ c, _ := newAnthropicOAuthBoundaryTestContext("apikey")
+ c.Request.Header.Set("anthropic-beta", "oauth-2025-04-20,claude-code-20250219,context-1m-2025-08-07")
+ account := &Account{
+ ID: 902,
+ Name: "anthropic-api-key",
+ Platform: PlatformAnthropic,
+ Type: AccountTypeAPIKey,
+ Concurrency: 1,
+ Credentials: map[string]any{"api_key": "test-api-key"},
+ Status: StatusActive,
+ Schedulable: true,
+ }
+ body := []byte(`{"model":"claude-sonnet-4","system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.92; cc_entrypoint=cli; cch=00000;"},{"type":"text","text":"Project prompt"}],"messages":[{"role":"user","content":"hi"}]}`)
+
+ require.True(t, svc.isAccountSchedulableForSelection(account))
+ req, err := svc.buildUpstreamRequest(
+ context.Background(), c, account, body, "test-api-key", "apikey", "claude-sonnet-4", false,
+ )
+ require.NoError(t, err)
+ require.Equal(t, "test-api-key", getHeaderRaw(req.Header, "x-api-key"))
+ require.Equal(t, "sub2api-gateway/1", getHeaderRaw(req.Header, "user-agent"))
+ require.Empty(t, getHeaderRaw(req.Header, "x-app"))
+ require.NotContains(t, getHeaderRaw(req.Header, "anthropic-beta"), "oauth-2025-04-20")
+ require.NotContains(t, getHeaderRaw(req.Header, "anthropic-beta"), "claude-code-20250219")
+ require.Contains(t, getHeaderRaw(req.Header, "anthropic-beta"), "context-1m-2025-08-07")
+ upstreamBody, readErr := io.ReadAll(req.Body)
+ require.NoError(t, readErr)
+ require.NotContains(t, string(upstreamBody), "x-anthropic-billing-header")
+ require.Contains(t, string(upstreamBody), "Project prompt")
+}
+
+func TestGatewayService_AnthropicOAuthIsNotSchedulableForGatewayInference(t *testing.T) {
+ svc := newAnthropicOAuthBoundaryTestService(nil)
+ for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
+ account := newAnthropicOAuthBoundaryTestAccount(accountType)
+ require.False(t, svc.isAccountSchedulableForSelection(account))
+ require.False(t, svc.isAccountSchedulableForModelSelection(context.Background(), account, "claude-sonnet-4"))
+ }
+}
+
+func TestStripUntrustedAnthropicBillingAttribution(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ want string
+ }{
+ {
+ name: "array_preserves_ordinary_blocks",
+ body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_entrypoint=cli; cch=00000;"},{"type":"text","text":"Project prompt"}],"messages":[]}`,
+ want: `{"system":[{"type":"text","text":"Project prompt"}],"messages":[]}`,
+ },
+ {
+ name: "string_removes_only_attribution",
+ body: `{"system":" X-Anthropic-Billing-Header: forged","messages":[]}`,
+ want: `{"messages":[]}`,
+ },
+ {
+ name: "ordinary_system_unchanged",
+ body: `{"system":"Project prompt","messages":[]}`,
+ want: `{"system":"Project prompt","messages":[]}`,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.JSONEq(t, tt.want, string(stripUntrustedAnthropicBillingAttribution([]byte(tt.body))))
+ })
+ }
+}
+
+func TestAccountTestService_AnthropicOAuthProbeRejectsBeforeUpstream(t *testing.T) {
+ for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
+ t.Run(accountType, func(t *testing.T) {
+ upstream := &anthropicHTTPUpstreamRecorder{err: errors.New("unexpected upstream call")}
+ svc := &AccountTestService{
+ httpUpstream: upstream,
+ tlsFPProfileService: &TLSFingerprintProfileService{},
+ }
+ c, _ := newAnthropicOAuthBoundaryTestContext("account-test")
+
+ err := svc.testClaudeAccountConnection(c, newAnthropicOAuthBoundaryTestAccount(accountType), "claude-sonnet-4")
+
+ require.ErrorContains(t, err, anthropicOAuthGatewayDisabledMessage)
+ require.Nil(t, upstream.lastReq, "account probe must not synthesize an official-client request")
+ })
+ }
+}
+
+func newAnthropicOAuthBoundaryTestService(upstream HTTPUpstream) *GatewayService {
+ return &GatewayService{
+ cfg: &config.Config{
+ Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
+ },
+ httpUpstream: upstream,
+ rateLimitService: &RateLimitService{},
+ deferredService: &DeferredService{},
+ tlsFPProfileService: &TLSFingerprintProfileService{},
+ }
+}
+
+func newAnthropicOAuthBoundaryTestContext(path string) (*gin.Context, *httptest.ResponseRecorder) {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/"+path, nil)
+ c.Request.Header.Set("User-Agent", "claude-cli/99.0.0 (forged, cli)")
+ c.Request.Header.Set("X-App", "cli")
+ return c, recorder
+}
+
+func newAnthropicOAuthBoundaryTestAccount(accountType string) *Account {
+ return &Account{
+ ID: 901,
+ Name: "anthropic-oauth-boundary",
+ Platform: PlatformAnthropic,
+ Type: accountType,
+ Concurrency: 1,
+ Credentials: map[string]any{"access_token": "oauth-token"},
+ Status: StatusActive,
+ Schedulable: true,
+ }
+}
diff --git a/backend/internal/service/gateway_anthropic_oauth_policy.go b/backend/internal/service/gateway_anthropic_oauth_policy.go
new file mode 100644
index 00000000000..cfcc1b8e763
--- /dev/null
+++ b/backend/internal/service/gateway_anthropic_oauth_policy.go
@@ -0,0 +1,83 @@
+package service
+
+import (
+ "bytes"
+ "errors"
+ "strings"
+
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
+)
+
+// ErrAnthropicOAuthGatewayDisabled prevents shared/public gateway traffic from
+// using Anthropic OAuth or setup-token credentials. Downstream request fields
+// cannot authenticate official Claude Code provenance, so allowing those
+// credentials would make provider attribution dependent on attacker-controlled
+// headers and body content.
+var ErrAnthropicOAuthGatewayDisabled = errors.New("Anthropic OAuth and setup-token credentials are disabled for shared gateway inference; use an Anthropic API key or service account")
+
+func rejectAnthropicOAuthGatewayCredential(account *Account) error {
+ if account != nil && account.IsAnthropicOAuthOrSetupToken() {
+ return ErrAnthropicOAuthGatewayDisabled
+ }
+ return nil
+}
+
+func isGatewayInferenceCredentialAllowed(account *Account) bool {
+ return rejectAnthropicOAuthGatewayCredential(account) == nil
+}
+
+const anthropicBillingAttributionPrefix = "x-anthropic-billing-header:"
+
+// stripUntrustedAnthropicBillingAttribution removes provider-attribution
+// blocks supplied by an untrusted downstream client. Ordinary system content
+// is preserved byte-for-byte.
+func stripUntrustedAnthropicBillingAttribution(body []byte) []byte {
+ system := gjson.GetBytes(body, "system")
+ if !system.Exists() {
+ return body
+ }
+ if system.Type == gjson.String {
+ if isAnthropicBillingAttributionText(system.String()) {
+ if next, err := sjson.DeleteBytes(body, "system"); err == nil {
+ return next
+ }
+ }
+ return body
+ }
+ if !system.IsArray() {
+ return body
+ }
+
+ kept := make([][]byte, 0, len(system.Array()))
+ removed := false
+ for _, item := range system.Array() {
+ if isAnthropicBillingAttributionText(item.Get("text").String()) {
+ removed = true
+ continue
+ }
+ kept = append(kept, []byte(item.Raw))
+ }
+ if !removed {
+ return body
+ }
+ if len(kept) == 0 {
+ if next, err := sjson.DeleteBytes(body, "system"); err == nil {
+ return next
+ }
+ return body
+ }
+
+ rawArray := make([]byte, 0, len(system.Raw))
+ rawArray = append(rawArray, '[')
+ rawArray = append(rawArray, bytes.Join(kept, []byte(","))...)
+ rawArray = append(rawArray, ']')
+ if next, err := sjson.SetRawBytes(body, "system", rawArray); err == nil {
+ return next
+ }
+ return body
+}
+
+func isAnthropicBillingAttributionText(text string) bool {
+ return strings.HasPrefix(strings.ToLower(strings.TrimSpace(text)), anthropicBillingAttributionPrefix)
+}
diff --git a/backend/internal/service/gateway_anthropic_vertex_service_account_test.go b/backend/internal/service/gateway_anthropic_vertex_service_account_test.go
index aa779805518..f1d96c47015 100644
--- a/backend/internal/service/gateway_anthropic_vertex_service_account_test.go
+++ b/backend/internal/service/gateway_anthropic_vertex_service_account_test.go
@@ -13,7 +13,6 @@ import (
)
func TestGatewayService_BuildAnthropicVertexServiceAccountRequest(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -44,7 +43,6 @@ func TestGatewayService_BuildAnthropicVertexServiceAccountRequest(t *testing.T)
"service_account",
"claude-sonnet-4-5@20250929",
false,
- false,
)
require.NoError(t, err)
require.Equal(t, "https://us-east5-aiplatform.googleapis.com/v1/projects/vertex-proj/locations/us-east5/publishers/anthropic/models/claude-sonnet-4-5@20250929:rawPredict", req.URL.String())
diff --git a/backend/internal/service/gateway_beta_test.go b/backend/internal/service/gateway_beta_test.go
index ecaffe21c9d..b50a888ff6b 100644
--- a/backend/internal/service/gateway_beta_test.go
+++ b/backend/internal/service/gateway_beta_test.go
@@ -86,10 +86,10 @@ func TestStripBetaTokens(t *testing.T) {
want: "oauth-2025-04-20,interleaved-thinking-2025-05-14",
},
{
- name: "DroppedBetas is empty (filtering moved to configurable beta policy)",
- header: "oauth-2025-04-20,context-1m-2025-08-07,fast-mode-2026-02-01,interleaved-thinking-2025-05-14",
+ name: "DroppedBetas removes unauthenticated official-client identity tokens",
+ header: "oauth-2025-04-20,claude-code-20250219,context-1m-2025-08-07,fast-mode-2026-02-01,interleaved-thinking-2025-05-14",
tokens: claude.DroppedBetas,
- want: "oauth-2025-04-20,context-1m-2025-08-07,fast-mode-2026-02-01,interleaved-thinking-2025-05-14",
+ want: "context-1m-2025-08-07,fast-mode-2026-02-01,interleaved-thinking-2025-05-14",
},
}
@@ -114,24 +114,33 @@ func TestMergeAnthropicBetaDropping_Context1M(t *testing.T) {
func TestMergeAnthropicBetaDropping_DroppedBetas(t *testing.T) {
required := []string{"oauth-2025-04-20", "interleaved-thinking-2025-05-14"}
incoming := "context-1m-2025-08-07,fast-mode-2026-02-01,foo-beta,oauth-2025-04-20"
- // DroppedBetas is now empty — filtering moved to configurable beta policy.
- // Without a policy filter set, nothing gets dropped from the static set.
drop := droppedBetaSet()
got := mergeAnthropicBetaDropping(required, incoming, drop)
- require.Equal(t, "oauth-2025-04-20,interleaved-thinking-2025-05-14,context-1m-2025-08-07,fast-mode-2026-02-01,foo-beta", got)
+ require.Equal(t, "interleaved-thinking-2025-05-14,context-1m-2025-08-07,fast-mode-2026-02-01,foo-beta", got)
+ require.NotContains(t, got, claude.BetaOAuth)
require.Contains(t, got, "context-1m-2025-08-07")
require.Contains(t, got, "fast-mode-2026-02-01")
}
+func TestMergeAnthropicBetaDropping_PreservesIncomingRedactThinking(t *testing.T) {
+ required := []string{claude.BetaInterleavedThinking}
+ incoming := claude.BetaRedactThinking
+
+ got := mergeAnthropicBetaDropping(required, incoming, droppedBetaSet())
+
+ require.Contains(t, got, claude.BetaRedactThinking)
+}
+
func TestDroppedBetaSet(t *testing.T) {
- // Base set contains DroppedBetas (now empty — filtering moved to configurable beta policy)
base := droppedBetaSet()
require.Len(t, base, len(claude.DroppedBetas))
+ require.Contains(t, base, claude.BetaOAuth)
+ require.Contains(t, base, claude.BetaClaudeCode)
// With extra tokens
- extended := droppedBetaSet(claude.BetaClaudeCode)
- require.Contains(t, extended, claude.BetaClaudeCode)
+ extended := droppedBetaSet("custom-drop")
+ require.Contains(t, extended, "custom-drop")
require.Len(t, extended, len(claude.DroppedBetas)+1)
}
diff --git a/backend/internal/service/gateway_billing_block.go b/backend/internal/service/gateway_billing_block.go
deleted file mode 100644
index 45c307fdf83..00000000000
--- a/backend/internal/service/gateway_billing_block.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package service
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "fmt"
-
- "github.com/tidwall/gjson"
-)
-
-// fingerprintSalt 是计算 cc_version 后缀指纹的盐值。
-//
-// 来源:与 Parrot src/transform/cc_mimicry.py 的 FINGERPRINT_SALT 完全一致;
-// 这是真实 Claude Code CLI 抓包推导出的常量,改动会导致 fp 与 CLI 不一致,
-// 进一步触发 Anthropic 的第三方检测。
-const fingerprintSalt = "59cf53e54c78"
-
-// computeClaudeCodeFingerprint 复刻真实 Claude Code CLI 的 cc_version 指纹算法:
-//
-// 1. 取 messages 中第一条 role=user 的纯文本(首块 text)
-// 2. 取该文本的第 4、7、20 字符(不足以 '0' 补齐)
-// 3. SHA256(SALT + chars + cc_version) 取 hex 前 3 字符
-//
-// 算法来自 Parrot src/transform/cc_mimicry.py:compute_fingerprint,与官方 CLI 字节对齐。
-// 任何偏差都会导致 cc_version=X.Y.Z.{fp} 在上游侧与真实 CLI 不一致。
-func computeClaudeCodeFingerprint(body []byte, version string) string {
- firstText := extractFirstUserText(body)
- indices := []int{4, 7, 20}
- chars := make([]byte, 0, 3)
- for _, i := range indices {
- if i < len(firstText) {
- chars = append(chars, firstText[i])
- } else {
- chars = append(chars, '0')
- }
- }
- sum := sha256.Sum256([]byte(fingerprintSalt + string(chars) + version))
- return hex.EncodeToString(sum[:])[:3]
-}
-
-// extractFirstUserText 提取 messages 中第一条 user 消息的首段 text 内容。
-// 兼容 string 和 []block 两种 content 格式。
-func extractFirstUserText(body []byte) string {
- messages := gjson.GetBytes(body, "messages")
- if !messages.IsArray() {
- return ""
- }
- first := ""
- messages.ForEach(func(_, msg gjson.Result) bool {
- if msg.Get("role").String() != "user" {
- return true
- }
- content := msg.Get("content")
- if content.Type == gjson.String {
- first = content.String()
- return false
- }
- if content.IsArray() {
- content.ForEach(func(_, block gjson.Result) bool {
- if block.Get("type").String() == "text" {
- first = block.Get("text").String()
- return false
- }
- return true
- })
- return false
- }
- return false
- })
- return first
-}
-
-// buildBillingAttributionBlockJSON 构造 system 数组的 billing attribution block。
-//
-// 形态严格对齐真实 Claude Code CLI:
-//
-// {"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.92.{fp}; cc_entrypoint=cli; cch=00000;"}
-//
-// cch=00000 是签名占位符,由 signBillingHeaderCCH 在 buildUpstreamRequest 阶段
-// 替换为基于完整 body 的 xxhash64 5 位十六进制摘要。
-//
-// 此 block 不带 cache_control(与真实 CLI 一致;cache breakpoint 由后续的
-// Claude Code prompt block 承担)。
-func buildBillingAttributionBlockJSON(body []byte, cliVersion string) ([]byte, error) {
- if cliVersion == "" {
- return nil, fmt.Errorf("cliVersion required")
- }
- fp := computeClaudeCodeFingerprint(body, cliVersion)
- text := fmt.Sprintf(
- "x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=cli; cch=00000;",
- cliVersion, fp,
- )
- return json.Marshal(map[string]string{
- "type": "text",
- "text": text,
- })
-}
diff --git a/backend/internal/service/gateway_billing_header.go b/backend/internal/service/gateway_billing_header.go
deleted file mode 100644
index 91fbfd8fdd1..00000000000
--- a/backend/internal/service/gateway_billing_header.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package service
-
-import (
- "fmt"
- "regexp"
- "strings"
-
- "github.com/cespare/xxhash/v2"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
-)
-
-// ccVersionInBillingRe matches the semver part of cc_version (X.Y.Z), preserving
-// the trailing message-derived suffix (e.g. ".c02") if present.
-var ccVersionInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+`)
-
-// cchPlaceholderRe matches the cch=00000 placeholder in billing header text,
-// scoped to x-anthropic-billing-header to avoid touching user content.
-var cchPlaceholderRe = regexp.MustCompile(`(x-anthropic-billing-header:[^"]*?\bcch=)(00000)(;)`)
-
-const cchSeed uint64 = 0x6E52736AC806831E
-
-// syncBillingHeaderVersion rewrites cc_version in x-anthropic-billing-header
-// system text blocks to match the version extracted from userAgent.
-// Only touches system array blocks whose text starts with "x-anthropic-billing-header".
-func syncBillingHeaderVersion(body []byte, userAgent string) []byte {
- version := ExtractCLIVersion(userAgent)
- if version == "" {
- return body
- }
-
- systemResult := gjson.GetBytes(body, "system")
- if !systemResult.Exists() || !systemResult.IsArray() {
- return body
- }
-
- replacement := "cc_version=" + version
- idx := 0
- systemResult.ForEach(func(_, item gjson.Result) bool {
- text := item.Get("text")
- if text.Exists() && text.Type == gjson.String &&
- strings.HasPrefix(text.String(), "x-anthropic-billing-header") {
- newText := ccVersionInBillingRe.ReplaceAllString(text.String(), replacement)
- if newText != text.String() {
- if updated, err := sjson.SetBytes(body, fmt.Sprintf("system.%d.text", idx), newText); err == nil {
- body = updated
- }
- }
- }
- idx++
- return true
- })
-
- return body
-}
-
-// signBillingHeaderCCH computes the xxHash64-based CCH signature for the request
-// body and replaces the cch=00000 placeholder with the computed 5-hex-char hash.
-// The body must contain the placeholder when this function is called.
-func signBillingHeaderCCH(body []byte) []byte {
- if !cchPlaceholderRe.Match(body) {
- return body
- }
- cch := fmt.Sprintf("%05x", xxHash64Seeded(body, cchSeed)&0xFFFFF)
- return cchPlaceholderRe.ReplaceAll(body, []byte("${1}"+cch+"${3}"))
-}
-
-// xxHash64Seeded computes xxHash64 of data with a custom seed.
-func xxHash64Seeded(data []byte, seed uint64) uint64 {
- d := xxhash.NewWithSeed(seed)
- _, _ = d.Write(data)
- return d.Sum64()
-}
diff --git a/backend/internal/service/gateway_billing_header_test.go b/backend/internal/service/gateway_billing_header_test.go
deleted file mode 100644
index ffc4091c633..00000000000
--- a/backend/internal/service/gateway_billing_header_test.go
+++ /dev/null
@@ -1,165 +0,0 @@
-package service
-
-import (
- "fmt"
- "testing"
-
- "github.com/cespare/xxhash/v2"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
-)
-
-func TestSyncBillingHeaderVersion(t *testing.T) {
- tests := []struct {
- name string
- body string
- userAgent string
- wantSub string // substring expected in result
- unchanged bool // expect body to remain the same
- }{
- {
- name: "replaces cc_version preserving message-derived suffix",
- body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81.df2; cc_entrypoint=cli; cch=00000;"},{"type":"text","text":"You are Claude Code.","cache_control":{"type":"ephemeral"}}],"messages":[]}`,
- userAgent: "claude-cli/2.1.22 (external, cli)",
- wantSub: "cc_version=2.1.22.df2",
- },
- {
- name: "no billing header in system",
- body: `{"system":[{"type":"text","text":"You are Claude Code."}],"messages":[]}`,
- userAgent: "claude-cli/2.1.22",
- unchanged: true,
- },
- {
- name: "no system field",
- body: `{"messages":[]}`,
- userAgent: "claude-cli/2.1.22",
- unchanged: true,
- },
- {
- name: "user-agent without version",
- body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
- userAgent: "Mozilla/5.0",
- unchanged: true,
- },
- {
- name: "empty user-agent",
- body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
- userAgent: "",
- unchanged: true,
- },
- {
- name: "version already matches",
- body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.22; cc_entrypoint=cli; cch=00000;"}],"messages":[]}`,
- userAgent: "claude-cli/2.1.22",
- unchanged: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := syncBillingHeaderVersion([]byte(tt.body), tt.userAgent)
- if tt.unchanged {
- assert.Equal(t, tt.body, string(result), "body should remain unchanged")
- } else {
- assert.Contains(t, string(result), tt.wantSub)
- // Ensure old semver is gone
- assert.NotContains(t, string(result), "cc_version=2.1.81")
- }
- })
- }
-}
-
-func TestSignBillingHeaderCCH(t *testing.T) {
- t.Run("replaces placeholder with hash", func(t *testing.T) {
- body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63.a43; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
- result := signBillingHeaderCCH(body)
-
- // Should not have the placeholder anymore
- assert.NotContains(t, string(result), "cch=00000")
-
- // Should have a 5 hex-char cch value
- billingText := gjson.GetBytes(result, "system.0.text").String()
- require.Contains(t, billingText, "cch=")
- assert.Regexp(t, `cch=[0-9a-f]{5};`, billingText)
- })
-
- t.Run("no placeholder - body unchanged", func(t *testing.T) {
- body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=abcde;"}],"messages":[]}`)
- result := signBillingHeaderCCH(body)
- assert.Equal(t, string(body), string(result))
- })
-
- t.Run("no billing header - body unchanged", func(t *testing.T) {
- body := []byte(`{"system":[{"type":"text","text":"You are Claude Code."}],"messages":[]}`)
- result := signBillingHeaderCCH(body)
- assert.Equal(t, string(body), string(result))
- })
-
- t.Run("cch=00000 in user content is not touched", func(t *testing.T) {
- body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"keep literal cch=00000 in this message"}]}]}`)
- result := signBillingHeaderCCH(body)
-
- // Billing header should be signed
- billingText := gjson.GetBytes(result, "system.0.text").String()
- assert.NotContains(t, billingText, "cch=00000")
-
- // User message should keep its literal cch=00000
- userText := gjson.GetBytes(result, "messages.0.content.0.text").String()
- assert.Contains(t, userText, "cch=00000")
- })
-
- t.Run("signing is deterministic", func(t *testing.T) {
- body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hi"}]}`)
- r1 := signBillingHeaderCCH(body)
- body2 := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":"hi"}]}`)
- r2 := signBillingHeaderCCH(body2)
- assert.Equal(t, string(r1), string(r2))
- })
-
- t.Run("matches reference algorithm", func(t *testing.T) {
- // Verify: signBillingHeaderCCH(body) produces cch = xxHash64(body_with_placeholder, seed) & 0xFFFFF
- body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.63.a43; cc_entrypoint=cli; cch=00000;"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
- expectedCCH := fmt.Sprintf("%05x", xxHash64Seeded(body, cchSeed)&0xFFFFF)
-
- result := signBillingHeaderCCH(body)
- billingText := gjson.GetBytes(result, "system.0.text").String()
- assert.Contains(t, billingText, "cch="+expectedCCH+";")
- })
-}
-
-func TestXXHash64Seeded(t *testing.T) {
- t.Run("matches cespare/xxhash for seed 0", func(t *testing.T) {
- inputs := []string{"", "a", "hello world", "The quick brown fox jumps over the lazy dog"}
- for _, s := range inputs {
- data := []byte(s)
- expected := xxhash.Sum64(data)
- got := xxHash64Seeded(data, 0)
- assert.Equal(t, expected, got, "mismatch for input %q", s)
- }
- })
-
- t.Run("large input matches cespare", func(t *testing.T) {
- data := make([]byte, 256)
- for i := range data {
- data[i] = byte(i)
- }
- expected := xxhash.Sum64(data)
- got := xxHash64Seeded(data, 0)
- assert.Equal(t, expected, got)
- })
-
- t.Run("deterministic with custom seed", func(t *testing.T) {
- data := []byte("hello world")
- h1 := xxHash64Seeded(data, cchSeed)
- h2 := xxHash64Seeded(data, cchSeed)
- assert.Equal(t, h1, h2)
- })
-
- t.Run("different seeds produce different results", func(t *testing.T) {
- data := []byte("test data for hashing")
- h1 := xxHash64Seeded(data, 0)
- h2 := xxHash64Seeded(data, cchSeed)
- assert.NotEqual(t, h1, h2)
- })
-}
diff --git a/backend/internal/service/gateway_body_order_test.go b/backend/internal/service/gateway_body_order_test.go
index e0c3cafdbb6..f38ee77c765 100644
--- a/backend/internal/service/gateway_body_order_test.go
+++ b/backend/internal/service/gateway_body_order_test.go
@@ -7,7 +7,6 @@ import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
@@ -109,37 +108,6 @@ func TestReplaceModelInBody_PreservesTopLevelFieldOrder(t *testing.T) {
require.Contains(t, resultStr, `"model":"claude-3-5-sonnet-20241022"`)
}
-func TestNormalizeClaudeOAuthRequestBody_PreservesTopLevelFieldOrder(t *testing.T) {
- body := []byte(`{"alpha":1,"model":"claude-3-5-sonnet-latest","temperature":0.2,"system":"You are OpenCode, the best coding agent on the planet.","messages":[],"tool_choice":{"type":"auto"},"omega":2}`)
-
- result, modelID := normalizeClaudeOAuthRequestBody(body, "claude-3-5-sonnet-latest", claudeOAuthNormalizeOptions{
- injectMetadata: true,
- metadataUserID: "user-1",
- })
- resultStr := string(result)
-
- require.Equal(t, claude.NormalizeModelID("claude-3-5-sonnet-latest"), modelID)
- assertJSONTokenOrder(t, resultStr, `"alpha"`, `"model"`, `"temperature"`, `"system"`, `"messages"`, `"omega"`, `"tools"`, `"metadata"`, `"max_tokens"`)
- require.Contains(t, resultStr, `"temperature":0.2`)
- require.NotContains(t, resultStr, `"tool_choice"`)
- require.Contains(t, resultStr, `"system":"`+claudeCodeSystemPrompt+`"`)
- require.Contains(t, resultStr, `"tools":[]`)
- require.Contains(t, resultStr, `"metadata":{"user_id":"user-1"}`)
- require.Contains(t, resultStr, `"max_tokens":128000`)
-}
-
-func TestInjectClaudeCodePrompt_PreservesFieldOrder(t *testing.T) {
- body := []byte(`{"alpha":1,"system":[{"id":"block-1","type":"text","text":"Custom"}],"messages":[],"omega":2}`)
-
- result := injectClaudeCodePrompt(body, []any{
- map[string]any{"id": "block-1", "type": "text", "text": "Custom"},
- })
- resultStr := string(result)
-
- assertJSONTokenOrder(t, resultStr, `"alpha"`, `"system"`, `"messages"`, `"omega"`)
- require.Contains(t, resultStr, `{"id":"block-1","type":"text","text":"`+claudeCodeSystemPrompt+`\n\nCustom"}`)
-}
-
func TestEnforceCacheControlLimit_PreservesTopLevelFieldOrder(t *testing.T) {
body := []byte(`{"alpha":1,"system":[{"type":"text","text":"s1","cache_control":{"type":"ephemeral"}},{"type":"text","text":"s2","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":[{"type":"text","text":"m1","cache_control":{"type":"ephemeral"}},{"type":"text","text":"m2","cache_control":{"type":"ephemeral"}},{"type":"text","text":"m3","cache_control":{"type":"ephemeral"}}]}],"omega":2}`)
diff --git a/backend/internal/service/gateway_custom_relay_proxy_security_test.go b/backend/internal/service/gateway_custom_relay_proxy_security_test.go
new file mode 100644
index 00000000000..202cdeac036
--- /dev/null
+++ b/backend/internal/service/gateway_custom_relay_proxy_security_test.go
@@ -0,0 +1,39 @@
+package service
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestCustomRelayURLNeverSerializesProxyCredentials(t *testing.T) {
+ svc := &GatewayService{}
+ got := svc.buildCustomRelayURL("https://relay.example.com/", "/v1/messages")
+ if got != "https://relay.example.com/v1/messages?beta=true" {
+ t.Fatalf("custom relay URL = %q", got)
+ }
+ if strings.Contains(got, "proxy=") || strings.Contains(got, "secret") {
+ t.Fatalf("custom relay URL contains proxy material: %q", got)
+ }
+}
+
+func TestCustomRelayRejectsAccountProxyCombination(t *testing.T) {
+ proxyID := int64(9)
+ account := &Account{
+ ProxyID: &proxyID,
+ Proxy: &Proxy{
+ Protocol: "http",
+ Host: "proxy.example.com",
+ Port: 8080,
+ Username: "alice",
+ Password: "proxy-secret",
+ },
+ }
+ if err := validateCustomRelayProxyIsolation(account); err == nil {
+ t.Fatal("custom relay accepted an account proxy")
+ }
+
+ account.ProxyID = nil
+ if err := validateCustomRelayProxyIsolation(account); err != nil {
+ t.Fatalf("proxy-free custom relay rejected: %v", err)
+ }
+}
diff --git a/backend/internal/service/gateway_debug_env_test.go b/backend/internal/service/gateway_debug_env_test.go
index bd88a6671b2..00f38103b45 100644
--- a/backend/internal/service/gateway_debug_env_test.go
+++ b/backend/internal/service/gateway_debug_env_test.go
@@ -1,6 +1,10 @@
package service
-import "testing"
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
func TestParseDebugEnvBool(t *testing.T) {
t.Run("empty is false", func(t *testing.T) {
@@ -29,3 +33,51 @@ func TestParseDebugEnvBool(t *testing.T) {
}
})
}
+
+func TestGatewayBodyDebugIsDisabledInReleaseMode(t *testing.T) {
+ if gatewayBodyDebugAllowed("release") {
+ t.Fatal("full gateway body logging must be disabled in release mode")
+ }
+ if !gatewayBodyDebugAllowed("debug") {
+ t.Fatal("debug mode should permit explicitly configured body logging")
+ }
+}
+
+func TestInitDebugGatewayBodyFileUsesPrivatePermissions(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "gateway-debug", "requests.log")
+ svc := &GatewayService{}
+ svc.initDebugGatewayBodyFile(path)
+ f := svc.debugGatewayBodyFile.Load()
+ if f == nil {
+ t.Fatal("debug file was not initialized")
+ }
+ t.Cleanup(func() { _ = f.Close() })
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := info.Mode().Perm(); got != 0o600 {
+ t.Fatalf("debug file mode=%#o, want 0600", got)
+ }
+ dirInfo, err := os.Stat(filepath.Dir(path))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := dirInfo.Mode().Perm(); got != 0o700 {
+ t.Fatalf("debug directory mode=%#o, want 0700", got)
+ }
+}
+
+func TestSafeHeaderValueForLogRedactsCredentialHeaders(t *testing.T) {
+ for _, key := range []string{
+ "Authorization", "X-Api-Key", "Api-Key", "X-Goog-Api-Key",
+ "Cookie", "Set-Cookie", "Proxy-Authorization", "X-Auth-Token",
+ } {
+ t.Run(key, func(t *testing.T) {
+ if got := safeHeaderValueForLog(key, "super-secret"); got != "[redacted]" {
+ t.Fatalf("%s=%q, want redacted", key, got)
+ }
+ })
+ }
+}
diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go
index 7ac77f77ad6..d0e14a94226 100644
--- a/backend/internal/service/gateway_forward_as_chat_completions.go
+++ b/backend/internal/service/gateway_forward_as_chat_completions.go
@@ -34,6 +34,9 @@ func (s *GatewayService) ForwardAsChatCompletions(
parsed *ParsedRequest,
) (*ForwardResult, error) {
startTime := time.Now()
+ if err := rejectAnthropicOAuthGatewayCredential(account); err != nil {
+ return nil, err
+ }
// 1. Parse Chat Completions request
var ccReq apicompat.ChatCompletionsRequest
@@ -90,42 +93,30 @@ func (s *GatewayService) ForwardAsChatCompletions(
return nil, fmt.Errorf("marshal anthropic request: %w", err)
}
- // 6. Apply Claude Code mimicry for OAuth accounts.
- // Chat Completions 协议进来的请求永远不是 Claude Code 客户端,所以对 OAuth 账号
- // 必须完整执行 /v1/messages 主路径上的伪装链路(system 重写 + normalize + metadata 注入),
- // 否则会被 Anthropic 判为第三方应用并扣 extra usage。
- // 见 applyClaudeCodeOAuthMimicryToBody 的 godoc。
- isClaudeCode := false
- shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
-
- if shouldMimicClaudeCode {
- anthropicBody = s.applyClaudeCodeOAuthMimicryToBody(ctx, c, account, anthropicBody, anthropicReq.System, mappedModel)
- }
-
- // 7. Enforce cache_control block limit
+ // 6. Enforce cache_control block limit
anthropicBody = enforceCacheControlLimit(anthropicBody)
- // 8. Get access token
+ // 7. Get access token
token, tokenType, err := s.GetAccessToken(ctx, account)
if err != nil {
return nil, fmt.Errorf("get access token: %w", err)
}
- // 9. Get proxy URL
+ // 8. Get proxy URL
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
- // 10. Build upstream request
+ // 9. Build upstream request
upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream)
- upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, anthropicBody, token, tokenType, mappedModel, reqStream, shouldMimicClaudeCode)
+ upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, anthropicBody, token, tokenType, mappedModel, reqStream)
releaseUpstreamCtx()
if err != nil {
return nil, fmt.Errorf("build upstream request: %w", err)
}
- // 11. Send request
+ // 10. Send request
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
if resp != nil && resp.Body != nil {
@@ -146,7 +137,7 @@ func (s *GatewayService) ForwardAsChatCompletions(
}
defer func() { _ = resp.Body.Close() }()
- // 12. Handle error response with failover
+ // 11. Handle error response with failover
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
_ = resp.Body.Close()
@@ -178,10 +169,10 @@ func (s *GatewayService) ForwardAsChatCompletions(
return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg)
}
- // 13. Extract reasoning effort from CC request body
+ // 12. Extract reasoning effort from CC request body
reasoningEffort := extractCCReasoningEffortFromBody(body)
- // 14. Handle normal response
+ // 13. Handle normal response
// Read Anthropic SSE → convert to Responses events → convert to CC format
var result *ForwardResult
var handleErr error
@@ -225,10 +216,7 @@ func (s *GatewayService) handleCCBufferedFromAnthropic(
requestID := resp.Header.Get("x-request-id")
scanner := bufio.NewScanner(resp.Body)
- maxLineSize := defaultMaxLineSize
- if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.cfg.Gateway.MaxLineSize
- }
+ maxLineSize := resolveGatewayMaxLineSize(s.cfg)
scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
var finalResp *apicompat.AnthropicResponse
@@ -318,10 +306,7 @@ func (s *GatewayService) handleCCBufferedFromAnthropic(
if s.responseHeaderFilter != nil {
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
}
- // Marshal then bytes-replace so tool name mapping is reversed at byte level
- // (parity with Parrot non-stream flow that marshals → restore → emit).
if respBytes, err := json.Marshal(ccResp); err == nil {
- respBytes = reverseToolNamesIfPresent(c, respBytes)
c.Data(http.StatusOK, "application/json; charset=utf-8", respBytes)
} else {
c.JSON(http.StatusOK, ccResp)
@@ -370,12 +355,10 @@ func (s *GatewayService) handleCCStreamingFromAnthropic(
var usage ClaudeUsage
var firstTokenMs *int
firstChunk := true
+ var compatibilityErr error
scanner := bufio.NewScanner(resp.Body)
- maxLineSize := defaultMaxLineSize
- if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.cfg.Gateway.MaxLineSize
- }
+ maxLineSize := resolveGatewayMaxLineSize(s.cfg)
scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
resultWithUsage := func() *ForwardResult {
@@ -396,10 +379,7 @@ func (s *GatewayService) handleCCStreamingFromAnthropic(
if err != nil {
return false
}
- // Reverse tool name mapping: fake → real, per-chunk bytes.Replace.
- // c 可能持有请求侧注入的 ToolNameRewrite;无则仅做静态前缀还原。
- out := string(reverseToolNamesIfPresent(c, []byte(sse)))
- if _, err := fmt.Fprint(c.Writer, out); err != nil {
+ if _, err := fmt.Fprint(c.Writer, sse); err != nil {
return true // client disconnected
}
return false
@@ -425,6 +405,14 @@ func (s *GatewayService) handleCCStreamingFromAnthropic(
responsesEvents := apicompat.AnthropicEventToResponsesEvents(event, anthState)
for _, resEvt := range responsesEvents {
ccChunks := apicompat.ResponsesEventToChatChunks(&resEvt, ccState)
+ if err := ccState.Err(); err != nil {
+ compatibilityErr = err
+ logger.L().Warn("forward_as_cc stream: compatibility resource limit exceeded",
+ zap.Error(err),
+ zap.String("request_id", requestID),
+ )
+ return true
+ }
for _, chunk := range ccChunks {
if disconnected := writeChunk(chunk); disconnected {
return true
@@ -456,6 +444,9 @@ func (s *GatewayService) handleCCStreamingFromAnthropic(
}
if processAnthropicEvent(&event) {
+ if compatibilityErr != nil {
+ return resultWithUsage(), compatibilityErr
+ }
return resultWithUsage(), nil
}
}
@@ -473,6 +464,9 @@ func (s *GatewayService) handleCCStreamingFromAnthropic(
finalResEvents := apicompat.FinalizeAnthropicResponsesStream(anthState)
for _, resEvt := range finalResEvents {
ccChunks := apicompat.ResponsesEventToChatChunks(&resEvt, ccState)
+ if err := ccState.Err(); err != nil {
+ return resultWithUsage(), err
+ }
for _, chunk := range ccChunks {
writeChunk(chunk) //nolint:errcheck
}
diff --git a/backend/internal/service/gateway_forward_as_chat_completions_test.go b/backend/internal/service/gateway_forward_as_chat_completions_test.go
index 5003e5b39d8..ce9a5e9aba1 100644
--- a/backend/internal/service/gateway_forward_as_chat_completions_test.go
+++ b/backend/internal/service/gateway_forward_as_chat_completions_test.go
@@ -36,7 +36,6 @@ func TestExtractCCReasoningEffortFromBody(t *testing.T) {
func TestHandleCCBufferedFromAnthropic_PreservesMessageStartCacheUsageAndReasoning(t *testing.T) {
t.Parallel()
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -71,7 +70,6 @@ func TestHandleCCBufferedFromAnthropic_PreservesMessageStartCacheUsageAndReasoni
func TestHandleCCStreamingFromAnthropic_PreservesMessageStartCacheUsageAndReasoning(t *testing.T) {
t.Parallel()
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go
index 8f8a1e940e0..c5048431a2a 100644
--- a/backend/internal/service/gateway_forward_as_responses.go
+++ b/backend/internal/service/gateway_forward_as_responses.go
@@ -36,6 +36,9 @@ func (s *GatewayService) ForwardAsResponses(
parsed *ParsedRequest,
) (*ForwardResult, error) {
startTime := time.Now()
+ if err := rejectAnthropicOAuthGatewayCredential(account); err != nil {
+ return nil, err
+ }
// 1. Parse Responses request
var responsesReq apicompat.ResponsesRequest
@@ -87,42 +90,30 @@ func (s *GatewayService) ForwardAsResponses(
return nil, fmt.Errorf("marshal anthropic request: %w", err)
}
- // 6. Apply Claude Code mimicry for OAuth accounts (non-Claude-Code endpoints).
- // OpenAI Responses 协议进来的请求永远不是 Claude Code 客户端,所以对 OAuth 账号
- // 必须完整执行 /v1/messages 主路径上的伪装链路(system 重写 + normalize + metadata 注入),
- // 否则会被 Anthropic 判为第三方应用并扣 extra usage。
- // 见 applyClaudeCodeOAuthMimicryToBody 的 godoc。
- isClaudeCode := false
- shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
-
- if shouldMimicClaudeCode {
- anthropicBody = s.applyClaudeCodeOAuthMimicryToBody(ctx, c, account, anthropicBody, anthropicReq.System, mappedModel)
- }
-
- // 7. Enforce cache_control block limit
+ // 6. Enforce cache_control block limit
anthropicBody = enforceCacheControlLimit(anthropicBody)
- // 8. Get access token
+ // 7. Get access token
token, tokenType, err := s.GetAccessToken(ctx, account)
if err != nil {
return nil, fmt.Errorf("get access token: %w", err)
}
- // 9. Get proxy URL
+ // 8. Get proxy URL
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
- // 10. Build upstream request
+ // 9. Build upstream request
upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream)
- upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, anthropicBody, token, tokenType, mappedModel, reqStream, shouldMimicClaudeCode)
+ upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, anthropicBody, token, tokenType, mappedModel, reqStream)
releaseUpstreamCtx()
if err != nil {
return nil, fmt.Errorf("build upstream request: %w", err)
}
- // 11. Send request
+ // 10. Send request
resp, err := s.httpUpstream.DoWithTLS(upstreamReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if err != nil {
if resp != nil && resp.Body != nil {
@@ -143,7 +134,7 @@ func (s *GatewayService) ForwardAsResponses(
}
defer func() { _ = resp.Body.Close() }()
- // 12. Handle error response with failover
+ // 11. Handle error response with failover
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
_ = resp.Body.Close()
@@ -176,7 +167,7 @@ func (s *GatewayService) ForwardAsResponses(
return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg)
}
- // 13. Handle normal response (convert Anthropic → Responses)
+ // 12. Handle normal response (convert Anthropic → Responses)
var result *ForwardResult
var handleErr error
if clientStream {
@@ -234,10 +225,7 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse(
requestID := resp.Header.Get("x-request-id")
scanner := bufio.NewScanner(resp.Body)
- maxLineSize := defaultMaxLineSize
- if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.cfg.Gateway.MaxLineSize
- }
+ maxLineSize := resolveGatewayMaxLineSize(s.cfg)
scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
// Accumulate the final Anthropic response from streaming events
@@ -338,7 +326,6 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse(
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
}
if respBytes, err := json.Marshal(responsesResp); err == nil {
- respBytes = reverseToolNamesIfPresent(c, respBytes)
c.Data(http.StatusOK, "application/json; charset=utf-8", respBytes)
} else {
c.JSON(http.StatusOK, responsesResp)
@@ -383,10 +370,7 @@ func (s *GatewayService) handleResponsesStreamingResponse(
firstChunk := true
scanner := bufio.NewScanner(resp.Body)
- maxLineSize := defaultMaxLineSize
- if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.cfg.Gateway.MaxLineSize
- }
+ maxLineSize := resolveGatewayMaxLineSize(s.cfg)
scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
resultWithUsage := func() *ForwardResult {
@@ -430,8 +414,7 @@ func (s *GatewayService) handleResponsesStreamingResponse(
)
continue
}
- out := string(reverseToolNamesIfPresent(c, []byte(sse)))
- if _, err := fmt.Fprint(c.Writer, out); err != nil {
+ if _, err := fmt.Fprint(c.Writer, sse); err != nil {
logger.L().Info("forward_as_responses stream: client disconnected",
zap.String("request_id", requestID),
)
@@ -451,8 +434,7 @@ func (s *GatewayService) handleResponsesStreamingResponse(
if err != nil {
continue
}
- out := string(reverseToolNamesIfPresent(c, []byte(sse)))
- fmt.Fprint(c.Writer, out) //nolint:errcheck
+ fmt.Fprint(c.Writer, sse) //nolint:errcheck
}
c.Writer.Flush()
}
diff --git a/backend/internal/service/gateway_forward_as_responses_test.go b/backend/internal/service/gateway_forward_as_responses_test.go
index e48d8b22fa8..811e05dddfb 100644
--- a/backend/internal/service/gateway_forward_as_responses_test.go
+++ b/backend/internal/service/gateway_forward_as_responses_test.go
@@ -26,7 +26,6 @@ func TestExtractResponsesReasoningEffortFromBody(t *testing.T) {
func TestHandleResponsesBufferedStreamingResponse_PreservesMessageStartCacheUsage(t *testing.T) {
t.Parallel()
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -59,7 +58,6 @@ func TestHandleResponsesBufferedStreamingResponse_PreservesMessageStartCacheUsag
func TestHandleResponsesStreamingResponse_PreservesMessageStartCacheUsage(t *testing.T) {
t.Parallel()
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go
index 728328373c6..a5ccda5b873 100644
--- a/backend/internal/service/gateway_multiplatform_test.go
+++ b/backend/internal/service/gateway_multiplatform_test.go
@@ -332,6 +332,27 @@ func TestGatewayService_SelectAccountForModelWithPlatform_Anthropic(t *testing.T
require.Equal(t, PlatformAnthropic, acc.Platform, "应只返回 anthropic 平台账户")
}
+func TestGatewayService_SelectAccountForModelWithPlatform_AnthropicSkipsOAuthForAPIKey(t *testing.T) {
+ ctx := context.Background()
+ repo := &mockAccountRepoForPlatform{
+ accounts: []Account{
+ {ID: 1, Platform: PlatformAnthropic, Type: AccountTypeOAuth, Priority: 1, Status: StatusActive, Schedulable: true, Credentials: map[string]any{"access_token": "oauth"}},
+ {ID: 2, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Priority: 2, Status: StatusActive, Schedulable: true, Credentials: map[string]any{"api_key": "api-key"}},
+ },
+ accountsByID: map[int64]*Account{},
+ }
+ for i := range repo.accounts {
+ repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
+ }
+
+ svc := &GatewayService{accountRepo: repo, cache: &mockGatewayCacheForPlatform{}, cfg: testConfig()}
+ account, err := svc.selectAccountForModelWithPlatform(ctx, nil, "", "claude-3-5-sonnet-20241022", nil, PlatformAnthropic)
+
+ require.NoError(t, err)
+ require.NotNil(t, account)
+ require.Equal(t, int64(2), account.ID, "OAuth must not win scheduling even with a higher priority")
+}
+
// TestGatewayService_SelectAccountForModelWithPlatform_Antigravity 测试 antigravity 单平台选择
func TestGatewayService_SelectAccountForModelWithPlatform_Antigravity(t *testing.T) {
ctx := context.Background()
@@ -1163,6 +1184,44 @@ func TestGatewayService_isModelSupportedByAccount(t *testing.T) {
model: "gemini-2.5-pro",
expected: true,
},
+ {
+ name: "OpenAI passthrough保留旧模型任意透传",
+ account: &Account{
+ Platform: PlatformOpenAI,
+ Extra: map[string]any{"openai_passthrough": true},
+ },
+ model: "gpt-5.4",
+ expected: true,
+ },
+ {
+ name: "OpenAI passthrough无精确映射拒绝GPT56 preview",
+ account: &Account{
+ Platform: PlatformOpenAI,
+ Extra: map[string]any{"openai_passthrough": true},
+ },
+ model: "gpt-5.6-sol",
+ expected: false,
+ },
+ {
+ name: "OpenAI passthrough精确同档映射支持GPT56 preview",
+ account: &Account{
+ Platform: PlatformOpenAI,
+ Credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6-sol": "openai/gpt-5.6-sol-high"}},
+ Extra: map[string]any{"openai_passthrough": true},
+ },
+ model: "gpt-5.6-sol-low",
+ expected: true,
+ },
+ {
+ name: "OpenAI passthrough跨档映射拒绝GPT56 preview",
+ account: &Account{
+ Platform: PlatformOpenAI,
+ Credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.6-sol": "gpt-5.6-terra"}},
+ Extra: map[string]any{"openai_passthrough": true},
+ },
+ model: "gpt-5.6-sol",
+ expected: false,
+ },
}
for _, tt := range tests {
@@ -1229,6 +1288,33 @@ func TestGatewayService_selectAccountWithMixedScheduling(t *testing.T) {
require.Equal(t, int64(2), acc.ID, "应选择优先级最高的账户(包含启用混合调度的antigravity)")
})
+ t.Run("混合调度-Anthropic分组包含Kiro Claude兼容账户", func(t *testing.T) {
+ repo := &mockAccountRepoForPlatform{
+ accounts: []Account{
+ {ID: 1, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true},
+ {ID: 2, Platform: PlatformKiro, Priority: 1, Status: StatusActive, Schedulable: true},
+ },
+ accountsByID: map[int64]*Account{},
+ }
+ for i := range repo.accounts {
+ repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
+ }
+
+ cache := &mockGatewayCacheForPlatform{}
+
+ svc := &GatewayService{
+ accountRepo: repo,
+ cache: cache,
+ cfg: testConfig(),
+ }
+
+ acc, err := svc.selectAccountWithMixedScheduling(ctx, nil, "", "claude-sonnet-4-6", nil, PlatformAnthropic)
+ require.NoError(t, err)
+ require.NotNil(t, acc)
+ require.Equal(t, int64(2), acc.ID)
+ require.Equal(t, PlatformKiro, acc.Platform)
+ })
+
t.Run("混合调度-路由优先选择路由账号", func(t *testing.T) {
groupID := int64(30)
requestedModel := "claude-sonnet-4-5"
diff --git a/backend/internal/service/gateway_oauth_metadata_test.go b/backend/internal/service/gateway_oauth_metadata_test.go
deleted file mode 100644
index ed6f1887145..00000000000
--- a/backend/internal/service/gateway_oauth_metadata_test.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package service
-
-import (
- "regexp"
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-func TestBuildOAuthMetadataUserID_FallbackWithoutAccountUUID(t *testing.T) {
- svc := &GatewayService{}
-
- parsed := &ParsedRequest{
- Model: "claude-sonnet-4-5",
- Stream: true,
- MetadataUserID: "",
- System: nil,
- Messages: nil,
- }
-
- account := &Account{
- ID: 123,
- Type: AccountTypeOAuth,
- Extra: map[string]any{}, // intentionally missing account_uuid / claude_user_id
- }
-
- fp := &Fingerprint{ClientID: "deadbeef"} // should be used as user id in legacy format
-
- got := svc.buildOAuthMetadataUserID(parsed, account, fp)
- require.NotEmpty(t, got)
-
- // Legacy format: user_{client}_account__session_{uuid}
- re := regexp.MustCompile(`^user_[a-zA-Z0-9]+_account__session_[a-f0-9-]{36}$`)
- require.True(t, re.MatchString(got), "unexpected user_id format: %s", got)
-}
-
-func TestBuildOAuthMetadataUserID_UsesAccountUUIDWhenPresent(t *testing.T) {
- svc := &GatewayService{}
-
- parsed := &ParsedRequest{
- Model: "claude-sonnet-4-5",
- Stream: true,
- MetadataUserID: "",
- }
-
- account := &Account{
- ID: 123,
- Type: AccountTypeOAuth,
- Extra: map[string]any{
- "account_uuid": "acc-uuid",
- "claude_user_id": "clientid123",
- "anthropic_user_id": "",
- },
- }
-
- got := svc.buildOAuthMetadataUserID(parsed, account, nil)
- require.NotEmpty(t, got)
-
- // New format: user_{client}_account_{account_uuid}_session_{uuid}
- re := regexp.MustCompile(`^user_clientid123_account_acc-uuid_session_[a-f0-9-]{36}$`)
- require.True(t, re.MatchString(got), "unexpected user_id format: %s", got)
-}
diff --git a/backend/internal/service/gateway_prompt_test.go b/backend/internal/service/gateway_prompt_test.go
index f3a22c1d506..7117627c9fd 100644
--- a/backend/internal/service/gateway_prompt_test.go
+++ b/backend/internal/service/gateway_prompt_test.go
@@ -1,8 +1,6 @@
package service
import (
- "encoding/json"
- "strings"
"testing"
"github.com/stretchr/testify/require"
@@ -83,377 +81,3 @@ func TestIsClaudeCodeClient(t *testing.T) {
})
}
}
-
-func TestSystemIncludesClaudeCodePrompt(t *testing.T) {
- tests := []struct {
- name string
- system any
- want bool
- }{
- {
- name: "nil system",
- system: nil,
- want: false,
- },
- {
- name: "empty string",
- system: "",
- want: false,
- },
- {
- name: "string with Claude Code prompt",
- system: claudeCodeSystemPrompt,
- want: true,
- },
- {
- name: "string with different content",
- system: "You are a helpful assistant.",
- want: false,
- },
- {
- name: "empty array",
- system: []any{},
- want: false,
- },
- {
- name: "array with Claude Code prompt",
- system: []any{
- map[string]any{
- "type": "text",
- "text": claudeCodeSystemPrompt,
- },
- },
- want: true,
- },
- {
- name: "array with Claude Code prompt in second position",
- system: []any{
- map[string]any{"type": "text", "text": "First prompt"},
- map[string]any{"type": "text", "text": claudeCodeSystemPrompt},
- },
- want: true,
- },
- {
- name: "array without Claude Code prompt",
- system: []any{
- map[string]any{"type": "text", "text": "Custom prompt"},
- },
- want: false,
- },
- {
- name: "array with partial match (should not match)",
- system: []any{
- map[string]any{"type": "text", "text": "You are Claude"},
- },
- want: false,
- },
- // json.RawMessage cases (conversion path: ForwardAsResponses / ForwardAsChatCompletions)
- {
- name: "json.RawMessage string with Claude Code prompt",
- system: json.RawMessage(`"` + claudeCodeSystemPrompt + `"`),
- want: true,
- },
- {
- name: "json.RawMessage string without Claude Code prompt",
- system: json.RawMessage(`"You are a helpful assistant"`),
- want: false,
- },
- {
- name: "json.RawMessage nil (empty)",
- system: json.RawMessage(nil),
- want: false,
- },
- {
- name: "json.RawMessage empty string",
- system: json.RawMessage(`""`),
- want: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := systemIncludesClaudeCodePrompt(tt.system)
- require.Equal(t, tt.want, got)
- })
- }
-}
-
-func TestInjectClaudeCodePrompt(t *testing.T) {
- claudePrefix := strings.TrimSpace(claudeCodeSystemPrompt)
-
- tests := []struct {
- name string
- body string
- system any
- wantSystemLen int
- wantFirstText string
- wantSecondText string
- }{
- {
- name: "nil system",
- body: `{"model":"claude-3"}`,
- system: nil,
- wantSystemLen: 1,
- wantFirstText: claudeCodeSystemPrompt,
- },
- {
- name: "empty string system",
- body: `{"model":"claude-3"}`,
- system: "",
- wantSystemLen: 1,
- wantFirstText: claudeCodeSystemPrompt,
- },
- {
- name: "string system",
- body: `{"model":"claude-3"}`,
- system: "Custom prompt",
- wantSystemLen: 2,
- wantFirstText: claudeCodeSystemPrompt,
- wantSecondText: claudePrefix + "\n\nCustom prompt",
- },
- {
- name: "string system equals Claude Code prompt",
- body: `{"model":"claude-3"}`,
- system: claudeCodeSystemPrompt,
- wantSystemLen: 1,
- wantFirstText: claudeCodeSystemPrompt,
- },
- {
- name: "array system",
- body: `{"model":"claude-3"}`,
- system: []any{map[string]any{"type": "text", "text": "Custom"}},
- // Claude Code + Custom = 2
- wantSystemLen: 2,
- wantFirstText: claudeCodeSystemPrompt,
- wantSecondText: claudePrefix + "\n\nCustom",
- },
- {
- name: "array system with existing Claude Code prompt (should dedupe)",
- body: `{"model":"claude-3"}`,
- system: []any{
- map[string]any{"type": "text", "text": claudeCodeSystemPrompt},
- map[string]any{"type": "text", "text": "Other"},
- },
- // Claude Code at start + Other = 2 (deduped)
- wantSystemLen: 2,
- wantFirstText: claudeCodeSystemPrompt,
- wantSecondText: claudePrefix + "\n\nOther",
- },
- {
- name: "empty array",
- body: `{"model":"claude-3"}`,
- system: []any{},
- wantSystemLen: 1,
- wantFirstText: claudeCodeSystemPrompt,
- },
- // json.RawMessage cases (conversion path: ForwardAsResponses / ForwardAsChatCompletions)
- {
- name: "json.RawMessage string system",
- body: `{"model":"claude-3","system":"Custom prompt"}`,
- system: json.RawMessage(`"Custom prompt"`),
- wantSystemLen: 2,
- wantFirstText: claudeCodeSystemPrompt,
- wantSecondText: claudePrefix + "\n\nCustom prompt",
- },
- {
- name: "json.RawMessage nil system",
- body: `{"model":"claude-3"}`,
- system: json.RawMessage(nil),
- wantSystemLen: 1,
- wantFirstText: claudeCodeSystemPrompt,
- },
- {
- name: "json.RawMessage Claude Code prompt (should not duplicate)",
- body: `{"model":"claude-3","system":"` + claudeCodeSystemPrompt + `"}`,
- system: json.RawMessage(`"` + claudeCodeSystemPrompt + `"`),
- wantSystemLen: 1,
- wantFirstText: claudeCodeSystemPrompt,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := injectClaudeCodePrompt([]byte(tt.body), tt.system)
-
- var parsed map[string]any
- err := json.Unmarshal(result, &parsed)
- require.NoError(t, err)
-
- system, ok := parsed["system"].([]any)
- require.True(t, ok, "system should be an array")
- require.Len(t, system, tt.wantSystemLen)
-
- first, ok := system[0].(map[string]any)
- require.True(t, ok)
- require.Equal(t, tt.wantFirstText, first["text"])
- require.Equal(t, "text", first["type"])
-
- // Check cache_control
- cc, ok := first["cache_control"].(map[string]any)
- require.True(t, ok)
- require.Equal(t, "ephemeral", cc["type"])
-
- if tt.wantSecondText != "" && len(system) > 1 {
- second, ok := system[1].(map[string]any)
- require.True(t, ok)
- require.Equal(t, tt.wantSecondText, second["text"])
- }
- })
- }
-}
-
-func TestRewriteSystemForNonClaudeCode(t *testing.T) {
- tests := []struct {
- name string
- body string
- system any
- wantSystemText string // system array 第一个 block 的 text
- wantMessagesLen int // messages 数组长度
- wantFirstMsgRole string // 第一条消息的 role
- wantFirstMsgText string // 第一条消息的 content[0].text
- wantAckMsgText string // 第二条消息的 content[0].text
- }{
- {
- name: "nil system - no messages injected",
- body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
- system: nil,
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 1, // 原始 1 条消息,不注入
- },
- {
- name: "empty string system - no messages injected",
- body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
- system: "",
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 1,
- },
- {
- name: "custom string system - migrated to messages",
- body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
- system: "You are a personal assistant running inside OpenClaw.",
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 3, // instruction + ack + original
- wantFirstMsgRole: "user",
- wantFirstMsgText: "[System Instructions]\nYou are a personal assistant running inside OpenClaw.",
- wantAckMsgText: "Understood. I will follow these instructions.",
- },
- {
- name: "system equals Claude Code prompt - no messages injected",
- body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
- system: claudeCodeSystemPrompt,
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 1,
- },
- {
- name: "array system with custom blocks - text joined and migrated",
- body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
- system: []any{
- map[string]any{"type": "text", "text": "First instruction"},
- map[string]any{"type": "text", "text": "Second instruction"},
- },
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 3,
- wantFirstMsgRole: "user",
- wantFirstMsgText: "[System Instructions]\nFirst instruction\n\nSecond instruction",
- wantAckMsgText: "Understood. I will follow these instructions.",
- },
- {
- name: "empty array system - no messages injected",
- body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
- system: []any{},
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 1,
- },
- {
- name: "json.RawMessage string system",
- body: `{"model":"claude-3","system":"Custom prompt","messages":[{"role":"user","content":"hello"}]}`,
- system: json.RawMessage(`"Custom prompt"`),
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 3,
- wantFirstMsgRole: "user",
- wantFirstMsgText: "[System Instructions]\nCustom prompt",
- wantAckMsgText: "Understood. I will follow these instructions.",
- },
- {
- name: "json.RawMessage nil system",
- body: `{"model":"claude-3","messages":[{"role":"user","content":"hello"}]}`,
- system: json.RawMessage(nil),
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 1,
- },
- {
- name: "multiple original messages preserved",
- body: `{"model":"claude-3","messages":[{"role":"user","content":"msg1"},{"role":"assistant","content":"resp1"},{"role":"user","content":"msg2"}]}`,
- system: "Be helpful",
- wantSystemText: claudeCodeSystemPrompt,
- wantMessagesLen: 5, // 2 injected + 3 original
- wantFirstMsgRole: "user",
- wantFirstMsgText: "[System Instructions]\nBe helpful",
- wantAckMsgText: "Understood. I will follow these instructions.",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := rewriteSystemForNonClaudeCode([]byte(tt.body), tt.system)
-
- var parsed map[string]any
- err := json.Unmarshal(result, &parsed)
- require.NoError(t, err)
-
- // system 应为 array 格式,对齐真实 Claude Code CLI 的 2-block 形态:
- // [0] billing attribution block (x-anthropic-billing-header: cc_version=...;)
- // [1] Claude Code prompt block (带 cache_control)
- systemArr, ok := parsed["system"].([]any)
- require.True(t, ok, "system should be an array, got %T", parsed["system"])
- require.Len(t, systemArr, 2, "system array should have exactly 2 blocks (billing + cc prompt)")
-
- billingBlock, ok := systemArr[0].(map[string]any)
- require.True(t, ok)
- require.Equal(t, "text", billingBlock["type"])
- require.Contains(t, billingBlock["text"], "x-anthropic-billing-header:")
- require.Contains(t, billingBlock["text"], "cc_version=")
- require.Contains(t, billingBlock["text"], "cc_entrypoint=cli")
- require.Contains(t, billingBlock["text"], "cch=00000")
-
- systemBlock, ok := systemArr[1].(map[string]any)
- require.True(t, ok)
- require.Equal(t, "text", systemBlock["type"])
- require.Equal(t, tt.wantSystemText, systemBlock["text"])
- cc, ok := systemBlock["cache_control"].(map[string]any)
- require.True(t, ok, "cc prompt block should have cache_control")
- require.Equal(t, "ephemeral", cc["type"])
-
- // 检查 messages
- messages, ok := parsed["messages"].([]any)
- require.True(t, ok, "messages should be an array")
- require.Len(t, messages, tt.wantMessagesLen)
-
- if tt.wantFirstMsgRole != "" && len(messages) >= 2 {
- // 检查注入的 instruction 消息
- firstMsg, ok := messages[0].(map[string]any)
- require.True(t, ok)
- require.Equal(t, tt.wantFirstMsgRole, firstMsg["role"])
-
- firstContent, ok := firstMsg["content"].([]any)
- require.True(t, ok)
- require.Len(t, firstContent, 1)
- firstBlock, ok := firstContent[0].(map[string]any)
- require.True(t, ok)
- require.Equal(t, tt.wantFirstMsgText, firstBlock["text"])
-
- // 检查注入的 ack 消息
- ackMsg, ok := messages[1].(map[string]any)
- require.True(t, ok)
- require.Equal(t, "assistant", ackMsg["role"])
-
- ackContent, ok := ackMsg["content"].([]any)
- require.True(t, ok)
- require.Len(t, ackContent, 1)
- ackBlock, ok := ackContent[0].(map[string]any)
- require.True(t, ok)
- require.Equal(t, tt.wantAckMsgText, ackBlock["text"])
- }
- })
- }
-}
diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go
index 140bdc67e29..abd5a2064bb 100644
--- a/backend/internal/service/gateway_record_usage_test.go
+++ b/backend/internal/service/gateway_record_usage_test.go
@@ -17,13 +17,14 @@ import (
func newGatewayRecordUsageServiceForTest(usageRepo UsageLogRepository, userRepo UserRepository, subRepo UserSubscriptionRepository) *GatewayService {
cfg := &config.Config{}
cfg.Default.RateMultiplier = 1.1
- return NewGatewayService(
+ svc := NewGatewayService(
nil,
nil,
usageRepo,
nil,
userRepo,
subRepo,
+ nil, // walletRepo
nil,
nil,
cfg,
@@ -33,7 +34,6 @@ func newGatewayRecordUsageServiceForTest(usageRepo UsageLogRepository, userRepo
nil,
&BillingCacheService{},
nil,
- nil,
&DeferredService{},
nil,
nil,
@@ -44,7 +44,12 @@ func newGatewayRecordUsageServiceForTest(usageRepo UsageLogRepository, userRepo
nil,
nil,
nil,
+ nil,
+ nil,
+ nil,
)
+ svc.requireUsageBillingOutbox = false
+ return svc
}
func newGatewayRecordUsageServiceWithBillingRepoForTest(usageRepo UsageLogRepository, billingRepo UsageBillingRepository, userRepo UserRepository, subRepo UserSubscriptionRepository) *GatewayService {
@@ -141,6 +146,49 @@ func TestGatewayServiceRecordUsage_BillingFingerprintIncludesRequestPayloadHash(
require.Equal(t, payloadHash, billingRepo.lastCmd.RequestPayloadHash)
}
+func TestGatewayServiceRecordUsage_WalletModeStandardGroupBillsWallet(t *testing.T) {
+ groupID := int64(3)
+ subID := int64(45)
+ walletBal := 100.0
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ billingRepo := &openAIRecordUsageBillingRepoStub{result: &UsageBillingApplyResult{Applied: true}}
+ svc := newGatewayRecordUsageServiceWithBillingRepoForTest(usageRepo, billingRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{})
+
+ err := svc.RecordUsage(context.Background(), &RecordUsageInput{
+ Result: &ForwardResult{
+ RequestID: "gateway_wallet_standard_group",
+ Usage: ClaudeUsage{
+ InputTokens: 100,
+ OutputTokens: 50,
+ },
+ Model: "claude-sonnet-4",
+ Duration: time.Second,
+ },
+ APIKey: &APIKey{
+ ID: 501,
+ GroupID: &groupID,
+ Group: &Group{ID: groupID, RateMultiplier: 1.0},
+ },
+ User: &User{ID: 601},
+ Account: &Account{ID: 701},
+ Subscription: &UserSubscription{
+ ID: subID,
+ WalletBalanceUSD: &walletBal,
+ },
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, usageRepo.lastLog)
+ require.Equal(t, BillingTypeSubscription, usageRepo.lastLog.BillingType)
+ require.NotNil(t, usageRepo.lastLog.SubscriptionID)
+ require.Equal(t, subID, *usageRepo.lastLog.SubscriptionID)
+ require.NotNil(t, billingRepo.lastCmd)
+ require.Equal(t, subID, *billingRepo.lastCmd.SubscriptionID)
+ require.Greater(t, billingRepo.lastCmd.WalletCost, 0.0)
+ require.Zero(t, billingRepo.lastCmd.BalanceCost)
+ require.Zero(t, billingRepo.lastCmd.SubscriptionCost)
+}
+
func TestGatewayServiceRecordUsage_BillingFingerprintFallsBackToContextRequestID(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{}
billingRepo := &openAIRecordUsageBillingRepoStub{result: &UsageBillingApplyResult{Applied: true}}
@@ -192,6 +240,52 @@ func TestGatewayServiceRecordUsage_PreservesRequestedAndUpstreamModels(t *testin
require.Equal(t, mappedModel, *usageRepo.lastLog.UpstreamModel)
}
+func TestGatewayServiceRecordUsage_ClaudeSelectorRequestedSourceStoresMappedGPTModel(t *testing.T) {
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ userRepo := &openAIRecordUsageUserRepoStub{}
+ subRepo := &openAIRecordUsageSubRepoStub{}
+ svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, subRepo)
+ usage := ClaudeUsage{InputTokens: 18, OutputTokens: 11}
+
+ claudeCost, err := svc.billingService.CalculateCost("claude-haiku-4-5", UsageTokens{
+ InputTokens: 18,
+ OutputTokens: 11,
+ }, 1.1)
+ require.NoError(t, err)
+ expectedGPTCost, err := svc.billingService.CalculateCost("gpt-5.4-mini", UsageTokens{
+ InputTokens: 18,
+ OutputTokens: 11,
+ }, 1.1)
+ require.NoError(t, err)
+
+ err = svc.RecordUsage(context.Background(), &RecordUsageInput{
+ Result: &ForwardResult{
+ RequestID: "gateway_claude_requested_source_mapped_gpt",
+ Usage: usage,
+ Model: "claude-haiku-4-5",
+ UpstreamModel: "gpt-5.4-mini",
+ Duration: time.Second,
+ },
+ APIKey: &APIKey{ID: 501, Quota: 100},
+ User: &User{ID: 601},
+ Account: &Account{ID: 701},
+ ChannelUsageFields: ChannelUsageFields{
+ OriginalModel: "claude-haiku-4-5",
+ ChannelMappedModel: "gpt-5.4-mini",
+ BillingModelSource: BillingModelSourceRequested,
+ },
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, usageRepo.lastLog)
+ require.Equal(t, "gpt-5.4-mini", usageRepo.lastLog.Model)
+ require.Equal(t, "claude-haiku-4-5", usageRepo.lastLog.RequestedModel)
+ require.NotNil(t, usageRepo.lastLog.UpstreamModel)
+ require.Equal(t, "gpt-5.4-mini", *usageRepo.lastLog.UpstreamModel)
+ require.InDelta(t, expectedGPTCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12)
+ require.NotEqual(t, claudeCost.ActualCost, usageRepo.lastLog.ActualCost)
+}
+
func TestGatewayServiceRecordUsage_UsageLogWriteErrorDoesNotSkipBilling(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: false, err: MarkUsageLogCreateNotPersisted(context.Canceled)}
userRepo := &openAIRecordUsageUserRepoStub{}
@@ -346,7 +440,7 @@ func TestGatewayServiceRecordUsage_GeneratesRequestIDWhenAllSourcesMissing(t *te
require.Equal(t, billingRepo.lastCmd.RequestID, usageRepo.lastLog.RequestID)
}
-func TestGatewayServiceRecordUsage_DroppedUsageLogDoesNotSyncFallback(t *testing.T) {
+func TestGatewayServiceRecordUsage_DroppedUsageLogFallsBackToSyncCreate(t *testing.T) {
usageRepo := &openAIRecordUsageBestEffortLogRepoStub{
bestEffortErr: MarkUsageLogCreateDropped(errors.New("usage log best-effort queue full")),
}
@@ -370,7 +464,8 @@ func TestGatewayServiceRecordUsage_DroppedUsageLogDoesNotSyncFallback(t *testing
require.NoError(t, err)
require.Equal(t, 1, usageRepo.bestEffortCalls)
- require.Equal(t, 0, usageRepo.createCalls)
+ require.Equal(t, 1, usageRepo.createCalls)
+ require.NoError(t, usageRepo.lastCtxErr, "sync fallback must receive a live detached context")
}
func TestGatewayServiceRecordUsage_BillingErrorSkipsUsageLogWrite(t *testing.T) {
@@ -450,3 +545,84 @@ func TestGatewayServiceRecordUsage_ReasoningEffortNil(t *testing.T) {
require.NotNil(t, usageRepo.lastLog)
require.Nil(t, usageRepo.lastLog.ReasoningEffort)
}
+
+func TestGatewayUsageBillingProducer_PersistsAllProviderSnapshotsBeforeWake(t *testing.T) {
+ tests := []struct {
+ name string
+ inboundEndpoint string
+ model string
+ longContext bool
+ imageCount int
+ imageSize string
+ }{
+ {name: "anthropic messages", inboundEndpoint: "/v1/messages", model: "claude-sonnet-4"},
+ {name: "chat completions", inboundEndpoint: "/v1/chat/completions", model: "claude-sonnet-4"},
+ {name: "responses", inboundEndpoint: "/v1/responses", model: "claude-sonnet-4"},
+ {name: "gemini long context", inboundEndpoint: "/v1beta/models/gemini-3.1-pro:streamGenerateContent", model: "gemini-3.1-pro", longContext: true},
+ {name: "gemini image", inboundEndpoint: "/v1beta/models/gemini-3-pro-image-preview:generateContent", model: "gemini-3-pro-image-preview", imageCount: 1, imageSize: "2K"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ usageRepo := &openAIRecordUsageLogRepoStub{}
+ billingRepo := &openAIRecordUsageBillingRepoStub{}
+ svc := newGatewayRecordUsageServiceWithBillingRepoForTest(usageRepo, billingRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{})
+ outbox := &openAIUsageOutboxRepoStub{}
+ wake := &openAIUsageOutboxWakeStub{}
+ svc.requireUsageBillingOutbox = true
+ svc.usageBillingOutboxRepo = outbox
+ svc.usageBillingOutboxWake = wake
+ svc.resolver = NewModelPricingResolver(nil, svc.billingService)
+ groupID := int64(44)
+ result := &ForwardResult{
+ RequestID: "gateway-producer-" + tt.name,
+ Model: tt.model, UpstreamModel: tt.model,
+ Usage: ClaudeUsage{InputTokens: 10, OutputTokens: 2}, Duration: time.Second,
+ ImageCount: tt.imageCount, ImageSize: tt.imageSize,
+ }
+ apiKey := &APIKey{ID: 11, Key: "sk-gateway-producer", GroupID: &groupID, Group: &Group{ID: groupID, RateMultiplier: 1}}
+ var err error
+ if tt.longContext {
+ err = svc.RecordUsageWithLongContext(context.Background(), &RecordUsageLongContextInput{
+ Result: result, APIKey: apiKey, User: &User{ID: 22}, Account: &Account{ID: 33, Type: AccountTypeOAuth},
+ RequestPayloadHash: strings.Repeat("b", 64),
+ InboundEndpoint: tt.inboundEndpoint, UpstreamEndpoint: tt.inboundEndpoint,
+ LongContextThreshold: 200000, LongContextMultiplier: 2,
+ })
+ } else {
+ err = svc.RecordUsage(context.Background(), &RecordUsageInput{
+ Result: result, APIKey: apiKey, User: &User{ID: 22}, Account: &Account{ID: 33, Type: AccountTypeOAuth},
+ RequestPayloadHash: strings.Repeat("b", 64),
+ InboundEndpoint: tt.inboundEndpoint, UpstreamEndpoint: tt.inboundEndpoint,
+ })
+ }
+
+ require.NoError(t, err)
+ require.Len(t, outbox.envelopes, 1)
+ require.Equal(t, 1, wake.calls)
+ require.Zero(t, billingRepo.calls, "producer must not apply billing before durable replay")
+ require.Zero(t, usageRepo.calls, "producer must not write usage before billing replay")
+ log := outbox.envelopes[0].UsageLog()
+ require.Equal(t, tt.model, *log.BillingModel)
+ require.NotEmpty(t, *log.PricingSource)
+ require.NotEmpty(t, *log.PricingRevision)
+ require.Len(t, *log.PricingHash, 64)
+ require.Equal(t, tt.inboundEndpoint, *log.InboundEndpoint)
+ })
+ }
+}
+
+func TestGatewayUsageBillingProducer_FailsClosedWithoutDurableOutbox(t *testing.T) {
+ svc := newGatewayRecordUsageServiceForTest(&openAIRecordUsageLogRepoStub{}, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{})
+ svc.requireUsageBillingOutbox = true
+ svc.resolver = NewModelPricingResolver(nil, svc.billingService)
+ groupID := int64(44)
+
+ err := svc.RecordUsage(context.Background(), &RecordUsageInput{
+ Result: &ForwardResult{RequestID: "gateway-no-outbox", Model: "claude-sonnet-4", Usage: ClaudeUsage{InputTokens: 1, OutputTokens: 1}, Duration: time.Second},
+ APIKey: &APIKey{ID: 11, Key: "sk-no-outbox", GroupID: &groupID, Group: &Group{ID: groupID, RateMultiplier: 1}},
+ User: &User{ID: 22}, Account: &Account{ID: 33, Type: AccountTypeOAuth},
+ })
+
+ require.ErrorIs(t, err, ErrUsageBillingOutboxUnavailable)
+}
diff --git a/backend/internal/service/gateway_sanitize_test.go b/backend/internal/service/gateway_sanitize_test.go
deleted file mode 100644
index a62bc8c7e9d..00000000000
--- a/backend/internal/service/gateway_sanitize_test.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package service
-
-import (
- "strings"
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-func TestSanitizeOpenCodeText_RewritesCanonicalSentence(t *testing.T) {
- in := "You are OpenCode, the best coding agent on the planet."
- got := sanitizeSystemText(in)
- require.Equal(t, strings.TrimSpace(claudeCodeSystemPrompt), got)
-}
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index 074013c3774..7a45bdd8bca 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -13,7 +13,6 @@ import (
mathrand "math/rand"
"net"
"net/http"
- "net/url"
"os"
"path/filepath"
"regexp"
@@ -30,7 +29,6 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
- "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
"github.com/cespare/xxhash/v2"
"github.com/google/uuid"
gocache "github.com/patrickmn/go-cache"
@@ -45,12 +43,8 @@ const (
claudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true"
claudeAPICountTokensURL = "https://api.anthropic.com/v1/messages/count_tokens?beta=true"
stickySessionTTL = time.Hour // 粘性会话TTL
- defaultMaxLineSize = 500 * 1024 * 1024
- // Canonical Claude Code banner. Keep it EXACT (no trailing whitespace/newlines)
- // to match real Claude CLI traffic as closely as possible. When we need a visual
- // separator between system blocks, we add "\n\n" at concatenation time.
- claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
- maxCacheControlBlocks = 4 // Anthropic API 允许的最大 cache_control 块数量
+ defaultMaxLineSize = config.DefaultGatewayMaxLineSize
+ maxCacheControlBlocks = 4 // Anthropic API 允许的最大 cache_control 块数量
defaultUserGroupRateCacheTTL = 30 * time.Second
defaultModelsListCacheTTL = 15 * time.Second
@@ -58,10 +52,6 @@ const (
debugGatewayBodyEnv = "SUB2API_DEBUG_GATEWAY_BODY"
)
-const (
- claudeMimicDebugInfoKey = "claude_mimic_debug_info"
-)
-
const (
cacheTTLTarget5m = "5m"
cacheTTLTarget1h = "1h"
@@ -174,13 +164,6 @@ func (s *GatewayService) debugModelRoutingEnabled() bool {
return s.debugModelRouting.Load()
}
-func (s *GatewayService) debugClaudeMimicEnabled() bool {
- if s == nil {
- return false
- }
- return s.debugClaudeMimic.Load()
-}
-
func parseDebugEnvBool(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "on":
@@ -215,138 +198,20 @@ func redactAuthHeaderValue(v string) string {
func safeHeaderValueForLog(key string, v string) string {
key = strings.ToLower(strings.TrimSpace(key))
switch key {
- case "authorization", "x-api-key":
+ case "authorization", "proxy-authorization":
return redactAuthHeaderValue(v)
+ case "x-api-key", "api-key", "x-goog-api-key", "x-auth-token", "cookie", "set-cookie":
+ return "[redacted]"
default:
return strings.TrimSpace(v)
}
}
-func extractSystemPreviewFromBody(body []byte) string {
- if len(body) == 0 {
- return ""
- }
- sys := gjson.GetBytes(body, "system")
- if !sys.Exists() {
- return ""
- }
-
- switch {
- case sys.IsArray():
- for _, item := range sys.Array() {
- if !item.IsObject() {
- continue
- }
- if strings.EqualFold(item.Get("type").String(), "text") {
- if t := item.Get("text").String(); strings.TrimSpace(t) != "" {
- return t
- }
- }
- }
- return ""
- case sys.Type == gjson.String:
- return sys.String()
- default:
- return ""
- }
-}
-
-func buildClaudeMimicDebugLine(req *http.Request, body []byte, account *Account, tokenType string, mimicClaudeCode bool) string {
- if req == nil {
- return ""
- }
-
- // Only log a minimal fingerprint to avoid leaking user content.
- interesting := []string{
- "user-agent",
- "x-app",
- "anthropic-dangerous-direct-browser-access",
- "anthropic-version",
- "anthropic-beta",
- "x-stainless-lang",
- "x-stainless-package-version",
- "x-stainless-os",
- "x-stainless-arch",
- "x-stainless-runtime",
- "x-stainless-runtime-version",
- "x-stainless-retry-count",
- "x-stainless-timeout",
- "authorization",
- "x-api-key",
- "content-type",
- "accept",
- "x-stainless-helper-method",
- }
-
- h := make([]string, 0, len(interesting))
- for _, k := range interesting {
- if v := req.Header.Get(k); v != "" {
- h = append(h, fmt.Sprintf("%s=%q", k, safeHeaderValueForLog(k, v)))
- }
- }
-
- metaUserID := strings.TrimSpace(gjson.GetBytes(body, "metadata.user_id").String())
- sysPreview := strings.TrimSpace(extractSystemPreviewFromBody(body))
-
- // Truncate preview to keep logs sane.
- if len(sysPreview) > 300 {
- sysPreview = sysPreview[:300] + "..."
- }
- sysPreview = strings.ReplaceAll(sysPreview, "\n", "\\n")
- sysPreview = strings.ReplaceAll(sysPreview, "\r", "\\r")
-
- aid := int64(0)
- aname := ""
- if account != nil {
- aid = account.ID
- aname = account.Name
- }
-
- return fmt.Sprintf(
- "url=%s account=%d(%s) tokenType=%s mimic=%t meta.user_id=%q system.preview=%q headers={%s}",
- req.URL.String(),
- aid,
- aname,
- tokenType,
- mimicClaudeCode,
- metaUserID,
- sysPreview,
- strings.Join(h, " "),
- )
-}
-
-func logClaudeMimicDebug(req *http.Request, body []byte, account *Account, tokenType string, mimicClaudeCode bool) {
- line := buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode)
- if line == "" {
- return
- }
- logger.LegacyPrintf("service.gateway", "[ClaudeMimicDebug] %s", line)
-}
-
-func isClaudeCodeCredentialScopeError(msg string) bool {
- m := strings.ToLower(strings.TrimSpace(msg))
- if m == "" {
- return false
- }
- return strings.Contains(m, "only authorized for use with claude code") &&
- strings.Contains(m, "cannot be used for other api requests")
-}
-
// sseDataRe matches SSE data lines with optional whitespace after colon.
// Some upstream APIs return non-standard "data:" without space (should be "data: ").
var (
sseDataRe = regexp.MustCompile(`^data:\s*`)
claudeCliUserAgentRe = regexp.MustCompile(`(?i)^claude-cli/\d+\.\d+\.\d+`)
-
- // claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表
- // 支持多种变体:标准版、Agent SDK 版、Explore Agent 版、Compact 版等
- // 注意:前缀之间不应存在包含关系,否则会导致冗余匹配
- claudeCodePromptPrefixes = []string{
- "You are Claude Code, Anthropic's official CLI for Claude", // 标准版 & Agent SDK 版(含 running within...)
- "You are a Claude agent, built on Anthropic's Claude Agent SDK", // Agent SDK 变体
- "You are a file search specialist for Claude Code", // Explore Agent 版
- "You are a helpful AI assistant tasked with summarizing conversations", // Compact 版
- }
)
// ErrNoAvailableAccounts 表示没有可用的账号
@@ -357,27 +222,32 @@ var ErrClaudeCodeOnly = errors.New("this group only allows Claude Code clients")
// allowedHeaders 白名单headers(参考CRS项目)
var allowedHeaders = map[string]bool{
- "accept": true,
- "x-stainless-retry-count": true,
- "x-stainless-timeout": true,
- "x-stainless-lang": true,
- "x-stainless-package-version": true,
- "x-stainless-os": true,
- "x-stainless-arch": true,
- "x-stainless-runtime": true,
- "x-stainless-runtime-version": true,
- "x-stainless-helper-method": true,
- "anthropic-dangerous-direct-browser-access": true,
- "anthropic-version": true,
- "x-app": true,
- "anthropic-beta": true,
- "accept-language": true,
- "sec-fetch-mode": true,
- "user-agent": true,
- "content-type": true,
- "accept-encoding": true,
- "x-claude-code-session-id": true,
- "x-client-request-id": true,
+ "accept": true,
+ "anthropic-version": true,
+ "anthropic-beta": true,
+ "accept-language": true,
+ "sec-fetch-mode": true,
+ "content-type": true,
+ "accept-encoding": true,
+ "x-client-request-id": true,
+}
+
+const anthropicGatewayUserAgent = "sub2api-gateway/1"
+
+// applyAnthropicGatewayIdentity removes downstream-controlled SDK/CLI identity
+// claims and sends a truthful service identity instead.
+func applyAnthropicGatewayIdentity(headers http.Header) {
+ for key := range headers {
+ lower := strings.ToLower(strings.TrimSpace(key))
+ if strings.HasPrefix(lower, "x-stainless-") ||
+ lower == "x-app" ||
+ lower == "x-claude-code-session-id" ||
+ lower == "anthropic-dangerous-direct-browser-access" ||
+ lower == "user-agent" {
+ delete(headers, key)
+ }
+ }
+ setHeaderRaw(headers, "User-Agent", anthropicGatewayUserAgent)
}
// GatewayCache 定义网关服务的缓存操作接口。
@@ -488,9 +358,10 @@ type ClaudeUsage struct {
// ForwardResult 转发结果
type ForwardResult struct {
- RequestID string
- Usage ClaudeUsage
- Model string
+ RequestID string
+ Usage ClaudeUsage
+ Model string
+ UsageBillingIdentity *GatewayUsageBillingIdentity
// UpstreamModel is the actual upstream model after mapping.
// Prefer empty when it is identical to Model; persistence normalizes equal values away as no-op mappings.
UpstreamModel string
@@ -535,41 +406,44 @@ func (s *GatewayService) TempUnscheduleRetryableError(ctx context.Context, accou
// GatewayService handles API gateway operations
type GatewayService struct {
- accountRepo AccountRepository
- groupRepo GroupRepository
- usageLogRepo UsageLogRepository
- usageBillingRepo UsageBillingRepository
- userRepo UserRepository
- userSubRepo UserSubscriptionRepository
- userGroupRateRepo UserGroupRateRepository
- cache GatewayCache
- digestStore *DigestSessionStore
- cfg *config.Config
- schedulerSnapshot *SchedulerSnapshotService
- billingService *BillingService
- rateLimitService *RateLimitService
- billingCacheService *BillingCacheService
- identityService *IdentityService
- httpUpstream HTTPUpstream
- deferredService *DeferredService
- concurrencyService *ConcurrencyService
- claudeTokenProvider *ClaudeTokenProvider
- sessionLimitCache SessionLimitCache // 会话数量限制缓存(仅 Anthropic OAuth/SetupToken)
- rpmCache RPMCache // RPM 计数缓存(仅 Anthropic OAuth/SetupToken)
- userGroupRateResolver *userGroupRateResolver
- userGroupRateCache *gocache.Cache
- userGroupRateSF singleflight.Group
- modelsListCache *gocache.Cache
- modelsListCacheTTL time.Duration
- settingService *SettingService
- responseHeaderFilter *responseheaders.CompiledHeaderFilter
- debugModelRouting atomic.Bool
- debugClaudeMimic atomic.Bool
- channelService *ChannelService
- resolver *ModelPricingResolver
- debugGatewayBodyFile atomic.Pointer[os.File] // non-nil when SUB2API_DEBUG_GATEWAY_BODY is set
- tlsFPProfileService *TLSFingerprintProfileService
- balanceNotifyService *BalanceNotifyService
+ accountRepo AccountRepository
+ groupRepo GroupRepository
+ usageLogRepo UsageLogRepository
+ usageBillingRepo UsageBillingRepository
+ usageBillingOutboxRepo UsageBillingOutboxRepository
+ usageBillingOutboxWake UsageBillingOutboxWaker
+ usageBillingAdmissionRepo UsageBillingAdmissionRepository
+ requireUsageBillingOutbox bool
+ userRepo UserRepository
+ userSubRepo UserSubscriptionRepository
+ walletRepo WalletRepository
+ userGroupRateRepo UserGroupRateRepository
+ cache GatewayCache
+ digestStore *DigestSessionStore
+ cfg *config.Config
+ schedulerSnapshot *SchedulerSnapshotService
+ billingService *BillingService
+ rateLimitService *RateLimitService
+ billingCacheService *BillingCacheService
+ httpUpstream HTTPUpstream
+ deferredService *DeferredService
+ concurrencyService *ConcurrencyService
+ claudeTokenProvider *ClaudeTokenProvider
+ sessionLimitCache SessionLimitCache // 会话数量限制缓存(仅 Anthropic OAuth/SetupToken)
+ rpmCache RPMCache // RPM 计数缓存(仅 Anthropic OAuth/SetupToken)
+ userGroupRateResolver *userGroupRateResolver
+ userGroupRateCache *gocache.Cache
+ userGroupRateSF singleflight.Group
+ modelsListCache *gocache.Cache
+ modelsListCacheTTL time.Duration
+ settingService *SettingService
+ responseHeaderFilter *responseheaders.CompiledHeaderFilter
+ debugModelRouting atomic.Bool
+ channelService *ChannelService
+ resolver *ModelPricingResolver
+ debugGatewayBodyFile atomic.Pointer[os.File] // non-nil when SUB2API_DEBUG_GATEWAY_BODY is set
+ tlsFPProfileService *TLSFingerprintProfileService
+ balanceNotifyService *BalanceNotifyService
}
// NewGatewayService creates a new GatewayService
@@ -580,6 +454,7 @@ func NewGatewayService(
usageBillingRepo UsageBillingRepository,
userRepo UserRepository,
userSubRepo UserSubscriptionRepository,
+ walletRepo WalletRepository,
userGroupRateRepo UserGroupRateRepository,
cache GatewayCache,
cfg *config.Config,
@@ -588,7 +463,6 @@ func NewGatewayService(
billingService *BillingService,
rateLimitService *RateLimitService,
billingCacheService *BillingCacheService,
- identityService *IdentityService,
httpUpstream HTTPUpstream,
deferredService *DeferredService,
claudeTokenProvider *ClaudeTokenProvider,
@@ -600,41 +474,48 @@ func NewGatewayService(
channelService *ChannelService,
resolver *ModelPricingResolver,
balanceNotifyService *BalanceNotifyService,
+ usageBillingOutboxRepo UsageBillingOutboxRepository,
+ usageBillingOutboxWorker *UsageBillingOutboxWorker,
+ usageBillingAdmissionRepo UsageBillingAdmissionRepository,
) *GatewayService {
userGroupRateTTL := resolveUserGroupRateCacheTTL(cfg)
modelsListTTL := resolveModelsListCacheTTL(cfg)
svc := &GatewayService{
- accountRepo: accountRepo,
- groupRepo: groupRepo,
- usageLogRepo: usageLogRepo,
- usageBillingRepo: usageBillingRepo,
- userRepo: userRepo,
- userSubRepo: userSubRepo,
- userGroupRateRepo: userGroupRateRepo,
- cache: cache,
- digestStore: digestStore,
- cfg: cfg,
- schedulerSnapshot: schedulerSnapshot,
- concurrencyService: concurrencyService,
- billingService: billingService,
- rateLimitService: rateLimitService,
- billingCacheService: billingCacheService,
- identityService: identityService,
- httpUpstream: httpUpstream,
- deferredService: deferredService,
- claudeTokenProvider: claudeTokenProvider,
- sessionLimitCache: sessionLimitCache,
- rpmCache: rpmCache,
- userGroupRateCache: gocache.New(userGroupRateTTL, time.Minute),
- settingService: settingService,
- modelsListCache: gocache.New(modelsListTTL, time.Minute),
- modelsListCacheTTL: modelsListTTL,
- responseHeaderFilter: compileResponseHeaderFilter(cfg),
- tlsFPProfileService: tlsFPProfileService,
- channelService: channelService,
- resolver: resolver,
- balanceNotifyService: balanceNotifyService,
+ accountRepo: accountRepo,
+ groupRepo: groupRepo,
+ usageLogRepo: usageLogRepo,
+ usageBillingRepo: usageBillingRepo,
+ usageBillingOutboxRepo: usageBillingOutboxRepo,
+ usageBillingOutboxWake: usageBillingOutboxWorker,
+ usageBillingAdmissionRepo: usageBillingAdmissionRepo,
+ requireUsageBillingOutbox: true,
+ userRepo: userRepo,
+ userSubRepo: userSubRepo,
+ walletRepo: walletRepo,
+ userGroupRateRepo: userGroupRateRepo,
+ cache: cache,
+ digestStore: digestStore,
+ cfg: cfg,
+ schedulerSnapshot: schedulerSnapshot,
+ concurrencyService: concurrencyService,
+ billingService: billingService,
+ rateLimitService: rateLimitService,
+ billingCacheService: billingCacheService,
+ httpUpstream: httpUpstream,
+ deferredService: deferredService,
+ claudeTokenProvider: claudeTokenProvider,
+ sessionLimitCache: sessionLimitCache,
+ rpmCache: rpmCache,
+ userGroupRateCache: gocache.New(userGroupRateTTL, time.Minute),
+ settingService: settingService,
+ modelsListCache: gocache.New(modelsListTTL, time.Minute),
+ modelsListCacheTTL: modelsListTTL,
+ responseHeaderFilter: compileResponseHeaderFilter(cfg),
+ tlsFPProfileService: tlsFPProfileService,
+ channelService: channelService,
+ resolver: resolver,
+ balanceNotifyService: balanceNotifyService,
}
svc.userGroupRateResolver = newUserGroupRateResolver(
userGroupRateRepo,
@@ -644,9 +525,12 @@ func NewGatewayService(
"service.gateway",
)
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)
+ if gatewayBodyDebugAllowed(cfg.Server.Mode) {
+ svc.initDebugGatewayBodyFile(path)
+ } else {
+ slog.Error("gateway body debug logging refused outside debug mode", "server_mode", cfg.Server.Mode)
+ }
}
return svc
}
@@ -740,7 +624,14 @@ func (s *GatewayService) BindStickySession(ctx context.Context, groupID *int64,
if sessionHash == "" || accountID <= 0 || s.cache == nil {
return nil
}
- return s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, accountID, stickySessionTTL)
+ return s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, accountID, s.stickySessionTTL())
+}
+
+func (s *GatewayService) stickySessionTTL() time.Duration {
+ if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.StickySessionTTLSeconds > 0 {
+ return time.Duration(s.cfg.Gateway.OpenAIWS.StickySessionTTLSeconds) * time.Second
+ }
+ return stickySessionTTL
}
// GetCachedSessionAccountID retrieves the account ID bound to a sticky session.
@@ -877,108 +768,12 @@ func (s *GatewayService) hashContent(content string) string {
return strconv.FormatUint(h, 36)
}
-type anthropicCacheControlPayload struct {
- Type string `json:"type"`
- TTL string `json:"ttl,omitempty"`
-}
-
-type anthropicSystemTextBlockPayload struct {
- Type string `json:"type"`
- Text string `json:"text"`
- CacheControl *anthropicCacheControlPayload `json:"cache_control,omitempty"`
-}
-
-type anthropicMetadataPayload struct {
- UserID string `json:"user_id"`
-}
-
// replaceModelInBody 替换请求体中的model字段
// 优先使用定点修改,尽量保持客户端原始字段顺序。
func (s *GatewayService) replaceModelInBody(body []byte, newModel string) []byte {
return ReplaceModelInBody(body, newModel)
}
-type claudeOAuthNormalizeOptions struct {
- injectMetadata bool
- metadataUserID string
- stripSystemCacheControl bool
-}
-
-// sanitizeSystemText rewrites only the fixed OpenCode identity sentence (if present).
-// We intentionally avoid broad keyword replacement in system prompts to prevent
-// accidentally changing user-provided instructions.
-func sanitizeSystemText(text string) string {
- if text == "" {
- return text
- }
- // Some clients include a fixed OpenCode identity sentence. Anthropic may treat
- // this as a non-Claude-Code fingerprint, so rewrite it to the canonical
- // Claude Code banner before generic "OpenCode"/"opencode" replacements.
- text = strings.ReplaceAll(
- text,
- "You are OpenCode, the best coding agent on the planet.",
- strings.TrimSpace(claudeCodeSystemPrompt),
- )
- return text
-}
-
-func marshalAnthropicSystemTextBlock(text string, includeCacheControl bool) ([]byte, error) {
- block := anthropicSystemTextBlockPayload{
- Type: "text",
- Text: text,
- }
- if includeCacheControl {
- block.CacheControl = &anthropicCacheControlPayload{
- Type: "ephemeral",
- TTL: claude.DefaultCacheControlTTL,
- }
- }
- return json.Marshal(block)
-}
-
-func marshalAnthropicMetadata(userID string) ([]byte, error) {
- return json.Marshal(anthropicMetadataPayload{UserID: userID})
-}
-
-func buildJSONArrayRaw(items [][]byte) []byte {
- if len(items) == 0 {
- return []byte("[]")
- }
-
- total := 2
- for _, item := range items {
- total += len(item)
- }
- total += len(items) - 1
-
- buf := make([]byte, 0, total)
- buf = append(buf, '[')
- for i, item := range items {
- if i > 0 {
- buf = append(buf, ',')
- }
- buf = append(buf, item...)
- }
- buf = append(buf, ']')
- return buf
-}
-
-func setJSONValueBytes(body []byte, path string, value any) ([]byte, bool) {
- next, err := sjson.SetBytes(body, path, value)
- if err != nil {
- return body, false
- }
- return next, true
-}
-
-func setJSONRawBytes(body []byte, path string, raw []byte) ([]byte, bool) {
- next, err := sjson.SetRawBytes(body, path, raw)
- if err != nil {
- return body, false
- }
- return next, true
-}
-
func deleteJSONPathBytes(body []byte, path string) ([]byte, bool) {
next, err := sjson.DeleteBytes(body, path)
if err != nil {
@@ -987,342 +782,6 @@ func deleteJSONPathBytes(body []byte, path string) ([]byte, bool) {
return next, true
}
-func normalizeClaudeOAuthSystemBody(body []byte, opts claudeOAuthNormalizeOptions) ([]byte, bool) {
- sys := gjson.GetBytes(body, "system")
- if !sys.Exists() {
- return body, false
- }
-
- out := body
- modified := false
-
- switch {
- case sys.Type == gjson.String:
- sanitized := sanitizeSystemText(sys.String())
- if sanitized != sys.String() {
- if next, ok := setJSONValueBytes(out, "system", sanitized); ok {
- out = next
- modified = true
- }
- }
- case sys.IsArray():
- index := 0
- sys.ForEach(func(_, item gjson.Result) bool {
- if item.Get("type").String() == "text" {
- textResult := item.Get("text")
- if textResult.Exists() && textResult.Type == gjson.String {
- text := textResult.String()
- sanitized := sanitizeSystemText(text)
- if sanitized != text {
- if next, ok := setJSONValueBytes(out, fmt.Sprintf("system.%d.text", index), sanitized); ok {
- out = next
- modified = true
- }
- }
- }
- }
-
- if opts.stripSystemCacheControl && item.Get("cache_control").Exists() {
- if next, ok := deleteJSONPathBytes(out, fmt.Sprintf("system.%d.cache_control", index)); ok {
- out = next
- modified = true
- }
- }
-
- index++
- return true
- })
- }
-
- return out, modified
-}
-
-func ensureClaudeOAuthMetadataUserID(body []byte, userID string) ([]byte, bool) {
- if strings.TrimSpace(userID) == "" {
- return body, false
- }
-
- metadata := gjson.GetBytes(body, "metadata")
- if !metadata.Exists() || metadata.Type == gjson.Null {
- raw, err := marshalAnthropicMetadata(userID)
- if err != nil {
- return body, false
- }
- return setJSONRawBytes(body, "metadata", raw)
- }
-
- trimmedRaw := strings.TrimSpace(metadata.Raw)
- if strings.HasPrefix(trimmedRaw, "{") {
- existing := metadata.Get("user_id")
- if existing.Exists() && existing.Type == gjson.String && existing.String() != "" {
- return body, false
- }
- return setJSONValueBytes(body, "metadata.user_id", userID)
- }
-
- raw, err := marshalAnthropicMetadata(userID)
- if err != nil {
- return body, false
- }
- return setJSONRawBytes(body, "metadata", raw)
-}
-
-func normalizeClaudeOAuthRequestBody(body []byte, modelID string, opts claudeOAuthNormalizeOptions) ([]byte, string) {
- if len(body) == 0 {
- return body, modelID
- }
-
- out := body
- modified := false
-
- if next, changed := normalizeClaudeOAuthSystemBody(out, opts); changed {
- out = next
- modified = true
- }
-
- rawModel := gjson.GetBytes(out, "model")
- if rawModel.Exists() && rawModel.Type == gjson.String {
- normalized := claude.NormalizeModelID(rawModel.String())
- if normalized != rawModel.String() {
- if next, ok := setJSONValueBytes(out, "model", normalized); ok {
- out = next
- modified = true
- }
- modelID = normalized
- }
- }
-
- // 确保 tools 字段存在(即使为空数组)
- if !gjson.GetBytes(out, "tools").Exists() {
- if next, ok := setJSONRawBytes(out, "tools", []byte("[]")); ok {
- out = next
- modified = true
- }
- }
-
- if opts.injectMetadata && opts.metadataUserID != "" {
- if next, changed := ensureClaudeOAuthMetadataUserID(out, opts.metadataUserID); changed {
- out = next
- modified = true
- }
- }
-
- // temperature:真实 Claude Code CLI 总是发送 temperature(默认 1,客户端可覆盖)。
- // 之前的实现直接 delete 会导致 payload 缺字段,与真实 CLI 字节级不一致。
- // 策略:客户端传了什么就透传;没传则补默认 1。
- if !gjson.GetBytes(out, "temperature").Exists() {
- if next, ok := setJSONValueBytes(out, "temperature", 1); ok {
- out = next
- modified = true
- }
- }
-
- // max_tokens:真实 CLI 的默认值是 128000。缺失时补齐以对齐指纹。
- if !gjson.GetBytes(out, "max_tokens").Exists() {
- if next, ok := setJSONValueBytes(out, "max_tokens", 128000); ok {
- out = next
- modified = true
- }
- }
-
- // context_management:thinking.type 为 enabled/adaptive 时,真实 CLI 会自动
- // 附带 {"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}。
- // 客户端显式传了就透传;否则按 CLI 行为补齐。
- if !gjson.GetBytes(out, "context_management").Exists() {
- thinkingType := gjson.GetBytes(out, "thinking.type").String()
- if thinkingType == "enabled" || thinkingType == "adaptive" {
- const cmDefault = `{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]}`
- if next, ok := setJSONRawBytes(out, "context_management", []byte(cmDefault)); ok {
- out = next
- modified = true
- }
- }
- }
-
- // tool_choice:与 Parrot 对齐,不再无条件删除。
- // - 客户端传了 {"type":"tool","name":"X"} → 保留结构,name 由
- // applyToolNameRewriteToBody 同步映射为假名
- // - 其他形态(auto/any/none)原样透传
- // 如果 body 里完全没有 tools(空数组),tool_choice 没意义时才删除
- if !gjson.GetBytes(out, "tools").IsArray() || len(gjson.GetBytes(out, "tools").Array()) == 0 {
- if gjson.GetBytes(out, "tool_choice").Exists() {
- if next, ok := deleteJSONPathBytes(out, "tool_choice"); ok {
- out = next
- modified = true
- }
- }
- }
-
- if !modified {
- return body, modelID
- }
-
- return out, modelID
-}
-
-func (s *GatewayService) buildOAuthMetadataUserID(parsed *ParsedRequest, account *Account, fp *Fingerprint) string {
- if parsed == nil || account == nil {
- return ""
- }
- if parsed.MetadataUserID != "" {
- return ""
- }
-
- userID := strings.TrimSpace(account.GetClaudeUserID())
- if userID == "" && fp != nil {
- userID = fp.ClientID
- }
- if userID == "" {
- // Fall back to a random, well-formed client id so we can still satisfy
- // Claude Code OAuth requirements when account metadata is incomplete.
- userID = generateClientID()
- }
-
- sessionHash := s.GenerateSessionHash(parsed)
- sessionID := uuid.NewString()
- if sessionHash != "" {
- seed := fmt.Sprintf("%d::%s", account.ID, sessionHash)
- sessionID = generateSessionUUID(seed)
- }
-
- // 根据指纹 UA 版本选择输出格式
- var uaVersion string
- if fp != nil {
- uaVersion = ExtractCLIVersion(fp.UserAgent)
- }
- accountUUID := strings.TrimSpace(account.GetExtraString("account_uuid"))
- return FormatMetadataUserID(userID, accountUUID, sessionID, uaVersion)
-}
-
-// applyClaudeCodeOAuthMimicryToBody 将"非 Claude Code 客户端 + Claude OAuth 账号"
-// 路径上原本只在 /v1/messages 里做的完整伪装应用到任意 body 上。
-//
-// 这是 /v1/messages 主路径上 rewriteSystemForNonClaudeCode +
-// normalizeClaudeOAuthRequestBody 流程的通用版,供 OpenAI 协议兼容层
-// (ForwardAsChatCompletions / ForwardAsResponses) 复用。
-//
-// 未抽离之前,OpenAI 协议兼容层仅做 injectClaudeCodePrompt(前置追加),
-// 而仓内 /v1/messages 路径自己的注释明确说过"仅前置追加无法通过 Anthropic
-// 第三方检测";那条注释就是本函数存在的根因。
-//
-// 参数:
-// - ctx / c:用于读取指纹和 gateway settings;c 可为 nil(如 count_tokens)。
-// - account:必须是 OAuth 账号,且调用方已判断不是 Claude Code 客户端。
-// - body:已经 marshal 成 Anthropic /v1/messages 格式的请求体。
-// - systemRaw:body 中原始 system 字段(用于判断是否需要 rewrite)。
-// - model:最终会发给上游的模型 ID(用于 haiku 旁路 + metadata 版本选择)。
-//
-// 返回:改写后的 body。即使中间任何一步失败,也会退化成原 body(不会 panic)。
-func (s *GatewayService) applyClaudeCodeOAuthMimicryToBody(
- ctx context.Context,
- c *gin.Context,
- account *Account,
- body []byte,
- systemRaw any,
- model string,
-) []byte {
- if account == nil || !account.IsOAuth() || len(body) == 0 {
- return body
- }
-
- systemRewritten := false
- if !strings.Contains(strings.ToLower(model), "haiku") {
- body = rewriteSystemForNonClaudeCode(body, systemRaw)
- systemRewritten = true
- }
-
- normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten}
-
- if s.identityService != nil && c != nil && c.Request != nil {
- if fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header); err == nil && fp != nil {
- mimicMPT := false
- if s.settingService != nil {
- _, mimicMPT, _ = s.settingService.GetGatewayForwardingSettings(ctx)
- }
- if !mimicMPT {
- if uid := s.buildOAuthMetadataUserIDFromBody(ctx, account, fp, body); uid != "" {
- normalizeOpts.injectMetadata = true
- normalizeOpts.metadataUserID = uid
- }
- }
- }
- }
-
- body, _ = normalizeClaudeOAuthRequestBody(body, model, normalizeOpts)
-
- // Phase D+E+F: messages cache 策略 + 工具名混淆 + tools[-1] 断点
- // 对齐 Parrot transform_request 里剩余的字段级改写。三步顺序有语义约束:
- // 1) strip:先清除客户端的 messages[*].cache_control(多轮稳定性)
- // 2) breakpoints:再注入 2 个断点(最后一条 + 倒数第二个 user turn)
- // 3) tool rewrite:最后改 tools[*].name / tool_choice.name 并在 tools[-1]
- // 上打断点;mapping 存入 gin.Context 供响应侧 bytes.Replace 还原。
- body = stripMessageCacheControl(body)
- body = addMessageCacheBreakpoints(body)
-
- if rw := buildToolNameRewriteFromBody(body); rw != nil {
- body = applyToolNameRewriteToBody(body, rw)
- if c != nil {
- c.Set(toolNameRewriteKey, rw)
- }
- } else {
- body = applyToolsLastCacheBreakpoint(body)
- }
-
- return body
-}
-
-// buildOAuthMetadataUserIDFromBody 是 buildOAuthMetadataUserID 的变体,
-// 适用于调用方手上没有 ParsedRequest 的场景(如 OpenAI 协议兼容层)。
-//
-// 与 buildOAuthMetadataUserID 的唯一区别:
-// - session hash 从 body 本体按同样规则重算,而不是读取 ParsedRequest 缓存值。
-// - 如果 body 里已经存在 metadata.user_id,则返回空(由 ensureClaudeOAuthMetadataUserID
-// 自行决定是否覆盖)。
-func (s *GatewayService) buildOAuthMetadataUserIDFromBody(
- ctx context.Context,
- account *Account,
- fp *Fingerprint,
- body []byte,
-) string {
- _ = ctx
- if account == nil {
- return ""
- }
- if existing := gjson.GetBytes(body, "metadata.user_id").String(); existing != "" {
- return ""
- }
-
- userID := strings.TrimSpace(account.GetClaudeUserID())
- if userID == "" && fp != nil {
- userID = fp.ClientID
- }
- if userID == "" {
- userID = generateClientID()
- }
-
- sessionID := uuid.NewString()
- if hash := hashBodyForSessionSeed(body); hash != "" {
- sessionID = generateSessionUUID(fmt.Sprintf("%d::%s", account.ID, hash))
- }
-
- var uaVersion string
- if fp != nil {
- uaVersion = ExtractCLIVersion(fp.UserAgent)
- }
- accountUUID := strings.TrimSpace(account.GetExtraString("account_uuid"))
- return FormatMetadataUserID(userID, accountUUID, sessionID, uaVersion)
-}
-
-// hashBodyForSessionSeed 为 sessionID 提供一个稳定但仅对本次请求特征化的种子。
-// 复用 SHA-256 + 截断,与 generateSessionUUID 的输入格式对齐。
-func hashBodyForSessionSeed(body []byte) string {
- if len(body) == 0 {
- return ""
- }
- sum := sha256.Sum256(body)
- return fmt.Sprintf("%x", sum[:16])
-}
-
// GenerateSessionUUID creates a deterministic UUID4 from a seed string.
func GenerateSessionUUID(seed string) string {
return generateSessionUUID(seed)
@@ -1379,7 +838,8 @@ func (s *GatewayService) SelectAccountForModelWithExclusions(ctx context.Context
return nil, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel)
}
- // anthropic/gemini 分组支持混合调度(包含启用了 mixed_scheduling 的 antigravity 账户)
+ // anthropic/gemini 分组支持混合调度。
+ // Anthropic additionally accepts Claude-compatible Kiro accounts.
// 注意:强制平台模式不走混合调度
if (platform == PlatformAnthropic || platform == PlatformGemini) && !hasForcePlatform {
account, err := s.selectAccountWithMixedScheduling(ctx, groupID, sessionHash, requestedModel, excludedIDs, platform)
@@ -1775,7 +1235,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
continue
}
if sessionHash != "" && s.cache != nil {
- _ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, item.account.ID, stickySessionTTL)
+ _ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, item.account.ID, s.stickySessionTTL())
}
if s.debugModelRoutingEnabled() {
logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed select: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), item.account.ID)
@@ -1866,7 +1326,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
"result", "slot_acquired",
)
if s.cache != nil {
- _ = s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, stickySessionTTL)
+ _ = s.cache.RefreshSessionTTL(ctx, derefGroupID(groupID), sessionHash, s.stickySessionTTL())
}
return s.newSelectionResult(ctx, account, true, result.ReleaseFunc, nil)
}
@@ -2021,7 +1481,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro
result.ReleaseFunc() // 释放槽位,继续尝试下一个账号
} else {
if sessionHash != "" && s.cache != nil {
- _ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.account.ID, stickySessionTTL)
+ _ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.account.ID, s.stickySessionTTL())
}
return s.newSelectionResult(ctx, selected.account, true, result.ReleaseFunc, nil)
}
@@ -2069,7 +1529,7 @@ func (s *GatewayService) tryAcquireByLegacyOrder(ctx context.Context, candidates
continue
}
if sessionHash != "" && s.cache != nil {
- _ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, acc.ID, stickySessionTTL)
+ _ = s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, acc.ID, s.stickySessionTTL())
}
selection, err := s.newSelectionResult(ctx, acc, true, result.ReleaseFunc, nil)
if err != nil {
@@ -2244,9 +1704,9 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i
}
return accounts, useMixed, err
}
- useMixed := (platform == PlatformAnthropic || platform == PlatformGemini) && !hasForcePlatform
+ useMixed := useMixedSchedulingForPlatform(platform, hasForcePlatform)
if useMixed {
- platforms := []string{platform, PlatformAntigravity}
+ platforms := mixedSchedulingPlatforms(platform)
var accounts []Account
var err error
if groupID != nil {
@@ -2264,11 +1724,11 @@ func (s *GatewayService) listSchedulableAccounts(ctx context.Context, groupID *i
return nil, useMixed, err
}
filtered := make([]Account, 0, len(accounts))
- for _, acc := range accounts {
- if acc.Platform == PlatformAntigravity && !acc.IsMixedSchedulingEnabled() {
+ for i := range accounts {
+ if !isAccountAllowedInMixedScheduling(&accounts[i], platform) {
continue
}
- filtered = append(filtered, acc)
+ filtered = append(filtered, accounts[i])
}
slog.Debug("account_scheduling_list_mixed",
"group_id", derefGroupID(groupID),
@@ -2336,23 +1796,20 @@ func (s *GatewayService) isAccountAllowedForPlatform(account *Account, platform
return false
}
if useMixed {
- if account.Platform == platform {
- return true
- }
- return account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()
+ return isAccountAllowedInMixedScheduling(account, platform)
}
return account.Platform == platform
}
func (s *GatewayService) isAccountSchedulableForSelection(account *Account) bool {
- if account == nil {
+ if account == nil || !isGatewayInferenceCredentialAllowed(account) {
return false
}
return account.IsSchedulable()
}
func (s *GatewayService) isAccountSchedulableForModelSelection(ctx context.Context, account *Account, requestedModel string) bool {
- if account == nil {
+ if account == nil || !isGatewayInferenceCredentialAllowed(account) {
return false
}
return account.IsSchedulableForModelWithContext(ctx, requestedModel)
@@ -3120,7 +2577,7 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
if selected != nil {
if sessionHash != "" && s.cache != nil {
- if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, stickySessionTTL); err != nil {
+ if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, s.stickySessionTTL()); err != nil {
logger.LegacyPrintf("service.gateway", "set session account failed: session=%s account_id=%d err=%v", sessionHash, selected.ID, err)
}
}
@@ -3242,7 +2699,7 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
// 4. 建立粘性绑定
if sessionHash != "" && s.cache != nil {
- if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, stickySessionTTL); err != nil {
+ if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, s.stickySessionTTL()); err != nil {
logger.LegacyPrintf("service.gateway", "set session account failed: session=%s account_id=%d err=%v", sessionHash, selected.ID, err)
}
}
@@ -3250,8 +2707,8 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
return selected, nil
}
-// selectAccountWithMixedScheduling 选择账户(支持混合调度)
-// 查询原生平台账户 + 启用 mixed_scheduling 的 antigravity 账户
+// selectAccountWithMixedScheduling 选择账户(支持混合调度)。
+// Anthropic 分组额外允许 Claude-compatible Kiro 账户;Antigravity 仍需显式启用 mixed_scheduling。
func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, nativePlatform string) (*Account, error) {
preferOAuth := nativePlatform == PlatformGemini
routingAccountIDs := s.routingAccountIDsForRequest(ctx, groupID, requestedModel, nativePlatform)
@@ -3277,14 +2734,14 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
if err == nil && accountID > 0 && containsInt64(routingAccountIDs, accountID) {
if _, excluded := excludedIDs[accountID]; !excluded {
account, err := s.getSchedulableAccount(ctx, accountID)
- // 检查账号分组归属和有效性:原生平台直接匹配,antigravity 需要启用混合调度
+ // 检查账号分组归属和有效性:原生平台/Kiro Claude-compatible 直接匹配,antigravity 需要启用混合调度。
if err == nil {
clearSticky := shouldClearStickySession(account, requestedModel)
if clearSticky {
_ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
}
if !clearSticky && s.isAccountInGroup(account, groupID) && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) && s.isAccountSchedulableForQuota(account) && s.isAccountSchedulableForWindowCost(ctx, account, true) && s.isAccountSchedulableForRPM(ctx, account, true) {
- if account.Platform == nativePlatform || (account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()) {
+ if isAccountAllowedInMixedScheduling(account, nativePlatform) {
if s.debugModelRoutingEnabled() {
logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] legacy mixed routed sticky hit: group_id=%v model=%s session=%s account=%d", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), accountID)
}
@@ -3335,8 +2792,8 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
fmt.Sprintf("Privacy not set, required by group [%s]", schedGroup.Name))
continue
}
- // 过滤:原生平台直接通过,antigravity 需要启用混合调度
- if acc.Platform == PlatformAntigravity && !acc.IsMixedSchedulingEnabled() {
+ // 过滤:原生平台/Kiro Claude-compatible 直接通过,antigravity 需要启用混合调度。
+ if !isAccountAllowedInMixedScheduling(acc, nativePlatform) {
continue
}
if requestedModel != "" && !s.isModelSupportedByAccountWithContext(ctx, acc, requestedModel) {
@@ -3380,7 +2837,7 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
if selected != nil {
if sessionHash != "" && s.cache != nil {
- if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, stickySessionTTL); err != nil {
+ if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, s.stickySessionTTL()); err != nil {
logger.LegacyPrintf("service.gateway", "set session account failed: session=%s account_id=%d err=%v", sessionHash, selected.ID, err)
}
}
@@ -3398,14 +2855,14 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
if err == nil && accountID > 0 {
if _, excluded := excludedIDs[accountID]; !excluded {
account, err := s.getSchedulableAccount(ctx, accountID)
- // 检查账号分组归属和有效性:原生平台直接匹配,antigravity 需要启用混合调度
+ // 检查账号分组归属和有效性:原生平台/Kiro Claude-compatible 直接匹配,antigravity 需要启用混合调度。
if err == nil {
clearSticky := shouldClearStickySession(account, requestedModel)
if clearSticky {
_ = s.cache.DeleteSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
}
if !clearSticky && s.isAccountInGroup(account, groupID) && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel)) && s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) && s.isAccountSchedulableForQuota(account) && s.isAccountSchedulableForWindowCost(ctx, account, true) && s.isAccountSchedulableForRPM(ctx, account, true) && !s.isStickyAccountUpstreamRestricted(ctx, groupID, account, requestedModel) {
- if account.Platform == nativePlatform || (account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled()) {
+ if isAccountAllowedInMixedScheduling(account, nativePlatform) {
return account, nil
}
}
@@ -3447,8 +2904,8 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
fmt.Sprintf("Privacy not set, required by group [%s]", schedGroup.Name))
continue
}
- // 过滤:原生平台直接通过,antigravity 需要启用混合调度
- if acc.Platform == PlatformAntigravity && !acc.IsMixedSchedulingEnabled() {
+ // 过滤:原生平台/Kiro Claude-compatible 直接通过,antigravity 需要启用混合调度。
+ if !isAccountAllowedInMixedScheduling(acc, nativePlatform) {
continue
}
if requestedModel != "" && !s.isModelSupportedByAccountWithContext(ctx, acc, requestedModel) {
@@ -3503,7 +2960,7 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
// 4. 建立粘性绑定
if sessionHash != "" && s.cache != nil {
- if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, stickySessionTTL); err != nil {
+ if err := s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, selected.ID, s.stickySessionTTL()); err != nil {
logger.LegacyPrintf("service.gateway", "set session account failed: session=%s account_id=%d err=%v", sessionHash, selected.ID, err)
}
}
@@ -3643,10 +3100,7 @@ func isPlatformFilteredForSelection(acc *Account, platform string, allowMixedSch
return true
}
if allowMixedScheduling {
- if acc.Platform == PlatformAntigravity {
- return !acc.IsMixedSchedulingEnabled()
- }
- return acc.Platform != platform
+ return !isAccountAllowedInMixedScheduling(acc, platform)
}
if strings.TrimSpace(platform) == "" {
return false
@@ -3722,8 +3176,14 @@ func (s *GatewayService) isModelSupportedByAccount(account *Account, requestedMo
}
// OpenAI 透传模式:仅替换认证,允许所有模型
if account.Platform == PlatformOpenAI && account.IsOpenAIPassthroughEnabled() {
+ if _, isGPT56Family := classifyOpenAIGPT56PreviewModel(requestedModel); isGPT56Family {
+ return account.IsModelSupported(requestedModel)
+ }
return true
}
+ if account.Platform == PlatformKiro {
+ requestedModel = claude.NormalizeModelID(strings.TrimSpace(requestedModel))
+ }
// OAuth/SetupToken 账号使用 Anthropic 标准映射(短ID → 长ID)
if account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey {
if account.Type == AccountTypeServiceAccount {
@@ -3854,14 +3314,15 @@ func sleepWithContext(ctx context.Context, d time.Duration) error {
}
}
-// isClaudeCodeClient 判断请求是否来自真正的 Claude Code 客户端。
+// isClaudeCodeClient classifies requests that have a Claude Code-compatible
+// shape. This is a routing/UX heuristic only; downstream-controlled headers and
+// metadata do not authenticate official-client provenance and must never unlock
+// Anthropic OAuth credentials.
// 判定条件:
// 1. User-Agent 匹配 claude-cli/X.Y.Z(大小写不敏感)
// 2. metadata.user_id 符合 Claude Code 格式(legacy 或 JSON 格式)
//
-// 只检查 metadata.user_id 非空不够严格:第三方工具(opencode 等)可能伪造 UA
-// 并附带任意 metadata.user_id 字符串,从而绕过 mimicry。必须通过 ParseMetadataUserID
-// 验证格式才能确认是真正的 Claude Code 客户端。
+// 格式校验用于减少误判,但不能作为认证或凭证授权边界。
func isClaudeCodeClient(userAgent string, metadataUserID string) bool {
if !claudeCliUserAgentRe.MatchString(userAgent) {
return false
@@ -3869,240 +3330,6 @@ func isClaudeCodeClient(userAgent string, metadataUserID string) bool {
return ParseMetadataUserID(metadataUserID) != nil
}
-// normalizeSystemParam 将 json.RawMessage 类型的 system 参数转为标准 Go 类型(string / []any / nil),
-// 避免 type switch 中 json.RawMessage(底层 []byte)无法匹配 case string / case []any / case nil 的问题。
-// 这是 Go 的 typed nil 陷阱:(json.RawMessage, nil) ≠ (nil, nil)。
-func normalizeSystemParam(system any) any {
- raw, ok := system.(json.RawMessage)
- if !ok {
- return system
- }
- if len(raw) == 0 {
- return nil
- }
- var parsed any
- if err := json.Unmarshal(raw, &parsed); err != nil {
- return nil
- }
- return parsed
-}
-
-// systemIncludesClaudeCodePrompt 检查 system 中是否已包含 Claude Code 提示词
-// 使用前缀匹配支持多种变体(标准版、Agent SDK 版等)
-func systemIncludesClaudeCodePrompt(system any) bool {
- system = normalizeSystemParam(system)
- switch v := system.(type) {
- case string:
- return hasClaudeCodePrefix(v)
- case []any:
- for _, item := range v {
- if m, ok := item.(map[string]any); ok {
- if text, ok := m["text"].(string); ok && hasClaudeCodePrefix(text) {
- return true
- }
- }
- }
- }
- return false
-}
-
-// hasClaudeCodePrefix 检查文本是否以 Claude Code 提示词的特征前缀开头
-func hasClaudeCodePrefix(text string) bool {
- for _, prefix := range claudeCodePromptPrefixes {
- if strings.HasPrefix(text, prefix) {
- return true
- }
- }
- return false
-}
-
-// injectClaudeCodePrompt 在 system 开头注入 Claude Code 提示词
-// 处理 null、字符串、数组三种格式
-func injectClaudeCodePrompt(body []byte, system any) []byte {
- system = normalizeSystemParam(system)
- claudeCodeBlock, err := marshalAnthropicSystemTextBlock(claudeCodeSystemPrompt, true)
- if err != nil {
- logger.LegacyPrintf("service.gateway", "Warning: failed to build Claude Code prompt block: %v", err)
- return body
- }
- // Opencode plugin applies an extra safeguard: it not only prepends the Claude Code
- // banner, it also prefixes the next system instruction with the same banner plus
- // a blank line. This helps when upstream concatenates system instructions.
- claudeCodePrefix := strings.TrimSpace(claudeCodeSystemPrompt)
-
- var items [][]byte
-
- switch v := system.(type) {
- case nil:
- items = [][]byte{claudeCodeBlock}
- case string:
- // Be tolerant of older/newer clients that may differ only by trailing whitespace/newlines.
- if strings.TrimSpace(v) == "" || strings.TrimSpace(v) == strings.TrimSpace(claudeCodeSystemPrompt) {
- items = [][]byte{claudeCodeBlock}
- } else {
- // Mirror opencode behavior: keep the banner as a separate system entry,
- // but also prefix the next system text with the banner.
- merged := v
- if !strings.HasPrefix(v, claudeCodePrefix) {
- merged = claudeCodePrefix + "\n\n" + v
- }
- nextBlock, buildErr := marshalAnthropicSystemTextBlock(merged, false)
- if buildErr != nil {
- logger.LegacyPrintf("service.gateway", "Warning: failed to build prefixed Claude Code system block: %v", buildErr)
- return body
- }
- items = [][]byte{claudeCodeBlock, nextBlock}
- }
- case []any:
- items = make([][]byte, 0, len(v)+1)
- items = append(items, claudeCodeBlock)
- prefixedNext := false
- systemResult := gjson.GetBytes(body, "system")
- if systemResult.IsArray() {
- systemResult.ForEach(func(_, item gjson.Result) bool {
- textResult := item.Get("text")
- if textResult.Exists() && textResult.Type == gjson.String &&
- strings.TrimSpace(textResult.String()) == strings.TrimSpace(claudeCodeSystemPrompt) {
- return true
- }
-
- raw := []byte(item.Raw)
- // Prefix the first subsequent text system block once.
- if !prefixedNext && item.Get("type").String() == "text" && textResult.Exists() && textResult.Type == gjson.String {
- text := textResult.String()
- if strings.TrimSpace(text) != "" && !strings.HasPrefix(text, claudeCodePrefix) {
- next, setErr := sjson.SetBytes(raw, "text", claudeCodePrefix+"\n\n"+text)
- if setErr == nil {
- raw = next
- prefixedNext = true
- }
- }
- }
- items = append(items, raw)
- return true
- })
- } else {
- for _, item := range v {
- m, ok := item.(map[string]any)
- if !ok {
- raw, marshalErr := json.Marshal(item)
- if marshalErr == nil {
- items = append(items, raw)
- }
- continue
- }
- if text, ok := m["text"].(string); ok && strings.TrimSpace(text) == strings.TrimSpace(claudeCodeSystemPrompt) {
- continue
- }
- if !prefixedNext {
- if blockType, _ := m["type"].(string); blockType == "text" {
- if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" && !strings.HasPrefix(text, claudeCodePrefix) {
- m["text"] = claudeCodePrefix + "\n\n" + text
- prefixedNext = true
- }
- }
- }
- raw, marshalErr := json.Marshal(m)
- if marshalErr == nil {
- items = append(items, raw)
- }
- }
- }
- default:
- items = [][]byte{claudeCodeBlock}
- }
-
- result, ok := setJSONRawBytes(body, "system", buildJSONArrayRaw(items))
- if !ok {
- logger.LegacyPrintf("service.gateway", "Warning: failed to inject Claude Code prompt")
- return body
- }
- return result
-}
-
-// rewriteSystemForNonClaudeCode 将非 Claude Code 客户端的 system prompt 迁移至 messages,
-// system 字段仅保留 Claude Code 标识提示词。
-// Anthropic 基于 system 参数内容检测第三方应用,仅前置追加 Claude Code 提示词
-// 无法通过检测,因为后续内容仍为非 Claude Code 格式。
-// 策略:将原始 system prompt 提取并注入为 user/assistant 消息对,system 仅保留 Claude Code 标识。
-func rewriteSystemForNonClaudeCode(body []byte, system any) []byte {
- system = normalizeSystemParam(system)
-
- // 1. 提取原始 system prompt 文本
- var originalSystemText string
- switch v := system.(type) {
- case string:
- originalSystemText = strings.TrimSpace(v)
- case []any:
- var parts []string
- for _, item := range v {
- if m, ok := item.(map[string]any); ok {
- if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" {
- parts = append(parts, text)
- }
- }
- }
- originalSystemText = strings.Join(parts, "\n\n")
- }
-
- // 2. 构造 system 数组,对齐真实 Claude Code CLI 的 2-block 形态:
- // [0] billing attribution block(cc_version={cliVer}.{fp}; cc_entrypoint=cli; cch=00000;)
- // [1] "You are Claude Code..." prompt block(带 cache_control 作为稳定缓存断点)
- //
- // billing block 的 cch=00000 是占位符,会被 buildUpstreamRequest 里的
- // signBillingHeaderCCH 替换成 xxhash64 签名。缺失 billing block 的系统 payload
- // 是 Anthropic 判定第三方的关键信号之一(真实 CLI 每个请求都带)。
- billingBlock, billingErr := buildBillingAttributionBlockJSON(body, claude.CLICurrentVersion)
- ccPromptBlock, ccErr := marshalAnthropicSystemTextBlock(claudeCodeSystemPrompt, true)
- if billingErr != nil || ccErr != nil {
- logger.LegacyPrintf("service.gateway", "Warning: failed to build system blocks (billing=%v, cc=%v)", billingErr, ccErr)
- return body
- }
- out, ok := setJSONRawBytes(body, "system", buildJSONArrayRaw([][]byte{billingBlock, ccPromptBlock}))
- if !ok {
- logger.LegacyPrintf("service.gateway", "Warning: failed to set Claude Code system prompt")
- return body
- }
-
- // 3. 将原始 system prompt 作为 user/assistant 消息对注入到 messages 开头
- // 模型仍通过 messages 接收完整指令,保留客户端功能
- ccPromptTrimmed := strings.TrimSpace(claudeCodeSystemPrompt)
- if originalSystemText != "" && originalSystemText != ccPromptTrimmed && !hasClaudeCodePrefix(originalSystemText) {
- instrMsg, err1 := json.Marshal(map[string]any{
- "role": "user",
- "content": []map[string]any{
- {"type": "text", "text": "[System Instructions]\n" + originalSystemText},
- },
- })
- ackMsg, err2 := json.Marshal(map[string]any{
- "role": "assistant",
- "content": []map[string]any{
- {"type": "text", "text": "Understood. I will follow these instructions."},
- },
- })
- if err1 != nil || err2 != nil {
- logger.LegacyPrintf("service.gateway", "Warning: failed to marshal system-to-messages injection")
- return out
- }
-
- // 重建 messages 数组:[instruction, ack, ...originalMessages]
- items := [][]byte{instrMsg, ackMsg}
- messagesResult := gjson.GetBytes(out, "messages")
- if messagesResult.IsArray() {
- messagesResult.ForEach(func(_, msg gjson.Result) bool {
- items = append(items, []byte(msg.Raw))
- return true
- })
- }
-
- if next, setOk := setJSONRawBytes(out, "messages", buildJSONArrayRaw(items)); setOk {
- out = next
- }
- }
-
- return out
-}
-
type cacheControlPath struct {
path string
log string
@@ -4318,6 +3545,9 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
if parsed == nil {
return nil, fmt.Errorf("parse request: empty request")
}
+ if err := rejectAnthropicOAuthGatewayCredential(account); err != nil {
+ return nil, err
+ }
// Web Search 模拟:纯 web_search 请求时,直接调用搜索 API 构造响应
if account != nil && s.shouldEmulateWebSearch(ctx, account, parsed.GroupID, parsed.Body) {
@@ -4376,94 +3606,13 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
})
}
- // Claude Code 客户端判定:UA 匹配 claude-cli/* 且携带 metadata.user_id。
- // 真正的 Claude Code 客户端自带完整的 system prompt、cache_control 断点和 header,
- // 不需要代理做任何 body 级别的 mimicry;强行替换反而会破坏客户端的缓存策略
- // (长 system prompt 被替换为 ~45 tokens 的短 prompt,低于 Anthropic 1024 token
- // 最低缓存门槛,导致系统级缓存失效)。
- //
- // 对于非 Claude Code 的第三方客户端(opencode 等),仍然走完整 mimicry。
- isClaudeCode := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID)
- shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCode
-
- if shouldMimicClaudeCode {
- // 与 Parrot 对齐:OAuth 账号无条件重写 system(即使客户端已发了 Claude Code
- // 风格的 system prompt)。原因:第三方工具(opencode 等)会发 "You are Claude
- // Code..." system prompt 但缺少 billing attribution block,导致 Anthropic
- // 检测到"有 CC prompt 但无 billing block"的不一致而判为 third-party。
- // Parrot 的 transform_request 从不检查客户端 system 内容,直接覆盖。
- systemRewritten := false
- if !strings.Contains(strings.ToLower(reqModel), "haiku") {
- body = rewriteSystemForNonClaudeCode(body, parsed.System)
- systemRewritten = true
- }
-
- // system 被重写时保留 CC prompt 的 cache_control: ephemeral(匹配真实 Claude Code 行为);
- // 未重写时(haiku / 已含 CC 前缀)剥离客户端 cache_control,与原有行为一致。
- // 两种情况下 enforceCacheControlLimit 都会兜底处理上限。
- normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: !systemRewritten}
- if s.identityService != nil {
- fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
- if err == nil && fp != nil {
- // metadata 透传开启时跳过 metadata 注入
- _, mimicMPT, _ := s.settingService.GetGatewayForwardingSettings(ctx)
- if !mimicMPT {
- if metadataUserID := s.buildOAuthMetadataUserID(parsed, account, fp); metadataUserID != "" {
- normalizeOpts.injectMetadata = true
- normalizeOpts.metadataUserID = metadataUserID
- }
- }
- }
- }
-
- body, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts)
-
- // D/E/F: messages cache 策略 + 工具名混淆 + tools[-1] 断点
- // 与 forward_as_chat_completions / forward_as_responses 路径对齐,
- // 保证原生 /v1/messages 路径也经过完整的 Parrot 字段级改写。
- body = stripMessageCacheControl(body)
- body = addMessageCacheBreakpoints(body)
- if rw := buildToolNameRewriteFromBody(body); rw != nil {
- body = applyToolNameRewriteToBody(body, rw)
- c.Set(toolNameRewriteKey, rw)
- } else {
- body = applyToolsLastCacheBreakpoint(body)
- }
- }
-
// 强制执行 cache_control 块数量限制(最多 4 个)
body = enforceCacheControlLimit(body)
// 应用模型映射:
// - APIKey 账号:使用账号级别的显式映射(如果配置),否则透传原始模型名
// - OAuth/SetupToken 账号:使用 Anthropic 标准映射(短ID → 长ID)
- mappedModel := reqModel
- mappingSource := ""
- if account.Type == AccountTypeAPIKey {
- mappedModel = account.GetMappedModel(reqModel)
- if mappedModel != reqModel {
- mappingSource = "account"
- }
- }
- if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount {
- if candidate, matched := account.ResolveMappedModel(reqModel); matched {
- mappedModel = candidate
- mappingSource = "account"
- } else {
- normalized := normalizeVertexAnthropicModelID(claude.NormalizeModelID(reqModel))
- if normalized != reqModel {
- mappedModel = normalized
- mappingSource = "vertex"
- }
- }
- }
- if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey {
- normalized := claude.NormalizeModelID(reqModel)
- if normalized != reqModel {
- mappedModel = normalized
- mappingSource = "prefix"
- }
- }
+ mappedModel, mappingSource := resolveGatewayAccountMappedModel(account, reqModel)
if mappedModel != reqModel {
// 替换请求体中的模型名
body = s.replaceModelInBody(body, mappedModel)
@@ -4481,11 +3630,14 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
return nil, err
}
- // 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递)
+ // 获取代理 URL。自定义 relay 与账号代理不能组合;否则只能把代理凭据
+ // 序列化给 relay,形成 query/access-log 泄露面。
proxyURL := ""
+ proxyLogURL := ""
if account.ProxyID != nil && account.Proxy != nil {
if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" {
proxyURL = account.Proxy.URL()
+ proxyLogURL = account.Proxy.LogURL()
}
}
@@ -4494,7 +3646,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
// 调试日志:记录即将转发的账号信息
logger.LegacyPrintf("service.gateway", "[Forward] Using account: ID=%d Name=%s Platform=%s Type=%s TLSFingerprint=%v Proxy=%s",
- account.ID, account.Name, account.Platform, account.Type, tlsProfile, proxyURL)
+ account.ID, account.Name, account.Platform, account.Type, tlsProfile, proxyLogURL)
// Pre-filter: strip empty text blocks (including nested in tool_result) to prevent upstream 400.
body = StripEmptyTextBlocks(body)
@@ -4507,7 +3659,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
for attempt := 1; attempt <= maxRetryAttempts; attempt++ {
// 构建上游请求(每次重试需要重新构建,因为请求体需要重新读取)
upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream)
- upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, body, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
+ upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, body, token, tokenType, reqModel, reqStream)
releaseUpstreamCtx()
if err != nil {
return nil, err
@@ -4589,7 +3741,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
filteredBody := FilterThinkingBlocksForRetry(body)
retryCtx, releaseRetryCtx := detachStreamUpstreamContext(ctx, reqStream)
- retryReq, buildErr := s.buildUpstreamRequest(retryCtx, c, account, filteredBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
+ retryReq, buildErr := s.buildUpstreamRequest(retryCtx, c, account, filteredBody, token, tokenType, reqModel, reqStream)
releaseRetryCtx()
if buildErr == nil {
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
@@ -4624,7 +3776,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
logger.LegacyPrintf("service.gateway", "Account %d: signature retry still failing and looks tool-related, retrying with tool blocks downgraded", account.ID)
filteredBody2 := FilterSignatureSensitiveBlocksForRetry(body)
retryCtx2, releaseRetryCtx2 := detachStreamUpstreamContext(ctx, reqStream)
- retryReq2, buildErr2 := s.buildUpstreamRequest(retryCtx2, c, account, filteredBody2, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
+ retryReq2, buildErr2 := s.buildUpstreamRequest(retryCtx2, c, account, filteredBody2, token, tokenType, reqModel, reqStream)
releaseRetryCtx2()
if buildErr2 == nil {
retryResp2, retryErr2 := s.httpUpstream.DoWithTLS(retryReq2, proxyURL, account.ID, account.Concurrency, tlsProfile)
@@ -4695,7 +3847,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
if applied && time.Since(retryStart) < maxRetryElapsed {
logger.LegacyPrintf("service.gateway", "Account %d: detected budget_tokens constraint error, retrying with rectified budget (budget_tokens=%d, max_tokens=%d)", account.ID, BudgetRectifyBudgetTokens, BudgetRectifyMaxTokens)
budgetRetryCtx, releaseBudgetRetryCtx := detachStreamUpstreamContext(ctx, reqStream)
- budgetRetryReq, buildErr := s.buildUpstreamRequest(budgetRetryCtx, c, account, rectifiedBody, token, tokenType, reqModel, reqStream, shouldMimicClaudeCode)
+ budgetRetryReq, buildErr := s.buildUpstreamRequest(budgetRetryCtx, c, account, rectifiedBody, token, tokenType, reqModel, reqStream)
releaseBudgetRetryCtx()
if buildErr == nil {
budgetRetryResp, retryErr := s.httpUpstream.DoWithTLS(budgetRetryReq, proxyURL, account.ID, account.Concurrency, tlsProfile)
@@ -4905,7 +4057,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
var firstTokenMs *int
var clientDisconnect bool
if reqStream {
- streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel, shouldMimicClaudeCode)
+ streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel)
if err != nil {
if err.Error() == "have error in stream" {
return nil, &UpstreamFailoverError{
@@ -5195,6 +4347,7 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
body []byte,
token string,
) (*http.Request, error) {
+ body = stripUntrustedAnthropicBillingAttribution(body)
targetURL := claudeAPIURL
baseURL := account.GetBaseURL()
if baseURL != "" {
@@ -5236,6 +4389,10 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
if getHeaderRaw(req.Header, "anthropic-version") == "" {
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
}
+ if existingBeta := getHeaderRaw(req.Header, "anthropic-beta"); existingBeta != "" {
+ setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(existingBeta, defaultDroppedBetasSet))
+ }
+ applyAnthropicGatewayIdentity(req.Header)
return req, nil
}
@@ -5282,10 +4439,7 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough(
sawTerminalEvent := false
scanner := bufio.NewScanner(resp.Body)
- maxLineSize := defaultMaxLineSize
- if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.cfg.Gateway.MaxLineSize
- }
+ maxLineSize := resolveGatewayMaxLineSize(s.cfg)
scanBuf := getSSEScannerBuf64K()
scanner.Buffer(scanBuf[:0], maxLineSize)
@@ -5343,6 +4497,12 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough(
flusher.Flush()
}
if !sawTerminalEvent {
+ if clientDisconnected && streamInterval > 0 {
+ lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
+ if time.Since(lastRead) >= streamInterval {
+ return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, fmt.Errorf("stream usage incomplete after timeout")
+ }
+ }
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, fmt.Errorf("stream usage incomplete: missing terminal event")
}
return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, nil
@@ -5383,8 +4543,7 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough(
}
if !clientDisconnected {
- restored := string(reverseToolNamesIfPresent(c, []byte(line)))
- if _, err := io.WriteString(w, restored); err != nil {
+ if _, err := io.WriteString(w, line); err != nil {
clientDisconnected = true
logger.LegacyPrintf("service.gateway", "[Anthropic passthrough] Client disconnected during streaming, continue draining upstream for usage: account=%d", account.ID)
} else if _, err := io.WriteString(w, "\n"); err != nil {
@@ -5554,7 +4713,6 @@ func (s *GatewayService) handleNonStreamingResponseAnthropicAPIKeyPassthrough(
if contentType == "" {
contentType = "application/json"
}
- body = reverseToolNamesIfPresent(c, body)
c.Data(resp.StatusCode, contentType, body)
return usage, nil
}
@@ -5587,7 +4745,10 @@ func (s *GatewayService) forwardBedrock(
reqStream := parsed.Stream
body := parsed.Body
- region := bedrockRuntimeRegion(account)
+ region, err := normalizeBedrockRegion(bedrockRuntimeRegion(account))
+ if err != nil {
+ return nil, err
+ }
mappedModel, ok := ResolveBedrockModelID(account, reqModel)
if !ok {
return nil, fmt.Errorf("unsupported bedrock model: %s", reqModel)
@@ -5861,7 +5022,10 @@ func (s *GatewayService) buildUpstreamRequestBedrock(
stream bool,
signer *BedrockSigner,
) (*http.Request, error) {
- targetURL := BuildBedrockURL(region, modelID, stream)
+ targetURL, err := BuildBedrockURL(region, modelID, stream)
+ if err != nil {
+ return nil, err
+ }
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
if err != nil {
@@ -5888,7 +5052,10 @@ func (s *GatewayService) buildUpstreamRequestBedrockAPIKey(
stream bool,
apiKey string,
) (*http.Request, error) {
- targetURL := BuildBedrockURL(region, modelID, stream)
+ targetURL, err := BuildBedrockURL(region, modelID, stream)
+ if err != nil {
+ return nil, err
+ }
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
if err != nil {
@@ -5929,7 +5096,11 @@ func (s *GatewayService) handleBedrockNonStreamingResponse(
return usage, nil
}
-func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, reqStream bool, mimicClaudeCode bool) (*http.Request, error) {
+func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, reqStream bool) (*http.Request, error) {
+ if err := rejectAnthropicOAuthGatewayCredential(account); err != nil {
+ return nil, err
+ }
+ body = stripUntrustedAnthropicBillingAttribution(body)
if account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount {
return s.buildUpstreamRequestAnthropicVertex(ctx, c, account, body, token, modelID, reqStream)
}
@@ -5954,52 +5125,18 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
if err != nil {
return nil, err
}
- targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages", account)
+ if err := validateCustomRelayProxyIsolation(account); err != nil {
+ return nil, err
+ }
+ targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages")
}
clientHeaders := http.Header{}
if c != nil && c.Request != nil {
clientHeaders = c.Request.Header
}
-
- // OAuth账号:应用统一指纹和metadata重写(受设置开关控制)
- var fingerprint *Fingerprint
- enableFP, enableMPT, enableCCH := true, false, false
- if s.settingService != nil {
- enableFP, enableMPT, enableCCH = s.settingService.GetGatewayForwardingSettings(ctx)
- }
- if account.IsOAuth() && s.identityService != nil {
- // 1. 获取或创建指纹(包含随机生成的ClientID)
- fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, clientHeaders)
- if err != nil {
- logger.LegacyPrintf("service.gateway", "Warning: failed to get fingerprint for account %d: %v", account.ID, err)
- // 失败时降级为透传原始headers
- } else {
- if enableFP {
- fingerprint = fp
- }
-
- // 2. 重写metadata.user_id(需要指纹中的ClientID和账号的account_uuid)
- // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
- // 当 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
- }
- }
- }
- }
- }
-
- // 同步 billing header cc_version 与实际发送的 User-Agent 版本
- if fingerprint != nil {
- body = syncBillingHeaderVersion(body, fingerprint.UserAgent)
- }
- // CCH 签名:将 cch=00000 占位符替换为 xxHash64 签名(需在所有 body 修改之后)
- if enableCCH {
- body = signBillingHeaderCCH(body)
+ if tokenType == "oauth" {
+ return nil, ErrAnthropicOAuthGatewayDisabled
}
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
@@ -6008,34 +5145,19 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
// 设置认证头(保持原始大小写)
- if tokenType == "oauth" {
- setHeaderRaw(req.Header, "authorization", "Bearer "+token)
- } else {
- setHeaderRaw(req.Header, "x-api-key", token)
- }
+ setHeaderRaw(req.Header, "x-api-key", token)
// 白名单透传 headers
- // OAuth mimicry 路径:跳过客户端 header 透传,与 Parrot 对齐。
- // Parrot 的 build_upstream_headers 只发 9 个精确 header,不透传任何客户端 header。
- // 透传客户端 header 会引入不一致的 x-stainless-* / anthropic-beta / user-agent /
- // x-claude-code-session-id 等值,和我们注入的伪装 header 冲突,被 Anthropic 判 third-party。
- if tokenType != "oauth" || !mimicClaudeCode {
- for key, values := range clientHeaders {
- lowerKey := strings.ToLower(key)
- if allowedHeaders[lowerKey] {
- wireKey := resolveWireCasing(key)
- for _, v := range values {
- addHeaderRaw(req.Header, wireKey, v)
- }
+ for key, values := range clientHeaders {
+ lowerKey := strings.ToLower(key)
+ if allowedHeaders[lowerKey] {
+ wireKey := resolveWireCasing(key)
+ for _, v := range values {
+ addHeaderRaw(req.Header, wireKey, v)
}
}
}
- // OAuth账号:应用缓存的指纹到请求头(覆盖白名单透传的头)
- if fingerprint != nil {
- s.identityService.ApplyFingerprint(req, fingerprint)
- }
-
// 确保必要的headers存在(保持原始大小写)
if getHeaderRaw(req.Header, "content-type") == "" {
setHeaderRaw(req.Header, "content-type", "application/json")
@@ -6043,80 +5165,31 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
if getHeaderRaw(req.Header, "anthropic-version") == "" {
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
}
- if tokenType == "oauth" {
- applyClaudeOAuthHeaderDefaults(req)
- }
// Build effective drop set: merge static defaults with dynamic beta policy filter rules
policyFilterSet := s.getBetaPolicyFilterSet(ctx, c, account, modelID)
effectiveDropSet := mergeDropSets(policyFilterSet)
- // 处理 anthropic-beta header(OAuth 账号需要包含 oauth beta)
- if tokenType == "oauth" {
- if mimicClaudeCode {
- // 非 Claude Code 客户端:按 opencode 的策略处理:
- // - 强制 Claude Code 指纹相关请求头(尤其是 user-agent/x-stainless/x-app)
- // - 保留 incoming beta 的同时,确保 OAuth 所需 beta 存在
- applyClaudeCodeMimicHeaders(req, reqStream)
-
- incomingBeta := getHeaderRaw(req.Header, "anthropic-beta")
- // Claude Code OAuth credentials are scoped to Claude Code.
- // Non-haiku models MUST include claude-code beta for Anthropic to recognize
- // this as a legitimate Claude Code request; without it, the request is
- // rejected as third-party ("out of extra usage").
- // Haiku models are exempt from third-party detection and don't need it.
- requiredBetas := []string{claude.BetaOAuth, claude.BetaInterleavedThinking}
- if !strings.Contains(strings.ToLower(modelID), "haiku") {
- requiredBetas = claude.FullClaudeCodeMimicryBetas()
- }
- setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, effectiveDropSet))
- } else {
- // Claude Code 客户端:尽量透传原始 header,仅补齐 oauth beta
- 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 := 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 != "" {
- setHeaderRaw(req.Header, "anthropic-beta", beta)
- }
+ // API-key accounts: apply beta policy filter to strip controlled tokens.
+ 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 != "" {
+ setHeaderRaw(req.Header, "anthropic-beta", beta)
}
}
}
- // 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖
- if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" {
- if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
- if parsed := ParseMetadataUserID(uid); parsed != nil {
- setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID)
- }
- }
- }
+ applyAnthropicGatewayIdentity(req.Header)
// === 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),
+ "url": req.URL.String(),
+ "token_type": tokenType,
})
- // 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" {
- c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode))
- }
- if s.debugClaudeMimicEnabled() {
- logClaudeMimicDebug(req, body, account, tokenType, mimicClaudeCode)
- }
-
return req, nil
}
@@ -6174,53 +5247,6 @@ func (s *GatewayService) buildUpstreamRequestAnthropicVertex(
return req, nil
}
-// getBetaHeader 处理anthropic-beta header
-// 对于OAuth账号,需要确保包含oauth-2025-04-20
-func (s *GatewayService) getBetaHeader(modelID string, clientBetaHeader string) string {
- // 如果客户端传了anthropic-beta
- if clientBetaHeader != "" {
- // 已包含oauth beta则直接返回
- if strings.Contains(clientBetaHeader, claude.BetaOAuth) {
- return clientBetaHeader
- }
-
- // 需要添加oauth beta
- parts := strings.Split(clientBetaHeader, ",")
- for i, p := range parts {
- parts[i] = strings.TrimSpace(p)
- }
-
- // 在claude-code-20250219后面插入oauth beta
- claudeCodeIdx := -1
- for i, p := range parts {
- if p == claude.BetaClaudeCode {
- claudeCodeIdx = i
- break
- }
- }
-
- if claudeCodeIdx >= 0 {
- // 在claude-code后面插入
- newParts := make([]string, 0, len(parts)+1)
- newParts = append(newParts, parts[:claudeCodeIdx+1]...)
- newParts = append(newParts, claude.BetaOAuth)
- newParts = append(newParts, parts[claudeCodeIdx+1:]...)
- return strings.Join(newParts, ",")
- }
-
- // 没有claude-code,放在第一位
- return claude.BetaOAuth + "," + clientBetaHeader
- }
-
- // 客户端没传,根据模型生成
- // haiku 模型不需要 claude-code beta
- if strings.Contains(strings.ToLower(modelID), "haiku") {
- return claude.HaikuBetaHeader
- }
-
- return claude.DefaultBetaHeader
-}
-
func requestNeedsBetaFeatures(body []byte) bool {
tools := gjson.GetBytes(body, "tools")
if tools.Exists() && tools.IsArray() && len(tools.Array()) > 0 {
@@ -6241,23 +5267,6 @@ func defaultAPIKeyBetaHeader(body []byte) string {
return claude.APIKeyBetaHeader
}
-func applyClaudeOAuthHeaderDefaults(req *http.Request) {
- if req == nil {
- return
- }
- if getHeaderRaw(req.Header, "Accept") == "" {
- setHeaderRaw(req.Header, "Accept", "application/json")
- }
- for key, value := range claude.DefaultHeaders {
- if value == "" {
- continue
- }
- if getHeaderRaw(req.Header, key) == "" {
- setHeaderRaw(req.Header, resolveWireCasing(key), value)
- }
- }
-}
-
func mergeAnthropicBeta(required []string, incoming string) string {
seen := make(map[string]struct{}, len(required)+8)
out := make([]string, 0, len(required)+8)
@@ -6571,35 +5580,6 @@ func buildBetaTokenSet(tokens []string) map[string]struct{} {
var defaultDroppedBetasSet = buildBetaTokenSet(claude.DroppedBetas)
-// applyClaudeCodeMimicHeaders forces "Claude Code-like" request headers.
-// This mirrors opencode-anthropic-auth behavior: do not trust downstream
-// headers when using Claude Code-scoped OAuth credentials.
-func applyClaudeCodeMimicHeaders(req *http.Request, isStream bool) {
- if req == nil {
- return
- }
- // Start with the standard defaults (fill missing).
- 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
- }
- setHeaderRaw(req.Header, resolveWireCasing(key), value)
- }
- // Real Claude CLI uses Accept: application/json (even for streaming).
- setHeaderRaw(req.Header, "Accept", "application/json")
- if isStream {
- setHeaderRaw(req.Header, "x-stainless-helper-method", "stream")
- }
- // Real Claude CLI 每个请求都会生成一个新的 UUID 放在 x-client-request-id。
- // 上游会以此作为会话/请求指纹的一部分,缺失或重复都可能触发第三方判定。
- if getHeaderRaw(req.Header, "x-client-request-id") == "" {
- setHeaderRaw(req.Header, "x-client-request-id", uuid.NewString())
- }
-}
-
func truncateForLog(b []byte, maxBytes int) string {
if maxBytes <= 0 {
maxBytes = 2048
@@ -6852,20 +5832,6 @@ func (s *GatewayService) handleErrorResponse(ctx context.Context, resp *http.Res
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
- // Print a compact upstream request fingerprint when we hit the Claude Code OAuth
- // credential scope error. This avoids requiring env-var tweaks in a fixed deploy.
- if isClaudeCodeCredentialScopeError(upstreamMsg) && c != nil {
- if v, ok := c.Get(claudeMimicDebugInfoKey); ok {
- if line, ok := v.(string); ok && strings.TrimSpace(line) != "" {
- logger.LegacyPrintf("service.gateway", "[ClaudeMimicDebugOnError] status=%d request_id=%s %s",
- resp.StatusCode,
- resp.Header.Get("x-request-id"),
- line,
- )
- }
- }
- }
-
// Enrich Ops error logs with upstream status + message, and optionally a truncated body snippet.
upstreamDetail := ""
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
@@ -7024,18 +5990,6 @@ func (s *GatewayService) handleRetryExhaustedError(ctx context.Context, resp *ht
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
- if isClaudeCodeCredentialScopeError(upstreamMsg) && c != nil {
- if v, ok := c.Get(claudeMimicDebugInfoKey); ok {
- if line, ok := v.(string); ok && strings.TrimSpace(line) != "" {
- logger.LegacyPrintf("service.gateway", "[ClaudeMimicDebugOnError] status=%d request_id=%s %s",
- resp.StatusCode,
- resp.Header.Get("x-request-id"),
- line,
- )
- }
- }
- }
-
upstreamDetail := ""
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
@@ -7115,7 +6069,7 @@ type streamingResult struct {
clientDisconnect bool // 客户端是否在流式传输过程中断开
}
-func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string, mimicClaudeCode bool) (*streamingResult, error) {
+func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*streamingResult, error) {
// 更新5h窗口状态
s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header)
@@ -7144,10 +6098,7 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
var firstTokenMs *int
scanner := bufio.NewScanner(resp.Body)
// 设置更大的buffer以处理长行
- maxLineSize := defaultMaxLineSize
- if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.cfg.Gateway.MaxLineSize
- }
+ maxLineSize := resolveGatewayMaxLineSize(s.cfg)
scanBuf := getSSEScannerBuf64K()
scanner.Buffer(scanBuf[:0], maxLineSize)
@@ -7445,8 +6396,7 @@ func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http
for _, block := range outputBlocks {
if !clientDisconnected {
- restored := reverseToolNamesIfPresent(c, []byte(block))
- if _, werr := fmt.Fprint(w, string(restored)); werr != nil {
+ if _, werr := fmt.Fprint(w, block); werr != nil {
clientDisconnected = true
logger.LegacyPrintf("service.gateway", "Client disconnected during streaming, continuing to drain upstream for billing")
break
@@ -7801,8 +6751,6 @@ func (s *GatewayService) handleNonStreamingResponse(ctx context.Context, resp *h
}
}
- body = reverseToolNamesIfPresent(c, body)
-
// 写入响应
c.Data(resp.StatusCode, contentType, body)
@@ -7839,6 +6787,27 @@ func (s *GatewayService) getUserGroupRateMultiplier(ctx context.Context, userID,
return resolver.Resolve(ctx, userID, groupID, groupDefaultMultiplier)
}
+func (s *GatewayService) resolveUsageRateMultiplier(ctx context.Context, userID int64, apiKey *APIKey, subscription *UserSubscription) RateMultiplierResolution {
+ systemDefault := 1.0
+ if s != nil && s.cfg != nil {
+ systemDefault = s.cfg.Default.RateMultiplier
+ }
+ if s == nil || apiKey == nil {
+ return RateMultiplierResolution{Multiplier: systemDefault, Source: RateMultiplierSourceSystemDefault}
+ }
+ resolver := s.userGroupRateResolver
+ if resolver == nil {
+ resolver = newUserGroupRateResolver(
+ s.userGroupRateRepo,
+ s.userGroupRateCache,
+ resolveUserGroupRateCacheTTL(s.cfg),
+ &s.userGroupRateSF,
+ "service.gateway",
+ )
+ }
+ return resolveEffectiveRateMultiplier(ctx, resolver, userID, apiKey.GroupID, apiKey.Group, subscription, systemDefault)
+}
+
// RecordUsageInput 记录使用量的输入参数
type RecordUsageInput struct {
Result *ForwardResult
@@ -7874,15 +6843,16 @@ type usageLogBestEffortWriter interface {
// postUsageBillingParams 统一扣费所需的参数
type postUsageBillingParams struct {
- Cost *CostBreakdown
- User *User
- APIKey *APIKey
- Account *Account
- Subscription *UserSubscription
- RequestPayloadHash string
- IsSubscriptionBill bool
- AccountRateMultiplier float64
- APIKeyService APIKeyQuotaUpdater
+ Cost *CostBreakdown
+ User *User
+ APIKey *APIKey
+ Account *Account
+ Subscription *UserSubscription
+ EffectiveBillingGroupID *int64
+ RequestPayloadHash string
+ IsSubscriptionBill bool
+ AccountRateMultiplier float64
+ APIKeyService APIKeyQuotaUpdater
}
func (p *postUsageBillingParams) shouldDeductAPIKeyQuota() bool {
@@ -7900,9 +6870,10 @@ func (p *postUsageBillingParams) shouldUpdateAccountQuota() bool {
// postUsageBilling is the legacy fallback billing path used when the unified
// billing repo is unavailable (nil). Production uses applyUsageBilling → repo.Apply
// for atomic billing. This path only runs in tests or degraded mode.
-func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *billingDeps) {
+func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *billingDeps) error {
billingCtx, cancel := detachedBillingContext(ctx)
defer cancel()
+ var billingErrors []error
cost := p.Cost
@@ -7910,14 +6881,30 @@ func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *bill
// Subscription usage tracked by ActualCost so group rate multiplier
// consumes the quota at the expected speed.
if cost.ActualCost > 0 {
- if err := deps.userSubRepo.IncrementUsage(billingCtx, p.Subscription.ID, cost.ActualCost); err != nil {
+ if p.Subscription != nil && p.Subscription.IsWalletMode() {
+ // 钱包模式 (v4) legacy fallback:直接调 walletRepo.Deduct。
+ // 该路径仅在 unified billing repo nil 时触发(测试或降级),生产
+ // 走 applyUsageBilling → repo.Apply → deductUsageBillingWallet。
+ if deps.walletRepo != nil {
+ if _, err := deps.walletRepo.Deduct(billingCtx, WalletDeductCommand{
+ SubscriptionID: p.Subscription.ID,
+ CostUSD: cost.ActualCost,
+ PostpaidSettlement: true,
+ }); err != nil {
+ slog.Error("wallet deduct failed", "subscription_id", p.Subscription.ID, "error", err)
+ billingErrors = append(billingErrors, fmt.Errorf("deduct wallet: %w", err))
+ }
+ }
+ } else if err := deps.userSubRepo.IncrementUsage(billingCtx, p.Subscription.ID, cost.ActualCost); err != nil {
slog.Error("increment subscription usage failed", "subscription_id", p.Subscription.ID, "error", err)
+ billingErrors = append(billingErrors, fmt.Errorf("increment subscription usage: %w", err))
}
}
} else {
if cost.ActualCost > 0 {
if err := deps.userRepo.DeductBalance(billingCtx, p.User.ID, cost.ActualCost); err != nil {
slog.Error("deduct balance failed", "user_id", p.User.ID, "error", err)
+ billingErrors = append(billingErrors, fmt.Errorf("deduct balance: %w", err))
}
}
}
@@ -7925,12 +6912,14 @@ func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *bill
if p.shouldDeductAPIKeyQuota() {
if err := p.APIKeyService.UpdateQuotaUsed(billingCtx, p.APIKey.ID, cost.ActualCost); err != nil {
slog.Error("update api key quota failed", "api_key_id", p.APIKey.ID, "error", err)
+ billingErrors = append(billingErrors, fmt.Errorf("update api key quota: %w", err))
}
}
if p.shouldUpdateRateLimits() {
if err := p.APIKeyService.UpdateRateLimitUsage(billingCtx, p.APIKey.ID, cost.ActualCost); err != nil {
slog.Error("update api key rate limit usage failed", "api_key_id", p.APIKey.ID, "error", err)
+ billingErrors = append(billingErrors, fmt.Errorf("update api key rate limit usage: %w", err))
}
}
@@ -7938,6 +6927,7 @@ func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *bill
accountCost := cost.TotalCost * p.AccountRateMultiplier
if err := deps.accountRepo.IncrementQuotaUsed(billingCtx, p.Account.ID, accountCost); err != nil {
slog.Error("increment account quota used failed", "account_id", p.Account.ID, "cost", accountCost, "error", err)
+ billingErrors = append(billingErrors, fmt.Errorf("update account quota: %w", err))
}
}
@@ -7945,10 +6935,14 @@ func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *bill
// cache updates. The legacy path does DB writes directly; the finalize path
// does cache queue + notifications. Notifications are dispatched separately
// by the caller after recording the usage log.
+ return errors.Join(billingErrors...)
}
func resolveUsageBillingRequestID(ctx context.Context, upstreamRequestID string) string {
if ctx != nil {
+ if billingRequestID, _ := ctx.Value(ctxkey.UsageBillingRequestID).(string); strings.TrimSpace(billingRequestID) != "" {
+ return strings.TrimSpace(billingRequestID)
+ }
if clientRequestID, _ := ctx.Value(ctxkey.ClientRequestID).(string); strings.TrimSpace(clientRequestID) != "" {
return "client:" + strings.TrimSpace(clientRequestID)
}
@@ -7962,6 +6956,21 @@ func resolveUsageBillingRequestID(ctx context.Context, upstreamRequestID string)
return "generated:" + generateRequestID()
}
+func (s *GatewayService) AdmitUsageBillingRequest(
+ ctx context.Context,
+ apiKey *APIKey,
+ user *User,
+ account *Account,
+ subscription *UserSubscription,
+ quote UsageBillingReservationQuote,
+) (context.Context, *UsageBillingAdmissionSession, error) {
+ return admitUsageBillingRequest(ctx, s.cfg, s.requireUsageBillingOutbox, s.usageBillingAdmissionRepo, apiKey, user, account, subscription, quote)
+}
+
+func (s *GatewayService) AbandonUsageBillingRequest(ctx context.Context, session *UsageBillingAdmissionSession) error {
+ return abandonUsageBillingRequest(ctx, s.usageBillingAdmissionRepo, session)
+}
+
func resolveUsageBillingPayloadFingerprint(ctx context.Context, requestPayloadHash string) string {
if payloadHash := strings.TrimSpace(requestPayloadHash); payloadHash != "" {
return payloadHash
@@ -7983,12 +6992,14 @@ func buildUsageBillingCommand(requestID string, usageLog *UsageLog, p *postUsage
}
cmd := &UsageBillingCommand{
- RequestID: requestID,
- APIKeyID: p.APIKey.ID,
- UserID: p.User.ID,
- AccountID: p.Account.ID,
- AccountType: p.Account.Type,
- RequestPayloadHash: strings.TrimSpace(p.RequestPayloadHash),
+ RequestID: requestID,
+ APIKeyID: p.APIKey.ID,
+ AuthCacheLocator: APIKeyStoredAuthCacheLocator(p.APIKey),
+ UserID: p.User.ID,
+ AccountID: p.Account.ID,
+ EffectiveBillingGroupID: copyInt64(p.EffectiveBillingGroupID),
+ AccountType: p.Account.Type,
+ RequestPayloadHash: strings.TrimSpace(p.RequestPayloadHash),
}
if usageLog != nil {
cmd.Model = usageLog.Model
@@ -8013,9 +7024,16 @@ func buildUsageBillingCommand(requestID string, usageLog *UsageLog, p *postUsage
// user-specific) rate multiplier consumes subscription quota at the expected
// speed. TotalCost remains the raw (pre-multiplier) value; downstream guards
// on "> 0" still correctly skip free subscriptions (RateMultiplier == 0).
+ //
+ // 钱包模式 (v4) 与 v3 老订阅互斥:v4 走 WalletCost (FOR UPDATE 扣 wallet_balance_usd
+ // 并落 ledger 流水),v3 走 SubscriptionCost (group 维度限额累加)。
if p.IsSubscriptionBill && p.Subscription != nil && p.Cost.TotalCost > 0 {
cmd.SubscriptionID = &p.Subscription.ID
- cmd.SubscriptionCost = p.Cost.ActualCost
+ if p.Subscription.IsWalletMode() {
+ cmd.WalletCost = p.Cost.ActualCost
+ } else {
+ cmd.SubscriptionCost = p.Cost.ActualCost
+ }
} else if p.Cost.ActualCost > 0 {
cmd.BalanceCost = p.Cost.ActualCost
}
@@ -8034,6 +7052,17 @@ func buildUsageBillingCommand(requestID string, usageLog *UsageLog, p *postUsage
return cmd
}
+func resolveEffectiveBillingGroupID(calledGroupID *int64, effectiveGroup *Group, subscription *UserSubscription) *int64 {
+ if effectiveGroup != nil && effectiveGroup.ID > 0 {
+ id := effectiveGroup.ID
+ return &id
+ }
+ if subscription != nil && !subscription.IsWalletMode() && subscription.GroupID != nil && *subscription.GroupID > 0 {
+ return copyInt64(subscription.GroupID)
+ }
+ return copyInt64(calledGroupID)
+}
+
func applyUsageBilling(ctx context.Context, requestID string, usageLog *UsageLog, p *postUsageBillingParams, deps *billingDeps, repo UsageBillingRepository) (bool, error) {
if p == nil || deps == nil {
return false, nil
@@ -8041,8 +7070,7 @@ func applyUsageBilling(ctx context.Context, requestID string, usageLog *UsageLog
cmd := buildUsageBillingCommand(requestID, usageLog, p)
if cmd == nil || cmd.RequestID == "" || repo == nil {
- postUsageBilling(ctx, p, deps)
- return true, nil
+ return false, postUsageBilling(ctx, p, deps)
}
billingCtx, cancel := detachedBillingContext(ctx)
@@ -8174,9 +7202,16 @@ func detachedBillingContext(ctx context.Context) (context.Context, context.Cance
}
func detachStreamUpstreamContext(ctx context.Context, stream bool) (context.Context, context.CancelFunc) {
+ if ctx == nil {
+ return context.Background(), func() {}
+ }
if !stream {
return ctx, func() {}
}
+ return context.WithoutCancel(ctx), func() {}
+}
+
+func detachUpstreamContext(ctx context.Context) (context.Context, context.CancelFunc) {
if ctx == nil {
return context.Background(), func() {}
}
@@ -8188,6 +7223,7 @@ type billingDeps struct {
accountRepo AccountRepository
userRepo UserRepository
userSubRepo UserSubscriptionRepository
+ walletRepo WalletRepository
billingCacheService *BillingCacheService
deferredService *DeferredService
balanceNotifyService *BalanceNotifyService
@@ -8198,6 +7234,7 @@ func (s *GatewayService) billingDeps() *billingDeps {
accountRepo: s.accountRepo,
userRepo: s.userRepo,
userSubRepo: s.userSubRepo,
+ walletRepo: s.walletRepo,
billingCacheService: s.billingCacheService,
deferredService: s.deferredService,
balanceNotifyService: s.balanceNotifyService,
@@ -8214,10 +7251,16 @@ func writeUsageLogBestEffort(ctx context.Context, repo UsageLogRepository, usage
if writer, ok := repo.(usageLogBestEffortWriter); ok {
if err := writer.CreateBestEffort(usageCtx, usageLog); err != nil {
logger.LegacyPrintf(logKey, "Create usage log failed: %v", err)
- if IsUsageLogCreateDropped(err) {
- return
- }
- if _, syncErr := repo.Create(usageCtx, usageLog); syncErr != nil {
+ fallbackCtx := usageCtx
+ if usageCtx.Err() != nil {
+ // A full best-effort queue can consume the detached window. Start a
+ // fresh one for the idempotent synchronous fallback instead of
+ // carrying an already-dead context into the final persistence attempt.
+ var fallbackCancel context.CancelFunc
+ fallbackCtx, fallbackCancel = detachedBillingContext(context.Background())
+ defer fallbackCancel()
+ }
+ if _, syncErr := repo.Create(fallbackCtx, usageLog); syncErr != nil {
logger.LegacyPrintf(logKey, "Create usage log sync fallback failed: %v", syncErr)
}
}
@@ -8351,14 +7394,21 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
cacheTTLOverridden = (result.Usage.CacheCreation5mTokens + result.Usage.CacheCreation1hTokens) > 0
}
- // 获取费率倍数(优先级:用户专属 > 分组默认 > 系统默认)
- multiplier := 1.0
- if s.cfg != nil {
- multiplier = s.cfg.Default.RateMultiplier
+ // 获取费率倍数(优先级:订阅锁定倍率 > 用户专属 > 分组默认 > 系统默认)
+ rateResolution := s.resolveUsageRateMultiplier(ctx, user.ID, apiKey, subscription)
+ multiplier := canonicalUsageBillingRate(rateResolution.Multiplier)
+ imageMultiplier := canonicalUsageBillingRate(resolveImageRateMultiplier(apiKey, multiplier))
+ usageBillingIdentity := result.UsageBillingIdentity
+ if s.requireUsageBillingOutbox && subscription != nil && subscription.IsWalletMode() && usageBillingIdentity == nil {
+ return ErrUsageBillingLifecycleContractInvalid
}
- if apiKey.GroupID != nil && apiKey.Group != nil {
- groupDefault := apiKey.Group.RateMultiplier
- multiplier = s.getUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, groupDefault)
+ if usageBillingIdentity != nil {
+ if usageBillingIdentity.Pricing == nil || !validFenceToken(usageBillingIdentity.AdmissionAttemptID) ||
+ strings.TrimSpace(usageBillingIdentity.BillingModel) == "" || usageBillingIdentity.RateMultiplier <= 0 {
+ return ErrUsageBillingLifecycleContractInvalid
+ }
+ multiplier = canonicalUsageBillingRate(usageBillingIdentity.RateMultiplier)
+ imageMultiplier = canonicalUsageBillingRate(resolveImageRateMultiplier(apiKey, multiplier))
}
// 确定计费模型
@@ -8367,7 +7417,14 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
billingModel = input.ChannelMappedModel
}
if input.BillingModelSource == BillingModelSourceRequested && input.OriginalModel != "" {
- billingModel = input.OriginalModel
+ if mappedBillingModel := mappedBillingModelOverRequested(input.OriginalModel, input.ChannelMappedModel, result.UpstreamModel, billingModel); mappedBillingModel != "" {
+ billingModel = mappedBillingModel
+ } else {
+ billingModel = input.OriginalModel
+ }
+ }
+ if usageBillingIdentity != nil {
+ billingModel = strings.TrimSpace(usageBillingIdentity.BillingModel)
}
// 确定 RequestedModel(渠道映射前的原始模型)
@@ -8375,12 +7432,25 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
if input.OriginalModel != "" {
requestedModel = input.OriginalModel
}
+ logModel := usageLogModelForMappedClaudeCompat(requestedModel, result.Model, input.ChannelMappedModel, result.UpstreamModel, billingModel)
// 计算费用
- cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, opts)
+ var cost *CostBreakdown
+ if usageBillingIdentity != nil {
+ var costErr error
+ cost, costErr = s.calculateFrozenGatewayUsageCost(ctx, result, apiKey, usageBillingIdentity)
+ if costErr != nil {
+ return errors.Join(ErrUsageBillingLifecycleContractInvalid, costErr)
+ }
+ } else {
+ cost = s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, opts)
+ }
- // 判断计费方式:订阅模式 vs 余额模式
- isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
+ // 判断计费方式:订阅模式 vs 余额模式。2026-05-17 follow-up:用户切 key 到
+ // plan_groups 链内 standard group 时,subscription 由 middleware GetActiveSubscriptionCoveringGroup
+ // 找到,billing 应该按订阅扣 sub quota。EffectiveBillingContext 统一判断。
+ isSubscriptionBilling, effectiveBillingGroup := EffectiveBillingContext(apiKey.Group, subscription)
+ effectiveBillingGroupID := resolveEffectiveBillingGroupID(apiKey.GroupID, effectiveBillingGroup, subscription)
billingType := BillingTypeBalance
if isSubscriptionBilling {
billingType = BillingTypeSubscription
@@ -8389,7 +7459,10 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
// 创建使用日志
accountRateMultiplier := account.BillingRateMultiplier()
usageLog := s.buildRecordUsageLog(ctx, input, result, apiKey, user, account, subscription,
- requestedModel, multiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost, opts)
+ requestedModel, logModel, multiplier, imageMultiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost, opts)
+ if usageBillingIdentity != nil {
+ usageLog.UsageBillingAttemptID = usageBillingIdentity.AdmissionAttemptID
+ }
// 计算账号统计定价费用(使用最终上游模型匹配自定义规则)
if apiKey.GroupID != nil {
@@ -8416,17 +7489,54 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
}
requestID := usageLog.RequestID
- _, billingErr := applyUsageBilling(ctx, requestID, usageLog, &postUsageBillingParams{
- Cost: cost,
- User: user,
- APIKey: apiKey,
- Account: account,
- Subscription: subscription,
- RequestPayloadHash: resolveUsageBillingPayloadFingerprint(ctx, input.RequestPayloadHash),
- IsSubscriptionBill: isSubscriptionBilling,
- AccountRateMultiplier: accountRateMultiplier,
- APIKeyService: input.APIKeyService,
- }, s.billingDeps(), s.usageBillingRepo)
+ billingParams := &postUsageBillingParams{
+ Cost: cost,
+ User: user,
+ APIKey: apiKey,
+ Account: account,
+ Subscription: subscription,
+ EffectiveBillingGroupID: effectiveBillingGroupID,
+ RequestPayloadHash: resolveUsageBillingPayloadFingerprint(ctx, input.RequestPayloadHash),
+ IsSubscriptionBill: isSubscriptionBilling,
+ AccountRateMultiplier: accountRateMultiplier,
+ APIKeyService: input.APIKeyService,
+ }
+ if s.requireUsageBillingOutbox {
+ if s.usageBillingOutboxRepo == nil {
+ return ErrUsageBillingOutboxUnavailable
+ }
+ quote := (*PricingQuote)(nil)
+ var err error
+ if usageBillingIdentity != nil {
+ quote = usageBillingIdentity.Pricing
+ } else {
+ quote, err = s.resolveRecordUsagePricingQuote(ctx, result, apiKey, billingModel, cost, opts)
+ if err != nil {
+ return fmt.Errorf("%w: immutable pricing quote is missing: %v", ErrUsageBillingOutboxUnavailable, err)
+ }
+ }
+ usageLog.BillingModel = optionalTrimmedStringPtr(billingModel)
+ usageLog.PricingSource = optionalTrimmedStringPtr(quote.Evidence.Source)
+ usageLog.PricingRevision = optionalTrimmedStringPtr(quote.Evidence.Revision)
+ usageLog.PricingHash = optionalTrimmedStringPtr(quote.Evidence.Hash)
+ cmd := buildUsageBillingCommand(requestID, usageLog, billingParams)
+ if cmd == nil {
+ return fmt.Errorf("%w: billing command is invalid", ErrUsageBillingEnvelopeInvalid)
+ }
+ envelope, err := NewUsageBillingEnvelopeFromUsageLog(usageLog, cmd)
+ if err != nil {
+ return err
+ }
+ if err := enqueueUsageBillingOutboxDurably(ctx, s.usageBillingOutboxRepo, envelope, "service.gateway.usage_billing_outbox"); err != nil {
+ return err
+ }
+ if s.usageBillingOutboxWake != nil {
+ s.usageBillingOutboxWake.Wake()
+ }
+ return nil
+ }
+
+ _, billingErr := applyUsageBilling(ctx, requestID, usageLog, billingParams, s.billingDeps(), s.usageBillingRepo)
if billingErr != nil {
return billingErr
@@ -8436,6 +7546,75 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
return nil
}
+func (s *GatewayService) resolveRecordUsagePricingQuote(
+ ctx context.Context,
+ result *ForwardResult,
+ apiKey *APIKey,
+ billingModel string,
+ cost *CostBreakdown,
+ opts *recordUsageOpts,
+) (*PricingQuote, error) {
+ if s == nil || s.billingService == nil || result == nil || cost == nil {
+ return nil, ErrOpenAIPricingUnavailable
+ }
+ if result.ImageCount > 0 {
+ if result.ImageCount <= 0 || !positiveFinitePrice(cost.TotalCost/float64(result.ImageCount)) {
+ return nil, ErrOpenAIPricingUnavailable
+ }
+ unitPrice := cost.TotalCost / float64(result.ImageCount)
+ source := PricingSourceBuiltinFallback
+ if apiKey != nil && apiKey.Group != nil {
+ var groupPrice *float64
+ switch result.ImageSize {
+ case "1K":
+ groupPrice = apiKey.Group.ImagePrice1K
+ case "2K":
+ groupPrice = apiKey.Group.ImagePrice2K
+ case "4K":
+ groupPrice = apiKey.Group.ImagePrice4K
+ }
+ if groupPrice != nil {
+ source = PricingSourceGroupImage
+ }
+ }
+ if source != PricingSourceGroupImage && s.billingService.pricingService != nil {
+ if pricing := s.billingService.pricingService.GetModelPricing(billingModel); pricing != nil && pricing.OutputCostPerImage > 0 {
+ source = PricingSourceLiteLLM
+ }
+ }
+ resolved := &ResolvedPricing{
+ Mode: BillingModeImage, Source: source, SourceExact: true,
+ DefaultPerRequestPrice: unitPrice,
+ RequestTiers: []PricingInterval{{TierLabel: result.ImageSize, PerRequestPrice: pricingFloat64Ptr(unitPrice)}},
+ }
+ return freezeResolvedPricingQuote(resolved)
+ }
+
+ resolver := s.resolver
+ if resolver == nil {
+ resolver = NewModelPricingResolver(s.channelService, s.billingService)
+ }
+ var groupID *int64
+ if apiKey != nil {
+ groupID = apiKey.GroupID
+ }
+ resolved := resolver.Resolve(ctx, PricingInput{Model: billingModel, GroupID: groupID})
+ if resolved == nil {
+ return nil, ErrOpenAIPricingUnavailable
+ }
+ resolved = cloneResolvedPricing(resolved)
+ if opts != nil && opts.LongContextThreshold > 0 && opts.LongContextMultiplier > 1 && resolved.Source != PricingSourceChannel {
+ if resolved.BasePricing == nil {
+ return nil, ErrOpenAIPricingUnavailable
+ }
+ resolved.BasePricing.LongContextInputThreshold = opts.LongContextThreshold
+ resolved.BasePricing.LongContextInputMultiplier = opts.LongContextMultiplier
+ resolved.BasePricing.LongContextOutputMultiplier = 1
+ resolved.Revision = "split-excess-long-context-v1"
+ }
+ return freezeResolvedPricingQuote(resolved)
+}
+
// calculateRecordUsageCost 根据请求类型和选项计算费用。
func (s *GatewayService) calculateRecordUsageCost(
ctx context.Context,
@@ -8443,11 +7622,12 @@ func (s *GatewayService) calculateRecordUsageCost(
apiKey *APIKey,
billingModel string,
multiplier float64,
+ imageMultiplier float64,
opts *recordUsageOpts,
) *CostBreakdown {
// 图片生成计费
if result.ImageCount > 0 {
- return s.calculateImageCost(ctx, result, apiKey, billingModel, multiplier)
+ return s.calculateImageCost(ctx, result, apiKey, billingModel, imageMultiplier)
}
// Token 计费
@@ -8488,7 +7668,8 @@ func (s *GatewayService) calculateImageCost(
Model: billingModel,
GroupID: &gid,
Tokens: tokens,
- RequestCount: 1,
+ RequestCount: result.ImageCount,
+ SizeTier: result.ImageSize,
RateMultiplier: multiplier,
Resolver: s.resolver,
Resolved: resolved,
@@ -8572,7 +7753,9 @@ func (s *GatewayService) buildRecordUsageLog(
account *Account,
subscription *UserSubscription,
requestedModel string,
+ logModel string,
multiplier float64,
+ imageMultiplier float64,
accountRateMultiplier float64,
billingType int8,
cacheTTLOverridden bool,
@@ -8586,9 +7769,9 @@ func (s *GatewayService) buildRecordUsageLog(
APIKeyID: apiKey.ID,
AccountID: account.ID,
RequestID: requestID,
- Model: result.Model,
+ Model: logModel,
RequestedModel: requestedModel,
- UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, result.Model),
+ UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, requestedModel),
ReasoningEffort: result.ReasoningEffort,
InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint),
UpstreamEndpoint: optionalTrimmedStringPtr(input.UpstreamEndpoint),
@@ -8617,6 +7800,9 @@ func (s *GatewayService) buildRecordUsageLog(
SubscriptionID: optionalSubscriptionID(subscription),
CreatedAt: time.Now(),
}
+ if result.ImageCount > 0 {
+ usageLog.RateMultiplier = imageMultiplier
+ }
if cost != nil {
usageLog.InputCost = cost.InputCost
usageLog.OutputCost = cost.OutputCost
@@ -8768,6 +7954,10 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
s.countTokensError(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
return fmt.Errorf("parse request: empty request")
}
+ if err := rejectAnthropicOAuthGatewayCredential(account); err != nil {
+ s.countTokensError(c, http.StatusServiceUnavailable, "api_error", "No compatible Anthropic account is available")
+ return err
+ }
if account != nil && account.IsAnthropicAPIKeyPassthroughEnabled() {
passthroughBody := parsed.Body
@@ -8792,22 +7982,6 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
// Pre-filter: strip empty text blocks to prevent upstream 400.
body = StripEmptyTextBlocks(body)
- isClaudeCodeCT := IsClaudeCodeClient(ctx) || isClaudeCodeClient(c.GetHeader("User-Agent"), parsed.MetadataUserID)
- shouldMimicClaudeCode := account.IsOAuth() && !isClaudeCodeCT
-
- if shouldMimicClaudeCode {
- normalizeOpts := claudeOAuthNormalizeOptions{stripSystemCacheControl: true}
- body, reqModel = normalizeClaudeOAuthRequestBody(body, reqModel, normalizeOpts)
-
- body = stripMessageCacheControl(body)
- body = addMessageCacheBreakpoints(body)
- if rw := buildToolNameRewriteFromBody(body); rw != nil {
- body = applyToolNameRewriteToBody(body, rw)
- } else {
- body = applyToolsLastCacheBreakpoint(body)
- }
- }
-
// Antigravity 账户不支持 count_tokens,返回 404 让客户端 fallback 到本地估算。
// 返回 nil 避免 handler 层记录为错误,也不设置 ops 上游错误上下文。
if account.Platform == PlatformAntigravity {
@@ -8849,13 +8023,13 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
}
// 构建上游请求
- upstreamReq, err := s.buildCountTokensRequest(ctx, c, account, body, token, tokenType, reqModel, shouldMimicClaudeCode)
+ upstreamReq, err := s.buildCountTokensRequest(ctx, c, account, body, token, tokenType, reqModel)
if err != nil {
s.countTokensError(c, http.StatusInternalServerError, "api_error", "Failed to build request")
return err
}
- // 获取代理URL(自定义 base URL 模式下,proxy 通过 buildCustomRelayURL 作为查询参数传递)
+ // 获取代理 URL;自定义 relay 路径在 request builder 中拒绝 proxy 组合。
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
if !account.IsCustomBaseURLEnabled() || account.GetCustomBaseURL() == "" {
@@ -8889,7 +8063,7 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
logger.LegacyPrintf("service.gateway", "Account %d: detected thinking block signature error on count_tokens, retrying with filtered thinking blocks", account.ID)
filteredBody := FilterThinkingBlocksForRetry(body)
- retryReq, buildErr := s.buildCountTokensRequest(ctx, c, account, filteredBody, token, tokenType, reqModel, shouldMimicClaudeCode)
+ retryReq, buildErr := s.buildCountTokensRequest(ctx, c, account, filteredBody, token, tokenType, reqModel)
if buildErr == nil {
retryResp, retryErr := s.httpUpstream.DoWithTLS(retryReq, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
if retryErr == nil {
@@ -9077,6 +8251,7 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
body []byte,
token string,
) (*http.Request, error) {
+ body = stripUntrustedAnthropicBillingAttribution(body)
targetURL := claudeAPICountTokensURL
baseURL := account.GetBaseURL()
if baseURL != "" {
@@ -9117,12 +8292,20 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
if req.Header.Get("anthropic-version") == "" {
req.Header.Set("anthropic-version", "2023-06-01")
}
+ if existingBeta := getHeaderRaw(req.Header, "anthropic-beta"); existingBeta != "" {
+ setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(existingBeta, defaultDroppedBetasSet))
+ }
+ applyAnthropicGatewayIdentity(req.Header)
return req, nil
}
// buildCountTokensRequest 构建 count_tokens 上游请求
-func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, mimicClaudeCode bool) (*http.Request, error) {
+func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string) (*http.Request, error) {
+ if err := rejectAnthropicOAuthGatewayCredential(account); err != nil {
+ return nil, err
+ }
+ body = stripUntrustedAnthropicBillingAttribution(body)
// 确定目标 URL
targetURL := claudeAPICountTokensURL
if account.Type == AccountTypeAPIKey {
@@ -9143,42 +8326,18 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
if err != nil {
return nil, err
}
- targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages/count_tokens", account)
+ if err := validateCustomRelayProxyIsolation(account); err != nil {
+ return nil, err
+ }
+ targetURL = s.buildCustomRelayURL(validatedURL, "/v1/messages/count_tokens")
}
clientHeaders := http.Header{}
if c != nil && c.Request != nil {
clientHeaders = c.Request.Header
}
-
- // OAuth 账号:应用统一指纹和重写 userID(受设置开关控制)
- // 如果启用了会话ID伪装,会在重写后替换 session 部分为固定值
- ctEnableFP, ctEnableMPT, ctEnableCCH := true, false, false
- if s.settingService != nil {
- ctEnableFP, ctEnableMPT, ctEnableCCH = 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 {
- 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
- }
- }
- }
- }
- }
-
- // 同步 billing header cc_version 与实际发送的 User-Agent 版本
- if ctFingerprint != nil && ctEnableFP {
- body = syncBillingHeaderVersion(body, ctFingerprint.UserAgent)
- }
- if ctEnableCCH {
- body = signBillingHeaderCCH(body)
+ if tokenType == "oauth" {
+ return nil, ErrAnthropicOAuthGatewayDisabled
}
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
@@ -9187,11 +8346,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
}
// 设置认证头(保持原始大小写)
- if tokenType == "oauth" {
- setHeaderRaw(req.Header, "authorization", "Bearer "+token)
- } else {
- setHeaderRaw(req.Header, "x-api-key", token)
- }
+ setHeaderRaw(req.Header, "x-api-key", token)
// 白名单透传 headers(恢复真实 wire casing)
for key, values := range clientHeaders {
@@ -9204,11 +8359,6 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
}
}
- // OAuth 账号:应用指纹到请求头(受设置开关控制)
- if ctEnableFP && ctFingerprint != nil {
- s.identityService.ApplyFingerprint(req, ctFingerprint)
- }
-
// 确保必要的 headers 存在(保持原始大小写)
if getHeaderRaw(req.Header, "content-type") == "" {
setHeaderRaw(req.Header, "content-type", "application/json")
@@ -9216,62 +8366,23 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
if getHeaderRaw(req.Header, "anthropic-version") == "" {
setHeaderRaw(req.Header, "anthropic-version", "2023-06-01")
}
- if tokenType == "oauth" {
- applyClaudeOAuthHeaderDefaults(req)
- }
// Build effective drop set for count_tokens: merge static defaults with dynamic beta policy filter rules
ctEffectiveDropSet := mergeDropSets(s.getBetaPolicyFilterSet(ctx, c, account, modelID))
- // OAuth 账号:处理 anthropic-beta header
- if tokenType == "oauth" {
- if mimicClaudeCode {
- applyClaudeCodeMimicHeaders(req, false)
-
- incomingBeta := getHeaderRaw(req.Header, "anthropic-beta")
- requiredBetas := append(claude.FullClaudeCodeMimicryBetas(), claude.BetaTokenCounting)
- setHeaderRaw(req.Header, "anthropic-beta", mergeAnthropicBetaDropping(requiredBetas, incomingBeta, ctEffectiveDropSet))
- } else {
- clientBetaHeader := getHeaderRaw(req.Header, "anthropic-beta")
- if clientBetaHeader == "" {
- setHeaderRaw(req.Header, "anthropic-beta", claude.CountTokensBetaHeader)
- } else {
- beta := s.getBetaHeader(modelID, clientBetaHeader)
- if !strings.Contains(beta, claude.BetaTokenCounting) {
- beta = beta + "," + claude.BetaTokenCounting
- }
- setHeaderRaw(req.Header, "anthropic-beta", stripBetaTokensWithSet(beta, ctEffectiveDropSet))
- }
- }
- } else {
- // API-key accounts: apply beta policy filter to strip controlled tokens
- 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 != "" {
- setHeaderRaw(req.Header, "anthropic-beta", beta)
- }
- }
- }
- }
-
- // 同步 X-Claude-Code-Session-Id 头:取 body 中已处理的 metadata.user_id 的 session_id 覆盖
- if sessionHeader := getHeaderRaw(req.Header, "X-Claude-Code-Session-Id"); sessionHeader != "" {
- if uid := gjson.GetBytes(body, "metadata.user_id").String(); uid != "" {
- if parsed := ParseMetadataUserID(uid); parsed != nil {
- setHeaderRaw(req.Header, "X-Claude-Code-Session-Id", parsed.SessionID)
+ // API-key accounts: apply beta policy filter to strip controlled tokens.
+ 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 != "" {
+ setHeaderRaw(req.Header, "anthropic-beta", beta)
}
}
}
- if c != nil && tokenType == "oauth" {
- c.Set(claudeMimicDebugInfoKey, buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode))
- }
- if s.debugClaudeMimicEnabled() {
- logClaudeMimicDebug(req, body, account, tokenType, mimicClaudeCode)
- }
+ applyAnthropicGatewayIdentity(req.Header)
return req, nil
}
@@ -9287,36 +8398,21 @@ func (s *GatewayService) countTokensError(c *gin.Context, status int, errType, m
})
}
-// buildCustomRelayURL 构建自定义中继转发 URL
-// 在 path 后附加 beta=true 和可选的 proxy 查询参数
-func (s *GatewayService) buildCustomRelayURL(baseURL, path string, account *Account) string {
- u := strings.TrimRight(baseURL, "/") + path + "?beta=true"
- if account.ProxyID != nil && account.Proxy != nil {
- proxyURL := account.Proxy.URL()
- if proxyURL != "" {
- u += "&proxy=" + url.QueryEscape(proxyURL)
- }
+// buildCustomRelayURL 构建自定义中继转发 URL。代理配置绝不能进入 query;
+// custom relay + proxy 的不兼容组合由 validateCustomRelayProxyIsolation 拒绝。
+func (s *GatewayService) buildCustomRelayURL(baseURL, path string) string {
+ return strings.TrimRight(baseURL, "/") + path + "?beta=true"
+}
+
+func validateCustomRelayProxyIsolation(account *Account) error {
+ if account != nil && account.ProxyID != nil {
+ return errors.New("custom relay cannot be combined with an account proxy; remove the proxy before enabling custom_base_url")
}
- return u
+ return nil
}
func (s *GatewayService) validateUpstreamBaseURL(raw string) (string, error) {
- if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
- normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
- if err != nil {
- return "", fmt.Errorf("invalid base_url: %w", err)
- }
- return normalized, nil
- }
- normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
- AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
- RequireAllowlist: true,
- AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
- })
- if err != nil {
- return "", fmt.Errorf("invalid base_url: %w", err)
- }
- return normalized, nil
+ return validateUpstreamBaseURLFormat(raw, s.cfg)
}
// GetAvailableModels returns the list of models available for a group
@@ -9446,6 +8542,10 @@ func reconcileCachedTokens(usage map[string]any) bool {
const debugGatewayBodyDefaultFilename = "gateway_debug.log"
+func gatewayBodyDebugAllowed(serverMode string) bool {
+ return strings.EqualFold(strings.TrimSpace(serverMode), "debug")
+}
+
// initDebugGatewayBodyFile 初始化网关调试日志文件。
//
// - "1"/"true" 等布尔值 → 当前目录下 gateway_debug.log
@@ -9463,17 +8563,22 @@ func (s *GatewayService) initDebugGatewayBodyFile(path string) {
// 确保父目录存在
if dir := filepath.Dir(path); dir != "." {
- if err := os.MkdirAll(dir, 0755); err != nil {
+ if err := os.MkdirAll(dir, 0o700); 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)
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
slog.Error("failed to open gateway debug log file", "path", path, "error", err)
return
}
+ if err := f.Chmod(0o600); err != nil {
+ _ = f.Close()
+ slog.Error("failed to restrict gateway debug log permissions", "path", path, "error", err)
+ return
+ }
s.debugGatewayBodyFile.Store(f)
slog.Info("gateway debug logging enabled", "path", path)
}
diff --git a/backend/internal/service/gateway_service_antigravity_whitelist_test.go b/backend/internal/service/gateway_service_antigravity_whitelist_test.go
index c078be32649..15e14ea04e2 100644
--- a/backend/internal/service/gateway_service_antigravity_whitelist_test.go
+++ b/backend/internal/service/gateway_service_antigravity_whitelist_test.go
@@ -68,6 +68,26 @@ func TestGatewayService_isModelSupportedByAccount_AntigravityNoMapping(t *testin
require.False(t, svc.isModelSupportedByAccount(account, "gpt-4"))
}
+func TestGatewayService_isModelSupportedByAccount_KiroClaudeAliasNormalization(t *testing.T) {
+ svc := &GatewayService{}
+
+ account := &Account{
+ Platform: PlatformKiro,
+ Credentials: map[string]any{
+ "model_mapping": map[string]any{
+ "claude-opus-4-7": "claude-opus-4-7",
+ "claude-sonnet-4-6": "claude-sonnet-4-6",
+ "claude-haiku-4-5-20251001": "claude-haiku-4-5-20251001",
+ },
+ },
+ }
+
+ require.True(t, svc.isModelSupportedByAccount(account, "claude-opus-4-7"))
+ require.True(t, svc.isModelSupportedByAccount(account, "claude-sonnet-4-6"))
+ require.True(t, svc.isModelSupportedByAccount(account, "claude-haiku-4-5"))
+ require.False(t, svc.isModelSupportedByAccount(account, "claude-sonnet-4-5-20250929"))
+}
+
// TestGatewayService_isModelSupportedByAccountWithContext_ThinkingMode 测试 thinking 模式下的模型支持检查
// 验证调度时使用映射后的最终模型名(包括 thinking 后缀)来检查 model_mapping 支持
func TestGatewayService_isModelSupportedByAccountWithContext_ThinkingMode(t *testing.T) {
diff --git a/backend/internal/service/gateway_service_streaming_test.go b/backend/internal/service/gateway_service_streaming_test.go
index c8803d39ed1..334a115c4eb 100644
--- a/backend/internal/service/gateway_service_streaming_test.go
+++ b/backend/internal/service/gateway_service_streaming_test.go
@@ -1,10 +1,12 @@
package service
import (
+ "bufio"
"context"
"io"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
"time"
@@ -13,8 +15,9 @@ import (
"github.com/stretchr/testify/require"
)
+type upstreamContextTestKey string
+
func TestGatewayService_StreamingReusesScannerBufferAndStillParsesUsage(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
StreamDataIntervalTimeout: 0,
@@ -42,7 +45,7 @@ func TestGatewayService_StreamingReusesScannerBufferAndStillParsesUsage(t *testi
_, _ = pw.Write([]byte("data: [DONE]\n\n"))
}()
- result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model", false)
+ result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model")
_ = pr.Close()
require.NoError(t, err)
require.NotNil(t, result)
@@ -50,3 +53,47 @@ func TestGatewayService_StreamingReusesScannerBufferAndStillParsesUsage(t *testi
require.Equal(t, 3, result.usage.InputTokens)
require.Equal(t, 7, result.usage.OutputTokens)
}
+
+func TestGatewayService_StreamingRejectsLineAboveConfiguredLimit(t *testing.T) {
+ const maxLineSize = 1024 * 1024
+ cfg := &config.Config{
+ Gateway: config.GatewayConfig{
+ StreamDataIntervalTimeout: 0,
+ MaxLineSize: maxLineSize,
+ },
+ }
+
+ svc := &GatewayService{
+ cfg: cfg,
+ rateLimitService: &RateLimitService{},
+ }
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
+
+ pr, pw := io.Pipe()
+ resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: pr}
+
+ go func() {
+ defer func() { _ = pw.Close() }()
+ _, _ = pw.Write([]byte("data: " + strings.Repeat("x", maxLineSize) + "\n\n"))
+ }()
+
+ result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model")
+ _ = pr.Close()
+ require.ErrorIs(t, err, bufio.ErrTooLong)
+ require.NotNil(t, result)
+ require.Contains(t, rec.Body.String(), "response_too_large")
+}
+
+func TestDetachUpstreamContextIgnoresClientCancel(t *testing.T) {
+ parent, cancel := context.WithCancel(context.WithValue(context.Background(), upstreamContextTestKey("test-key"), "test-value"))
+ upstreamCtx, release := detachUpstreamContext(parent)
+ defer release()
+
+ cancel()
+
+ require.NoError(t, upstreamCtx.Err())
+ require.Equal(t, "test-value", upstreamCtx.Value(upstreamContextTestKey("test-key")))
+}
diff --git a/backend/internal/service/gateway_service_subscription_billing_test.go b/backend/internal/service/gateway_service_subscription_billing_test.go
index 42a81035c0b..585a714e183 100644
--- a/backend/internal/service/gateway_service_subscription_billing_test.go
+++ b/backend/internal/service/gateway_service_subscription_billing_test.go
@@ -83,3 +83,126 @@ func TestBuildUsageBillingCommand_SubscriptionAppliesRateMultiplier(t *testing.T
})
}
}
+
+// TestBuildUsageBillingCommand_WalletModeRoutesToWalletCost 锁定 v4 钱包订阅
+// 走 cmd.WalletCost(而不是 SubscriptionCost)— 这俩字段在事务内必须互斥。
+func TestBuildUsageBillingCommand_WalletModeRoutesToWalletCost(t *testing.T) {
+ t.Parallel()
+
+ groupID := int64(7)
+ subID := int64(42)
+ walletBal := 100.0
+
+ t.Run("wallet mode subscription deducts WalletCost", func(t *testing.T) {
+ t.Parallel()
+ p := &postUsageBillingParams{
+ Cost: &CostBreakdown{TotalCost: 1.0, ActualCost: 2.5},
+ User: &User{ID: 1},
+ APIKey: &APIKey{ID: 2, GroupID: &groupID},
+ Account: &Account{ID: 3},
+ Subscription: &UserSubscription{
+ ID: subID,
+ // WalletBalanceUSD != nil → IsWalletMode() == true
+ WalletBalanceUSD: &walletBal,
+ },
+ IsSubscriptionBill: true,
+ }
+
+ cmd := buildUsageBillingCommand("req-w1", nil, p)
+ if cmd == nil {
+ t.Fatal("buildUsageBillingCommand returned nil")
+ }
+ if cmd.WalletCost != 2.5 {
+ t.Errorf("WalletCost = %v, want 2.5", cmd.WalletCost)
+ }
+ if cmd.SubscriptionCost != 0 {
+ t.Errorf("SubscriptionCost must be 0 in wallet mode, got %v", cmd.SubscriptionCost)
+ }
+ if cmd.BalanceCost != 0 {
+ t.Errorf("BalanceCost must be 0 in wallet mode, got %v", cmd.BalanceCost)
+ }
+ if cmd.SubscriptionID == nil || *cmd.SubscriptionID != subID {
+ t.Errorf("SubscriptionID must be set to %d, got %v", subID, cmd.SubscriptionID)
+ }
+ })
+
+ t.Run("v3 group subscription still uses SubscriptionCost", func(t *testing.T) {
+ t.Parallel()
+ p := &postUsageBillingParams{
+ Cost: &CostBreakdown{TotalCost: 1.0, ActualCost: 2.5},
+ User: &User{ID: 1},
+ APIKey: &APIKey{ID: 2, GroupID: &groupID},
+ Account: &Account{ID: 3},
+ Subscription: &UserSubscription{
+ ID: subID,
+ GroupID: &groupID,
+ // WalletBalanceUSD == nil → IsWalletMode() == false
+ },
+ IsSubscriptionBill: true,
+ }
+
+ cmd := buildUsageBillingCommand("req-w2", nil, p)
+ if cmd.SubscriptionCost != 2.5 {
+ t.Errorf("SubscriptionCost = %v, want 2.5", cmd.SubscriptionCost)
+ }
+ if cmd.WalletCost != 0 {
+ t.Errorf("WalletCost must be 0 in v3 mode, got %v", cmd.WalletCost)
+ }
+ })
+}
+
+func TestShouldUseSubscriptionBilling_WalletModeIgnoresStandardGroup(t *testing.T) {
+ t.Parallel()
+
+ walletBal := 100.0
+
+ tests := []struct {
+ name string
+ subscription *UserSubscription
+ group *Group
+ want bool
+ }{
+ {
+ name: "nil subscription uses balance billing",
+ subscription: nil,
+ group: &Group{SubscriptionType: SubscriptionTypeSubscription},
+ want: false,
+ },
+ {
+ name: "wallet mode with standard group uses subscription billing",
+ subscription: &UserSubscription{
+ ID: 45,
+ WalletBalanceUSD: &walletBal,
+ },
+ group: &Group{ID: 3},
+ want: true,
+ },
+ {
+ name: "v3 subscription group still uses subscription billing",
+ subscription: &UserSubscription{ID: 16},
+ group: &Group{SubscriptionType: SubscriptionTypeSubscription},
+ want: true,
+ },
+ {
+ // 2026-05-17 follow-up:语义升级。一旦 middleware 注入 subscription
+ // (经 GetActiveSubscriptionCoveringGroup 找到 plan_groups 覆盖),
+ // billing 就该信它走订阅 quota,而不是 fallback 到主余额 balance
+ // (避免老 bug:24+ 用户 818 次静默扣 balance)。
+ // 详见 docs/plans/2026-05-16-wallet-v4-group-switch-billing-fix.md。
+ name: "v3 subscription on standard group (covering) uses subscription billing",
+ subscription: &UserSubscription{ID: 16},
+ group: &Group{ID: 3},
+ want: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ if got, _ := EffectiveBillingContext(tt.group, tt.subscription); got != tt.want {
+ t.Fatalf("EffectiveBillingContext() subscription billing = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/backend/internal/service/gateway_sticky_ttl_test.go b/backend/internal/service/gateway_sticky_ttl_test.go
new file mode 100644
index 00000000000..a990f899daf
--- /dev/null
+++ b/backend/internal/service/gateway_sticky_ttl_test.go
@@ -0,0 +1,34 @@
+package service
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGatewayServiceStickySessionTTL_ConfigOverridesDefault(t *testing.T) {
+ cache := &stubGatewayCache{}
+ svc := &GatewayService{
+ cache: cache,
+ cfg: &config.Config{
+ Gateway: config.GatewayConfig{
+ OpenAIWS: config.GatewayOpenAIWSConfig{
+ StickySessionTTLSeconds: 600,
+ },
+ },
+ },
+ }
+
+ require.Equal(t, 600*time.Second, svc.stickySessionTTL())
+ require.NoError(t, svc.BindStickySession(context.Background(), nil, "session-hash", 42))
+ require.Equal(t, 600*time.Second, cache.lastSetTTL)
+}
+
+func TestGatewayServiceStickySessionTTL_DefaultWhenUnset(t *testing.T) {
+ svc := &GatewayService{cfg: &config.Config{}}
+
+ require.Equal(t, stickySessionTTL, svc.stickySessionTTL())
+}
diff --git a/backend/internal/service/gateway_stream_limits.go b/backend/internal/service/gateway_stream_limits.go
new file mode 100644
index 00000000000..4533418291c
--- /dev/null
+++ b/backend/internal/service/gateway_stream_limits.go
@@ -0,0 +1,14 @@
+package service
+
+import "github.com/Wei-Shaw/sub2api/internal/config"
+
+func resolveGatewayMaxLineSize(cfg *config.Config) int {
+ limit := defaultMaxLineSize
+ if cfg != nil && cfg.Gateway.MaxLineSize > 0 {
+ limit = cfg.Gateway.MaxLineSize
+ }
+ if limit > config.MaxGatewayMaxLineSize {
+ return config.MaxGatewayMaxLineSize
+ }
+ return limit
+}
diff --git a/backend/internal/service/gateway_stream_limits_test.go b/backend/internal/service/gateway_stream_limits_test.go
new file mode 100644
index 00000000000..a77b15fbe60
--- /dev/null
+++ b/backend/internal/service/gateway_stream_limits_test.go
@@ -0,0 +1,20 @@
+package service
+
+import (
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/stretchr/testify/require"
+)
+
+func TestResolveGatewayMaxLineSizeUsesSecureDefaultAndHardCeiling(t *testing.T) {
+ t.Parallel()
+
+ require.Equal(t, config.DefaultGatewayMaxLineSize, resolveGatewayMaxLineSize(nil))
+ require.Equal(t, 2*1024*1024, resolveGatewayMaxLineSize(&config.Config{
+ Gateway: config.GatewayConfig{MaxLineSize: 2 * 1024 * 1024},
+ }))
+ require.Equal(t, config.MaxGatewayMaxLineSize, resolveGatewayMaxLineSize(&config.Config{
+ Gateway: config.GatewayConfig{MaxLineSize: 512 * 1024 * 1024},
+ }))
+}
diff --git a/backend/internal/service/gateway_streaming_test.go b/backend/internal/service/gateway_streaming_test.go
index ef09a882e8e..36528637121 100644
--- a/backend/internal/service/gateway_streaming_test.go
+++ b/backend/internal/service/gateway_streaming_test.go
@@ -138,7 +138,6 @@ func TestParseSSEUsage_DoneEvent(t *testing.T) {
// --- 流式响应端到端测试 ---
func TestHandleStreamingResponse_CacheTokens(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newMinimalGatewayService()
rec := httptest.NewRecorder()
@@ -155,7 +154,7 @@ func TestHandleStreamingResponse_CacheTokens(t *testing.T) {
_, _ = pw.Write([]byte("data: [DONE]\n\n"))
}()
- result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model", false)
+ result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model")
_ = pr.Close()
require.NoError(t, err)
require.NotNil(t, result)
@@ -167,7 +166,6 @@ func TestHandleStreamingResponse_CacheTokens(t *testing.T) {
}
func TestHandleStreamingResponse_EmptyStream(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newMinimalGatewayService()
rec := httptest.NewRecorder()
@@ -182,7 +180,7 @@ func TestHandleStreamingResponse_EmptyStream(t *testing.T) {
_ = pw.Close()
}()
- result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model", false)
+ result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model")
_ = pr.Close()
require.Error(t, err)
require.Contains(t, err.Error(), "missing terminal event")
@@ -190,7 +188,6 @@ func TestHandleStreamingResponse_EmptyStream(t *testing.T) {
}
func TestHandleStreamingResponse_SpecialCharactersInJSON(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newMinimalGatewayService()
rec := httptest.NewRecorder()
@@ -209,7 +206,7 @@ func TestHandleStreamingResponse_SpecialCharactersInJSON(t *testing.T) {
_, _ = pw.Write([]byte("data: [DONE]\n\n"))
}()
- result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model", false)
+ result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model")
_ = pr.Close()
require.NoError(t, err)
require.NotNil(t, result)
@@ -225,7 +222,6 @@ func TestHandleStreamingResponse_SpecialCharactersInJSON(t *testing.T) {
// 上游中途读错误(如 HTTP/2 GOAWAY 触发的 unexpected EOF)发生在向客户端写入任何字节前:
// 网关应返回 *UpstreamFailoverError 触发账号 failover/重试,而不是把错误事件直接发给客户端。
func TestHandleStreamingResponse_StreamReadErrorBeforeOutput_TriggersFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newMinimalGatewayService()
rec := httptest.NewRecorder()
@@ -238,7 +234,7 @@ func TestHandleStreamingResponse_StreamReadErrorBeforeOutput_TriggersFailover(t
Body: &streamReadCloser{err: io.ErrUnexpectedEOF},
}
- result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model", false)
+ result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model")
require.Error(t, err)
require.Nil(t, result, "失败移交场景下不应返回 streamingResult")
@@ -264,7 +260,6 @@ func TestHandleStreamingResponse_StreamReadErrorBeforeOutput_TriggersFailover(t
// 上游已经发送过事件(c.Writer 已写过字节)后再发生读错误:
// SSE 协议无 resume,网关只能透传 stream_read_error 错误事件给客户端,不能 failover。
func TestHandleStreamingResponse_StreamReadErrorAfterOutput_PassesThrough(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newMinimalGatewayService()
rec := httptest.NewRecorder()
@@ -281,7 +276,7 @@ func TestHandleStreamingResponse_StreamReadErrorAfterOutput_PassesThrough(t *tes
},
}
- result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model", false)
+ result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model")
require.Error(t, err)
require.Contains(t, err.Error(), "stream read error", "已开始流后应透传普通 stream read error")
@@ -355,7 +350,6 @@ func TestSanitizeStreamError_KnownErrors(t *testing.T) {
// failover ResponseBody 必须用 sanitize 过的消息,避免泄露给客户端 / 写入 ops 日志
// 时携带内部地址信息。
func TestHandleStreamingResponse_FailoverBodyDoesNotLeakAddresses(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := newMinimalGatewayService()
rec := httptest.NewRecorder()
@@ -378,7 +372,7 @@ func TestHandleStreamingResponse_FailoverBodyDoesNotLeakAddresses(t *testing.T)
Body: &streamReadCloser{err: netErr},
}
- _, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model", false)
+ _, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "model", "model")
require.Error(t, err)
var failoverErr *UpstreamFailoverError
diff --git a/backend/internal/service/gateway_tool_rewrite.go b/backend/internal/service/gateway_tool_rewrite.go
deleted file mode 100644
index c76cab62d72..00000000000
--- a/backend/internal/service/gateway_tool_rewrite.go
+++ /dev/null
@@ -1,313 +0,0 @@
-package service
-
-import (
- "fmt"
- "hash/fnv"
- "math/rand"
- "sort"
- "strings"
-
- "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
-)
-
-// toolNameRewriteKey 是 gin.Context 上存 ToolNameRewrite 映射的 key。
-// 请求阶段写入,响应阶段读取,用于 bytes 级逆向还原假名 → 真名。
-const toolNameRewriteKey = "claude_tool_name_rewrite"
-
-// staticToolNameRewrites 是"静态前缀映射",与 Parrot src/transform/cc_mimicry.py
-// TOOL_NAME_REWRITES 完全一致。只有以这些前缀开头的工具会被重写。
-var staticToolNameRewrites = map[string]string{
- "sessions_": "cc_sess_",
- "session_": "cc_ses_",
-}
-
-// fakeToolNamePrefixes 是"动态映射"的前缀池,与 Parrot _FAKE_PREFIXES 一致。
-// 当 tools 数量 > dynamicToolMapThreshold 时随机选用其中前缀生成可读假名。
-var fakeToolNamePrefixes = []string{
- "analyze_", "compute_", "fetch_", "generate_", "lookup_", "modify_",
- "process_", "query_", "render_", "resolve_", "sync_", "update_",
- "validate_", "convert_", "extract_", "manage_", "monitor_", "parse_",
- "review_", "search_", "transform_", "handle_", "invoke_", "notify_",
-}
-
-// dynamicToolMapThreshold 与 Parrot 一致:tools 数量超过 5 才启用动态映射。
-// 少量工具不需要混淆(一般是 Claude Code 自己的核心工具 bash/edit/read 等)。
-const dynamicToolMapThreshold = 5
-
-// ToolNameRewrite 是单次请求内的工具名混淆映射。
-// - Forward: real → fake,请求阶段在 body 上应用。
-// - Reverse: fake → real,响应阶段对每个 chunk 做 bytes.Replace 还原。
-//
-// ReverseOrdered 是按假名长度倒序的 (fake, real) 列表,用于防止短假名是长假名的
-// 子串时 bytes.Replace 先被吃掉(对齐 Parrot _restore_tool_names_in_chunk 的
-// `sorted(..., key=lambda x: len(x[1]), reverse=True)`)。
-type ToolNameRewrite struct {
- Forward map[string]string
- Reverse map[string]string
- ReverseOrdered [][2]string
-}
-
-// buildDynamicToolMap 构造 tools 的动态假名映射。
-//
-// 与 Parrot _build_dynamic_tool_map 语义等价:
-// - tools 数量 ≤ dynamicToolMapThreshold 时返回 nil(不做动态映射,走静态 fallback)
-// - 同一组 tool_names 在同进程内映射稳定(保证 cache 命中)
-//
-// Parrot 用 `random.Random(hash(tuple(tool_names)))` 作 seed + shuffle 前缀池;
-// Go 无法字节级复刻 Python hash,但"稳定性"和"前缀池打散"两个不变量都保留:
-// 用 fnv64a(strings.Join(names, "\x00")) 作 seed 喂 math/rand.New。
-// 字节级不同不影响上游判定(Anthropic 不会验证我们的随机种子算法)。
-func buildDynamicToolMap(toolNames []string) map[string]string {
- if len(toolNames) <= dynamicToolMapThreshold {
- return nil
- }
- h := fnv.New64a()
- for i, n := range toolNames {
- if i > 0 {
- _, _ = h.Write([]byte{0})
- }
- _, _ = h.Write([]byte(n))
- }
- rng := rand.New(rand.NewSource(int64(h.Sum64())))
-
- available := make([]string, len(fakeToolNamePrefixes))
- copy(available, fakeToolNamePrefixes)
- rng.Shuffle(len(available), func(i, j int) { available[i], available[j] = available[j], available[i] })
-
- mapping := make(map[string]string, len(toolNames))
- for i, name := range toolNames {
- prefix := available[i%len(available)]
- headLen := 3
- if len(name) < 3 {
- headLen = len(name)
- }
- fake := fmt.Sprintf("%s%s%02d", prefix, name[:headLen], i)
- mapping[name] = fake
- }
- return mapping
-}
-
-// sanitizeToolName 把真名转成假名。
-// 与 Parrot _sanitize_tool_name 语义一致:动态映射优先,再走静态前缀映射。
-func sanitizeToolName(name string, dynamic map[string]string) string {
- if dynamic != nil {
- if fake, ok := dynamic[name]; ok {
- return fake
- }
- }
- for prefix, replacement := range staticToolNameRewrites {
- if strings.HasPrefix(name, prefix) {
- return replacement + name[len(prefix):]
- }
- }
- return name
-}
-
-// shouldMimicToolName 指示某个 tool 是否需要重命名。
-// server tool(type != "" 且不是 "function" / "custom")是 Anthropic 协议语义的一部分,
-// 比如 "web_search_20250305" / "computer_20250124";误改会导致上游拒绝。
-func shouldMimicToolName(toolType string) bool {
- if toolType == "" || toolType == "function" || toolType == "custom" {
- return true
- }
- return false
-}
-
-// buildToolNameRewriteFromBody 扫描 body 的 tools[*].name,构造 ToolNameRewrite
-// 并返回它。若不需要混淆(tools 数量不足 + 没有匹配静态前缀的工具)返回 nil。
-//
-// 注意:只扫描,不改 body。真正的 body 改写在 applyToolNameRewriteToBody。
-func buildToolNameRewriteFromBody(body []byte) *ToolNameRewrite {
- tools := gjson.GetBytes(body, "tools")
- if !tools.IsArray() {
- return nil
- }
-
- mimicableNames := make([]string, 0)
- toolsArr := tools.Array()
- for _, t := range toolsArr {
- if !shouldMimicToolName(t.Get("type").String()) {
- continue
- }
- name := t.Get("name").String()
- if name == "" {
- continue
- }
- mimicableNames = append(mimicableNames, name)
- }
-
- dynamic := buildDynamicToolMap(mimicableNames)
-
- rw := &ToolNameRewrite{
- Forward: make(map[string]string),
- Reverse: make(map[string]string),
- }
- for _, name := range mimicableNames {
- fake := sanitizeToolName(name, dynamic)
- if fake == name {
- continue
- }
- rw.Forward[name] = fake
- rw.Reverse[fake] = name
- }
- if len(rw.Forward) == 0 {
- return nil
- }
-
- rw.ReverseOrdered = make([][2]string, 0, len(rw.Reverse))
- for fake, real := range rw.Reverse {
- rw.ReverseOrdered = append(rw.ReverseOrdered, [2]string{fake, real})
- }
- sort.SliceStable(rw.ReverseOrdered, func(i, j int) bool {
- return len(rw.ReverseOrdered[i][0]) > len(rw.ReverseOrdered[j][0])
- })
-
- return rw
-}
-
-// applyToolNameRewriteToBody 把已构造的 ToolNameRewrite 应用到 body 上:
-// - 改写 $.tools[*].name(仅对 shouldMimicToolName 通过的 tool)
-// - 在 $.tools[last].cache_control 上打 ephemeral 缓存断点(Parrot 行为对齐,
-// ttl 客户端已有则透传,否则默认 claude.DefaultCacheControlTTL)
-// - 改写 $.tool_choice.name(仅当 $.tool_choice.type == "tool")
-//
-// 历史 $.messages[*].content[*].name(tool_use)不在请求侧改写——这与 Parrot 一致;
-// 响应侧 bytes.Replace 会连带还原它们。
-func applyToolNameRewriteToBody(body []byte, rw *ToolNameRewrite) []byte {
- if rw == nil || len(rw.Forward) == 0 {
- body = applyToolsLastCacheBreakpoint(body)
- return body
- }
-
- tools := gjson.GetBytes(body, "tools")
- if tools.IsArray() {
- idx := -1
- tools.ForEach(func(_, t gjson.Result) bool {
- idx++
- if !shouldMimicToolName(t.Get("type").String()) {
- return true
- }
- name := t.Get("name").String()
- if name == "" {
- return true
- }
- fake, ok := rw.Forward[name]
- if !ok {
- return true
- }
- if next, err := sjson.SetBytes(body, fmt.Sprintf("tools.%d.name", idx), fake); err == nil {
- body = next
- }
- return true
- })
- }
-
- if tc := gjson.GetBytes(body, "tool_choice"); tc.Exists() && tc.Get("type").String() == "tool" {
- name := tc.Get("name").String()
- if fake, ok := rw.Forward[name]; ok {
- if next, err := sjson.SetBytes(body, "tool_choice.name", fake); err == nil {
- body = next
- }
- }
- }
-
- body = applyToolsLastCacheBreakpoint(body)
- return body
-}
-
-// applyToolsLastCacheBreakpoint 在 tools 数组最后一个工具上注入 cache_control
-// 断点,对齐 Parrot `tools[-1]["cache_control"] = {"type":"ephemeral","ttl":"1h"}`
-// 行为,但 ttl 按本仓规则:
-// - 客户端已为该 tool 显式设置 cache_control.ttl → 完全透传不覆盖
-// - 否则注入 {"type":"ephemeral","ttl": claude.DefaultCacheControlTTL}
-//
-// 纯副作用函数,tools 不存在或为空数组时 no-op。
-func applyToolsLastCacheBreakpoint(body []byte) []byte {
- tools := gjson.GetBytes(body, "tools")
- if !tools.IsArray() {
- return body
- }
- arr := tools.Array()
- if len(arr) == 0 {
- return body
- }
- lastIdx := len(arr) - 1
- existingCC := arr[lastIdx].Get("cache_control")
-
- if existingCC.Exists() && existingCC.Get("ttl").String() != "" {
- return body
- }
-
- if existingCC.Exists() {
- if next, err := sjson.SetBytes(body, fmt.Sprintf("tools.%d.cache_control.ttl", lastIdx), claude.DefaultCacheControlTTL); err == nil {
- body = next
- }
- return body
- }
-
- raw := fmt.Sprintf(`{"type":"ephemeral","ttl":%q}`, claude.DefaultCacheControlTTL)
- if next, err := sjson.SetRawBytes(body, fmt.Sprintf("tools.%d.cache_control", lastIdx), []byte(raw)); err == nil {
- body = next
- }
- return body
-}
-
-// restoreToolNamesInBytes 对 bytes chunk 做逆向还原:假名 → 真名。
-// 按 ReverseOrdered 的假名长度倒序逐个 bytes.Replace,防止子串冲突
-// (与 Parrot _restore_tool_names_in_chunk 的 sorted(..., reverse=True) 等价)。
-// 再做静态前缀还原(cc_sess_ → sessions_ / cc_ses_ → session_)。
-//
-// rw 可为 nil;nil 时仍会做静态前缀还原。
-func restoreToolNamesInBytes(data []byte, rw *ToolNameRewrite) []byte {
- if rw != nil {
- for _, pair := range rw.ReverseOrdered {
- fake, real := pair[0], pair[1]
- if fake == "" || fake == real {
- continue
- }
- data = replaceAllBytes(data, fake, real)
- }
- }
- for prefix, replacement := range staticToolNameRewrites {
- data = replaceAllBytes(data, replacement, prefix)
- }
- return data
-}
-
-// replaceAllBytes 是 bytes.ReplaceAll 的便捷封装,避免每个调用点各自做 []byte 转换。
-func replaceAllBytes(data []byte, from, to string) []byte {
- if len(data) == 0 || from == to || !strings.Contains(string(data), from) {
- return data
- }
- return []byte(strings.ReplaceAll(string(data), from, to))
-}
-
-// toolNameRewriteFromContext 从 gin.Context 取出请求阶段保存的工具名映射。
-// 找不到(c==nil 或 key 不存在或类型不对)时返回 nil;调用方必须能处理 nil。
-func toolNameRewriteFromContext(c interface {
- Get(string) (any, bool)
-}) *ToolNameRewrite {
- if c == nil {
- return nil
- }
- raw, ok := c.Get(toolNameRewriteKey)
- if !ok || raw == nil {
- return nil
- }
- rw, _ := raw.(*ToolNameRewrite)
- return rw
-}
-
-// reverseToolNamesIfPresent 是响应侧 5 处注入点的统一封装:从 c 取出 mapping
-// 并对 chunk 做 bytes 级假名→真名替换。c 没有 mapping 时仍会做静态前缀还原。
-func reverseToolNamesIfPresent(c interface {
- Get(string) (any, bool)
-}, chunk []byte) []byte {
- rw := toolNameRewriteFromContext(c)
- if rw == nil && len(staticToolNameRewrites) == 0 {
- return chunk
- }
- return restoreToolNamesInBytes(chunk, rw)
-}
diff --git a/backend/internal/service/gateway_tool_rewrite_test.go b/backend/internal/service/gateway_tool_rewrite_test.go
deleted file mode 100644
index 8f0e3939c38..00000000000
--- a/backend/internal/service/gateway_tool_rewrite_test.go
+++ /dev/null
@@ -1,185 +0,0 @@
-package service
-
-import (
- "strings"
- "testing"
-
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
-)
-
-func TestBuildDynamicToolMap_BelowThreshold(t *testing.T) {
- // Parrot 行为:tools 数量 ≤ 5 时不做动态映射。
- names := []string{"bash", "edit", "read", "write", "search"}
- require.Nil(t, buildDynamicToolMap(names))
-}
-
-func TestBuildDynamicToolMap_AboveThresholdIsStable(t *testing.T) {
- // Parrot 不变量:同一组 tool_names 在同进程内映射稳定(保证 cache 命中)。
- names := []string{"alpha", "beta", "gamma", "delta", "epsilon", "zeta"}
- a := buildDynamicToolMap(names)
- b := buildDynamicToolMap(names)
- require.NotNil(t, a)
- require.Equal(t, a, b, "same input tool_names must yield identical mapping")
- require.Len(t, a, 6)
- for _, name := range names {
- require.Contains(t, a, name)
- require.NotEqual(t, name, a[name])
- }
-}
-
-func TestSanitizeToolName_StaticPrefix(t *testing.T) {
- require.Equal(t, "cc_sess_list", sanitizeToolName("sessions_list", nil))
- require.Equal(t, "cc_ses_get", sanitizeToolName("session_get", nil))
- require.Equal(t, "bash", sanitizeToolName("bash", nil))
-}
-
-func TestSanitizeToolName_DynamicTakesPrecedence(t *testing.T) {
- dyn := map[string]string{"sessions_list": "analyze_ses00"}
- got := sanitizeToolName("sessions_list", dyn)
- require.Equal(t, "analyze_ses00", got, "dynamic mapping wins over static prefix")
-}
-
-func TestRestoreToolNamesInBytes_LongestFirst(t *testing.T) {
- // 当假名 "abc_12" 是另一个更长假名的子串(真实场景极少但算法必须防御)时,
- // 长的必须先替换。本测试用显式构造的映射来验证排序不变量。
- rw := &ToolNameRewrite{
- Forward: map[string]string{"foo": "abc_12", "bar": "abc_12_ext"},
- Reverse: map[string]string{"abc_12": "foo", "abc_12_ext": "bar"},
- }
- // 手工构造 ReverseOrdered:长的在前
- rw.ReverseOrdered = [][2]string{
- {"abc_12_ext", "bar"},
- {"abc_12", "foo"},
- }
- data := []byte(`{"tool":"abc_12_ext","other":"abc_12"}`)
- restored := string(restoreToolNamesInBytes(data, rw))
- require.Equal(t, `{"tool":"bar","other":"foo"}`, restored)
-}
-
-func TestRestoreToolNamesInBytes_StaticPrefixRollback(t *testing.T) {
- data := []byte(`{"name":"sessions_list","id":"cc_ses_xyz"}`)
- got := string(restoreToolNamesInBytes(data, nil))
- require.Equal(t, `{"name":"sessions_list","id":"session_xyz"}`, got)
-}
-
-func TestApplyToolNameRewriteToBody_RenamesToolsAndToolChoice(t *testing.T) {
- body := []byte(`{"tools":[{"name":"sessions_list","input_schema":{}},{"name":"session_get","input_schema":{}},{"name":"web_search","type":"web_search_20250305"}],"tool_choice":{"type":"tool","name":"sessions_list"}}`)
- rw := buildToolNameRewriteFromBody(body)
- require.NotNil(t, rw)
- require.Contains(t, rw.Forward, "sessions_list")
- require.Contains(t, rw.Forward, "session_get")
- // web_search is a server tool, not rewritten
- require.NotContains(t, rw.Forward, "web_search")
-
- out := applyToolNameRewriteToBody(body, rw)
-
- // tools[0].name and tools[1].name rewritten; tools[2].name untouched
- require.Equal(t, "cc_sess_list", gjson.GetBytes(out, "tools.0.name").String())
- require.Equal(t, "cc_ses_get", gjson.GetBytes(out, "tools.1.name").String())
- require.Equal(t, "web_search", gjson.GetBytes(out, "tools.2.name").String())
-
- // tool_choice.name rewritten
- require.Equal(t, "cc_sess_list", gjson.GetBytes(out, "tool_choice.name").String())
- require.Equal(t, "tool", gjson.GetBytes(out, "tool_choice.type").String())
-}
-
-func TestApplyToolsLastCacheBreakpoint_InjectsDefault(t *testing.T) {
- body := []byte(`{"tools":[{"name":"a","input_schema":{}},{"name":"b","input_schema":{}}]}`)
- out := applyToolsLastCacheBreakpoint(body)
- require.Equal(t, "ephemeral", gjson.GetBytes(out, "tools.1.cache_control.type").String())
- require.Equal(t, "5m", gjson.GetBytes(out, "tools.1.cache_control.ttl").String())
- // First tool untouched
- require.False(t, gjson.GetBytes(out, "tools.0.cache_control").Exists())
-}
-
-func TestApplyToolsLastCacheBreakpoint_PassesThroughClientTTL(t *testing.T) {
- body := []byte(`{"tools":[{"name":"a","input_schema":{},"cache_control":{"type":"ephemeral","ttl":"1h"}}]}`)
- out := applyToolsLastCacheBreakpoint(body)
- // User-provided ttl must be preserved.
- require.Equal(t, "1h", gjson.GetBytes(out, "tools.0.cache_control.ttl").String())
-}
-
-func TestStripMessageCacheControl(t *testing.T) {
- body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hi","cache_control":{"type":"ephemeral"}}]}]}`)
- out := stripMessageCacheControl(body)
- require.False(t, gjson.GetBytes(out, "messages.0.content.0.cache_control").Exists())
-}
-
-func TestAddMessageCacheBreakpoints_LastMessageOnly(t *testing.T) {
- body := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
- out := addMessageCacheBreakpoints(body)
- require.Equal(t, "ephemeral", gjson.GetBytes(out, "messages.0.content.0.cache_control.type").String())
- require.Equal(t, "5m", gjson.GetBytes(out, "messages.0.content.0.cache_control.ttl").String())
-}
-
-func TestAddMessageCacheBreakpoints_SecondToLastUserTurn(t *testing.T) {
- // Parrot 不变量:messages ≥ 4 时才打第二个断点,且位置是"倒数第二个 user turn"。
- body := []byte(`{"messages":[
- {"role":"user","content":[{"type":"text","text":"q1"}]},
- {"role":"assistant","content":[{"type":"text","text":"a1"}]},
- {"role":"user","content":[{"type":"text","text":"q2"}]},
- {"role":"assistant","content":[{"type":"text","text":"a2"}]}
- ]}`)
- out := addMessageCacheBreakpoints(body)
- // 最后一条 assistant 被打断点
- require.Equal(t, "ephemeral", gjson.GetBytes(out, "messages.3.content.0.cache_control.type").String())
- // 倒数第二个 user turn = index 0(唯一另一个 user)
- require.Equal(t, "ephemeral", gjson.GetBytes(out, "messages.0.content.0.cache_control.type").String())
- // 其他不打断点
- require.False(t, gjson.GetBytes(out, "messages.1.content.0.cache_control").Exists())
- require.False(t, gjson.GetBytes(out, "messages.2.content.0.cache_control").Exists())
-}
-
-func TestAddMessageCacheBreakpoints_StringContentPromoted(t *testing.T) {
- body := []byte(`{"messages":[{"role":"user","content":"hi"}]}`)
- out := addMessageCacheBreakpoints(body)
- // content 升级成数组
- require.True(t, gjson.GetBytes(out, "messages.0.content").IsArray())
- require.Equal(t, "text", gjson.GetBytes(out, "messages.0.content.0.type").String())
- require.Equal(t, "hi", gjson.GetBytes(out, "messages.0.content.0.text").String())
- require.Equal(t, "5m", gjson.GetBytes(out, "messages.0.content.0.cache_control.ttl").String())
-}
-
-func TestBuildToolNameRewriteFromBody_ReverseOrderedByLengthDesc(t *testing.T) {
- // 超过阈值触发动态映射,验证 ReverseOrdered 按假名长度倒序排列
- body := []byte(`{"tools":[
- {"name":"t1","input_schema":{}},
- {"name":"t2","input_schema":{}},
- {"name":"t3","input_schema":{}},
- {"name":"t4","input_schema":{}},
- {"name":"t5","input_schema":{}},
- {"name":"t6","input_schema":{}}
- ]}`)
- rw := buildToolNameRewriteFromBody(body)
- require.NotNil(t, rw)
- require.NotEmpty(t, rw.ReverseOrdered)
- for i := 1; i < len(rw.ReverseOrdered); i++ {
- require.GreaterOrEqual(t, len(rw.ReverseOrdered[i-1][0]), len(rw.ReverseOrdered[i][0]),
- "ReverseOrdered must be sorted by fake-name length descending")
- }
-}
-
-func TestRestoreToolNamesInBytes_NoMapping_NoStaticMatch_IsNoop(t *testing.T) {
- data := []byte("plain text without any tool names")
- require.Equal(t, string(data), string(restoreToolNamesInBytes(data, nil)))
-}
-
-// Ensure the fake name format follows Parrot's "{prefix}{name[:3]}{i:02d}".
-func TestBuildDynamicToolMap_FakeNameShape(t *testing.T) {
- names := []string{"alphabet", "bravo", "charlie", "delta", "echo", "foxtrot"}
- m := buildDynamicToolMap(names)
- require.NotNil(t, m)
- for _, name := range names {
- fake, ok := m[name]
- require.True(t, ok)
- // fake = prefix + head3 + "%02d"
- // ends with two decimal digits
- require.Regexp(t, `^[a-z]+_[a-z0-9]{1,3}\d{2}$`, fake)
- head := name
- if len(head) > 3 {
- head = head[:3]
- }
- require.True(t, strings.Contains(fake, head), "fake %q should contain head3 %q of %q", fake, head, name)
- }
-}
diff --git a/backend/internal/service/gateway_usage_billing_admission.go b/backend/internal/service/gateway_usage_billing_admission.go
new file mode 100644
index 00000000000..7674e4e0c07
--- /dev/null
+++ b/backend/internal/service/gateway_usage_billing_admission.go
@@ -0,0 +1,197 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
+)
+
+// GatewayUsageBillingIdentity is the immutable gateway-path pricing and
+// attempt identity that must be reused by settlement after the upstream result
+// arrives.
+type GatewayUsageBillingIdentity struct {
+ BillingModel string
+ Pricing *PricingQuote
+ RateMultiplier float64
+ AdmissionAttemptID string
+ AdmissionRef UsageBillingAdmissionAttemptRef
+}
+
+// PrepareGatewayWalletUsageBillingAdmission reserves a credits wallet before
+// supported gateway transports. Monthly subscriptions and legacy balance
+// billing retain their existing path and never share this reservation.
+func (s *GatewayService) PrepareGatewayWalletUsageBillingAdmission(
+ ctx context.Context,
+ apiKey *APIKey,
+ user *User,
+ account *Account,
+ subscription *UserSubscription,
+ parsed *ParsedRequest,
+) (*GatewayUsageBillingIdentity, error) {
+ if subscription == nil || !subscription.IsWalletMode() {
+ return nil, nil
+ }
+ if s == nil || apiKey == nil || user == nil || account == nil || parsed == nil ||
+ apiKey.GroupID == nil || apiKey.Group == nil || !gatewayWalletUsageBillingPlatformSupported(account.Platform) ||
+ len(parsed.Body) == 0 || strings.TrimSpace(parsed.Model) == "" || !usageBillingRequestContextPrepared(ctx) {
+ return nil, ErrUsageBillingLifecycleContractInvalid
+ }
+ billingModel, _ := resolveGatewayAccountMappedModel(account, parsed.Model)
+ resolver := s.resolver
+ if resolver == nil {
+ resolver = NewModelPricingResolver(s.channelService, s.billingService)
+ }
+ pricing, err := resolver.ResolveQuote(ctx, PricingInput{Model: billingModel, GroupID: apiKey.GroupID})
+ if err != nil {
+ return nil, errors.Join(ErrUsageBillingLifecycleContractInvalid, err)
+ }
+ resolved := pricing.CloneResolved()
+ if resolved == nil || (resolved.Mode != "" && resolved.Mode != BillingModeToken) {
+ return nil, ErrUsageBillingLifecycleContractInvalid
+ }
+ rate := canonicalUsageBillingRate(s.resolveUsageRateMultiplier(ctx, user.ID, apiKey, subscription).Multiplier)
+ quote, err := BuildUsageBillingReservationQuote(UsageBillingReservationQuoteBuildInput{
+ RequestPayloadHash: HashUsageRequestPayload(parsed.Body),
+ BillingModel: billingModel,
+ Pricing: pricing,
+ RateMultiplier: rate,
+ RequestBody: parsed.Body,
+ MaxOutputTokens: parsed.MaxTokens,
+ })
+ if err != nil {
+ return nil, errors.Join(ErrUsageBillingLifecycleContractInvalid, err)
+ }
+ _, session, err := s.AdmitUsageBillingRequest(ctx, apiKey, user, account, subscription, quote)
+ if err != nil {
+ return nil, err
+ }
+ if err := dispatchUsageBillingRequest(ctx, s.usageBillingAdmissionRepo, session); err != nil {
+ return nil, err
+ }
+ identity := &GatewayUsageBillingIdentity{
+ BillingModel: billingModel, Pricing: pricing, RateMultiplier: quote.RateMultiplier,
+ }
+ if session != nil {
+ identity.AdmissionAttemptID = session.AttemptID
+ identity.AdmissionRef = session.Ref()
+ }
+ return identity, nil
+}
+
+func gatewayWalletUsageBillingPlatformSupported(platform string) bool {
+ switch platform {
+ case PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformKiro, PlatformCursor:
+ return true
+ default:
+ return false
+ }
+}
+
+func (s *GatewayService) MarkGatewayUsageBillingAttemptFailed(ctx context.Context, identity *GatewayUsageBillingIdentity) error {
+ if identity == nil {
+ return nil
+ }
+ if s == nil || s.usageBillingAdmissionRepo == nil || identity.AdmissionRef.Validate() != nil {
+ return ErrUsageBillingOutboxUnavailable
+ }
+ mutationCtx, cancel := newUsageBillingLifecycleMutationContext(ctx)
+ defer cancel()
+ return s.usageBillingAdmissionRepo.MarkAttemptFailed(mutationCtx, identity.AdmissionRef)
+}
+
+func (s *GatewayService) MarkGatewayUsageBillingOrphaned(ctx context.Context, identity *GatewayUsageBillingIdentity) error {
+ if identity == nil {
+ return nil
+ }
+ if s == nil || s.usageBillingAdmissionRepo == nil || identity.AdmissionRef.Validate() != nil {
+ return ErrUsageBillingOutboxUnavailable
+ }
+ mutationCtx, cancel := newUsageBillingLifecycleMutationContext(ctx)
+ defer cancel()
+ return s.usageBillingAdmissionRepo.MarkOrphaned(mutationCtx, identity.AdmissionRef)
+}
+
+func (s *GatewayService) AbandonGatewayUsageBilling(ctx context.Context, identity *GatewayUsageBillingIdentity) error {
+ if identity == nil {
+ return nil
+ }
+ if s == nil || s.usageBillingAdmissionRepo == nil || identity.AdmissionRef.Validate() != nil {
+ return ErrUsageBillingAdmissionInvalid
+ }
+ session := &UsageBillingAdmissionSession{
+ RequestID: identity.AdmissionRef.RequestID, APIKeyID: identity.AdmissionRef.APIKeyID,
+ OwnerToken: identity.AdmissionRef.OwnerToken, AttemptID: identity.AdmissionRef.AttemptID,
+ }
+ return abandonUsageBillingRequest(ctx, s.usageBillingAdmissionRepo, session)
+}
+
+func resolveGatewayAccountMappedModel(account *Account, requestedModel string) (string, string) {
+ requestedModel = strings.TrimSpace(requestedModel)
+ if account == nil {
+ return requestedModel, ""
+ }
+ mappedModel := requestedModel
+ mappingSource := ""
+ if account.Type == AccountTypeAPIKey {
+ mappedModel = account.GetMappedModel(requestedModel)
+ if mappedModel != requestedModel {
+ mappingSource = "account"
+ }
+ }
+ if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount {
+ if candidate, matched := account.ResolveMappedModel(requestedModel); matched {
+ mappedModel = candidate
+ mappingSource = "account"
+ } else {
+ normalized := normalizeVertexAnthropicModelID(claude.NormalizeModelID(requestedModel))
+ if normalized != requestedModel {
+ mappedModel = normalized
+ mappingSource = "vertex"
+ }
+ }
+ }
+ if mappingSource == "" && account.Platform == PlatformAnthropic && account.Type != AccountTypeAPIKey {
+ normalized := claude.NormalizeModelID(requestedModel)
+ if normalized != requestedModel {
+ mappedModel = normalized
+ mappingSource = "prefix"
+ }
+ }
+ return mappedModel, mappingSource
+}
+
+func (s *GatewayService) calculateFrozenGatewayUsageCost(
+ ctx context.Context,
+ result *ForwardResult,
+ apiKey *APIKey,
+ identity *GatewayUsageBillingIdentity,
+) (*CostBreakdown, error) {
+ if s == nil || s.billingService == nil || result == nil || apiKey == nil || identity == nil || identity.Pricing == nil ||
+ result.ImageCount > 0 {
+ return nil, ErrUsageBillingLifecycleContractInvalid
+ }
+ resolved := identity.Pricing.CloneResolved()
+ if resolved == nil || (resolved.Mode != "" && resolved.Mode != BillingModeToken) {
+ return nil, ErrUsageBillingLifecycleContractInvalid
+ }
+ resolver := s.resolver
+ if resolver == nil {
+ resolver = NewModelPricingResolver(s.channelService, s.billingService)
+ }
+ tokens := UsageTokens{
+ InputTokens: result.Usage.InputTokens,
+ OutputTokens: result.Usage.OutputTokens,
+ CacheCreationTokens: result.Usage.CacheCreationInputTokens,
+ CacheReadTokens: result.Usage.CacheReadInputTokens,
+ CacheCreation5mTokens: result.Usage.CacheCreation5mTokens,
+ CacheCreation1hTokens: result.Usage.CacheCreation1hTokens,
+ ImageOutputTokens: result.Usage.ImageOutputTokens,
+ }
+ return s.billingService.CalculateCostUnified(CostInput{
+ Ctx: ctx, Model: identity.BillingModel, GroupID: apiKey.GroupID, Tokens: tokens,
+ RequestCount: 1, RateMultiplier: identity.RateMultiplier,
+ Resolver: resolver, Resolved: resolved,
+ })
+}
diff --git a/backend/internal/service/gateway_usage_billing_admission_test.go b/backend/internal/service/gateway_usage_billing_admission_test.go
new file mode 100644
index 00000000000..b34a15c9f87
--- /dev/null
+++ b/backend/internal/service/gateway_usage_billing_admission_test.go
@@ -0,0 +1,168 @@
+package service
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGatewayWalletUsageBillingAdmissionFreezesMappedClaudePricingBeforeTransport(t *testing.T) {
+ repo := &usageBillingAdmissionRepoCapture{}
+ billing := NewBillingService(&config.Config{}, nil)
+ svc := &GatewayService{
+ cfg: &config.Config{}, billingService: billing,
+ resolver: NewModelPricingResolver(nil, billing),
+ requireUsageBillingOutbox: true, usageBillingAdmissionRepo: repo,
+ }
+ groupID := int64(22)
+ walletBalance := 10.0
+ body := []byte(`{"model":"claude-sonnet-4-6","max_tokens":128,"messages":[{"role":"user","content":"hello"}]}`)
+ ctx, err := PrepareUsageBillingRequestContext(context.Background())
+ require.NoError(t, err)
+ apiKey := &APIKey{
+ ID: 11, Key: "sk-wallet-vip", GroupID: &groupID,
+ Group: &Group{ID: groupID, Name: WalletDefaultVIPGroupName, Platform: PlatformAnthropic, Status: StatusActive,
+ Hydrated: true, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard, RateMultiplier: 1},
+ }
+ user := &User{ID: 22, AllowedGroups: []int64{groupID}}
+ account := &Account{ID: 33, Type: AccountTypeAPIKey, Platform: PlatformAnthropic}
+ subscription := &UserSubscription{ID: 71, UserID: user.ID, WalletBalanceUSD: &walletBalance}
+
+ identity, err := svc.PrepareGatewayWalletUsageBillingAdmission(ctx, apiKey, user, account, subscription, &ParsedRequest{
+ Body: body, Model: "claude-sonnet-4-6", MaxTokens: 128, GroupID: &groupID,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, identity)
+ require.Equal(t, "claude-sonnet-4-6", identity.BillingModel)
+ require.NotNil(t, identity.Pricing)
+ require.NotEmpty(t, identity.AdmissionAttemptID)
+ require.Len(t, repo.admissions, 1)
+ require.Len(t, repo.dispatched, 1)
+ require.Equal(t, repo.dispatched[0].AttemptID, identity.AdmissionAttemptID)
+ require.Equal(t, identity.Pricing.Evidence.Hash, repo.admissions[0].PricingHash())
+}
+
+func TestGatewayWalletRecordUsageSettlesWithFrozenAttemptAndPricing(t *testing.T) {
+ admissionRepo := &usageBillingAdmissionRepoCapture{}
+ outboxRepo := &openAIUsageOutboxRepoStub{}
+ billing := NewBillingService(&config.Config{}, nil)
+ svc := &GatewayService{
+ cfg: &config.Config{}, billingService: billing,
+ resolver: NewModelPricingResolver(nil, billing),
+ requireUsageBillingOutbox: true, usageBillingAdmissionRepo: admissionRepo,
+ usageBillingOutboxRepo: outboxRepo,
+ }
+ groupID := int64(22)
+ walletBalance := 10.0
+ body := []byte(`{"model":"claude-sonnet-4-6","max_tokens":128,"messages":[{"role":"user","content":"hello"}]}`)
+ ctx, err := PrepareUsageBillingRequestContext(context.Background())
+ require.NoError(t, err)
+ group := &Group{ID: groupID, Name: WalletDefaultVIPGroupName, Platform: PlatformAnthropic, Status: StatusActive,
+ Hydrated: true, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard, RateMultiplier: 1}
+ apiKey := &APIKey{ID: 11, Key: "sk-wallet-vip", GroupID: &groupID, Group: group}
+ user := &User{ID: 22, AllowedGroups: []int64{groupID}}
+ account := &Account{ID: 33, Type: AccountTypeAPIKey, Platform: PlatformAnthropic}
+ subscription := &UserSubscription{ID: 71, UserID: user.ID, WalletBalanceUSD: &walletBalance}
+ identity, err := svc.PrepareGatewayWalletUsageBillingAdmission(ctx, apiKey, user, account, subscription, &ParsedRequest{
+ Body: body, Model: "claude-sonnet-4-6", MaxTokens: 128, GroupID: &groupID,
+ })
+ require.NoError(t, err)
+
+ err = svc.RecordUsage(ctx, &RecordUsageInput{
+ Result: &ForwardResult{
+ Usage: ClaudeUsage{InputTokens: 10, OutputTokens: 6}, Model: "claude-sonnet-4-6",
+ UpstreamModel: "claude-sonnet-4-6", Duration: time.Second, UsageBillingIdentity: identity,
+ },
+ APIKey: apiKey, User: user, Account: account, Subscription: subscription,
+ RequestPayloadHash: HashUsageRequestPayload(body),
+ })
+ require.NoError(t, err)
+ require.Len(t, outboxRepo.envelopes, 1)
+ envelope := outboxRepo.envelopes[0]
+ require.Equal(t, identity.AdmissionAttemptID, envelope.AdmissionAttemptID())
+ require.Equal(t, identity.Pricing.Evidence.Hash, envelope.PricingHash())
+ require.Positive(t, envelope.WalletCost())
+ require.Zero(t, envelope.SubscriptionCost())
+ require.True(t, admissionRepo.admissions[0].MatchesEnvelope(envelope))
+}
+
+func TestGatewayWalletUsageBillingAdmissionRejectsUnsupportedAccount(t *testing.T) {
+ billing := NewBillingService(&config.Config{}, nil)
+ svc := &GatewayService{cfg: &config.Config{}, billingService: billing, resolver: NewModelPricingResolver(nil, billing), requireUsageBillingOutbox: true}
+ groupID := int64(22)
+ walletBalance := 10.0
+ body := []byte(`{"model":"claude-sonnet-4-6","max_tokens":64}`)
+ ctx, err := PrepareUsageBillingRequestContext(context.Background())
+ require.NoError(t, err)
+ _, err = svc.PrepareGatewayWalletUsageBillingAdmission(ctx,
+ &APIKey{ID: 11, Key: "sk-wallet-vip", GroupID: &groupID, Group: &Group{ID: groupID}},
+ &User{ID: 22},
+ &Account{ID: 33, Type: AccountTypeAPIKey, Platform: PlatformOpenAI},
+ &UserSubscription{ID: 71, UserID: 22, WalletBalanceUSD: &walletBalance},
+ &ParsedRequest{Body: body, Model: "claude-sonnet-4-6", MaxTokens: 64, GroupID: &groupID},
+ )
+ require.ErrorIs(t, err, ErrUsageBillingLifecycleContractInvalid)
+}
+
+func TestGatewayWalletUsageBillingAdmissionSupportsAdditionalGatewayAccounts(t *testing.T) {
+ tests := []struct {
+ platform string
+ model string
+ body []byte
+ }{
+ {PlatformGemini, "gemini-3-1-pro", []byte(`{"contents":[{"parts":[{"text":"hello"}]}],"generationConfig":{"maxOutputTokens":128}}`)},
+ {PlatformAntigravity, "gemini-3-1-pro", []byte(`{"contents":[{"parts":[{"text":"hello"}]}],"generationConfig":{"maxOutputTokens":128}}`)},
+ {PlatformKiro, "claude-sonnet-4-6", []byte(`{"model":"claude-sonnet-4-6","max_tokens":128,"messages":[{"role":"user","content":"hello"}]}`)},
+ {PlatformCursor, "claude-sonnet-4-6", []byte(`{"model":"claude-sonnet-4-6","max_tokens":128,"messages":[{"role":"user","content":"hello"}]}`)},
+ }
+ for _, tt := range tests {
+ t.Run(tt.platform, func(t *testing.T) {
+ repo := &usageBillingAdmissionRepoCapture{}
+ billing := NewBillingService(&config.Config{}, nil)
+ svc := &GatewayService{
+ cfg: &config.Config{}, billingService: billing,
+ resolver: NewModelPricingResolver(nil, billing),
+ requireUsageBillingOutbox: true, usageBillingAdmissionRepo: repo,
+ }
+ groupID := int64(22)
+ walletBalance := 10.0
+ ctx, err := PrepareUsageBillingRequestContext(context.Background())
+ require.NoError(t, err)
+
+ identity, err := svc.PrepareGatewayWalletUsageBillingAdmission(ctx,
+ &APIKey{ID: 11, Key: "sk-wallet-gateway", GroupID: &groupID, Group: &Group{
+ ID: groupID, Name: WalletDefaultVIPGroupName, Platform: tt.platform, Status: StatusActive,
+ Hydrated: true, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard, RateMultiplier: 1,
+ }},
+ &User{ID: 22, AllowedGroups: []int64{groupID}},
+ &Account{ID: 33, Type: AccountTypeAPIKey, Platform: tt.platform},
+ &UserSubscription{ID: 71, UserID: 22, WalletBalanceUSD: &walletBalance},
+ &ParsedRequest{Body: tt.body, Model: tt.model, MaxTokens: 128, GroupID: &groupID},
+ )
+
+ require.NoError(t, err)
+ require.NotNil(t, identity)
+ require.Equal(t, tt.model, identity.BillingModel)
+ require.Len(t, repo.admissions, 1)
+ require.Len(t, repo.dispatched, 1)
+ })
+ }
+}
+
+func TestGatewayWalletRecordUsageRequiresAdmissionIdentityWhenOutboxIsRequired(t *testing.T) {
+ walletBalance := 10.0
+ svc := &GatewayService{requireUsageBillingOutbox: true}
+
+ err := svc.RecordUsage(context.Background(), &RecordUsageInput{
+ Result: &ForwardResult{RequestID: "wallet-missing-admission", Model: "claude-sonnet-4-6"},
+ APIKey: &APIKey{ID: 11},
+ User: &User{ID: 22},
+ Account: &Account{ID: 33},
+ Subscription: &UserSubscription{ID: 71, WalletBalanceUSD: &walletBalance},
+ })
+
+ require.ErrorIs(t, err, ErrUsageBillingLifecycleContractInvalid)
+}
diff --git a/backend/internal/service/gateway_websearch_emulation.go b/backend/internal/service/gateway_websearch_emulation.go
index a42b5585e32..4283e246859 100644
--- a/backend/internal/service/gateway_websearch_emulation.go
+++ b/backend/internal/service/gateway_websearch_emulation.go
@@ -155,8 +155,7 @@ func (s *GatewayService) handleWebSearchEmulation(
return nil, fmt.Errorf("web search emulation: no query found in messages")
}
- slog.Info("web search emulation: executing search",
- "account_id", account.ID, "account_name", account.Name, "query", query)
+ logWebSearchExecution(account, query)
resp, providerName, err := doWebSearch(ctx, account, query)
if err != nil {
@@ -184,6 +183,18 @@ func (s *GatewayService) handleWebSearchEmulation(
return writeWebSearchNonStreamResponse(c, query, resp, model, startTime)
}
+func logWebSearchExecution(account *Account, query string) {
+ if account == nil {
+ slog.Info("web search emulation: executing search", "query_bytes", len(query))
+ return
+ }
+ slog.Info("web search emulation: executing search",
+ "account_id", account.ID,
+ "account_name", account.Name,
+ "query_bytes", len(query),
+ )
+}
+
func doWebSearch(ctx context.Context, account *Account, query string) (*websearch.SearchResponse, string, error) {
proxyURL := resolveAccountProxyURL(account)
mgr := getWebSearchManager()
diff --git a/backend/internal/service/gemini_error_policy_test.go b/backend/internal/service/gemini_error_policy_test.go
index 4bd1ced77c5..38977ed2ab3 100644
--- a/backend/internal/service/gemini_error_policy_test.go
+++ b/backend/internal/service/gemini_error_policy_test.go
@@ -189,7 +189,6 @@ func TestCheckErrorPolicy_GeminiAccounts(t *testing.T) {
// ---------------------------------------------------------------------------
func TestGeminiErrorPolicyIntegration(t *testing.T) {
- gin.SetMode(gin.TestMode)
tests := []struct {
name string
diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go
index ea0c0d7dd39..9d828da6282 100644
--- a/backend/internal/service/gemini_messages_compat_service.go
+++ b/backend/internal/service/gemini_messages_compat_service.go
@@ -24,7 +24,6 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/googleapi"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
- "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
@@ -452,22 +451,7 @@ func (s *GeminiMessagesCompatService) listSchedulableAccountsOnce(ctx context.Co
}
func (s *GeminiMessagesCompatService) validateUpstreamBaseURL(raw string) (string, error) {
- if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
- normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
- if err != nil {
- return "", fmt.Errorf("invalid base_url: %w", err)
- }
- return normalized, nil
- }
- normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
- AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
- RequireAllowlist: true,
- AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
- })
- if err != nil {
- return "", fmt.Errorf("invalid base_url: %w", err)
- }
- return normalized, nil
+ return validateUpstreamBaseURLFormat(raw, s.cfg)
}
// HasAntigravityAccounts 检查是否有可用的 antigravity 账户
@@ -502,7 +486,7 @@ func (s *GeminiMessagesCompatService) SelectAccountForAIStudioEndpoints(ctx cont
}
switch a.Type {
case AccountTypeAPIKey:
- if strings.TrimSpace(a.GetCredential("api_key")) != "" {
+ if schedulerAccountHasConfiguredAPIKey(a) {
return 0
}
return 9
@@ -567,6 +551,17 @@ func (s *GeminiMessagesCompatService) SelectAccountForAIStudioEndpoints(ctx cont
return s.hydrateSelectedAccount(ctx, selected)
}
+func schedulerAccountHasConfiguredAPIKey(account *Account) bool {
+ if account == nil {
+ return false
+ }
+ if strings.TrimSpace(account.GetCredential("api_key")) != "" {
+ return true
+ }
+ configured, _ := account.Credentials[SchedulerMetadataAPIKeyConfigured].(bool)
+ return configured
+}
+
func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*ForwardResult, error) {
startTime := time.Now()
@@ -1685,6 +1680,9 @@ func sleepGeminiBackoff(attempt int) {
var (
sensitiveQueryParamRegex = regexp.MustCompile(`(?i)([?&](?:key|client_secret|access_token|refresh_token)=)[^&"\s]+`)
+ sensitiveAssignmentRegex = regexp.MustCompile(`(?i)\b(authorization|proxy-authorization|x-api-key|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|session[-_]?token|client[-_]?secret|password|passwd|passphrase|token|secret)\b(\s*(?::|=)\s*|\s+)(?:bearer\s+)?["']?[^,\s;"'}]{8,}`)
+ sensitiveBearerRegex = regexp.MustCompile(`(?i)\bbearer\s+[^,\s;"'}]{8,}`)
+ knownSecretValueRegex = regexp.MustCompile(`(?i)\b(?:sk-[a-z0-9_-]{12,}|[sr]k_(?:live|test)_[a-z0-9]{12,}|gh[pousr]_[a-z0-9]{20,}|AIza[a-z0-9_-]{35}|GOCSPX-[a-z0-9_-]{24,}|xox[baprs]-[a-z0-9-]{12,}|eyJ[a-z0-9_-]{10,}\.[a-z0-9_-]{10,}\.[a-z0-9_-]{8,})\b`)
retryInRegex = regexp.MustCompile(`Please retry in ([0-9.]+)s`)
)
@@ -1692,7 +1690,10 @@ func sanitizeUpstreamErrorMessage(msg string) string {
if msg == "" {
return msg
}
- return sensitiveQueryParamRegex.ReplaceAllString(msg, `$1***`)
+ msg = sensitiveQueryParamRegex.ReplaceAllString(msg, `$1***`)
+ msg = sensitiveAssignmentRegex.ReplaceAllString(msg, `$1$2***`)
+ msg = sensitiveBearerRegex.ReplaceAllString(msg, `Bearer ***`)
+ return knownSecretValueRegex.ReplaceAllString(msg, `***`)
}
func (s *GeminiMessagesCompatService) writeGeminiMappedError(c *gin.Context, account *Account, upstreamStatus int, upstreamRequestID string, body []byte) error {
diff --git a/backend/internal/service/gemini_messages_compat_service_test.go b/backend/internal/service/gemini_messages_compat_service_test.go
index c2adf45dedc..680477f712f 100644
--- a/backend/internal/service/gemini_messages_compat_service_test.go
+++ b/backend/internal/service/gemini_messages_compat_service_test.go
@@ -194,7 +194,6 @@ func TestConvertClaudeToolsToGeminiTools_PreservesWebSearchAlongsideFunctions(t
}
func TestGeminiHandleNativeNonStreamingResponse_DebugDisabledDoesNotEmitHeaderLogs(t *testing.T) {
- gin.SetMode(gin.TestMode)
logSink, restore := captureStructuredLog(t)
defer restore()
@@ -226,7 +225,6 @@ func TestGeminiHandleNativeNonStreamingResponse_DebugDisabledDoesNotEmitHeaderLo
}
func TestGeminiMessagesCompatServiceForward_PreservesRequestedModelAndMappedUpstreamModel(t *testing.T) {
- gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
@@ -262,7 +260,6 @@ func TestGeminiMessagesCompatServiceForward_PreservesRequestedModelAndMappedUpst
}
func TestGeminiMessagesCompatServiceForward_NormalizesWebSearchToolForAIStudio(t *testing.T) {
- gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
diff --git a/backend/internal/service/gemini_oauth_service.go b/backend/internal/service/gemini_oauth_service.go
index 08a74a37245..16315f99c4e 100644
--- a/backend/internal/service/gemini_oauth_service.go
+++ b/backend/internal/service/gemini_oauth_service.go
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
+ "net/url"
"regexp"
"strconv"
"strings"
@@ -463,7 +464,7 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
proxyURL = proxy.URL()
}
}
- logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] ProxyURL: %s", proxyURL)
+ logger.LegacyPrintf("service.gemini_oauth", "[GeminiOAuth] Proxy target: %s", safeProxyTargetForLog(proxyURL))
redirectURI := session.RedirectURI
@@ -672,6 +673,24 @@ func (s *GeminiOAuthService) ExchangeCode(ctx context.Context, input *GeminiExch
return result, nil
}
+func safeProxyTargetForLog(raw string) string {
+ trimmed := strings.TrimSpace(raw)
+ if trimmed == "" {
+ return "direct"
+ }
+ parsed, err := url.Parse(trimmed)
+ if err != nil || parsed.Host == "" {
+ return "configured"
+ }
+ scheme := strings.ToLower(parsed.Scheme)
+ switch scheme {
+ case "http", "https", "socks5", "socks5h":
+ return (&url.URL{Scheme: scheme, Host: parsed.Host}).String()
+ default:
+ return "configured"
+ }
+}
+
func (s *GeminiOAuthService) RefreshToken(ctx context.Context, oauthType, refreshToken, proxyURL string) (*GeminiTokenInfo, error) {
var lastErr error
diff --git a/backend/internal/service/gemini_oauth_service_test.go b/backend/internal/service/gemini_oauth_service_test.go
index 397b581d123..d589acfadd0 100644
--- a/backend/internal/service/gemini_oauth_service_test.go
+++ b/backend/internal/service/gemini_oauth_service_test.go
@@ -1379,6 +1379,21 @@ func TestGeminiOAuthService_RefreshAccountToken_UnauthorizedClient_NoFallback(t
// 新增测试:GeminiOAuthService.ExchangeCode
// =====================
+func TestSafeProxyTargetForLogDropsCredentialsAndURLState(t *testing.T) {
+ raw := "http://proxy-user:proxy-password@proxy.example.com:8080/private?token=query-secret#fragment-secret"
+
+ got := safeProxyTargetForLog(raw)
+
+ if got != "http://proxy.example.com:8080" {
+ t.Fatalf("safe proxy target = %q", got)
+ }
+ for _, secret := range []string{"proxy-user", "proxy-password", "private", "query-secret", "fragment-secret"} {
+ if strings.Contains(got, secret) {
+ t.Fatalf("safe proxy target leaked %q: %q", secret, got)
+ }
+ }
+}
+
func TestGeminiOAuthService_ExchangeCode_SessionNotFound(t *testing.T) {
t.Parallel()
diff --git a/backend/internal/service/gemini_token_cache.go b/backend/internal/service/gemini_token_cache.go
index 70f246da31e..e36184654ea 100644
--- a/backend/internal/service/gemini_token_cache.go
+++ b/backend/internal/service/gemini_token_cache.go
@@ -12,6 +12,6 @@ type GeminiTokenCache interface {
SetAccessToken(ctx context.Context, cacheKey string, token string, ttl time.Duration) error
DeleteAccessToken(ctx context.Context, cacheKey string) error
- AcquireRefreshLock(ctx context.Context, cacheKey string, ttl time.Duration) (bool, error)
- ReleaseRefreshLock(ctx context.Context, cacheKey string) error
+ AcquireRefreshLock(ctx context.Context, cacheKey string, ttl time.Duration) (acquired bool, ownershipToken string, err error)
+ ReleaseRefreshLock(ctx context.Context, cacheKey string, ownershipToken string) error
}
diff --git a/backend/internal/service/gemini_token_provider.go b/backend/internal/service/gemini_token_provider.go
index 172b941134c..e42ae946977 100644
--- a/backend/internal/service/gemini_token_provider.go
+++ b/backend/internal/service/gemini_token_provider.go
@@ -65,7 +65,15 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou
// 1) Try cache first.
if p.tokenCache != nil {
if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" {
- return token, nil
+ latestAccount, cacheHitIsCurrent, validationErr := validateOAuthTokenCacheHit(ctx, account, p.accountRepo)
+ if validationErr != nil {
+ return "", validationErr
+ }
+ if cacheHitIsCurrent {
+ return token, nil
+ }
+ account = latestAccount
+ cacheKey = GeminiTokenCacheKey(account)
}
}
@@ -82,7 +90,16 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou
} else if result.LockHeld {
if p.refreshPolicy.OnLockHeld == ProviderLockHeldWaitForCache && p.tokenCache != nil {
if token, cacheErr := p.tokenCache.GetAccessToken(ctx, cacheKey); cacheErr == nil && strings.TrimSpace(token) != "" {
- return token, nil
+ latestAccount, cacheHitIsCurrent, validationErr := validateOAuthTokenCacheHit(ctx, account, p.accountRepo)
+ if validationErr != nil {
+ return "", validationErr
+ }
+ if cacheHitIsCurrent {
+ return token, nil
+ }
+ account = latestAccount
+ cacheKey = GeminiTokenCacheKey(account)
+ expiresAt = account.GetCredentialAsTime("expires_at")
}
}
slog.Debug("gemini_token_lock_held_use_old", "account_id", account.ID)
@@ -92,9 +109,9 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou
}
} else if needsRefresh && p.tokenCache != nil {
// Backward-compatible test path when refreshAPI is not injected.
- locked, lockErr := p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
+ locked, ownershipToken, lockErr := p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
if lockErr == nil && locked {
- defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey) }()
+ defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey, ownershipToken) }()
} else if lockErr != nil {
slog.Warn("gemini_token_lock_failed", "account_id", account.ID, "error", lockErr)
}
@@ -144,8 +161,10 @@ func (p *GeminiTokenProvider) GetAccessToken(ctx context.Context, account *Accou
// 3) Populate cache with TTL.
if p.tokenCache != nil {
- latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
- if isStale && latestAccount != nil {
+ latestAccount, isStale, versionErr := CheckTokenVersion(ctx, account, p.accountRepo)
+ if versionErr != nil {
+ slog.Warn("gemini_token_version_check_unavailable", "account_id", account.ID, "error", versionErr)
+ } else if isStale && latestAccount != nil {
slog.Debug("gemini_token_version_stale_use_latest", "account_id", account.ID)
accessToken = latestAccount.GetCredential("access_token")
if strings.TrimSpace(accessToken) == "" {
@@ -181,6 +200,10 @@ func GeminiTokenCacheKey(account *Account) string {
return vertexServiceAccountCacheKey(account, key)
}
}
+ return credentialBoundOAuthTokenCacheKey(geminiTokenCacheBaseKey(account), account)
+}
+
+func geminiTokenCacheBaseKey(account *Account) string {
projectID := strings.TrimSpace(account.GetCredential("project_id"))
if projectID != "" {
return "gemini:" + projectID
diff --git a/backend/internal/service/gin_testmain_test.go b/backend/internal/service/gin_testmain_test.go
new file mode 100644
index 00000000000..9bcd44e2064
--- /dev/null
+++ b/backend/internal/service/gin_testmain_test.go
@@ -0,0 +1,13 @@
+package service
+
+import (
+ "os"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+)
+
+func TestMain(m *testing.M) {
+ gin.SetMode(gin.TestMode)
+ os.Exit(m.Run())
+}
diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go
index bb4c5aa1ba1..cdb3098519b 100644
--- a/backend/internal/service/group.go
+++ b/backend/internal/service/group.go
@@ -26,9 +26,12 @@ type Group struct {
DefaultValidityDays int
// 图片生成计费配置(antigravity 和 gemini 平台使用)
- ImagePrice1K *float64
- ImagePrice2K *float64
- ImagePrice4K *float64
+ AllowImageGeneration bool
+ ImageRateIndependent bool
+ ImageRateMultiplier float64
+ ImagePrice1K *float64
+ ImagePrice2K *float64
+ ImagePrice4K *float64
// Claude Code 客户端限制
ClaudeCodeOnly bool
@@ -80,6 +83,32 @@ func (g *Group) IsSubscriptionType() bool {
return g.SubscriptionType == SubscriptionTypeSubscription
}
+// EffectiveBillingContext 决定计费 mode 和真正用来检查 limits / 记录 usage 的 group。
+//
+// 三种情况(2026-05-17 follow-up,见 docs/plans/2026-05-16-wallet-v4-group-switch-billing-fix.md):
+// - subscription == nil:落到 balance 模式(老余额用户兼容)
+// - subscription 是钱包(IsWalletMode):走钱包扣费,group 不参与 limits 检查
+// - subscription 是月卡:用户在 admin 切了 key.group_id 到 plan_groups 链内任一 group →
+// billing limits 用 sub 的主 group(sub.Group)而不是被调用的 group,这样 usage tracking
+// 不分散到多个 standard group 的 cache key,而是统一聚到 sub 主 group quota。
+//
+// 返回:
+// - isSubscriptionBilling: 是否走订阅模式(true 跳过 user.Balance fallback)
+// - effectiveGroup: 用来 checkSubscriptionEligibility / RecordUsage 的 group;
+// 钱包模式返回 calledGroup(钱包检查不依赖 group 限额);月卡模式返回 sub.Group(若存在)
+func EffectiveBillingContext(calledGroup *Group, subscription *UserSubscription) (isSubscriptionBilling bool, effectiveGroup *Group) {
+ if subscription == nil {
+ return false, calledGroup
+ }
+ if subscription.IsWalletMode() {
+ return true, calledGroup
+ }
+ if subscription.Group != nil {
+ return true, subscription.Group
+ }
+ return true, calledGroup
+}
+
func (g *Group) HasDailyLimit() bool {
return g.DailyLimitUSD != nil && *g.DailyLimitUSD > 0
}
diff --git a/backend/internal/service/group_effective_billing_test.go b/backend/internal/service/group_effective_billing_test.go
new file mode 100644
index 00000000000..b99fb336e90
--- /dev/null
+++ b/backend/internal/service/group_effective_billing_test.go
@@ -0,0 +1,110 @@
+//go:build unit
+
+package service
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestEffectiveBillingContext 验证 2026-05-17 follow-up 引入的 EffectiveBillingContext helper:
+// 用户在 admin 切 key 到 plan_groups 链内 standard group 时,billing 必须按订阅扣 sub quota
+// 而不是 fallback 主余额(老 bug)。
+func TestEffectiveBillingContext(t *testing.T) {
+ bal := 100.0
+ subGroup := &Group{
+ ID: 13,
+ Name: "paid-trial-v3",
+ Status: StatusActive,
+ SubscriptionType: SubscriptionTypeSubscription,
+ }
+ calledGroup := &Group{
+ ID: 3,
+ Name: "openai-default",
+ Status: StatusActive,
+ SubscriptionType: SubscriptionTypeStandard,
+ }
+
+ t.Run("nil_subscription_returns_balance_mode", func(t *testing.T) {
+ isSubBilling, effective := EffectiveBillingContext(calledGroup, nil)
+ require.False(t, isSubBilling, "无订阅应为余额模式")
+ require.Equal(t, calledGroup, effective)
+ })
+
+ t.Run("wallet_subscription_keeps_called_group", func(t *testing.T) {
+ walletSub := &UserSubscription{
+ ID: 201,
+ UserID: 42,
+ GroupID: nil,
+ WalletBalanceUSD: &bal,
+ Status: SubscriptionStatusActive,
+ }
+ isSubBilling, effective := EffectiveBillingContext(calledGroup, walletSub)
+ require.True(t, isSubBilling, "钱包订阅是订阅模式")
+ require.Equal(t, calledGroup, effective, "钱包模式 effective group 不变(limits 不绑 group)")
+ })
+
+ t.Run("monthly_sub_exact_match_uses_sub_group", func(t *testing.T) {
+ subID := subGroup.ID
+ monthlySub := &UserSubscription{
+ ID: 301,
+ UserID: 42,
+ GroupID: &subID,
+ Group: subGroup,
+ Status: SubscriptionStatusActive,
+ }
+ // exact match 场景:called group == sub.Group(用户没切)
+ isSubBilling, effective := EffectiveBillingContext(subGroup, monthlySub)
+ require.True(t, isSubBilling)
+ require.Equal(t, subGroup, effective, "exact match 时返回 sub group(跟 called group 一致)")
+ })
+
+ t.Run("monthly_sub_covering_uses_sub_group_not_called_group", func(t *testing.T) {
+ // 关键场景:用户买月卡 paid-trial-v3 (group 13),后台把 key 切到 openai-default (group 3)。
+ // middleware 通过 GetActiveSubscriptionCoveringGroup 找到 sub 13,billing 必须用 sub.Group
+ // 来检 limits + 扣 quota,而不是用 called group 3 (limits=0 → 老 bug 静默扣 balance)。
+ subID := subGroup.ID
+ coveringSub := &UserSubscription{
+ ID: 401,
+ UserID: 42,
+ GroupID: &subID,
+ Group: subGroup,
+ Status: SubscriptionStatusActive,
+ }
+ isSubBilling, effective := EffectiveBillingContext(calledGroup, coveringSub)
+ require.True(t, isSubBilling, "plan_groups 覆盖时仍是订阅模式")
+ require.Equal(t, subGroup, effective, "covering 时 effective group 必须是 sub 主 group 不是 called group")
+ require.NotEqual(t, calledGroup, effective)
+ })
+
+ t.Run("monthly_sub_with_nil_group_falls_back_to_called", func(t *testing.T) {
+ // 防御性:sub.Group 没被 preload 的情况,fallback 到 called group(保持老行为)
+ subID := int64(13)
+ subWithoutGroup := &UserSubscription{
+ ID: 501,
+ UserID: 42,
+ GroupID: &subID,
+ Group: nil,
+ Status: SubscriptionStatusActive,
+ }
+ isSubBilling, effective := EffectiveBillingContext(calledGroup, subWithoutGroup)
+ require.True(t, isSubBilling)
+ require.Equal(t, calledGroup, effective, "sub.Group=nil 时 fallback 到 called group")
+ })
+
+ t.Run("nil_called_group_with_subscription", func(t *testing.T) {
+ // 边界:nil called group 不应导致 panic
+ subID := int64(13)
+ monthlySub := &UserSubscription{
+ ID: 601,
+ UserID: 42,
+ GroupID: &subID,
+ Group: subGroup,
+ Status: SubscriptionStatusActive,
+ }
+ isSubBilling, effective := EffectiveBillingContext(nil, monthlySub)
+ require.True(t, isSubBilling)
+ require.Equal(t, subGroup, effective)
+ })
+}
diff --git a/backend/internal/service/group_service.go b/backend/internal/service/group_service.go
index 87174e03764..2206be44cb2 100644
--- a/backend/internal/service/group_service.go
+++ b/backend/internal/service/group_service.go
@@ -45,19 +45,25 @@ type GroupSortOrderUpdate struct {
// CreateGroupRequest 创建分组请求
type CreateGroupRequest struct {
- Name string `json:"name"`
- Description string `json:"description"`
- RateMultiplier float64 `json:"rate_multiplier"`
- IsExclusive bool `json:"is_exclusive"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ RateMultiplier float64 `json:"rate_multiplier"`
+ IsExclusive bool `json:"is_exclusive"`
+ AllowImageGeneration bool `json:"allow_image_generation"`
+ ImageRateIndependent bool `json:"image_rate_independent"`
+ ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
}
// UpdateGroupRequest 更新分组请求
type UpdateGroupRequest struct {
- Name *string `json:"name"`
- Description *string `json:"description"`
- RateMultiplier *float64 `json:"rate_multiplier"`
- IsExclusive *bool `json:"is_exclusive"`
- Status *string `json:"status"`
+ Name *string `json:"name"`
+ Description *string `json:"description"`
+ RateMultiplier *float64 `json:"rate_multiplier"`
+ IsExclusive *bool `json:"is_exclusive"`
+ Status *string `json:"status"`
+ AllowImageGeneration *bool `json:"allow_image_generation"`
+ ImageRateIndependent *bool `json:"image_rate_independent"`
+ ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
}
// GroupService 分组管理服务
@@ -76,6 +82,13 @@ func NewGroupService(groupRepo GroupRepository, authCacheInvalidator APIKeyAuthC
// Create 创建分组
func (s *GroupService) Create(ctx context.Context, req CreateGroupRequest) (*Group, error) {
+ imageRateMultiplier := 1.0
+ if req.ImageRateMultiplier != nil {
+ if *req.ImageRateMultiplier < 0 {
+ return nil, fmt.Errorf("image_rate_multiplier must be >= 0")
+ }
+ imageRateMultiplier = *req.ImageRateMultiplier
+ }
// 检查名称是否已存在
exists, err := s.groupRepo.ExistsByName(ctx, req.Name)
if err != nil {
@@ -87,13 +100,19 @@ func (s *GroupService) Create(ctx context.Context, req CreateGroupRequest) (*Gro
// 创建分组
group := &Group{
- Name: req.Name,
- Description: req.Description,
- Platform: PlatformAnthropic,
- RateMultiplier: req.RateMultiplier,
- IsExclusive: req.IsExclusive,
- Status: StatusActive,
- SubscriptionType: SubscriptionTypeStandard,
+ Name: req.Name,
+ Description: req.Description,
+ Platform: PlatformAnthropic,
+ RateMultiplier: req.RateMultiplier,
+ IsExclusive: req.IsExclusive,
+ Status: StatusActive,
+ SubscriptionType: SubscriptionTypeStandard,
+ AllowImageGeneration: req.AllowImageGeneration,
+ ImageRateIndependent: req.ImageRateIndependent,
+ ImageRateMultiplier: imageRateMultiplier,
+ }
+ if err := validateReservedBusinessGroupCreate(group); err != nil {
+ return nil, err
}
if err := s.groupRepo.Create(ctx, group); err != nil {
@@ -136,6 +155,19 @@ func (s *GroupService) Update(ctx context.Context, id int64, req UpdateGroupRequ
if err != nil {
return nil, fmt.Errorf("get group: %w", err)
}
+ candidate := *group
+ if req.Name != nil {
+ candidate.Name = *req.Name
+ }
+ if req.IsExclusive != nil {
+ candidate.IsExclusive = *req.IsExclusive
+ }
+ if req.Status != nil {
+ candidate.Status = *req.Status
+ }
+ if err := validateReservedBusinessGroupUpdate(group, &candidate); err != nil {
+ return nil, err
+ }
// 更新字段
if req.Name != nil && *req.Name != group.Name {
@@ -165,6 +197,18 @@ func (s *GroupService) Update(ctx context.Context, id int64, req UpdateGroupRequ
if req.Status != nil {
group.Status = *req.Status
}
+ if req.AllowImageGeneration != nil {
+ group.AllowImageGeneration = *req.AllowImageGeneration
+ }
+ if req.ImageRateIndependent != nil {
+ group.ImageRateIndependent = *req.ImageRateIndependent
+ }
+ if req.ImageRateMultiplier != nil {
+ if *req.ImageRateMultiplier < 0 {
+ return nil, fmt.Errorf("image_rate_multiplier must be >= 0")
+ }
+ group.ImageRateMultiplier = *req.ImageRateMultiplier
+ }
if err := s.groupRepo.Update(ctx, group); err != nil {
return nil, fmt.Errorf("update group: %w", err)
@@ -179,10 +223,13 @@ func (s *GroupService) Update(ctx context.Context, id int64, req UpdateGroupRequ
// Delete 删除分组
func (s *GroupService) Delete(ctx context.Context, id int64) error {
// 检查分组是否存在
- _, err := s.groupRepo.GetByID(ctx, id)
+ group, err := s.groupRepo.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("get group: %w", err)
}
+ if err := rejectReservedBusinessGroupDelete(group); err != nil {
+ return err
+ }
if s.authCacheInvalidator != nil {
s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, id)
diff --git a/backend/internal/service/hfc_abuse_risk.go b/backend/internal/service/hfc_abuse_risk.go
new file mode 100644
index 00000000000..84ca088b81f
--- /dev/null
+++ b/backend/internal/service/hfc_abuse_risk.go
@@ -0,0 +1,431 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "strings"
+ "time"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+)
+
+const (
+ HFCAbuseRiskSourceSignupRisk = "signup_risk"
+ HFCAbuseRiskSourceContentModerationAutoBan = "content_moderation_auto_ban"
+
+ HFCAbuseRiskStatusOpen = "open"
+ HFCAbuseRiskStatusReviewing = "reviewing"
+ HFCAbuseRiskStatusResolved = "resolved"
+ HFCAbuseRiskStatusFalsePositive = "false_positive"
+
+ HFCAbuseRiskActionMarkReviewing = "mark_reviewing"
+ HFCAbuseRiskActionResolveNoAction = "resolve_no_action"
+ HFCAbuseRiskActionMarkFalsePositive = "mark_false_positive"
+ HFCAbuseRiskActionDisableAPIKey = "disable_api_key"
+ HFCAbuseRiskActionDisableUserAPIKeys = "disable_user_api_keys"
+ HFCAbuseRiskActionFreezeUser = "freeze_user"
+
+ hfcAbuseRiskMaxActionNoteRunes = 500
+ hfcAbuseRiskActionPageSize = 200
+)
+
+var (
+ ErrHFCAbuseRiskEventNotFound = infraerrors.NotFound("HFC_ABUSE_RISK_EVENT_NOT_FOUND", "HFC abuse risk event not found")
+ ErrHFCAbuseRiskInvalidAction = infraerrors.BadRequest("HFC_ABUSE_RISK_INVALID_ACTION", "invalid HFC abuse risk action")
+ ErrHFCAbuseRiskMissingTarget = infraerrors.BadRequest("HFC_ABUSE_RISK_MISSING_TARGET", "risk event does not have the required target")
+)
+
+type HFCAbuseRiskEvidence struct {
+ Key string `json:"key"`
+ Label string `json:"label"`
+ Value string `json:"value"`
+}
+
+type HFCAbuseRiskEvent struct {
+ ID int64 `json:"id"`
+ Source string `json:"source"`
+ Severity string `json:"severity"`
+ Status string `json:"status"`
+ UserID *int64 `json:"user_id"`
+ UserEmail string `json:"user_email"`
+ APIKeyID *int64 `json:"api_key_id"`
+ APIKeyName string `json:"api_key_name"`
+ GroupID *int64 `json:"group_id"`
+ GroupName string `json:"group_name"`
+ RiskScore int `json:"risk_score"`
+ SignupIPPrefix string `json:"signup_ip_prefix"`
+ DeviceFingerprintHash string `json:"device_fingerprint_hash"`
+ SignupUserAgentHash string `json:"signup_user_agent_hash"`
+ PaymentOrderID *int64 `json:"payment_order_id"`
+ RedeemCodeID *int64 `json:"redeem_code_id"`
+ ReferralUserID *int64 `json:"referral_user_id"`
+ ContentModerationLogID *int64 `json:"content_moderation_log_id"`
+ Summary string `json:"summary"`
+ Evidence []HFCAbuseRiskEvidence `json:"evidence"`
+ ActionTaken string `json:"action_taken"`
+ ActionNote string `json:"action_note"`
+ ReviewedBy *int64 `json:"reviewed_by"`
+ ReviewedAt *time.Time `json:"reviewed_at"`
+ TelegramSent bool `json:"telegram_sent"`
+ TelegramSentAt *time.Time `json:"telegram_sent_at"`
+ TelegramError string `json:"telegram_error"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type HFCAbuseRiskEventFilter struct {
+ Pagination pagination.PaginationParams
+ Source string
+ Severity string
+ Status string
+ Search string
+ From *time.Time
+ To *time.Time
+}
+
+type HFCAbuseRiskActionInput struct {
+ EventID int64
+ Action string
+ AdminUserID int64
+ Note string
+}
+
+type HFCAbuseRiskActionResult struct {
+ Event *HFCAbuseRiskEvent `json:"event"`
+ DisabledAPIKeys int `json:"disabled_api_keys"`
+ FrozenUser bool `json:"frozen_user"`
+}
+
+type HFCAbuseRiskReviewUpdate struct {
+ EventID int64
+ Status string
+ ActionTaken string
+ ActionNote string
+ ReviewedBy *int64
+ ReviewedAt time.Time
+ TelegramSent *bool
+ TelegramError *string
+}
+
+type HFCAbuseRiskRepository interface {
+ CreateEvent(ctx context.Context, event *HFCAbuseRiskEvent) error
+ ListEvents(ctx context.Context, filter HFCAbuseRiskEventFilter) ([]HFCAbuseRiskEvent, *pagination.PaginationResult, error)
+ GetEvent(ctx context.Context, id int64) (*HFCAbuseRiskEvent, error)
+ UpdateReview(ctx context.Context, update HFCAbuseRiskReviewUpdate) error
+}
+
+type HFCAbuseRiskRecorder interface {
+ RecordEvent(ctx context.Context, event *HFCAbuseRiskEvent) (*HFCAbuseRiskEvent, error)
+}
+
+type HFCAbuseRiskService struct {
+ repo HFCAbuseRiskRepository
+ apiKeyRepo APIKeyRepository
+ userRepo UserRepository
+ authCacheInvalidator APIKeyAuthCacheInvalidator
+}
+
+func NewHFCAbuseRiskService(
+ repo HFCAbuseRiskRepository,
+ apiKeyRepo APIKeyRepository,
+ userRepo UserRepository,
+ authCacheInvalidator APIKeyAuthCacheInvalidator,
+) *HFCAbuseRiskService {
+ return &HFCAbuseRiskService{
+ repo: repo,
+ apiKeyRepo: apiKeyRepo,
+ userRepo: userRepo,
+ authCacheInvalidator: authCacheInvalidator,
+ }
+}
+
+func (s *HFCAbuseRiskService) RecordEvent(ctx context.Context, event *HFCAbuseRiskEvent) (*HFCAbuseRiskEvent, error) {
+ if s == nil || s.repo == nil || event == nil {
+ return event, nil
+ }
+ normalized := cloneHFCAbuseRiskEvent(event)
+ normalized.Source = strings.TrimSpace(normalized.Source)
+ if normalized.Source == "" {
+ normalized.Source = "hfc_abuse_risk"
+ }
+ normalized.Severity = normalizeHFCAbuseRiskSeverity(normalized.Severity)
+ normalized.Status = normalizeHFCAbuseRiskStatus(normalized.Status)
+ normalized.UserEmail = strings.TrimSpace(normalized.UserEmail)
+ normalized.APIKeyName = strings.TrimSpace(normalized.APIKeyName)
+ normalized.GroupName = strings.TrimSpace(normalized.GroupName)
+ normalized.SignupIPPrefix = strings.TrimSpace(normalized.SignupIPPrefix)
+ normalized.DeviceFingerprintHash = strings.TrimSpace(normalized.DeviceFingerprintHash)
+ normalized.SignupUserAgentHash = strings.TrimSpace(normalized.SignupUserAgentHash)
+ normalized.Summary = trimRunes(strings.TrimSpace(normalized.Summary), 500)
+ normalized.Evidence = sanitizeHFCAbuseRiskEvidence(normalized.Evidence)
+ if normalized.Status == "" {
+ normalized.Status = HFCAbuseRiskStatusOpen
+ }
+ if err := s.repo.CreateEvent(ctx, normalized); err != nil {
+ return nil, err
+ }
+ return normalized, nil
+}
+
+func (s *HFCAbuseRiskService) ListEvents(ctx context.Context, filter HFCAbuseRiskEventFilter) ([]HFCAbuseRiskEvent, *pagination.PaginationResult, error) {
+ if s == nil || s.repo == nil {
+ return []HFCAbuseRiskEvent{}, paginationResultOrDefault(filter.Pagination), nil
+ }
+ filter.Source = strings.TrimSpace(filter.Source)
+ filter.Severity = normalizeHFCAbuseRiskSeverityAllowEmpty(filter.Severity)
+ filter.Status = normalizeHFCAbuseRiskStatusAllowEmpty(filter.Status)
+ filter.Search = strings.TrimSpace(filter.Search)
+ return s.repo.ListEvents(ctx, filter)
+}
+
+func (s *HFCAbuseRiskService) GetEvent(ctx context.Context, id int64) (*HFCAbuseRiskEvent, error) {
+ if id <= 0 {
+ return nil, ErrHFCAbuseRiskEventNotFound
+ }
+ if s == nil || s.repo == nil {
+ return nil, ErrHFCAbuseRiskEventNotFound
+ }
+ return s.repo.GetEvent(ctx, id)
+}
+
+func (s *HFCAbuseRiskService) ApplyAction(ctx context.Context, input HFCAbuseRiskActionInput) (*HFCAbuseRiskActionResult, error) {
+ if s == nil || s.repo == nil || input.EventID <= 0 {
+ return nil, ErrHFCAbuseRiskEventNotFound
+ }
+ action := strings.TrimSpace(input.Action)
+ event, err := s.repo.GetEvent(ctx, input.EventID)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &HFCAbuseRiskActionResult{}
+ status := HFCAbuseRiskStatusResolved
+ switch action {
+ case HFCAbuseRiskActionMarkReviewing:
+ status = HFCAbuseRiskStatusReviewing
+ case HFCAbuseRiskActionResolveNoAction:
+ status = HFCAbuseRiskStatusResolved
+ case HFCAbuseRiskActionMarkFalsePositive:
+ status = HFCAbuseRiskStatusFalsePositive
+ case HFCAbuseRiskActionDisableAPIKey:
+ disabled, err := s.disableEventAPIKey(ctx, event)
+ if err != nil {
+ return nil, err
+ }
+ result.DisabledAPIKeys = disabled
+ case HFCAbuseRiskActionDisableUserAPIKeys:
+ disabled, err := s.disableEventUserAPIKeys(ctx, event)
+ if err != nil {
+ return nil, err
+ }
+ result.DisabledAPIKeys = disabled
+ case HFCAbuseRiskActionFreezeUser:
+ frozen, err := s.freezeEventUser(ctx, event)
+ if err != nil {
+ return nil, err
+ }
+ result.FrozenUser = frozen
+ default:
+ return nil, ErrHFCAbuseRiskInvalidAction
+ }
+
+ reviewedAt := time.Now()
+ var reviewer *int64
+ if input.AdminUserID > 0 {
+ reviewer = &input.AdminUserID
+ }
+ if err := s.repo.UpdateReview(ctx, HFCAbuseRiskReviewUpdate{
+ EventID: event.ID,
+ Status: status,
+ ActionTaken: action,
+ ActionNote: trimRunes(strings.TrimSpace(input.Note), hfcAbuseRiskMaxActionNoteRunes),
+ ReviewedBy: reviewer,
+ ReviewedAt: reviewedAt,
+ }); err != nil {
+ return nil, err
+ }
+ updated, err := s.repo.GetEvent(ctx, event.ID)
+ if err != nil {
+ return nil, err
+ }
+ result.Event = updated
+ return result, nil
+}
+
+func (s *HFCAbuseRiskService) disableEventAPIKey(ctx context.Context, event *HFCAbuseRiskEvent) (int, error) {
+ if event == nil || event.APIKeyID == nil || *event.APIKeyID <= 0 {
+ return 0, ErrHFCAbuseRiskMissingTarget
+ }
+ if s.apiKeyRepo == nil {
+ return 0, infraerrors.InternalServer("HFC_ABUSE_RISK_API_KEY_REPO_MISSING", "API key repository is unavailable")
+ }
+ key, err := s.apiKeyRepo.GetByID(ctx, *event.APIKeyID)
+ if err != nil {
+ return 0, err
+ }
+ if event.UserID != nil && *event.UserID > 0 && key.UserID != *event.UserID {
+ return 0, infraerrors.Forbidden("HFC_ABUSE_RISK_TARGET_MISMATCH", "API key does not belong to the event user")
+ }
+ return s.disableAPIKey(ctx, key)
+}
+
+func (s *HFCAbuseRiskService) disableEventUserAPIKeys(ctx context.Context, event *HFCAbuseRiskEvent) (int, error) {
+ if event == nil || event.UserID == nil || *event.UserID <= 0 {
+ return 0, ErrHFCAbuseRiskMissingTarget
+ }
+ if s.apiKeyRepo == nil {
+ return 0, infraerrors.InternalServer("HFC_ABUSE_RISK_API_KEY_REPO_MISSING", "API key repository is unavailable")
+ }
+ disabled := 0
+ for {
+ keys, _, err := s.apiKeyRepo.ListByUserID(ctx, *event.UserID, pagination.PaginationParams{
+ Page: 1,
+ PageSize: hfcAbuseRiskActionPageSize,
+ }, APIKeyListFilters{Status: StatusAPIKeyActive})
+ if err != nil {
+ return disabled, err
+ }
+ if len(keys) == 0 {
+ return disabled, nil
+ }
+ for i := range keys {
+ n, err := s.disableAPIKey(ctx, &keys[i])
+ if err != nil {
+ return disabled, err
+ }
+ disabled += n
+ }
+ }
+}
+
+func (s *HFCAbuseRiskService) disableAPIKey(ctx context.Context, key *APIKey) (int, error) {
+ if key == nil || key.ID <= 0 {
+ return 0, ErrHFCAbuseRiskMissingTarget
+ }
+ if key.Status == StatusAPIKeyDisabled {
+ return 0, nil
+ }
+ key.Status = StatusAPIKeyDisabled
+ if err := s.apiKeyRepo.Update(ctx, key); err != nil {
+ return 0, err
+ }
+ if s.authCacheInvalidator != nil && key.Key != "" {
+ s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, key.Key)
+ }
+ return 1, nil
+}
+
+func (s *HFCAbuseRiskService) freezeEventUser(ctx context.Context, event *HFCAbuseRiskEvent) (bool, error) {
+ if event == nil || event.UserID == nil || *event.UserID <= 0 {
+ return false, ErrHFCAbuseRiskMissingTarget
+ }
+ if s.userRepo == nil {
+ return false, infraerrors.InternalServer("HFC_ABUSE_RISK_USER_REPO_MISSING", "user repository is unavailable")
+ }
+ user, err := s.userRepo.GetByID(ctx, *event.UserID)
+ if err != nil {
+ return false, err
+ }
+ if user.Role == RoleAdmin {
+ return false, infraerrors.Forbidden("HFC_ABUSE_RISK_REFUSE_ADMIN_FREEZE", "admin users cannot be frozen from abuse review")
+ }
+ if user.Status == StatusDisabled {
+ return false, nil
+ }
+ user.Status = StatusDisabled
+ if err := s.userRepo.Update(ctx, user); err != nil {
+ return false, err
+ }
+ if s.authCacheInvalidator != nil {
+ s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, user.ID)
+ }
+ return true, nil
+}
+
+func cloneHFCAbuseRiskEvent(in *HFCAbuseRiskEvent) *HFCAbuseRiskEvent {
+ if in == nil {
+ return nil
+ }
+ out := *in
+ if in.Evidence != nil {
+ out.Evidence = make([]HFCAbuseRiskEvidence, len(in.Evidence))
+ copy(out.Evidence, in.Evidence)
+ }
+ return &out
+}
+
+func sanitizeHFCAbuseRiskEvidence(items []HFCAbuseRiskEvidence) []HFCAbuseRiskEvidence {
+ out := make([]HFCAbuseRiskEvidence, 0, len(items))
+ for _, item := range items {
+ key := trimRunes(strings.TrimSpace(item.Key), 80)
+ label := trimRunes(strings.TrimSpace(item.Label), 120)
+ value := trimRunes(strings.TrimSpace(item.Value), 500)
+ if key == "" && label == "" && value == "" {
+ continue
+ }
+ out = append(out, HFCAbuseRiskEvidence{
+ Key: key,
+ Label: label,
+ Value: value,
+ })
+ }
+ return out
+}
+
+func normalizeHFCAbuseRiskStatus(value string) string {
+ if status := normalizeHFCAbuseRiskStatusAllowEmpty(value); status != "" {
+ return status
+ }
+ return HFCAbuseRiskStatusOpen
+}
+
+func normalizeHFCAbuseRiskStatusAllowEmpty(value string) string {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case HFCAbuseRiskStatusOpen, HFCAbuseRiskStatusReviewing, HFCAbuseRiskStatusResolved, HFCAbuseRiskStatusFalsePositive:
+ return strings.ToLower(strings.TrimSpace(value))
+ default:
+ return ""
+ }
+}
+
+func normalizeHFCAbuseRiskSeverityAllowEmpty(value string) string {
+ raw := strings.ToLower(strings.TrimSpace(value))
+ switch raw {
+ case HFCAbuseRiskSeverityLow, HFCAbuseRiskSeverityMedium, HFCAbuseRiskSeverityHigh, HFCAbuseRiskSeverityCritical:
+ return raw
+ default:
+ return ""
+ }
+}
+
+func paginationResultOrDefault(params pagination.PaginationParams) *pagination.PaginationResult {
+ if params.Page <= 0 {
+ params.Page = 1
+ }
+ if params.PageSize <= 0 {
+ params.PageSize = 20
+ }
+ return &pagination.PaginationResult{
+ Total: 0,
+ Page: params.Page,
+ PageSize: params.Limit(),
+ Pages: 1,
+ }
+}
+
+func logHFCAbuseRiskRecordFailure(source string, userID int64, apiKeyID int64, err error) {
+ if err == nil {
+ return
+ }
+ slog.Warn("hfc_abuse_risk.record_event_failed",
+ "source", source,
+ "user_id", userID,
+ "api_key_id", apiKeyID,
+ "error", err)
+}
+
+func hfcRiskEvidence(key, label string, value any) HFCAbuseRiskEvidence {
+ return HFCAbuseRiskEvidence{Key: key, Label: label, Value: fmt.Sprint(value)}
+}
diff --git a/backend/internal/service/hfc_abuse_risk_test.go b/backend/internal/service/hfc_abuse_risk_test.go
new file mode 100644
index 00000000000..9ac27c91747
--- /dev/null
+++ b/backend/internal/service/hfc_abuse_risk_test.go
@@ -0,0 +1,186 @@
+package service
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHFCAbuseRiskService_RecordEventNormalizesReviewState(t *testing.T) {
+ repo := &hfcAbuseRiskRepoStub{}
+ svc := NewHFCAbuseRiskService(repo, nil, nil, nil)
+
+ event, err := svc.RecordEvent(context.Background(), &HFCAbuseRiskEvent{
+ Source: " signup_risk ",
+ Severity: "invalid",
+ Status: "invalid",
+ Summary: " registration allowed ",
+ Evidence: []HFCAbuseRiskEvidence{
+ hfcRiskEvidence("hold_reason", "原因", "duplicate_device_fingerprint"),
+ {},
+ },
+ })
+
+ require.NoError(t, err)
+ require.Equal(t, int64(1), event.ID)
+ require.Equal(t, HFCAbuseRiskSeverityHigh, repo.created.Severity)
+ require.Equal(t, HFCAbuseRiskStatusOpen, repo.created.Status)
+ require.Equal(t, "signup_risk", repo.created.Source)
+ require.Len(t, repo.created.Evidence, 1)
+}
+
+func TestHFCAbuseRiskService_DisableUserAPIKeysInvalidatesEachKey(t *testing.T) {
+ userID := int64(42)
+ event := &HFCAbuseRiskEvent{ID: 7, UserID: &userID, Status: HFCAbuseRiskStatusOpen}
+ repo := &hfcAbuseRiskRepoStub{event: event}
+ apiRepo := &hfcAbuseRiskAPIKeyRepoStub{
+ keys: []*APIKey{
+ {ID: 1, UserID: userID, Key: "sk-one", Name: "one", Status: StatusAPIKeyActive},
+ {ID: 2, UserID: userID, Key: "sk-two", Name: "two", Status: StatusAPIKeyActive},
+ },
+ }
+ invalidator := &hfcAbuseRiskCacheInvalidatorStub{}
+ svc := NewHFCAbuseRiskService(repo, apiRepo, nil, invalidator)
+
+ result, err := svc.ApplyAction(context.Background(), HFCAbuseRiskActionInput{
+ EventID: event.ID,
+ Action: HFCAbuseRiskActionDisableUserAPIKeys,
+ AdminUserID: 9,
+ })
+
+ require.NoError(t, err)
+ require.Equal(t, 2, result.DisabledAPIKeys)
+ require.Equal(t, HFCAbuseRiskStatusResolved, repo.event.Status)
+ require.Equal(t, []string{"sk-one", "sk-two"}, invalidator.keys)
+ require.Equal(t, StatusAPIKeyDisabled, apiRepo.keys[0].Status)
+ require.Equal(t, StatusAPIKeyDisabled, apiRepo.keys[1].Status)
+}
+
+func TestHFCAbuseRiskService_FreezeUserRefusesAdmin(t *testing.T) {
+ userID := int64(1)
+ event := &HFCAbuseRiskEvent{ID: 8, UserID: &userID, Status: HFCAbuseRiskStatusOpen}
+ repo := &hfcAbuseRiskRepoStub{event: event}
+ userRepo := &hfcAbuseRiskUserRepoStub{user: &User{ID: userID, Role: RoleAdmin, Status: StatusActive}}
+ svc := NewHFCAbuseRiskService(repo, nil, userRepo, nil)
+
+ _, err := svc.ApplyAction(context.Background(), HFCAbuseRiskActionInput{
+ EventID: event.ID,
+ Action: HFCAbuseRiskActionFreezeUser,
+ })
+
+ require.Error(t, err)
+ require.Equal(t, StatusActive, userRepo.user.Status)
+}
+
+type hfcAbuseRiskRepoStub struct {
+ created *HFCAbuseRiskEvent
+ event *HFCAbuseRiskEvent
+}
+
+func (r *hfcAbuseRiskRepoStub) CreateEvent(_ context.Context, event *HFCAbuseRiskEvent) error {
+ cp := cloneHFCAbuseRiskEvent(event)
+ cp.ID = 1
+ cp.CreatedAt = time.Now()
+ cp.UpdatedAt = cp.CreatedAt
+ r.created = cp
+ *event = *cloneHFCAbuseRiskEvent(cp)
+ return nil
+}
+
+func (r *hfcAbuseRiskRepoStub) ListEvents(_ context.Context, _ HFCAbuseRiskEventFilter) ([]HFCAbuseRiskEvent, *pagination.PaginationResult, error) {
+ return []HFCAbuseRiskEvent{}, &pagination.PaginationResult{Page: 1, PageSize: 20, Pages: 1}, nil
+}
+
+func (r *hfcAbuseRiskRepoStub) GetEvent(_ context.Context, id int64) (*HFCAbuseRiskEvent, error) {
+ if r.event == nil || r.event.ID != id {
+ return nil, ErrHFCAbuseRiskEventNotFound
+ }
+ return cloneHFCAbuseRiskEvent(r.event), nil
+}
+
+func (r *hfcAbuseRiskRepoStub) UpdateReview(_ context.Context, update HFCAbuseRiskReviewUpdate) error {
+ if r.event == nil || r.event.ID != update.EventID {
+ return ErrHFCAbuseRiskEventNotFound
+ }
+ r.event.Status = update.Status
+ r.event.ActionTaken = update.ActionTaken
+ r.event.ActionNote = update.ActionNote
+ r.event.ReviewedBy = update.ReviewedBy
+ r.event.ReviewedAt = &update.ReviewedAt
+ return nil
+}
+
+type hfcAbuseRiskAPIKeyRepoStub struct {
+ APIKeyRepository
+ keys []*APIKey
+}
+
+func (r *hfcAbuseRiskAPIKeyRepoStub) GetByID(_ context.Context, id int64) (*APIKey, error) {
+ for _, key := range r.keys {
+ if key.ID == id {
+ cp := *key
+ return &cp, nil
+ }
+ }
+ return nil, ErrAPIKeyNotFound
+}
+
+func (r *hfcAbuseRiskAPIKeyRepoStub) Update(_ context.Context, key *APIKey) error {
+ for _, item := range r.keys {
+ if item.ID == key.ID {
+ *item = *key
+ return nil
+ }
+ }
+ return ErrAPIKeyNotFound
+}
+
+func (r *hfcAbuseRiskAPIKeyRepoStub) ListByUserID(_ context.Context, userID int64, _ pagination.PaginationParams, filters APIKeyListFilters) ([]APIKey, *pagination.PaginationResult, error) {
+ out := make([]APIKey, 0)
+ for _, key := range r.keys {
+ if key.UserID != userID {
+ continue
+ }
+ if filters.Status != "" && key.Status != filters.Status {
+ continue
+ }
+ out = append(out, *key)
+ }
+ return out, &pagination.PaginationResult{Total: int64(len(out)), Page: 1, PageSize: 200, Pages: 1}, nil
+}
+
+type hfcAbuseRiskUserRepoStub struct {
+ UserRepository
+ user *User
+}
+
+func (r *hfcAbuseRiskUserRepoStub) GetByID(_ context.Context, id int64) (*User, error) {
+ if r.user == nil || r.user.ID != id {
+ return nil, ErrUserNotFound
+ }
+ cp := *r.user
+ return &cp, nil
+}
+
+func (r *hfcAbuseRiskUserRepoStub) Update(_ context.Context, user *User) error {
+ *r.user = *user
+ return nil
+}
+
+type hfcAbuseRiskCacheInvalidatorStub struct {
+ keys []string
+ userIDs []int64
+}
+
+func (s *hfcAbuseRiskCacheInvalidatorStub) InvalidateAuthCacheByKey(_ context.Context, key string) {
+ s.keys = append(s.keys, key)
+}
+
+func (s *hfcAbuseRiskCacheInvalidatorStub) InvalidateAuthCacheByUserID(_ context.Context, userID int64) {
+ s.userIDs = append(s.userIDs, userID)
+}
+
+func (s *hfcAbuseRiskCacheInvalidatorStub) InvalidateAuthCacheByGroupID(_ context.Context, _ int64) {}
diff --git a/backend/internal/service/idempotency.go b/backend/internal/service/idempotency.go
index 2a86bd60386..a60a09b8a7b 100644
--- a/backend/internal/service/idempotency.go
+++ b/backend/internal/service/idempotency.go
@@ -20,6 +20,14 @@ const (
IdempotencyStatusProcessing = "processing"
IdempotencyStatusSucceeded = "succeeded"
IdempotencyStatusFailedRetryable = "failed_retryable"
+
+ idempotencyScopeAdminSubscriptionsAssign = "admin.subscriptions.assign"
+ idempotencyScopeAdminSubscriptionsBulkAssign = "admin.subscriptions.bulk_assign"
+ idempotencyScopeAdminSubscriptionsExtend = "admin.subscriptions.extend"
+ idempotencyScopeAdminUsersBalanceUpdate = "admin.users.balance.update"
+ idempotencyScopeAdminRedeemCodesGenerate = "admin.redeem_codes.generate"
+ idempotencyScopeAdminRedeemCodesCreateRedeem = "admin.redeem_codes.create_and_redeem"
+ WeChatPaymentResumeIdempotencyScope = "payment.orders.wechat_resume.create"
)
var (
@@ -28,6 +36,7 @@ var (
ErrIdempotencyKeyConflict = infraerrors.Conflict("IDEMPOTENCY_KEY_CONFLICT", "idempotency key reused with different payload")
ErrIdempotencyInProgress = infraerrors.Conflict("IDEMPOTENCY_IN_PROGRESS", "idempotent request is still processing")
ErrIdempotencyRetryBackoff = infraerrors.Conflict("IDEMPOTENCY_RETRY_BACKOFF", "idempotent request is in retry backoff window")
+ ErrIdempotencyManualRecovery = infraerrors.Conflict("IDEMPOTENCY_MANUAL_RECOVERY_REQUIRED", "persistent idempotent request requires manual recovery")
ErrIdempotencyStoreUnavail = infraerrors.ServiceUnavailable("IDEMPOTENCY_STORE_UNAVAILABLE", "idempotency store unavailable")
ErrIdempotencyInvalidPayload = infraerrors.BadRequest("IDEMPOTENCY_PAYLOAD_INVALID", "failed to normalize request payload")
)
@@ -43,6 +52,7 @@ type IdempotencyRecord struct {
ErrorReason *string
LockedUntil *time.Time
ExpiresAt time.Time
+ Persistent bool
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -78,14 +88,20 @@ func DefaultIdempotencyConfig() IdempotencyConfig {
}
type IdempotencyExecuteOptions struct {
- Scope string
- ActorScope string
- Method string
- Route string
- IdempotencyKey string
- Payload any
- TTL time.Duration
- RequireKey bool
+ Scope string
+ ActorScope string
+ Method string
+ Route string
+ IdempotencyKey string
+ Payload any
+ TTL time.Duration
+ ProcessingTimeout time.Duration
+ FailedRetryBackoff time.Duration
+ RequireKey bool
+ // Persistent marks financial operations whose key must never become
+ // executable again after expiry. Succeeded rows always replay; incomplete
+ // rows require manual recovery.
+ Persistent bool
}
type IdempotencyExecuteResult struct {
@@ -93,6 +109,55 @@ type IdempotencyExecuteResult struct {
Replayed bool
}
+// IdempotencySensitiveResponse separates the one-time response from the
+// persistence-safe replay body. It is used for generated credentials: the
+// first caller receives the secret, while the database stores only metadata.
+type IdempotencySensitiveResponse struct {
+ PublicData any
+ StoredData any
+}
+
+func NewIdempotencySensitiveResponse(publicData, storedData any) *IdempotencySensitiveResponse {
+ return &IdempotencySensitiveResponse{PublicData: publicData, StoredData: storedData}
+}
+
+func SplitIdempotencySensitiveResponse(data any) (publicData, storedData any) {
+ if split, ok := data.(*IdempotencySensitiveResponse); ok && split != nil {
+ return split.PublicData, split.StoredData
+ }
+ return data, data
+}
+
+// PersistentFinancialIdempotencyScopes returns a fresh list of entitlement or
+// balance mutations whose keys may never become executable again. The database
+// migration carries the same allowlist as a fail-closed storage invariant.
+func PersistentFinancialIdempotencyScopes() []string {
+ return []string{
+ idempotencyScopeAdminSubscriptionsAssign,
+ idempotencyScopeAdminSubscriptionsBulkAssign,
+ idempotencyScopeAdminSubscriptionsExtend,
+ idempotencyScopeAdminUsersBalanceUpdate,
+ idempotencyScopeAdminRedeemCodesGenerate,
+ idempotencyScopeAdminRedeemCodesCreateRedeem,
+ UsageBillingReconciliationIdempotencyScope,
+ }
+}
+
+func IsPersistentFinancialIdempotencyScope(scope string) bool {
+ switch scope {
+ case idempotencyScopeAdminSubscriptionsAssign,
+ idempotencyScopeAdminSubscriptionsBulkAssign,
+ idempotencyScopeAdminSubscriptionsExtend,
+ idempotencyScopeAdminUsersBalanceUpdate,
+ idempotencyScopeAdminRedeemCodesGenerate,
+ idempotencyScopeAdminRedeemCodesCreateRedeem,
+ UsageBillingReconciliationIdempotencyScope:
+ return true
+ default:
+ return false
+ }
+}
+
type IdempotencyCoordinator struct {
repo IdempotencyRepository
cfg IdempotencyConfig
@@ -204,20 +269,22 @@ func (c *IdempotencyCoordinator) Execute(
if execute == nil {
return nil, infraerrors.InternalServer("IDEMPOTENCY_EXECUTOR_NIL", "idempotency executor is nil")
}
+ persistent := opts.Persistent || IsPersistentFinancialIdempotencyScope(opts.Scope)
key, err := NormalizeIdempotencyKey(opts.IdempotencyKey)
if err != nil {
return nil, err
}
if key == "" {
- if opts.RequireKey && !c.cfg.ObserveOnly {
+ if persistent || (opts.RequireKey && !c.cfg.ObserveOnly) {
return nil, ErrIdempotencyKeyRequired
}
data, execErr := execute(ctx)
if execErr != nil {
return nil, execErr
}
- return &IdempotencyExecuteResult{Data: data}, nil
+ publicData, _ := SplitIdempotencySensitiveResponse(data)
+ return &IdempotencyExecuteResult{Data: publicData}, nil
}
if c.repo == nil {
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "repo_nil")
@@ -239,7 +306,11 @@ func (c *IdempotencyCoordinator) Execute(
}
now := time.Now()
expiresAt := now.Add(ttl)
- lockedUntil := now.Add(c.cfg.ProcessingTimeout)
+ processingTimeout := opts.ProcessingTimeout
+ if processingTimeout <= 0 {
+ processingTimeout = c.cfg.ProcessingTimeout
+ }
+ lockedUntil := now.Add(processingTimeout)
keyHash := HashIdempotencyKey(key)
record := &IdempotencyRecord{
@@ -249,6 +320,7 @@ func (c *IdempotencyCoordinator) Execute(
Status: IdempotencyStatusProcessing,
LockedUntil: &lockedUntil,
ExpiresAt: expiresAt,
+ Persistent: persistent,
}
owner, err := c.repo.CreateProcessing(ctx, record)
@@ -286,8 +358,14 @@ func (c *IdempotencyCoordinator) Execute(
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "existing->fingerprint_mismatch", false, nil)
return nil, ErrIdempotencyKeyConflict
}
+ existingPersistent := persistent || existing.Persistent
reclaimedByExpired := false
- if !existing.ExpiresAt.After(now) {
+ if !existing.ExpiresAt.After(now) && existingPersistent && existing.Status != IdempotencyStatusSucceeded {
+ recordIdempotencyConflict(opts.Route, opts.Scope, map[string]string{"reason": "persistent_manual_recovery"})
+ logIdempotencyAudit(opts.Route, opts.Scope, keyHash, existing.Status+"->manual_recovery", false, nil)
+ return nil, ErrIdempotencyManualRecovery
+ }
+ if !existing.ExpiresAt.After(now) && !existingPersistent {
taken, reclaimErr := c.repo.TryReclaim(ctx, existing.ID, existing.Status, now, lockedUntil, expiresAt)
if reclaimErr != nil {
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "try_reclaim_expired_error")
@@ -343,10 +421,20 @@ func (c *IdempotencyCoordinator) Execute(
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "succeeded->replayed", true, nil)
return &IdempotencyExecuteResult{Data: data, Replayed: true}, nil
case IdempotencyStatusProcessing:
+ if existingPersistent && (existing.LockedUntil == nil || !existing.LockedUntil.After(now)) {
+ recordIdempotencyConflict(opts.Route, opts.Scope, map[string]string{"reason": "persistent_manual_recovery"})
+ logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->manual_recovery", false, nil)
+ return nil, ErrIdempotencyManualRecovery
+ }
recordIdempotencyConflict(opts.Route, opts.Scope, map[string]string{"reason": "in_progress"})
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->conflict", false, nil)
return nil, c.conflictWithRetryAfter(ErrIdempotencyInProgress, existing.LockedUntil, now)
case IdempotencyStatusFailedRetryable:
+ if existingPersistent {
+ recordIdempotencyConflict(opts.Route, opts.Scope, map[string]string{"reason": "persistent_manual_recovery"})
+ logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "failed_retryable->manual_recovery", false, nil)
+ return nil, ErrIdempotencyManualRecovery
+ }
if existing.LockedUntil != nil && existing.LockedUntil.After(now) {
recordIdempotencyConflict(opts.Route, opts.Scope, map[string]string{"reason": "retry_backoff"})
recordIdempotencyRetryBackoff(opts.Route, opts.Scope, nil)
@@ -398,7 +486,11 @@ func (c *IdempotencyCoordinator) Execute(
data, execErr := execute(ctx)
if execErr != nil {
- backoffUntil := time.Now().Add(c.cfg.FailedRetryBackoff)
+ failedRetryBackoff := opts.FailedRetryBackoff
+ if failedRetryBackoff <= 0 {
+ failedRetryBackoff = c.cfg.FailedRetryBackoff
+ }
+ backoffUntil := time.Now().Add(failedRetryBackoff)
reason := infraerrors.Reason(execErr)
if reason == "" {
reason = "EXECUTION_FAILED"
@@ -416,7 +508,8 @@ func (c *IdempotencyCoordinator) Execute(
return nil, execErr
}
- storedBody, marshalErr := c.marshalStoredResponse(data)
+ publicData, storedData := SplitIdempotencySensitiveResponse(data)
+ storedBody, marshalErr := c.marshalStoredResponse(storedData)
if marshalErr != nil {
RecordIdempotencyStoreUnavailable(opts.Route, opts.Scope, "marshal_response_error")
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->store_unavailable", false, map[string]string{
@@ -433,7 +526,7 @@ func (c *IdempotencyCoordinator) Execute(
}
logIdempotencyAudit(opts.Route, opts.Scope, keyHash, "processing->succeeded", false, nil)
- return &IdempotencyExecuteResult{Data: data}, nil
+ return &IdempotencyExecuteResult{Data: publicData}, nil
}
func (c *IdempotencyCoordinator) conflictWithRetryAfter(base *infraerrors.ApplicationError, lockedUntil *time.Time, now time.Time) error {
diff --git a/backend/internal/service/idempotency_migration_182_contract_test.go b/backend/internal/service/idempotency_migration_182_contract_test.go
new file mode 100644
index 00000000000..86503f3077e
--- /dev/null
+++ b/backend/internal/service/idempotency_migration_182_contract_test.go
@@ -0,0 +1,75 @@
+package service
+
+import (
+ "context"
+ "testing"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ dbmigrations "github.com/Wei-Shaw/sub2api/migrations"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPersistentFinancialIdempotencyMigrationBackfillsAndGuardsKnownScopes(t *testing.T) {
+ content, err := dbmigrations.FS.ReadFile("182_persistent_financial_idempotency.sql")
+ require.NoError(t, err)
+ forwardContent, err := dbmigrations.FS.ReadFile("185_usage_billing_reconciliation_audit.sql")
+ require.NoError(t, err)
+ sql := string(content) + "\n" + string(forwardContent)
+
+ for _, scope := range PersistentFinancialIdempotencyScopes() {
+ require.Contains(t, sql, "'"+scope+"'", "migration must guard %s", scope)
+ }
+
+ for _, required := range []string{
+ "LOCK TABLE idempotency_records IN SHARE ROW EXCLUSIVE MODE",
+ "SET is_reclaimable = FALSE",
+ "hfc_idempotency_scope_is_persistent",
+ "hfc_guard_persistent_financial_idempotency",
+ "OLD.scope",
+ "OLD.idempotency_key_hash",
+ "OLD.request_fingerprint",
+ "NEW.is_reclaimable IS DISTINCT FROM FALSE",
+ } {
+ require.Contains(t, sql, required)
+ }
+}
+
+func TestKnownFinancialScopeForcesKeyAndPersistenceEvenInObserveOnlyMode(t *testing.T) {
+ repo := newInMemoryIdempotencyRepo()
+ cfg := DefaultIdempotencyConfig()
+ cfg.ObserveOnly = true
+ coordinator := NewIdempotencyCoordinator(repo, cfg)
+
+ executeCalls := 0
+ _, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
+ Scope: "admin.users.balance.update",
+ Method: "POST",
+ Route: "/api/v1/admin/users/173/balance",
+ ActorScope: "admin:99",
+ Payload: map[string]any{"user_id": 173, "balance": 50, "operation": "add"},
+ }, func(context.Context) (any, error) {
+ executeCalls++
+ return nil, nil
+ })
+ require.Equal(t, infraerrors.Code(ErrIdempotencyKeyRequired), infraerrors.Code(err))
+ require.Zero(t, executeCalls)
+
+ result, err := coordinator.Execute(context.Background(), IdempotencyExecuteOptions{
+ Scope: "admin.users.balance.update",
+ Method: "POST",
+ Route: "/api/v1/admin/users/173/balance",
+ ActorScope: "admin:99",
+ IdempotencyKey: "balance-topup-173-20260712",
+ Payload: map[string]any{"user_id": 173, "balance": 50, "operation": "add"},
+ }, func(context.Context) (any, error) {
+ executeCalls++
+ return map[string]any{"ok": true}, nil
+ })
+ require.NoError(t, err)
+ require.False(t, result.Replayed)
+ require.Equal(t, 1, executeCalls)
+
+ record := repo.data[repo.key("admin.users.balance.update", HashIdempotencyKey("balance-topup-173-20260712"))]
+ require.NotNil(t, record)
+ require.True(t, record.Persistent)
+}
diff --git a/backend/internal/service/idempotency_persistent_test.go b/backend/internal/service/idempotency_persistent_test.go
new file mode 100644
index 00000000000..db88994c113
--- /dev/null
+++ b/backend/internal/service/idempotency_persistent_test.go
@@ -0,0 +1,89 @@
+package service
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/stretchr/testify/require"
+)
+
+func TestIdempotencyCoordinatorPersistentSuccessReplaysAfterClockExpiry(t *testing.T) {
+ repo := newInMemoryIdempotencyRepo()
+ coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
+ opts := IdempotencyExecuteOptions{
+ Scope: "admin.subscriptions.assign",
+ Method: "POST",
+ Route: "/api/v1/admin/subscriptions/assign",
+ ActorScope: "admin:99",
+ IdempotencyKey: "persistent-wallet-topup",
+ Payload: map[string]any{"user_id": 173, "wallet_initial_usd": 50},
+ RequireKey: true,
+ Persistent: true,
+ }
+ executeCalls := 0
+ execute := func(context.Context) (any, error) {
+ executeCalls++
+ return map[string]any{"subscription_id": 701}, nil
+ }
+
+ first, err := coordinator.Execute(context.Background(), opts, execute)
+ require.NoError(t, err)
+ require.False(t, first.Replayed)
+
+ keyHash := HashIdempotencyKey(opts.IdempotencyKey)
+ repo.mu.Lock()
+ record := repo.data[repo.key(opts.Scope, keyHash)]
+ require.NotNil(t, record)
+ require.True(t, record.Persistent)
+ record.ExpiresAt = time.Now().Add(-time.Hour)
+ repo.mu.Unlock()
+
+ replayed, err := coordinator.Execute(context.Background(), opts, execute)
+ require.NoError(t, err)
+ require.True(t, replayed.Replayed, "persistent financial keys must replay after the ordinary TTL")
+ require.Equal(t, 1, executeCalls, "clock expiry must never execute the financial side effect again")
+}
+
+func TestIdempotencyCoordinatorPersistentIncompleteRecordRequiresManualRecovery(t *testing.T) {
+ for _, status := range []string{IdempotencyStatusProcessing, IdempotencyStatusFailedRetryable} {
+ t.Run(status, func(t *testing.T) {
+ repo := newInMemoryIdempotencyRepo()
+ coordinator := NewIdempotencyCoordinator(repo, DefaultIdempotencyConfig())
+ opts := IdempotencyExecuteOptions{
+ Scope: "admin.subscriptions.assign." + status,
+ Method: "POST",
+ Route: "/api/v1/admin/subscriptions/assign",
+ ActorScope: "admin:99",
+ IdempotencyKey: "persistent-incomplete",
+ Payload: map[string]any{"user_id": 173},
+ RequireKey: true,
+ Persistent: true,
+ }
+ fingerprint, err := BuildIdempotencyFingerprint(opts.Method, opts.Route, opts.ActorScope, opts.Payload)
+ require.NoError(t, err)
+ past := time.Now().Add(-time.Hour)
+ keyHash := HashIdempotencyKey(opts.IdempotencyKey)
+ repo.data[repo.key(opts.Scope, keyHash)] = &IdempotencyRecord{
+ ID: 1,
+ Scope: opts.Scope,
+ IdempotencyKeyHash: keyHash,
+ RequestFingerprint: fingerprint,
+ Status: status,
+ LockedUntil: &past,
+ ExpiresAt: past,
+ Persistent: true,
+ }
+
+ executeCalls := 0
+ _, err = coordinator.Execute(context.Background(), opts, func(context.Context) (any, error) {
+ executeCalls++
+ return map[string]any{"unexpected": true}, nil
+ })
+ require.Error(t, err)
+ require.Equal(t, infraerrors.Reason(ErrIdempotencyManualRecovery), infraerrors.Reason(err))
+ require.Zero(t, executeCalls)
+ })
+ }
+}
diff --git a/backend/internal/service/idempotency_test.go b/backend/internal/service/idempotency_test.go
index 6ff75d1c3e7..f0ac92f8b15 100644
--- a/backend/internal/service/idempotency_test.go
+++ b/backend/internal/service/idempotency_test.go
@@ -190,6 +190,42 @@ func TestIdempotencyCoordinator_RequireKey(t *testing.T) {
require.Equal(t, infraerrors.Code(err), infraerrors.Code(ErrIdempotencyKeyRequired))
}
+func TestIdempotencyCoordinatorPersistsSafeReplayForSensitiveResponse(t *testing.T) {
+ resetIdempotencyMetricsForTest()
+ repo := newInMemoryIdempotencyRepo()
+ cfg := DefaultIdempotencyConfig()
+ cfg.ObserveOnly = false
+ coordinator := NewIdempotencyCoordinator(repo, cfg)
+ opts := IdempotencyExecuteOptions{
+ Scope: "user.api_keys.create", ActorScope: "user:7", Method: "POST",
+ Route: "/api/v1/keys", IdempotencyKey: "create-key-once", Payload: map[string]any{"name": "x"},
+ RequireKey: true,
+ }
+ raw := "sk-secret-must-not-persist"
+ result, err := coordinator.Execute(context.Background(), opts, func(context.Context) (any, error) {
+ return NewIdempotencySensitiveResponse(
+ map[string]any{"id": 1, "key": raw},
+ map[string]any{"id": 1, "key": "sk-sec••••"},
+ ), nil
+ })
+ require.NoError(t, err)
+ require.Equal(t, raw, result.Data.(map[string]any)["key"])
+
+ stored, err := repo.GetByScopeAndKeyHash(context.Background(), opts.Scope, HashIdempotencyKey(opts.IdempotencyKey))
+ require.NoError(t, err)
+ require.NotNil(t, stored)
+ require.NotNil(t, stored.ResponseBody)
+ require.NotContains(t, *stored.ResponseBody, raw)
+
+ replayed, err := coordinator.Execute(context.Background(), opts, func(context.Context) (any, error) {
+ t.Fatal("replay must not execute side effect")
+ return nil, nil
+ })
+ require.NoError(t, err)
+ require.True(t, replayed.Replayed)
+ require.NotEqual(t, raw, replayed.Data.(map[string]any)["key"])
+}
+
func TestIdempotencyCoordinator_ReplaySucceededResult(t *testing.T) {
resetIdempotencyMetricsForTest()
repo := newInMemoryIdempotencyRepo()
diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go
deleted file mode 100644
index 665922e3f2f..00000000000
--- a/backend/internal/service/identity_service.go
+++ /dev/null
@@ -1,442 +0,0 @@
-package service
-
-import (
- "context"
- "crypto/rand"
- "crypto/sha256"
- "encoding/hex"
- "fmt"
- "log/slog"
- "net/http"
- "regexp"
- "strconv"
- "strings"
- "time"
-
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/tidwall/gjson"
- "github.com/tidwall/sjson"
-)
-
-// 预编译正则表达式(避免每次调用重新编译)
-var (
- // 匹配 User-Agent 版本号: xxx/x.y.z
- userAgentVersionRegex = regexp.MustCompile(`/(\d+)\.(\d+)\.(\d+)`)
-)
-
-// 默认指纹值(当客户端未提供时使用)
-var defaultFingerprint = Fingerprint{
- UserAgent: "claude-cli/2.1.92 (external, cli)",
- StainlessLang: "js",
- StainlessPackageVersion: "0.70.0",
- StainlessOS: "Linux",
- StainlessArch: "arm64",
- StainlessRuntime: "node",
- StainlessRuntimeVersion: "v24.13.0",
-}
-
-// Fingerprint represents account fingerprint data
-type Fingerprint struct {
- ClientID string
- UserAgent string
- StainlessLang string
- StainlessPackageVersion string
- StainlessOS string
- StainlessArch string
- StainlessRuntime string
- StainlessRuntimeVersion string
- UpdatedAt int64 `json:",omitempty"` // Unix timestamp,用于判断是否需要续期TTL
-}
-
-// IdentityCache defines cache operations for identity service
-type IdentityCache interface {
- GetFingerprint(ctx context.Context, accountID int64) (*Fingerprint, error)
- SetFingerprint(ctx context.Context, accountID int64, fp *Fingerprint) error
- // GetMaskedSessionID 获取固定的会话ID(用于会话ID伪装功能)
- // 返回的 sessionID 是一个 UUID 格式的字符串
- // 如果不存在或已过期(15分钟无请求),返回空字符串
- GetMaskedSessionID(ctx context.Context, accountID int64) (string, error)
- // SetMaskedSessionID 设置固定的会话ID,TTL 为 15 分钟
- // 每次调用都会刷新 TTL
- SetMaskedSessionID(ctx context.Context, accountID int64, sessionID string) error
-}
-
-// IdentityService 管理OAuth账号的请求身份指纹
-type IdentityService struct {
- cache IdentityCache
-}
-
-// NewIdentityService 创建新的IdentityService
-func NewIdentityService(cache IdentityCache) *IdentityService {
- return &IdentityService{cache: cache}
-}
-
-// GetOrCreateFingerprint 获取或创建账号的指纹
-// 如果缓存存在,检测user-agent版本,新版本则更新
-// 如果缓存不存在,生成随机ClientID并从请求头创建指纹,然后缓存
-func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID int64, headers http.Header) (*Fingerprint, error) {
- // 尝试从缓存获取指纹
- cached, err := s.cache.GetFingerprint(ctx, accountID)
- if err == nil && cached != nil {
- needWrite := false
-
- // 检查客户端的user-agent是否是更新版本
- clientUA := headers.Get("User-Agent")
- if clientUA != "" && isNewerVersion(clientUA, cached.UserAgent) {
- // 版本升级:merge 语义 — 仅更新请求中实际携带的字段,保留缓存值
- // 避免缺失的头被硬编码默认值覆盖(如新 CLI 版本 + 旧 SDK 默认值的不一致)
- mergeHeadersIntoFingerprint(cached, headers)
- needWrite = true
- logger.LegacyPrintf("service.identity", "Updated fingerprint for account %d: %s (merge update)", accountID, clientUA)
- } else if time.Since(time.Unix(cached.UpdatedAt, 0)) > 24*time.Hour {
- // 距上次写入超过24小时,续期TTL
- needWrite = true
- }
-
- if needWrite {
- cached.UpdatedAt = time.Now().Unix()
- if err := s.cache.SetFingerprint(ctx, accountID, cached); err != nil {
- logger.LegacyPrintf("service.identity", "Warning: failed to refresh fingerprint for account %d: %v", accountID, err)
- }
- }
- return cached, nil
- }
-
- // 缓存不存在或解析失败,创建新指纹
- fp := s.createFingerprintFromHeaders(headers)
-
- // 生成随机ClientID
- fp.ClientID = generateClientID()
- fp.UpdatedAt = time.Now().Unix()
-
- // 保存到缓存(7天TTL,每24小时自动续期)
- if err := s.cache.SetFingerprint(ctx, accountID, fp); err != nil {
- logger.LegacyPrintf("service.identity", "Warning: failed to cache fingerprint for account %d: %v", accountID, err)
- }
-
- logger.LegacyPrintf("service.identity", "Created new fingerprint for account %d with client_id: %s", accountID, fp.ClientID)
- return fp, nil
-}
-
-// createFingerprintFromHeaders 从请求头创建指纹
-func (s *IdentityService) createFingerprintFromHeaders(headers http.Header) *Fingerprint {
- fp := &Fingerprint{}
-
- // 获取User-Agent
- if ua := headers.Get("User-Agent"); ua != "" {
- fp.UserAgent = ua
- } else {
- fp.UserAgent = defaultFingerprint.UserAgent
- }
-
- // 获取x-stainless-*头,如果没有则使用默认值
- fp.StainlessLang = getHeaderOrDefault(headers, "X-Stainless-Lang", defaultFingerprint.StainlessLang)
- fp.StainlessPackageVersion = getHeaderOrDefault(headers, "X-Stainless-Package-Version", defaultFingerprint.StainlessPackageVersion)
- fp.StainlessOS = getHeaderOrDefault(headers, "X-Stainless-OS", defaultFingerprint.StainlessOS)
- fp.StainlessArch = getHeaderOrDefault(headers, "X-Stainless-Arch", defaultFingerprint.StainlessArch)
- fp.StainlessRuntime = getHeaderOrDefault(headers, "X-Stainless-Runtime", defaultFingerprint.StainlessRuntime)
- fp.StainlessRuntimeVersion = getHeaderOrDefault(headers, "X-Stainless-Runtime-Version", defaultFingerprint.StainlessRuntimeVersion)
-
- return fp
-}
-
-// mergeHeadersIntoFingerprint 将请求头中实际存在的字段合并到现有指纹中(用于版本升级场景)
-// 关键语义:请求中有的字段 → 用新值覆盖;缺失的头 → 保留缓存中的已有值
-// 与 createFingerprintFromHeaders 的区别:后者用于首次创建,缺失头回退到 defaultFingerprint;
-// 本函数用于升级更新,缺失头保留缓存值,避免将已知的真实值退化为硬编码默认值
-func mergeHeadersIntoFingerprint(fp *Fingerprint, headers http.Header) {
- // User-Agent:版本升级的触发条件,一定存在
- if ua := headers.Get("User-Agent"); ua != "" {
- fp.UserAgent = ua
- }
- // X-Stainless-* 头:仅在请求中实际携带时才更新,否则保留缓存值
- mergeHeader(headers, "X-Stainless-Lang", &fp.StainlessLang)
- mergeHeader(headers, "X-Stainless-Package-Version", &fp.StainlessPackageVersion)
- mergeHeader(headers, "X-Stainless-OS", &fp.StainlessOS)
- mergeHeader(headers, "X-Stainless-Arch", &fp.StainlessArch)
- mergeHeader(headers, "X-Stainless-Runtime", &fp.StainlessRuntime)
- mergeHeader(headers, "X-Stainless-Runtime-Version", &fp.StainlessRuntimeVersion)
-}
-
-// mergeHeader 如果请求头中存在该字段则更新目标值,否则保留原值
-func mergeHeader(headers http.Header, key string, target *string) {
- if v := headers.Get(key); v != "" {
- *target = v
- }
-}
-
-// getHeaderOrDefault 获取header值,如果不存在则返回默认值
-func getHeaderOrDefault(headers http.Header, key, defaultValue string) string {
- if v := headers.Get(key); v != "" {
- return v
- }
- return defaultValue
-}
-
-// ApplyFingerprint 将指纹应用到请求头(覆盖原有的x-stainless-*头)
-// 使用 setHeaderRaw 保持原始大小写(如 X-Stainless-OS 而非 X-Stainless-Os)
-func (s *IdentityService) ApplyFingerprint(req *http.Request, fp *Fingerprint) {
- if fp == nil {
- return
- }
-
- // 设置user-agent
- if fp.UserAgent != "" {
- setHeaderRaw(req.Header, "User-Agent", fp.UserAgent)
- }
-
- // 设置x-stainless-*头(保持与 claude.DefaultHeaders 一致的大小写)
- if fp.StainlessLang != "" {
- setHeaderRaw(req.Header, "X-Stainless-Lang", fp.StainlessLang)
- }
- if fp.StainlessPackageVersion != "" {
- setHeaderRaw(req.Header, "X-Stainless-Package-Version", fp.StainlessPackageVersion)
- }
- if fp.StainlessOS != "" {
- setHeaderRaw(req.Header, "X-Stainless-OS", fp.StainlessOS)
- }
- if fp.StainlessArch != "" {
- setHeaderRaw(req.Header, "X-Stainless-Arch", fp.StainlessArch)
- }
- if fp.StainlessRuntime != "" {
- setHeaderRaw(req.Header, "X-Stainless-Runtime", fp.StainlessRuntime)
- }
- if fp.StainlessRuntimeVersion != "" {
- setHeaderRaw(req.Header, "X-Stainless-Runtime-Version", fp.StainlessRuntimeVersion)
- }
-}
-
-// RewriteUserID 重写body中的metadata.user_id
-// 支持旧拼接格式和新 JSON 格式的 user_id 解析,
-// 根据 fingerprintUA 版本选择输出格式。
-//
-// 重要:此函数使用 json.RawMessage 保留其他字段的原始字节,
-// 避免重新序列化导致 thinking 块等内容被修改。
-func (s *IdentityService) RewriteUserID(body []byte, accountID int64, accountUUID, cachedClientID, fingerprintUA string) ([]byte, error) {
- if len(body) == 0 || accountUUID == "" || cachedClientID == "" {
- return body, nil
- }
-
- metadata := gjson.GetBytes(body, "metadata")
- if !metadata.Exists() || metadata.Type == gjson.Null {
- return body, nil
- }
- if !strings.HasPrefix(strings.TrimSpace(metadata.Raw), "{") {
- return body, nil
- }
-
- userIDResult := metadata.Get("user_id")
- if !userIDResult.Exists() || userIDResult.Type != gjson.String {
- return body, nil
- }
- userID := userIDResult.String()
- if userID == "" {
- return body, nil
- }
-
- // 解析 user_id(兼容旧拼接格式和新 JSON 格式)
- parsed := ParseMetadataUserID(userID)
- if parsed == nil {
- return body, nil
- }
-
- sessionTail := parsed.SessionID // 原始session UUID
-
- // 生成新的session hash: SHA256(accountID::sessionTail) -> UUID格式
- seed := fmt.Sprintf("%d::%s", accountID, sessionTail)
- newSessionHash := generateUUIDFromSeed(seed)
-
- // 根据客户端版本选择输出格式
- version := ExtractCLIVersion(fingerprintUA)
- newUserID := FormatMetadataUserID(cachedClientID, accountUUID, newSessionHash, version)
- if newUserID == userID {
- return body, nil
- }
-
- newBody, err := sjson.SetBytes(body, "metadata.user_id", newUserID)
- if err != nil {
- return body, nil
- }
- return newBody, nil
-}
-
-// RewriteUserIDWithMasking 重写body中的metadata.user_id,支持会话ID伪装
-// 如果账号启用了会话ID伪装(session_id_masking_enabled),
-// 则在完成常规重写后,将 session 部分替换为固定的伪装ID(15分钟内保持不变)
-//
-// 重要:此函数使用 json.RawMessage 保留其他字段的原始字节,
-// 避免重新序列化导致 thinking 块等内容被修改。
-func (s *IdentityService) RewriteUserIDWithMasking(ctx context.Context, body []byte, account *Account, accountUUID, cachedClientID, fingerprintUA string) ([]byte, error) {
- // 先执行常规的 RewriteUserID 逻辑
- newBody, err := s.RewriteUserID(body, account.ID, accountUUID, cachedClientID, fingerprintUA)
- if err != nil {
- return newBody, err
- }
-
- // 检查是否启用会话ID伪装
- if !account.IsSessionIDMaskingEnabled() {
- return newBody, nil
- }
-
- metadata := gjson.GetBytes(newBody, "metadata")
- if !metadata.Exists() || metadata.Type == gjson.Null {
- return newBody, nil
- }
- if !strings.HasPrefix(strings.TrimSpace(metadata.Raw), "{") {
- return newBody, nil
- }
-
- userIDResult := metadata.Get("user_id")
- if !userIDResult.Exists() || userIDResult.Type != gjson.String {
- return newBody, nil
- }
- userID := userIDResult.String()
- if userID == "" {
- return newBody, nil
- }
-
- // 解析已重写的 user_id
- uidParsed := ParseMetadataUserID(userID)
- if uidParsed == nil {
- return newBody, nil
- }
-
- // 获取或生成固定的伪装 session ID
- maskedSessionID, err := s.cache.GetMaskedSessionID(ctx, account.ID)
- if err != nil {
- logger.LegacyPrintf("service.identity", "Warning: failed to get masked session ID for account %d: %v", account.ID, err)
- return newBody, nil
- }
-
- if maskedSessionID == "" {
- // 首次或已过期,生成新的伪装 session ID
- maskedSessionID = generateRandomUUID()
- logger.LegacyPrintf("service.identity", "Generated new masked session ID for account %d: %s", account.ID, maskedSessionID)
- }
-
- // 刷新 TTL(每次请求都刷新,保持 15 分钟有效期)
- if err := s.cache.SetMaskedSessionID(ctx, account.ID, maskedSessionID); err != nil {
- logger.LegacyPrintf("service.identity", "Warning: failed to set masked session ID for account %d: %v", account.ID, err)
- }
-
- // 用 FormatMetadataUserID 重建(保持与 RewriteUserID 相同的格式)
- version := ExtractCLIVersion(fingerprintUA)
- newUserID := FormatMetadataUserID(uidParsed.DeviceID, uidParsed.AccountUUID, maskedSessionID, version)
-
- slog.Debug("session_id_masking_applied",
- "account_id", account.ID,
- "before", userID,
- "after", newUserID,
- )
-
- if newUserID == userID {
- return newBody, nil
- }
-
- maskedBody, setErr := sjson.SetBytes(newBody, "metadata.user_id", newUserID)
- if setErr != nil {
- return newBody, nil
- }
- return maskedBody, nil
-}
-
-// generateRandomUUID 生成随机 UUID v4 格式字符串
-func generateRandomUUID() string {
- b := make([]byte, 16)
- if _, err := rand.Read(b); err != nil {
- // fallback: 使用时间戳生成
- h := sha256.Sum256([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
- b = h[:16]
- }
-
- // 设置 UUID v4 版本和变体位
- b[6] = (b[6] & 0x0f) | 0x40
- b[8] = (b[8] & 0x3f) | 0x80
-
- return fmt.Sprintf("%x-%x-%x-%x-%x",
- b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
-}
-
-// generateClientID 生成64位十六进制客户端ID(32字节随机数)
-func generateClientID() string {
- b := make([]byte, 32)
- if _, err := rand.Read(b); err != nil {
- // 极罕见的情况,使用时间戳+固定值作为fallback
- logger.LegacyPrintf("service.identity", "Warning: crypto/rand.Read failed: %v, using fallback", err)
- // 使用SHA256(当前纳秒时间)作为fallback
- h := sha256.Sum256([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
- return hex.EncodeToString(h[:])
- }
- return hex.EncodeToString(b)
-}
-
-// generateUUIDFromSeed 从种子生成确定性UUID v4格式字符串
-func generateUUIDFromSeed(seed string) string {
- hash := sha256.Sum256([]byte(seed))
- bytes := hash[:16]
-
- // 设置UUID v4版本和变体位
- bytes[6] = (bytes[6] & 0x0f) | 0x40
- bytes[8] = (bytes[8] & 0x3f) | 0x80
-
- return fmt.Sprintf("%x-%x-%x-%x-%x",
- bytes[0:4], bytes[4:6], bytes[6:8], bytes[8:10], bytes[10:16])
-}
-
-// parseUserAgentVersion 解析user-agent版本号
-// 例如:claude-cli/2.1.2 -> (2, 1, 2)
-func parseUserAgentVersion(ua string) (major, minor, patch int, ok bool) {
- // 匹配 xxx/x.y.z 格式
- matches := userAgentVersionRegex.FindStringSubmatch(ua)
- if len(matches) != 4 {
- return 0, 0, 0, false
- }
- major, _ = strconv.Atoi(matches[1])
- minor, _ = strconv.Atoi(matches[2])
- patch, _ = strconv.Atoi(matches[3])
- return major, minor, patch, true
-}
-
-// extractProduct 提取 User-Agent 中 "/" 前的产品名
-// 例如:claude-cli/2.1.22 (external, cli) -> "claude-cli"
-func extractProduct(ua string) string {
- if idx := strings.Index(ua, "/"); idx > 0 {
- return strings.ToLower(ua[:idx])
- }
- return ""
-}
-
-// isNewerVersion 比较版本号,判断newUA是否比cachedUA更新
-// 要求产品名一致(防止浏览器 UA 如 Mozilla/5.0 误判为更新版本)
-func isNewerVersion(newUA, cachedUA string) bool {
- // 校验产品名一致性
- newProduct := extractProduct(newUA)
- cachedProduct := extractProduct(cachedUA)
- if newProduct == "" || cachedProduct == "" || newProduct != cachedProduct {
- return false
- }
-
- newMajor, newMinor, newPatch, newOk := parseUserAgentVersion(newUA)
- cachedMajor, cachedMinor, cachedPatch, cachedOk := parseUserAgentVersion(cachedUA)
-
- if !newOk || !cachedOk {
- return false
- }
-
- // 比较版本号
- if newMajor > cachedMajor {
- return true
- }
- if newMajor < cachedMajor {
- return false
- }
-
- if newMinor > cachedMinor {
- return true
- }
- if newMinor < cachedMinor {
- return false
- }
-
- return newPatch > cachedPatch
-}
diff --git a/backend/internal/service/identity_service_order_test.go b/backend/internal/service/identity_service_order_test.go
deleted file mode 100644
index d1e12274127..00000000000
--- a/backend/internal/service/identity_service_order_test.go
+++ /dev/null
@@ -1,82 +0,0 @@
-package service
-
-import (
- "context"
- "strings"
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-type identityCacheStub struct {
- maskedSessionID string
-}
-
-func (s *identityCacheStub) GetFingerprint(_ context.Context, _ int64) (*Fingerprint, error) {
- return nil, nil
-}
-func (s *identityCacheStub) SetFingerprint(_ context.Context, _ int64, _ *Fingerprint) error {
- return nil
-}
-func (s *identityCacheStub) GetMaskedSessionID(_ context.Context, _ int64) (string, error) {
- return s.maskedSessionID, nil
-}
-func (s *identityCacheStub) SetMaskedSessionID(_ context.Context, _ int64, sessionID string) error {
- s.maskedSessionID = sessionID
- return nil
-}
-
-func TestIdentityService_RewriteUserID_PreservesTopLevelFieldOrder(t *testing.T) {
- cache := &identityCacheStub{}
- svc := NewIdentityService(cache)
-
- originalUserID := FormatMetadataUserID(
- "d61f76d0730d2b920763648949bad5c79742155c27037fc77ac3f9805cb90169",
- "",
- "7578cf37-aaca-46e4-a45c-71285d9dbb83",
- "2.1.78",
- )
- body := []byte(`{"alpha":1,"messages":[],"metadata":{"user_id":` + strconvQuote(originalUserID) + `},"max_tokens":64000,"thinking":{"type":"adaptive"},"output_config":{"effort":"high"},"stream":true}`)
-
- result, err := svc.RewriteUserID(body, 123, "acc-uuid", "client-xyz", "claude-cli/2.1.78 (external, cli)")
- require.NoError(t, err)
- resultStr := string(result)
-
- assertJSONTokenOrder(t, resultStr, `"alpha"`, `"messages"`, `"metadata"`, `"max_tokens"`, `"thinking"`, `"output_config"`, `"stream"`)
- require.NotContains(t, resultStr, originalUserID)
- require.Contains(t, resultStr, `"metadata":{"user_id":"`)
-}
-
-func TestIdentityService_RewriteUserIDWithMasking_PreservesTopLevelFieldOrder(t *testing.T) {
- cache := &identityCacheStub{maskedSessionID: "11111111-2222-4333-8444-555555555555"}
- svc := NewIdentityService(cache)
-
- originalUserID := FormatMetadataUserID(
- "d61f76d0730d2b920763648949bad5c79742155c27037fc77ac3f9805cb90169",
- "",
- "7578cf37-aaca-46e4-a45c-71285d9dbb83",
- "2.1.78",
- )
- body := []byte(`{"alpha":1,"messages":[],"metadata":{"user_id":` + strconvQuote(originalUserID) + `},"max_tokens":64000,"thinking":{"type":"adaptive"},"output_config":{"effort":"high"},"stream":true}`)
-
- account := &Account{
- ID: 123,
- Platform: PlatformAnthropic,
- Type: AccountTypeOAuth,
- Extra: map[string]any{
- "session_id_masking_enabled": true,
- },
- }
-
- result, err := svc.RewriteUserIDWithMasking(context.Background(), body, account, "acc-uuid", "client-xyz", "claude-cli/2.1.78 (external, cli)")
- require.NoError(t, err)
- resultStr := string(result)
-
- assertJSONTokenOrder(t, resultStr, `"alpha"`, `"messages"`, `"metadata"`, `"max_tokens"`, `"thinking"`, `"output_config"`, `"stream"`)
- require.Contains(t, resultStr, cache.maskedSessionID)
- require.True(t, strings.Contains(resultStr, `"metadata":{"user_id":"`))
-}
-
-func strconvQuote(v string) string {
- return `"` + strings.ReplaceAll(strings.ReplaceAll(v, `\`, `\\`), `"`, `\"`) + `"`
-}
diff --git a/backend/internal/service/identity_types.go b/backend/internal/service/identity_types.go
new file mode 100644
index 00000000000..f49e1fc3604
--- /dev/null
+++ b/backend/internal/service/identity_types.go
@@ -0,0 +1,25 @@
+package service
+
+import "context"
+
+// Fingerprint is retained for the provider-specific account-management cache.
+// Shared Anthropic gateway requests never consume it as proof of official
+// client identity.
+type Fingerprint struct {
+ ClientID string
+ UserAgent string
+ StainlessLang string
+ StainlessPackageVersion string
+ StainlessOS string
+ StainlessArch string
+ StainlessRuntime string
+ StainlessRuntimeVersion string
+ UpdatedAt int64 `json:",omitempty"`
+}
+
+// IdentityCache stores provider account-management fingerprints. It is not an
+// authentication source for gateway client provenance.
+type IdentityCache interface {
+ GetFingerprint(ctx context.Context, accountID int64) (*Fingerprint, error)
+ SetFingerprint(ctx context.Context, accountID int64, fp *Fingerprint) error
+}
diff --git a/backend/internal/service/image_billing_multiplier.go b/backend/internal/service/image_billing_multiplier.go
new file mode 100644
index 00000000000..23ec5ac104b
--- /dev/null
+++ b/backend/internal/service/image_billing_multiplier.go
@@ -0,0 +1,11 @@
+package service
+
+func resolveImageRateMultiplier(apiKey *APIKey, effectiveGroupMultiplier float64) float64 {
+ if apiKey != nil && apiKey.Group != nil && apiKey.Group.ImageRateIndependent {
+ if apiKey.Group.ImageRateMultiplier < 0 {
+ return 0
+ }
+ return apiKey.Group.ImageRateMultiplier
+ }
+ return effectiveGroupMultiplier
+}
diff --git a/backend/internal/service/image_generation_intent.go b/backend/internal/service/image_generation_intent.go
new file mode 100644
index 00000000000..4b278481a01
--- /dev/null
+++ b/backend/internal/service/image_generation_intent.go
@@ -0,0 +1,225 @@
+package service
+
+import (
+ "encoding/json"
+ "strings"
+
+ "github.com/tidwall/gjson"
+)
+
+const (
+ openAIResponsesEndpoint = "/v1/responses"
+ openAIResponsesCompactEndpoint = "/v1/responses/compact"
+ imageGenerationPermissionMessage = "Image generation is not enabled for this group"
+)
+
+// ImageGenerationPermissionMessage returns the stable end-user error text for disabled groups.
+func ImageGenerationPermissionMessage() string {
+ return imageGenerationPermissionMessage
+}
+
+// GroupAllowsImageGeneration preserves ungrouped-key behavior and enforces the flag when a group is present.
+func GroupAllowsImageGeneration(group *Group) bool {
+ return group == nil || group.AllowImageGeneration
+}
+
+// IsImageGenerationIntent classifies requests that can produce generated images.
+func IsImageGenerationIntent(endpoint string, requestedModel string, body []byte) bool {
+ if IsImageGenerationEndpoint(endpoint) {
+ return true
+ }
+ if isOpenAIImageGenerationModel(requestedModel) {
+ return true
+ }
+ if len(body) == 0 || !gjson.ValidBytes(body) {
+ return false
+ }
+ if model := strings.TrimSpace(gjson.GetBytes(body, "model").String()); isOpenAIImageGenerationModel(model) {
+ return true
+ }
+ if openAIJSONToolsContainImageGeneration(gjson.GetBytes(body, "tools")) {
+ return true
+ }
+ return openAIJSONToolChoiceSelectsImageGeneration(gjson.GetBytes(body, "tool_choice"))
+}
+
+// IsImageGenerationIntentMap is the map-backed variant used after service-side request mutation.
+func IsImageGenerationIntentMap(endpoint string, requestedModel string, reqBody map[string]any) bool {
+ if IsImageGenerationEndpoint(endpoint) {
+ return true
+ }
+ if isOpenAIImageGenerationModel(requestedModel) {
+ return true
+ }
+ if reqBody == nil {
+ return false
+ }
+ if isOpenAIImageGenerationModel(firstNonEmptyString(reqBody["model"])) {
+ return true
+ }
+ if hasOpenAIImageGenerationTool(reqBody) {
+ return true
+ }
+ return openAIAnyToolChoiceSelectsImageGeneration(reqBody["tool_choice"])
+}
+
+// IsImageGenerationEndpoint identifies dedicated generated-image endpoints.
+func IsImageGenerationEndpoint(endpoint string) bool {
+ switch normalizeImageGenerationEndpoint(endpoint) {
+ case "/v1/images/generations", "/v1/images/edits", "/images/generations", "/images/edits":
+ return true
+ default:
+ return false
+ }
+}
+
+func normalizeImageGenerationEndpoint(endpoint string) string {
+ endpoint = strings.TrimSpace(strings.ToLower(endpoint))
+ if endpoint == "" {
+ return ""
+ }
+ endpoint = strings.TrimPrefix(endpoint, "https://api.openai.com")
+ if idx := strings.IndexByte(endpoint, '?'); idx >= 0 {
+ endpoint = endpoint[:idx]
+ }
+ return strings.TrimRight(endpoint, "/")
+}
+
+func openAIJSONToolsContainImageGeneration(tools gjson.Result) bool {
+ if !tools.IsArray() {
+ return false
+ }
+ found := false
+ tools.ForEach(func(_, item gjson.Result) bool {
+ if strings.TrimSpace(item.Get("type").String()) == "image_generation" {
+ found = true
+ return false
+ }
+ return true
+ })
+ return found
+}
+
+func openAIJSONToolChoiceSelectsImageGeneration(choice gjson.Result) bool {
+ if !choice.Exists() {
+ return false
+ }
+ if choice.Type == gjson.String {
+ return strings.TrimSpace(choice.String()) == "image_generation"
+ }
+ if !choice.IsObject() {
+ return false
+ }
+ if strings.TrimSpace(choice.Get("type").String()) == "image_generation" {
+ return true
+ }
+ if strings.TrimSpace(choice.Get("tool.type").String()) == "image_generation" {
+ return true
+ }
+ if strings.TrimSpace(choice.Get("function.name").String()) == "image_generation" {
+ return true
+ }
+ return false
+}
+
+func openAIAnyToolChoiceSelectsImageGeneration(choice any) bool {
+ switch v := choice.(type) {
+ case string:
+ return strings.TrimSpace(v) == "image_generation"
+ case map[string]any:
+ if strings.TrimSpace(firstNonEmptyString(v["type"])) == "image_generation" {
+ return true
+ }
+ if tool, ok := v["tool"].(map[string]any); ok && strings.TrimSpace(firstNonEmptyString(tool["type"])) == "image_generation" {
+ return true
+ }
+ if fn, ok := v["function"].(map[string]any); ok && strings.TrimSpace(firstNonEmptyString(fn["name"])) == "image_generation" {
+ return true
+ }
+ }
+ return false
+}
+
+func getAPIKeyFromContext(c interface{ Get(string) (any, bool) }) *APIKey {
+ if c == nil {
+ return nil
+ }
+ v, exists := c.Get("api_key")
+ if !exists {
+ return nil
+ }
+ apiKey, _ := v.(*APIKey)
+ return apiKey
+}
+
+func apiKeyGroup(apiKey *APIKey) *Group {
+ if apiKey == nil {
+ return nil
+ }
+ return apiKey.Group
+}
+
+func cloneRequestMapForImageIntent(body []byte) map[string]any {
+ if len(body) == 0 {
+ return nil
+ }
+ var out map[string]any
+ if err := json.Unmarshal(body, &out); err != nil {
+ return nil
+ }
+ return out
+}
+
+func resolveOpenAIResponsesImageBillingConfig(reqBody map[string]any, fallbackModel string) (string, string, error) {
+ imageModel := ""
+ imageSize := ""
+ imageQuality := ""
+ hasImageTool := false
+ if reqBody != nil {
+ rawTools, _ := reqBody["tools"].([]any)
+ for _, rawTool := range rawTools {
+ toolMap, ok := rawTool.(map[string]any)
+ if !ok || strings.TrimSpace(firstNonEmptyString(toolMap["type"])) != "image_generation" {
+ continue
+ }
+ hasImageTool = true
+ imageModel = strings.TrimSpace(firstNonEmptyString(toolMap["model"]))
+ imageSize = strings.TrimSpace(firstNonEmptyString(toolMap["size"]))
+ imageQuality = strings.TrimSpace(firstNonEmptyString(toolMap["quality"]))
+ break
+ }
+ if imageSize == "" {
+ imageSize = strings.TrimSpace(firstNonEmptyString(reqBody["size"]))
+ }
+ if imageQuality == "" {
+ imageQuality = strings.TrimSpace(firstNonEmptyString(reqBody["quality"]))
+ }
+ }
+ if imageModel == "" && reqBody != nil {
+ bodyModel := strings.TrimSpace(firstNonEmptyString(reqBody["model"]))
+ if isOpenAIImageBillingModelAlias(bodyModel) || !hasImageTool {
+ imageModel = bodyModel
+ }
+ }
+ if imageModel == "" && hasImageTool {
+ imageModel = "gpt-image-2"
+ }
+ if imageModel == "" {
+ imageModel = strings.TrimSpace(fallbackModel)
+ }
+ sizeTier := normalizeOpenAIImageSizeTier(imageSize, imageQuality)
+ return imageModel, sizeTier, nil
+}
+
+func resolveOpenAIResponsesImageBillingConfigFromBody(body []byte, fallbackModel string) (string, string, error) {
+ reqBody := cloneRequestMapForImageIntent(body)
+ return resolveOpenAIResponsesImageBillingConfig(reqBody, fallbackModel)
+}
+
+func isOpenAIImageBillingModelAlias(model string) bool {
+ normalized := strings.ToLower(strings.TrimSpace(model))
+ if normalized == "" {
+ return false
+ }
+ return isOpenAIImageGenerationModel(normalized) || strings.Contains(normalized, "image")
+}
diff --git a/backend/internal/service/image_generation_intent_test.go b/backend/internal/service/image_generation_intent_test.go
new file mode 100644
index 00000000000..5e7bec79b7f
--- /dev/null
+++ b/backend/internal/service/image_generation_intent_test.go
@@ -0,0 +1,184 @@
+package service
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestIsImageGenerationIntent(t *testing.T) {
+ tests := []struct {
+ name string
+ endpoint string
+ model string
+ body []byte
+ want bool
+ }{
+ {
+ name: "images endpoint",
+ endpoint: "/v1/images/generations",
+ body: []byte(`{"model":"gpt-image-2"}`),
+ want: true,
+ },
+ {
+ name: "image model",
+ endpoint: "/v1/responses",
+ model: "gpt-image-2",
+ body: []byte(`{"model":"gpt-image-2"}`),
+ want: true,
+ },
+ {
+ name: "image tool",
+ endpoint: "/v1/responses",
+ model: "gpt-5.4",
+ body: []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation"}]}`),
+ want: true,
+ },
+ {
+ name: "image tool choice",
+ endpoint: "/v1/responses",
+ model: "gpt-5.4",
+ body: []byte(`{"model":"gpt-5.4","tool_choice":{"type":"image_generation"}}`),
+ want: true,
+ },
+ {
+ name: "required tool choice alone is text",
+ endpoint: "/v1/responses",
+ model: "gpt-5.4",
+ body: []byte(`{"model":"gpt-5.4","tool_choice":"required"}`),
+ want: false,
+ },
+ {
+ name: "text only gpt 5.4",
+ endpoint: "/v1/responses",
+ model: "gpt-5.4",
+ body: []byte(`{"model":"gpt-5.4","input":"write code"}`),
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.Equal(t, tt.want, IsImageGenerationIntent(tt.endpoint, tt.model, tt.body))
+ })
+ }
+}
+
+func TestResolveOpenAIResponsesImageBillingConfigUsesCurrentBodyModel(t *testing.T) {
+ imageModel, imageSize, err := resolveOpenAIResponsesImageBillingConfigFromBody(
+ []byte(`{"model":"mapped-image-model","tools":[{"type":"image_generation","size":"1024x1024"}]}`),
+ "requested-model",
+ )
+ require.NoError(t, err)
+ require.Equal(t, "mapped-image-model", imageModel)
+ require.Equal(t, "1K", imageSize)
+}
+
+func TestResolveOpenAIResponsesImageBillingConfigToolModelWins(t *testing.T) {
+ imageModel, imageSize, err := resolveOpenAIResponsesImageBillingConfigFromBody(
+ []byte(`{"model":"mapped-text-model","tools":[{"type":"image_generation","model":"gpt-image-2","size":"1536x1024"}]}`),
+ "requested-model",
+ )
+ require.NoError(t, err)
+ require.Equal(t, "gpt-image-2", imageModel)
+ require.Equal(t, "2K", imageSize)
+}
+
+func TestResolveOpenAIResponsesImageBillingConfigSupportsOfficialAndCustomSizes(t *testing.T) {
+ tests := []struct {
+ name string
+ body []byte
+ wantTier string
+ }{
+ {
+ name: "official 2k landscape",
+ body: []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation","model":"gpt-image-2","size":"2048x1152"}]}`),
+ wantTier: "2K",
+ },
+ {
+ name: "official 4k landscape",
+ body: []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation","model":"gpt-image-2","size":"3840x2160"}]}`),
+ wantTier: "4K",
+ },
+ {
+ name: "custom valid 2k",
+ body: []byte(`{"model":"gpt-5.5","tools":[{"type":"image_generation","model":"gpt-image-2","size":"1280x768"}]}`),
+ wantTier: "2K",
+ },
+ {
+ name: "default image tool model supports flexible size",
+ body: []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation","size":"2048x1152"}]}`),
+ wantTier: "2K",
+ },
+ {
+ name: "top level image size is moved into billing",
+ body: []byte(`{"model":"gpt-image-2","size":"2048x2048","tools":[{"type":"image_generation","model":"gpt-image-2"}]}`),
+ wantTier: "2K",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ imageModel, imageSize, err := resolveOpenAIResponsesImageBillingConfigFromBody(tt.body, "requested-model")
+ require.NoError(t, err)
+ require.NotEmpty(t, imageModel)
+ require.Equal(t, tt.wantTier, imageSize)
+ })
+ }
+}
+
+func TestResolveOpenAIResponsesImageBillingConfigDoesNotRejectUnknownSizes(t *testing.T) {
+ imageModel, imageSize, err := resolveOpenAIResponsesImageBillingConfigFromBody(
+ []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation","model":"gpt-image-1.5","size":"2048x1152"}]}`),
+ "requested-model",
+ )
+ require.NoError(t, err)
+ require.Equal(t, "gpt-image-1.5", imageModel)
+ require.Equal(t, "2K", imageSize)
+}
+
+func TestOpenAIImageOutputCounterDeduplicatesFinalImages(t *testing.T) {
+ counter := newOpenAIImageOutputCounter()
+ counter.AddSSEData([]byte(`{"type":"response.image_generation_call.partial_image","partial_image_b64":"abc"}`))
+ counter.AddSSEData([]byte(`{"type":"response.output_item.done","item":{"id":"ig_1","type":"image_generation_call","result":"final-a"}}`))
+ counter.AddSSEData([]byte(`{"type":"response.completed","response":{"output":[{"id":"ig_1","type":"image_generation_call","result":"final-a"},{"id":"ig_2","type":"image_generation_call","result":"final-b"}]}}`))
+ require.Equal(t, 2, counter.Count())
+}
+
+func TestOpenAIImageOutputCounterCountsImagesAPIStreamShapes(t *testing.T) {
+ counter := newOpenAIImageOutputCounter()
+ counter.AddSSEData([]byte(`{"type":"image_generation.completed","id":"ig_complete","b64_json":"final-a"}`))
+ counter.AddSSEData([]byte(`{"type":"response.output_item.done","item":{"id":"ig_item","type":"image_generation_call","result":"final-b"}}`))
+ counter.AddSSEData([]byte(`{"type":"response.completed","response":{"output":[{"id":"ig_done","type":"image_generation_call","result":"final-c"}]}}`))
+ require.Equal(t, 3, counter.Count())
+
+ dataCounter := newOpenAIImageOutputCounter()
+ dataCounter.AddSSEData([]byte(`{"data":[{"b64_json":"a"},{"b64_json":"b"}]}`))
+ dataCounter.AddSSEData([]byte(`{"data":[{"b64_json":"a"},{"b64_json":"b"},{"b64_json":"c"}]}`))
+ require.Equal(t, 3, dataCounter.Count())
+}
+
+func TestOpenAIImageOutputCounterCountsMultilineSSEDataPayload(t *testing.T) {
+ counter := newOpenAIImageOutputCounter()
+ counter.AddSSEData([]byte("{\"type\":\"image_generation.completed\",\n\"b64_json\":\"final-a\"}"))
+ require.Equal(t, 1, counter.Count())
+}
+
+func TestOpenAIImageOutputCounterCountsMultilineSSEBodyPayload(t *testing.T) {
+ counter := newOpenAIImageOutputCounter()
+ counter.AddSSEBody(
+ "data: {\"type\":\"image_generation.completed\",\n" +
+ "data: \"b64_json\":\"final-a\"}\n\n" +
+ "data: [DONE]\n\n",
+ )
+ require.Equal(t, 1, counter.Count())
+}
+
+func TestOpenAIImageOutputCounterFallsBackForInvalidMultilineSSEBody(t *testing.T) {
+ counter := newOpenAIImageOutputCounter()
+ counter.AddSSEBody(
+ "data: {\"type\":\"image_generation.completed\",\"b64_json\":\"final-a\"}\n" +
+ "data: {\"type\":\"image_generation.completed\",\"b64_json\":\"final-b\"}\n\n",
+ )
+ require.Equal(t, 2, counter.Count())
+}
diff --git a/backend/internal/service/image_output_accounting.go b/backend/internal/service/image_output_accounting.go
new file mode 100644
index 00000000000..219c0c59609
--- /dev/null
+++ b/backend/internal/service/image_output_accounting.go
@@ -0,0 +1,149 @@
+package service
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "strings"
+
+ "github.com/tidwall/gjson"
+)
+
+type openAIImageOutputCounter struct {
+ seen map[string]struct{}
+ count int
+ maxDataCount int
+}
+
+func newOpenAIImageOutputCounter() *openAIImageOutputCounter {
+ return &openAIImageOutputCounter{seen: make(map[string]struct{})}
+}
+
+func (c *openAIImageOutputCounter) Count() int {
+ if c == nil {
+ return 0
+ }
+ if c.maxDataCount > c.count {
+ return c.maxDataCount
+ }
+ return c.count
+}
+
+func (c *openAIImageOutputCounter) AddJSONResponse(body []byte) {
+ if c == nil || len(body) == 0 || !gjson.ValidBytes(body) {
+ return
+ }
+ c.addDataArray(gjson.GetBytes(body, "data"))
+ c.addOutputArray(gjson.GetBytes(body, "output"))
+ c.addOutputArray(gjson.GetBytes(body, "response.output"))
+}
+
+func (c *openAIImageOutputCounter) AddSSEData(data []byte) {
+ if c == nil || len(data) == 0 || strings.TrimSpace(string(data)) == "[DONE]" || !gjson.ValidBytes(data) {
+ return
+ }
+ root := gjson.ParseBytes(data)
+ c.addDataArray(root.Get("data"))
+ eventType := strings.TrimSpace(root.Get("type").String())
+ switch eventType {
+ case "response.output_item.done":
+ c.addImageOutputItem(root.Get("item"))
+ case "response.completed", "response.done":
+ c.addOutputArray(root.Get("response.output"))
+ case "image_generation.completed":
+ if item := root.Get("item"); item.Exists() {
+ c.addImageOutputItem(item)
+ return
+ }
+ if output := root.Get("output"); output.Exists() {
+ c.addImageOutputItem(output)
+ return
+ }
+ c.addImageOutputItem(root)
+ }
+}
+
+func (c *openAIImageOutputCounter) AddSSEBody(body string) {
+ if c == nil || strings.TrimSpace(body) == "" {
+ return
+ }
+ forEachOpenAISSEDataPayload(body, c.AddSSEData)
+}
+
+func (c *openAIImageOutputCounter) addDataArray(data gjson.Result) {
+ if !data.IsArray() {
+ return
+ }
+ count := len(data.Array())
+ if count > c.maxDataCount {
+ c.maxDataCount = count
+ }
+}
+
+func (c *openAIImageOutputCounter) addOutputArray(output gjson.Result) {
+ if !output.IsArray() {
+ return
+ }
+ output.ForEach(func(_, item gjson.Result) bool {
+ c.addImageOutputItem(item)
+ return true
+ })
+}
+
+func (c *openAIImageOutputCounter) addImageOutputItem(item gjson.Result) {
+ if !item.Exists() || !item.IsObject() {
+ return
+ }
+ itemType := strings.TrimSpace(item.Get("type").String())
+ if itemType != "" && itemType != "image_generation_call" && itemType != "image_generation.completed" {
+ return
+ }
+ if strings.Contains(strings.ToLower(item.Raw), "partial_image") {
+ return
+ }
+ result := strings.TrimSpace(item.Get("result").String())
+ if result == "" {
+ result = strings.TrimSpace(item.Get("b64_json").String())
+ }
+ if result == "" {
+ result = strings.TrimSpace(item.Get("url").String())
+ }
+ if result == "" && itemType != "image_generation.completed" {
+ return
+ }
+ key := strings.TrimSpace(item.Get("id").String())
+ if key == "" {
+ key = strings.TrimSpace(item.Get("call_id").String())
+ }
+ if key == "" {
+ key = hashOpenAIImageOutputResult(result)
+ }
+ if key == "" {
+ return
+ }
+ if _, exists := c.seen[key]; exists {
+ return
+ }
+ c.seen[key] = struct{}{}
+ c.count++
+}
+
+func hashOpenAIImageOutputResult(result string) string {
+ result = strings.TrimSpace(result)
+ if result == "" {
+ return ""
+ }
+ sum := sha256.Sum256([]byte(result))
+ return hex.EncodeToString(sum[:])
+}
+
+func countOpenAIResponseImageOutputsFromJSONBytes(body []byte) int {
+ counter := newOpenAIImageOutputCounter()
+ counter.AddJSONResponse(body)
+ return counter.Count()
+}
+
+func countOpenAIImageOutputsFromSSEBody(body string) int {
+ counter := newOpenAIImageOutputCounter()
+ counter.AddSSEBody(body)
+ return counter.Count()
+}
diff --git a/backend/internal/service/kiro_account_isolation.go b/backend/internal/service/kiro_account_isolation.go
new file mode 100644
index 00000000000..5af61b6d4f6
--- /dev/null
+++ b/backend/internal/service/kiro_account_isolation.go
@@ -0,0 +1,69 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+)
+
+func validateKiroAccountType(accountPlatform, accountType string) error {
+ if normalizePlatform(accountPlatform) != PlatformKiro {
+ return nil
+ }
+ if strings.TrimSpace(accountType) == "" || normalizeAccountType(accountType) == AccountTypeAPIKey {
+ return nil
+ }
+ return fmt.Errorf("kiro accounts must use apikey type")
+}
+
+func validateKiroAccountGroupIsolation(ctx context.Context, groupRepo GroupRepository, accountPlatform string, groupIDs []int64) error {
+ if len(groupIDs) == 0 {
+ return nil
+ }
+ if groupRepo == nil {
+ return errors.New("group repository not configured")
+ }
+
+ accountPlatform = normalizePlatform(accountPlatform)
+ for _, groupID := range groupIDs {
+ group, err := groupRepo.GetByIDLite(ctx, groupID)
+ if err != nil {
+ return fmt.Errorf("get group %d: %w", groupID, err)
+ }
+ if group == nil {
+ return fmt.Errorf("get group %d: %w", groupID, ErrGroupNotFound)
+ }
+ groupPlatform := normalizePlatform(group.Platform)
+ if !isKiroGroupAssignmentCompatible(accountPlatform, groupPlatform) {
+ return fmt.Errorf(
+ "kiro accounts can only be assigned to kiro or anthropic groups, and kiro groups only accept kiro accounts: account platform %s, group %d platform %s",
+ accountPlatform,
+ groupID,
+ groupPlatform,
+ )
+ }
+ }
+ return nil
+}
+
+func isKiroGroupAssignmentCompatible(accountPlatform, groupPlatform string) bool {
+ accountPlatform = normalizePlatform(accountPlatform)
+ groupPlatform = normalizePlatform(groupPlatform)
+
+ if accountPlatform == PlatformKiro {
+ return groupPlatform == PlatformKiro || groupPlatform == PlatformAnthropic
+ }
+ if groupPlatform == PlatformKiro {
+ return accountPlatform == PlatformKiro
+ }
+ return true
+}
+
+func normalizePlatform(platform string) string {
+ return strings.ToLower(strings.TrimSpace(platform))
+}
+
+func normalizeAccountType(accountType string) string {
+ return strings.ToLower(strings.TrimSpace(accountType))
+}
diff --git a/backend/internal/service/locked_rates_test.go b/backend/internal/service/locked_rates_test.go
new file mode 100644
index 00000000000..4331f426b13
--- /dev/null
+++ b/backend/internal/service/locked_rates_test.go
@@ -0,0 +1,73 @@
+package service
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestUserSubscriptionLockedRateForGroup(t *testing.T) {
+ sub := &UserSubscription{LockedRates: map[string]float64{
+ "1": 1.0,
+ "5": 3.5,
+ }}
+
+ rate, ok := sub.LockedRateForGroup(5)
+ require.True(t, ok)
+ require.Equal(t, 3.5, rate)
+
+ _, ok = sub.LockedRateForGroup(2)
+ require.False(t, ok)
+}
+
+func TestResolveEffectiveRateMultiplier_LockedRatesOverrideUserGroupRate(t *testing.T) {
+ userRate := 0.3
+ resolver := newUserGroupRateResolver(
+ &openAIUserGroupRateRepoStub{rate: &userRate},
+ nil,
+ 0,
+ nil,
+ "service.locked_rates.test",
+ )
+ sub := &UserSubscription{LockedRates: map[string]float64{"5": 3.5}}
+ groupID := int64(5)
+
+ got := resolveEffectiveRateMultiplier(
+ context.Background(),
+ resolver,
+ 21,
+ &groupID,
+ &Group{ID: groupID, RateMultiplier: 0.7},
+ sub,
+ 1.0,
+ )
+
+ require.Equal(t, 3.5, got.Multiplier)
+ require.Equal(t, RateMultiplierSourceLockedRates, got.Source)
+}
+
+func TestMonthlyLockedRateForGroup(t *testing.T) {
+ tests := []struct {
+ name string
+ platform string
+ want float64
+ ok bool
+ }{
+ {name: "cc-default", platform: "anthropic", want: 1.0, ok: true},
+ {name: "openai-default", platform: "openai", want: 1.0, ok: true},
+ {name: "cc-antigravity", platform: "anthropic", want: 3.5, ok: true},
+ {name: "claude-Max pool", platform: "anthropic", want: 8.5, ok: true},
+ {name: "gemini-default", platform: "gemini", ok: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, ok := monthlyLockedRateForGroup(tt.name, tt.platform)
+ require.Equal(t, tt.ok, ok)
+ if tt.ok {
+ require.Equal(t, tt.want, got)
+ }
+ })
+ }
+}
diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go
index 580897767dd..5b3cb7319f6 100644
--- a/backend/internal/service/model_pricing_resolver.go
+++ b/backend/internal/service/model_pricing_resolver.go
@@ -31,6 +31,10 @@ type ResolvedPricing struct {
// 来源标识
Source string // "channel", "litellm", "fallback"
+ // Revision identifies the source snapshot used to build this resolved price.
+ Revision string
+ // SourceExact reports whether channel pricing matched an exact model slug.
+ SourceExact bool
// 是否支持缓存细分
SupportsCacheBreakdown bool
@@ -62,8 +66,9 @@ type PricingInput struct {
// 2. 如果指定了 GroupID,查找渠道定价并覆盖
func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput) *ResolvedPricing {
var chPricing *ChannelModelPricing
+ var chPricingExact bool
if input.GroupID != nil && r.channelService != nil {
- chPricing = r.channelService.GetChannelModelPricing(ctx, *input.GroupID, input.Model)
+ chPricing, chPricingExact = r.channelService.GetChannelModelPricingMatch(ctx, *input.GroupID, input.Model)
if chPricing != nil {
mode := chPricing.BillingMode
if mode == "" {
@@ -71,8 +76,9 @@ func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput)
}
if mode == BillingModePerRequest || mode == BillingModeImage {
resolved := &ResolvedPricing{
- Mode: mode,
- Source: PricingSourceChannel,
+ Mode: mode,
+ Source: PricingSourceChannel,
+ SourceExact: chPricingExact,
}
r.applyRequestTierOverrides(chPricing, resolved)
return resolved
@@ -88,14 +94,14 @@ func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput)
BasePricing: basePricing,
Source: source,
SupportsCacheBreakdown: basePricing != nil && basePricing.SupportsCacheBreakdown,
+ SourceExact: true,
}
// 2. 如果有 GroupID,尝试渠道覆盖
if chPricing != nil {
resolved.Source = PricingSourceChannel
+ resolved.SourceExact = chPricingExact
r.applyTokenOverrides(chPricing, resolved)
- } else if input.GroupID != nil {
- r.applyChannelOverrides(ctx, *input.GroupID, input.Model, resolved)
}
return resolved
@@ -103,34 +109,13 @@ func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput)
// resolveBasePricing 从 LiteLLM 或 Fallback 获取基础定价
func (r *ModelPricingResolver) resolveBasePricing(model string) (*ModelPricing, string) {
- pricing, err := r.billingService.GetModelPricing(model)
+ pricing, source, err := r.billingService.GetModelPricingWithSource(model)
if err != nil {
slog.Debug("failed to get model pricing from LiteLLM, using fallback",
"model", model, "error", err)
return nil, PricingSourceFallback
}
- return pricing, PricingSourceLiteLLM
-}
-
-// applyChannelOverrides 应用渠道定价覆盖
-func (r *ModelPricingResolver) applyChannelOverrides(ctx context.Context, groupID int64, model string, resolved *ResolvedPricing) {
- chPricing := r.channelService.GetChannelModelPricing(ctx, groupID, model)
- if chPricing == nil {
- return
- }
-
- resolved.Source = PricingSourceChannel
- resolved.Mode = chPricing.BillingMode
- if resolved.Mode == "" {
- resolved.Mode = BillingModeToken
- }
-
- switch resolved.Mode {
- case BillingModeToken:
- r.applyTokenOverrides(chPricing, resolved)
- case BillingModePerRequest, BillingModeImage:
- r.applyRequestTierOverrides(chPricing, resolved)
- }
+ return pricing, source
}
// applyTokenOverrides 应用 token 模式的渠道覆盖
diff --git a/backend/internal/service/model_pricing_resolver_test.go b/backend/internal/service/model_pricing_resolver_test.go
index 4548c1d598e..172aa6bb6bf 100644
--- a/backend/internal/service/model_pricing_resolver_test.go
+++ b/backend/internal/service/model_pricing_resolver_test.go
@@ -5,8 +5,10 @@ package service
import (
"context"
"errors"
+ "math"
"testing"
+ "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
@@ -37,9 +39,9 @@ func TestResolve_NoGroupID(t *testing.T) {
require.Equal(t, BillingModeToken, resolved.Mode)
require.NotNil(t, resolved.BasePricing)
require.InDelta(t, 3e-6, resolved.BasePricing.InputPricePerToken, 1e-12)
- // BillingService.GetModelPricing uses fallback internally, but resolveBasePricing
- // reports "litellm" when GetModelPricing succeeds (regardless of internal source)
- require.Equal(t, "litellm", resolved.Source)
+ // The resolver preserves the actual source instead of labelling a built-in
+ // fallback as LiteLLM evidence.
+ require.Equal(t, PricingSourceBuiltinFallback, resolved.Source)
}
func TestResolve_UnknownModel(t *testing.T) {
@@ -192,6 +194,196 @@ func newResolverWithChannel(t *testing.T, pricing []ChannelModelPricing) *ModelP
// groupIDPtr returns a pointer to groupID 100 (the test constant).
func groupIDPtr() *int64 { v := int64(100); return &v }
+func TestResolvePricingQuoteGPT56RejectsWildcardChannelPricing(t *testing.T) {
+ const groupID int64 = 100
+ repo := &mockChannelRepository{
+ listAllFn: func(context.Context) ([]Channel, error) {
+ return []Channel{{
+ ID: 1, Name: "gpt56-pricing", Status: StatusActive, GroupIDs: []int64{groupID},
+ ModelPricing: []ChannelModelPricing{{
+ Platform: "openai", Models: []string{"gpt-5.6-*"}, BillingMode: BillingModeToken,
+ InputPrice: testPtrFloat64(1e-6), OutputPrice: testPtrFloat64(2e-6),
+ }},
+ }}, nil
+ },
+ getGroupPlatformsFn: func(context.Context, []int64) (map[int64]string, error) {
+ return map[int64]string{groupID: PlatformOpenAI}, nil
+ },
+ }
+ cfg := &config.Config{}
+ billing := NewBillingService(cfg, NewPricingService(cfg, nil))
+ resolver := NewModelPricingResolver(NewChannelService(repo, nil, nil, nil), billing)
+
+ quote, err := resolver.ResolveQuote(context.Background(), PricingInput{Model: "gpt-5.6-terra", GroupID: groupIDPtr()})
+ require.Error(t, err)
+ require.ErrorIs(t, err, ErrOpenAIPricingUnavailable)
+ require.Nil(t, quote)
+}
+
+func TestResolvePricingQuoteGPT56AcceptsExactChannelPricing(t *testing.T) {
+ const groupID int64 = 100
+ repo := &mockChannelRepository{
+ listAllFn: func(context.Context) ([]Channel, error) {
+ return []Channel{{
+ ID: 1, Name: "gpt56-pricing", Status: StatusActive, GroupIDs: []int64{groupID},
+ ModelPricing: []ChannelModelPricing{{
+ Platform: "openai", Models: []string{"gpt-5.6-terra"}, BillingMode: BillingModeToken,
+ InputPrice: testPtrFloat64(1e-6), OutputPrice: testPtrFloat64(2e-6),
+ }},
+ }}, nil
+ },
+ getGroupPlatformsFn: func(context.Context, []int64) (map[int64]string, error) {
+ return map[int64]string{groupID: PlatformOpenAI}, nil
+ },
+ }
+ cfg := &config.Config{}
+ billing := NewBillingService(cfg, NewPricingService(cfg, nil))
+ resolver := NewModelPricingResolver(NewChannelService(repo, nil, nil, nil), billing)
+
+ quote, err := resolver.ResolveQuote(context.Background(), PricingInput{Model: "gpt-5.6-terra", GroupID: groupIDPtr()})
+ require.NoError(t, err)
+ require.NotNil(t, quote)
+ require.Equal(t, PricingSourceChannel, quote.Evidence.Source)
+ require.Equal(t, channelPricingRevision, quote.Evidence.Revision)
+}
+
+func TestResolvePricingQuoteRejectsEmptyExactChannelTokenPricing(t *testing.T) {
+ resolver := newResolverWithChannel(t, []ChannelModelPricing{{
+ Platform: "anthropic", Models: []string{"custom-empty-price"}, BillingMode: BillingModeToken,
+ }})
+
+ quote, err := resolver.ResolveQuote(context.Background(), PricingInput{Model: "custom-empty-price", GroupID: groupIDPtr()})
+
+ require.Error(t, err)
+ require.ErrorIs(t, err, ErrOpenAIPricingUnavailable)
+ require.Nil(t, quote)
+}
+
+func TestResolvePricingQuoteAllowsExplicitPositiveNonGPTChannelTokenPricing(t *testing.T) {
+ resolver := newResolverWithChannel(t, []ChannelModelPricing{{
+ Platform: "anthropic", Models: []string{"custom-positive-price"}, BillingMode: BillingModeToken,
+ InputPrice: testPtrFloat64(1e-6),
+ }})
+
+ quote, err := resolver.ResolveQuote(context.Background(), PricingInput{Model: "custom-positive-price", GroupID: groupIDPtr()})
+
+ require.NoError(t, err)
+ require.NotNil(t, quote)
+ require.Equal(t, PricingSourceChannel, quote.Evidence.Source)
+}
+
+func TestResolvePricingQuoteRejectsInvalidChannelTokenPrices(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ price float64
+ }{
+ {name: "zero", price: 0},
+ {name: "negative", price: -1},
+ {name: "nan", price: math.NaN()},
+ {name: "positive infinity", price: math.Inf(1)},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ resolver := newResolverWithChannel(t, []ChannelModelPricing{{
+ Platform: "anthropic", Models: []string{"custom-invalid-price"}, BillingMode: BillingModeToken,
+ InputPrice: testPtrFloat64(tt.price),
+ }})
+ quote, err := resolver.ResolveQuote(context.Background(), PricingInput{Model: "custom-invalid-price", GroupID: groupIDPtr()})
+ require.Error(t, err)
+ require.ErrorIs(t, err, ErrOpenAIPricingUnavailable)
+ require.Nil(t, quote)
+ })
+ }
+}
+
+func TestResolveOpenAIImagePricingQuoteRejectsMissingRequestedSizeTier(t *testing.T) {
+ resolver := newResolverWithChannel(t, []ChannelModelPricing{{
+ Platform: "anthropic", Models: []string{"custom-image-priced"}, BillingMode: BillingModeImage,
+ Intervals: []PricingInterval{{TierLabel: "1K", PerRequestPrice: testPtrFloat64(0.25)}},
+ }})
+ svc := &OpenAIGatewayService{billingService: resolver.billingService, resolver: resolver}
+
+ quote, err := svc.ResolveOpenAIImagePricingQuote(context.Background(), "custom-image-priced", "4K", groupIDPtr(), nil)
+
+ require.Error(t, err)
+ require.ErrorIs(t, err, ErrOpenAIPricingUnavailable)
+ require.Nil(t, quote)
+}
+
+func TestResolvePricingQuoteAllowsZeroUnusedIntervalDimension(t *testing.T) {
+ resolver := newResolverWithChannel(t, []ChannelModelPricing{{
+ Platform: "anthropic", Models: []string{"custom-zero-input"}, BillingMode: BillingModeToken,
+ Intervals: []PricingInterval{{MinTokens: -1, InputPrice: testPtrFloat64(0), OutputPrice: testPtrFloat64(2e-6)}},
+ }})
+
+ quote, err := resolver.ResolveQuote(context.Background(), PricingInput{Model: "custom-zero-input", GroupID: groupIDPtr()})
+
+ require.NoError(t, err)
+ require.NotNil(t, quote)
+ _, err = resolver.billingService.CalculateCostUnified(CostInput{
+ Ctx: context.Background(), Model: "custom-zero-input", GroupID: groupIDPtr(),
+ Tokens: UsageTokens{OutputTokens: 10}, RateMultiplier: 1, Resolver: resolver, Resolved: quote.CloneResolved(),
+ })
+ require.NoError(t, err)
+ _, err = resolver.billingService.CalculateCostUnified(CostInput{
+ Ctx: context.Background(), Model: "custom-zero-input", GroupID: groupIDPtr(),
+ Tokens: UsageTokens{InputTokens: 10}, RateMultiplier: 1, Resolver: resolver, Resolved: quote.CloneResolved(),
+ })
+ require.Error(t, err, "a used input dimension without a positive price must fail closed")
+}
+
+func TestResolvePricingQuoteRejectsAllZeroOrInvalidIntervalPrices(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ outputPrice float64
+ }{
+ {name: "all zero", outputPrice: 0},
+ {name: "negative", outputPrice: -1},
+ {name: "nan", outputPrice: math.NaN()},
+ {name: "infinity", outputPrice: math.Inf(1)},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ resolver := newResolverWithChannel(t, []ChannelModelPricing{{
+ Platform: "anthropic", Models: []string{"custom-invalid-interval"}, BillingMode: BillingModeToken,
+ Intervals: []PricingInterval{{MinTokens: 0, InputPrice: testPtrFloat64(0), OutputPrice: testPtrFloat64(tt.outputPrice)}},
+ }})
+ quote, err := resolver.ResolveQuote(context.Background(), PricingInput{Model: "custom-invalid-interval", GroupID: groupIDPtr()})
+ require.Error(t, err)
+ require.ErrorIs(t, err, ErrOpenAIPricingUnavailable)
+ require.Nil(t, quote)
+ })
+ }
+}
+
+func TestResolvePricingQuoteRejectsAllZeroIntervalEvenWithUsableBasePrice(t *testing.T) {
+ resolver := newResolverWithChannel(t, []ChannelModelPricing{{
+ Platform: "anthropic", Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken,
+ Intervals: []PricingInterval{{MinTokens: 0, InputPrice: testPtrFloat64(0), OutputPrice: testPtrFloat64(0)}},
+ }})
+
+ quote, err := resolver.ResolveQuote(context.Background(), PricingInput{Model: "claude-sonnet-4", GroupID: groupIDPtr()})
+
+ require.Error(t, err)
+ require.ErrorIs(t, err, ErrOpenAIPricingUnavailable)
+ require.Nil(t, quote)
+}
+
+func TestValidateTokenPricingForUsageRejectsNonPositiveOrNonFiniteUsedDimension(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ price float64
+ }{
+ {name: "zero", price: 0},
+ {name: "negative", price: -1},
+ {name: "nan", price: math.NaN()},
+ {name: "infinity", price: math.Inf(1)},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateTokenPricingForUsage(&ModelPricing{OutputPricePerToken: tt.price}, UsageTokens{OutputTokens: 1}, "")
+ require.Error(t, err)
+ })
+ }
+}
+
// ---------------------------------------------------------------------------
// 1. Token mode overrides
// ---------------------------------------------------------------------------
diff --git a/backend/internal/service/model_router_service.go b/backend/internal/service/model_router_service.go
new file mode 100644
index 00000000000..d651279d2d9
--- /dev/null
+++ b/backend/internal/service/model_router_service.go
@@ -0,0 +1,301 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
+ "github.com/fsnotify/fsnotify"
+ "github.com/spf13/viper"
+)
+
+var ErrModelUnsupported = infraerrors.BadRequest("MODEL_UNSUPPORTED", "model unsupported")
+var ErrModelRoutesConfigRejected = errors.New("model routes config rejected")
+
+type ModelRouter interface {
+ ResolveGroupID(ctx context.Context, userID int64, modelName string) (int64, error)
+}
+
+type ModelRouteProvider interface {
+ Routes() []ModelRoute
+}
+
+type ModelRouterGroupRepository interface {
+ ListActive(ctx context.Context) ([]Group, error)
+}
+
+type ModelRoute struct {
+ Pattern string `mapstructure:"pattern" yaml:"pattern" json:"pattern"`
+ GroupName string `mapstructure:"group_name" yaml:"group_name" json:"group_name"`
+ ExampleModel string `mapstructure:"example_model" yaml:"example_model" json:"example_model,omitempty"`
+}
+
+type modelRoutesConfig struct {
+ Routes []ModelRoute `mapstructure:"routes"`
+}
+
+type ModelRouterService struct {
+ groupRepo ModelRouterGroupRepository
+ routes atomic.Value
+ viper *viper.Viper
+}
+
+func NewModelRouterService(groupRepo ModelRouterGroupRepository) *ModelRouterService {
+ svc := NewModelRouterServiceWithRoutes(groupRepo, DefaultModelRoutes())
+ svc.loadConfig()
+ return svc
+}
+
+func NewModelRouterServiceWithRoutes(groupRepo ModelRouterGroupRepository, routes []ModelRoute) *ModelRouterService {
+ svc := &ModelRouterService{groupRepo: groupRepo}
+ svc.setRoutes(routes)
+ return svc
+}
+
+func DefaultModelRoutes() []ModelRoute {
+ return []ModelRoute{
+ {Pattern: "claude-opus-*", GroupName: WalletDefaultVIPGroupName, ExampleModel: "claude-opus-4-7"},
+ {Pattern: "claude-sonnet-*", GroupName: WalletDefaultVIPGroupName, ExampleModel: "claude-sonnet-4-6"},
+ {Pattern: "claude-haiku-*", GroupName: WalletDefaultVIPGroupName, ExampleModel: "claude-haiku-4-5"},
+ {Pattern: "claude-fable-*", GroupName: WalletDefaultVIPGroupName, ExampleModel: "claude-fable-5"},
+ {Pattern: "fable", GroupName: WalletDefaultVIPGroupName, ExampleModel: "fable"},
+ {Pattern: "fable5", GroupName: WalletDefaultVIPGroupName, ExampleModel: "fable5"},
+ {Pattern: "gpt-*", GroupName: WalletDefaultOpenAIGroupName, ExampleModel: "gpt-5.6-high"},
+ {Pattern: "o1-*", GroupName: WalletDefaultOpenAIGroupName, ExampleModel: "o1-preview"},
+ {Pattern: "o3-*", GroupName: WalletDefaultOpenAIGroupName, ExampleModel: "o3-mini"},
+ }
+}
+
+func (s *ModelRouterService) ResolveGroupID(ctx context.Context, userID int64, modelName string) (int64, error) {
+ _ = userID
+ modelName = strings.TrimSpace(modelName)
+ if modelName == "" {
+ return 0, ErrModelUnsupported
+ }
+
+ groupName, ok := s.resolveGroupName(modelName)
+ if !ok {
+ return 0, ErrModelUnsupported
+ }
+
+ groups, err := s.groupRepo.ListActive(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("list active groups: %w", err)
+ }
+ for i := range groups {
+ group := &groups[i]
+ if group.Name == groupName && group.Status == StatusActive {
+ return group.ID, nil
+ }
+ }
+ return 0, ErrModelUnsupported
+}
+
+func (s *ModelRouterService) Routes() []ModelRoute {
+ return cloneModelRoutes(s.currentRoutes())
+}
+
+func (s *ModelRouterService) resolveGroupName(modelName string) (string, bool) {
+ return WalletModelRouteGroupName(s.currentRoutes(), modelName)
+}
+
+// WalletModelRouteGroupName resolves a model through the same HFC wallet route
+// policy used by ModelRouterService. Invalid or cross-business-group routes are
+// ignored so model discovery cannot advertise a route that request routing
+// would reject.
+func WalletModelRouteGroupName(routes []ModelRoute, modelName string) (string, bool) {
+ modelName = strings.TrimSpace(modelName)
+ if modelName == "" {
+ return "", false
+ }
+ for _, route := range routes {
+ pattern := strings.TrimSpace(route.Pattern)
+ groupName := strings.TrimSpace(route.GroupName)
+ if !walletModelRouteMatchesBusinessPolicy(pattern, groupName) {
+ continue
+ }
+ if matchModelRoute(pattern, modelName) {
+ return groupName, true
+ }
+ }
+ return "", false
+}
+
+func (s *ModelRouterService) currentRoutes() []ModelRoute {
+ if loaded := s.routes.Load(); loaded != nil {
+ if routes, ok := loaded.([]ModelRoute); ok {
+ return routes
+ }
+ }
+ return DefaultModelRoutes()
+}
+
+func (s *ModelRouterService) setRoutes(routes []ModelRoute) {
+ cleaned := cleanModelRoutes(routes)
+ if len(cleaned) == 0 {
+ cleaned = DefaultModelRoutes()
+ }
+ s.routes.Store(cleaned)
+}
+
+func (s *ModelRouterService) replaceRoutes(routes []ModelRoute) error {
+ if len(routes) == 0 {
+ return fmt.Errorf("%w: config contains no routes", ErrModelRoutesConfigRejected)
+ }
+ cleaned := cleanModelRoutes(routes)
+ if len(cleaned) == 0 {
+ return fmt.Errorf(
+ "%w: all %d configured routes were rejected by the HFC wallet routing policy",
+ ErrModelRoutesConfigRejected,
+ len(routes),
+ )
+ }
+ s.routes.Store(cleaned)
+ return nil
+}
+
+func cleanModelRoutes(routes []ModelRoute) []ModelRoute {
+ cleaned := make([]ModelRoute, 0, len(routes))
+ for _, route := range routes {
+ route.Pattern = strings.TrimSpace(route.Pattern)
+ route.GroupName = strings.TrimSpace(route.GroupName)
+ route.ExampleModel = strings.TrimSpace(route.ExampleModel)
+ if route.Pattern == "" || route.GroupName == "" {
+ continue
+ }
+ if route.GroupName != WalletDefaultOpenAIGroupName && route.GroupName != WalletDefaultVIPGroupName {
+ continue
+ }
+ if !walletModelRouteMatchesBusinessPolicy(route.Pattern, route.GroupName) {
+ continue
+ }
+ cleaned = append(cleaned, route)
+ }
+ return cleaned
+}
+
+func walletModelRouteMatchesBusinessPolicy(pattern, groupName string) bool {
+ if strings.Count(pattern, "*") > 1 || (strings.Contains(pattern, "*") && !strings.HasSuffix(pattern, "*")) {
+ return false
+ }
+ modelPrefix := strings.TrimSuffix(pattern, "*")
+ expectedGroup := ""
+ switch {
+ case strings.HasPrefix(modelPrefix, "gpt-"), strings.HasPrefix(modelPrefix, "o1-"), strings.HasPrefix(modelPrefix, "o3-"):
+ expectedGroup = WalletDefaultOpenAIGroupName
+ case strings.HasPrefix(modelPrefix, "claude-"), modelPrefix == "fable", modelPrefix == "fable5", strings.HasPrefix(modelPrefix, "fable-"):
+ expectedGroup = WalletDefaultVIPGroupName
+ default:
+ return false
+ }
+ return groupName == expectedGroup
+}
+
+func (s *ModelRouterService) loadConfig() {
+ configFile := findModelRoutesConfig()
+ if configFile == "" {
+ return
+ }
+
+ v := viper.New()
+ v.SetConfigFile(configFile)
+ s.viper = v
+ if err := s.ReloadConfig(); err != nil {
+ logger.LegacyPrintf(
+ "service.model_router",
+ "[ModelRouter] initial config rejected; keeping last-known-good routes: file=%q routes_kept=%d err=%v",
+ configFile,
+ len(s.currentRoutes()),
+ err,
+ )
+ }
+ v.OnConfigChange(func(event fsnotify.Event) {
+ if err := s.ReloadConfig(); err != nil {
+ logger.LegacyPrintf(
+ "service.model_router",
+ "[ModelRouter] hot reload rejected; keeping last-known-good routes: file=%q op=%q routes_kept=%d err=%v",
+ event.Name,
+ event.Op.String(),
+ len(s.currentRoutes()),
+ err,
+ )
+ }
+ })
+ v.WatchConfig()
+}
+
+// ReloadConfig atomically replaces the active routes only after the configured
+// file yields at least one route allowed by the HFC wallet routing policy.
+func (s *ModelRouterService) ReloadConfig() error {
+ if s.viper == nil {
+ return fmt.Errorf("%w: no config file is configured", ErrModelRoutesConfigRejected)
+ }
+ configFile := s.viper.ConfigFileUsed()
+ routes, err := readModelRoutesConfig(s.viper)
+ if err != nil {
+ return fmt.Errorf("%w: read config %q: %v", ErrModelRoutesConfigRejected, configFile, err)
+ }
+ if err := s.replaceRoutes(routes); err != nil {
+ return fmt.Errorf("%w (file=%q)", err, configFile)
+ }
+ return nil
+}
+
+func readModelRoutesConfig(v *viper.Viper) ([]ModelRoute, error) {
+ if err := v.ReadInConfig(); err != nil {
+ return nil, err
+ }
+ var cfg modelRoutesConfig
+ if err := v.Unmarshal(&cfg); err != nil {
+ return nil, err
+ }
+ return cfg.Routes, nil
+}
+
+func findModelRoutesConfig() string {
+ if explicit := strings.TrimSpace(os.Getenv("MODEL_ROUTES_CONFIG")); explicit != "" {
+ if fileExists(explicit) {
+ return explicit
+ }
+ }
+ for _, path := range []string{
+ filepath.Join("config", "model_routes.yaml"),
+ filepath.Join(".", "model_routes.yaml"),
+ filepath.Join("..", "config", "model_routes.yaml"),
+ filepath.Join("backend", "config", "model_routes.yaml"),
+ filepath.Join("/app", "data", "model_routes.yaml"),
+ filepath.Join("/app", "config", "model_routes.yaml"),
+ filepath.Join("/etc", "sub2api", "model_routes.yaml"),
+ } {
+ if fileExists(path) {
+ return path
+ }
+ }
+ return ""
+}
+
+func fileExists(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && !info.IsDir()
+}
+
+func cloneModelRoutes(routes []ModelRoute) []ModelRoute {
+ return append([]ModelRoute(nil), routes...)
+}
+
+func matchModelRoute(pattern, modelName string) bool {
+ if pattern == modelName {
+ return true
+ }
+ if strings.HasSuffix(pattern, "*") {
+ prefix := strings.TrimSuffix(pattern, "*")
+ return strings.HasPrefix(modelName, prefix)
+ }
+ return false
+}
diff --git a/backend/internal/service/model_router_service_test.go b/backend/internal/service/model_router_service_test.go
new file mode 100644
index 00000000000..e2d550dce16
--- /dev/null
+++ b/backend/internal/service/model_router_service_test.go
@@ -0,0 +1,194 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/spf13/viper"
+ "github.com/stretchr/testify/require"
+)
+
+type modelRouterGroupRepoStub struct {
+ groups []Group
+ err error
+}
+
+func (s *modelRouterGroupRepoStub) ListActive(ctx context.Context) ([]Group, error) {
+ if s.err != nil {
+ return nil, s.err
+ }
+ return append([]Group(nil), s.groups...), nil
+}
+
+func TestModelRouterServiceResolveGroupID(t *testing.T) {
+ routes := []ModelRoute{
+ {Pattern: "claude-opus-*", GroupName: "vip"},
+ {Pattern: "claude-sonnet-*", GroupName: "vip"},
+ {Pattern: "claude-haiku-*", GroupName: "vip"},
+ {Pattern: "gpt-*", GroupName: "openai-default"},
+ {Pattern: "o1-*", GroupName: "openai-default"},
+ {Pattern: "o3-*", GroupName: "openai-default"},
+ }
+ repo := &modelRouterGroupRepoStub{groups: []Group{
+ {ID: 2, Name: "openai-default", Status: StatusActive},
+ {ID: 3, Name: "vip", Status: StatusActive},
+ }}
+ router := NewModelRouterServiceWithRoutes(repo, routes)
+
+ tests := []struct {
+ name string
+ model string
+ want int64
+ }{
+ {name: "claude opus routes to vip", model: "claude-opus-4-20250514", want: 3},
+ {name: "claude sonnet routes to sonnet group", model: "claude-sonnet-4-6", want: 3},
+ {name: "claude haiku routes to sonnet group", model: "claude-haiku-4-5", want: 3},
+ {name: "gpt routes to gpt group", model: "gpt-5", want: 2},
+ {name: "o1 routes to gpt group", model: "o1-preview", want: 2},
+ {name: "o3 routes to gpt group", model: "o3-mini", want: 2},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := router.ResolveGroupID(context.Background(), 99, tt.model)
+ if err != nil {
+ t.Fatalf("ResolveGroupID returned error: %v", err)
+ }
+ if got != tt.want {
+ t.Fatalf("ResolveGroupID = %d, want %d", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestModelRouterServiceResolveGroupIDUnsupported(t *testing.T) {
+ router := NewModelRouterServiceWithRoutes(&modelRouterGroupRepoStub{groups: []Group{
+ {ID: 2, Name: "openai-default", Status: StatusActive},
+ }}, []ModelRoute{{Pattern: "gpt-*", GroupName: "openai-default"}})
+
+ for _, model := range []string{"", " ", "unknown-model-xyz", "anthropic/claude-sonnet-4-6"} {
+ t.Run(model, func(t *testing.T) {
+ _, err := router.ResolveGroupID(context.Background(), 99, model)
+ if !errors.Is(err, ErrModelUnsupported) {
+ t.Fatalf("ResolveGroupID error = %v, want ErrModelUnsupported", err)
+ }
+ })
+ }
+}
+
+func TestModelRouterServiceRequiresActiveGroup(t *testing.T) {
+ router := NewModelRouterServiceWithRoutes(&modelRouterGroupRepoStub{groups: []Group{
+ {ID: 2, Name: "openai-default", Status: StatusDisabled},
+ }}, []ModelRoute{{Pattern: "gpt-*", GroupName: "openai-default"}})
+
+ _, err := router.ResolveGroupID(context.Background(), 99, "gpt-5")
+ if !errors.Is(err, ErrModelUnsupported) {
+ t.Fatalf("ResolveGroupID error = %v, want ErrModelUnsupported", err)
+ }
+}
+
+func TestDefaultModelRoutesMatchHFCWalletBusinessGroups(t *testing.T) {
+ routes := DefaultModelRoutes()
+ repo := &modelRouterGroupRepoStub{groups: []Group{
+ {ID: 3, Name: "openai-default", Status: StatusActive},
+ {ID: 22, Name: "vip", Status: StatusActive},
+ }}
+ router := NewModelRouterServiceWithRoutes(repo, routes)
+
+ tests := []struct {
+ model string
+ want int64
+ }{
+ {model: "gpt-5.6-high", want: 3},
+ {model: "o1-preview", want: 3},
+ {model: "o3-mini", want: 3},
+ {model: "claude-opus-4-7", want: 22},
+ {model: "claude-sonnet-4-6", want: 22},
+ {model: "claude-haiku-4-5", want: 22},
+ {model: "claude-fable-5", want: 22},
+ {model: "fable5", want: 22},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.model, func(t *testing.T) {
+ got, err := router.ResolveGroupID(context.Background(), 99, tt.model)
+ if err != nil {
+ t.Fatalf("ResolveGroupID returned error: %v", err)
+ }
+ if got != tt.want {
+ t.Fatalf("ResolveGroupID = %d, want %d", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestModelRouterStartupWithoutConfigKeepsDefaultRoutes(t *testing.T) {
+ t.Chdir(t.TempDir())
+ t.Setenv("MODEL_ROUTES_CONFIG", filepath.Join(t.TempDir(), "missing-model-routes.yaml"))
+
+ router := NewModelRouterService(&modelRouterGroupRepoStub{})
+
+ require.Equal(t, DefaultModelRoutes(), router.Routes())
+ require.Nil(t, router.viper)
+}
+
+func TestModelRouterDropsRoutesThatViolateHFCModelGroupPolicy(t *testing.T) {
+ repo := &modelRouterGroupRepoStub{groups: []Group{
+ {ID: 3, Name: WalletDefaultOpenAIGroupName, Status: StatusActive},
+ {ID: 22, Name: WalletDefaultVIPGroupName, Status: StatusActive},
+ }}
+ router := NewModelRouterServiceWithRoutes(repo, []ModelRoute{
+ {Pattern: "gpt-*", GroupName: WalletDefaultVIPGroupName},
+ {Pattern: "claude-*", GroupName: WalletDefaultOpenAIGroupName},
+ {Pattern: "*", GroupName: WalletDefaultOpenAIGroupName},
+ {Pattern: "gpt-*", GroupName: WalletDefaultOpenAIGroupName},
+ })
+
+ groupID, err := router.ResolveGroupID(context.Background(), 7, "gpt-5.6-high")
+ require.NoError(t, err)
+ require.Equal(t, int64(3), groupID)
+ _, err = router.ResolveGroupID(context.Background(), 7, "claude-sonnet-4-6")
+ require.ErrorIs(t, err, ErrModelUnsupported)
+ require.Equal(t, []ModelRoute{{Pattern: "gpt-*", GroupName: WalletDefaultOpenAIGroupName}}, router.Routes())
+}
+
+func TestModelRouterReloadRejectsEmptyConfigAndKeepsLastKnownGoodRoutes(t *testing.T) {
+ initialRoutes := []ModelRoute{{Pattern: "gpt-*", GroupName: WalletDefaultOpenAIGroupName}}
+ router := NewModelRouterServiceWithRoutes(&modelRouterGroupRepoStub{}, initialRoutes)
+ router.viper = modelRouterTestViper(t, "routes: []\n")
+
+ err := router.ReloadConfig()
+
+ require.ErrorIs(t, err, ErrModelRoutesConfigRejected)
+ require.ErrorContains(t, err, "contains no routes")
+ require.Equal(t, initialRoutes, router.Routes())
+}
+
+func TestModelRouterReloadRejectsAllInvalidConfigAndKeepsLastKnownGoodRoutes(t *testing.T) {
+ initialRoutes := []ModelRoute{{Pattern: "claude-*", GroupName: WalletDefaultVIPGroupName}}
+ router := NewModelRouterServiceWithRoutes(&modelRouterGroupRepoStub{}, initialRoutes)
+ router.viper = modelRouterTestViper(t, `routes:
+ - pattern: "gpt-*"
+ group_name: "vip"
+ - pattern: "*"
+ group_name: "openai-default"
+`)
+
+ err := router.ReloadConfig()
+
+ require.ErrorIs(t, err, ErrModelRoutesConfigRejected)
+ require.ErrorContains(t, err, "all 2 configured routes were rejected")
+ require.Equal(t, initialRoutes, router.Routes())
+}
+
+func modelRouterTestViper(t *testing.T, contents string) *viper.Viper {
+ t.Helper()
+ configPath := filepath.Join(t.TempDir(), "model_routes.yaml")
+ require.NoError(t, os.WriteFile(configPath, []byte(contents), 0o600))
+ v := viper.New()
+ v.SetConfigFile(configPath)
+ return v
+}
diff --git a/backend/internal/service/monthly_locked_rates.go b/backend/internal/service/monthly_locked_rates.go
new file mode 100644
index 00000000000..81b51cf5575
--- /dev/null
+++ b/backend/internal/service/monthly_locked_rates.go
@@ -0,0 +1,60 @@
+package service
+
+import (
+ "context"
+ "strconv"
+ "strings"
+
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
+)
+
+const (
+ monthlyLockedRateGPTKiro = 1.0
+ monthlyLockedRateAntigravity = 3.5
+ monthlyLockedRateClaude = 8.5
+)
+
+func monthlyLockedRateForGroup(name, platform string) (float64, bool) {
+ n := strings.ToLower(strings.TrimSpace(name))
+ p := strings.ToLower(strings.TrimSpace(platform))
+ switch {
+ case strings.Contains(n, "antigravity") || p == "antigravity":
+ return monthlyLockedRateAntigravity, true
+ case strings.Contains(n, "kiro") || strings.Contains(n, "cc-default"):
+ return monthlyLockedRateGPTKiro, true
+ case p == "openai" || strings.Contains(n, "openai") || strings.Contains(n, "gpt"):
+ return monthlyLockedRateGPTKiro, true
+ case p == "anthropic" || strings.Contains(n, "claude"):
+ return monthlyLockedRateClaude, true
+ default:
+ return 0, false
+ }
+}
+
+func (s *SubscriptionService) buildMonthlyLockedRatesForPlan(ctx context.Context, input *AssignSubscriptionInput) (map[string]float64, error) {
+ if s == nil || s.entClient == nil || input == nil || input.PlanID == nil || input.IsCreditsAssign() {
+ return nil, nil
+ }
+
+ rows, err := s.entClient.SubscriptionPlanGroup.Query().
+ Where(subscriptionplangroup.PlanIDEQ(*input.PlanID)).
+ WithGroup().
+ All(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ rates := make(map[string]float64, len(rows))
+ for _, row := range rows {
+ if row == nil || row.GroupID <= 0 || row.Edges.Group == nil {
+ continue
+ }
+ if rate, ok := monthlyLockedRateForGroup(row.Edges.Group.Name, row.Edges.Group.Platform); ok {
+ rates[strconv.FormatInt(row.GroupID, 10)] = rate
+ }
+ }
+ if len(rates) == 0 {
+ return nil, nil
+ }
+ return rates, nil
+}
diff --git a/backend/internal/service/oauth_refresh_api.go b/backend/internal/service/oauth_refresh_api.go
index 571e9ecdb7c..fa07d2017f2 100644
--- a/backend/internal/service/oauth_refresh_api.go
+++ b/backend/internal/service/oauth_refresh_api.go
@@ -86,9 +86,8 @@ func (api *OAuthRefreshAPI) RefreshIfNeeded(
defer localMu.Unlock()
// 1. 获取分布式锁
- lockAcquired := false
if api.tokenCache != nil {
- acquired, lockErr := api.tokenCache.AcquireRefreshLock(ctx, cacheKey, api.lockTTL)
+ acquired, ownershipToken, lockErr := api.tokenCache.AcquireRefreshLock(ctx, cacheKey, api.lockTTL)
if lockErr != nil {
// Redis 错误,降级为无锁刷新(进程内互斥锁仍生效)
slog.Warn("oauth_refresh_lock_failed_degraded",
@@ -100,22 +99,23 @@ func (api *OAuthRefreshAPI) RefreshIfNeeded(
// 锁被其他 worker 持有
return &OAuthRefreshResult{LockHeld: true}, nil
} else {
- lockAcquired = true
- defer func() { _ = api.tokenCache.ReleaseRefreshLock(ctx, cacheKey) }()
+ defer func() { _ = api.tokenCache.ReleaseRefreshLock(ctx, cacheKey, ownershipToken) }()
}
}
// 2. 从 DB 重读最新 account(锁保护下,确保使用最新的 refresh_token)
+ if api.accountRepo == nil {
+ return nil, fmt.Errorf("read authoritative account before oauth refresh: account repository is nil")
+ }
freshAccount, err := api.accountRepo.GetByID(ctx, account.ID)
if err != nil {
slog.Warn("oauth_refresh_db_reread_failed",
"account_id", account.ID,
"error", err,
)
- // 降级使用传入的 account
- freshAccount = account
+ return nil, fmt.Errorf("read authoritative account before oauth refresh: %w", err)
} else if freshAccount == nil {
- freshAccount = account
+ return nil, fmt.Errorf("read authoritative account before oauth refresh: account %d not found", account.ID)
}
// 3. 二次检查是否仍需刷新(另一条路径可能已刷新)
@@ -156,8 +156,6 @@ func (api *OAuthRefreshAPI) RefreshIfNeeded(
}
}
- _ = lockAcquired // suppress unused warning when tokenCache is nil
-
return &OAuthRefreshResult{
Refreshed: true,
NewCredentials: newCredentials,
diff --git a/backend/internal/service/oauth_refresh_api_test.go b/backend/internal/service/oauth_refresh_api_test.go
index 4a60723b8bf..2c20700559a 100644
--- a/backend/internal/service/oauth_refresh_api_test.go
+++ b/backend/internal/service/oauth_refresh_api_test.go
@@ -79,6 +79,8 @@ func (e *refreshAPIExecutorStub) CacheKey(account *Account) string {
type refreshAPICacheStub struct {
lockResult bool
lockErr error
+ lockToken string
+ releaseToken string
releaseCalls int
}
@@ -92,12 +94,19 @@ func (c *refreshAPICacheStub) SetAccessToken(context.Context, string, string, ti
func (c *refreshAPICacheStub) DeleteAccessToken(context.Context, string) error { return nil }
-func (c *refreshAPICacheStub) AcquireRefreshLock(context.Context, string, time.Duration) (bool, error) {
- return c.lockResult, c.lockErr
+func (c *refreshAPICacheStub) AcquireRefreshLock(context.Context, string, time.Duration) (bool, string, error) {
+ if !c.lockResult || c.lockErr != nil {
+ return c.lockResult, "", c.lockErr
+ }
+ if c.lockToken == "" {
+ c.lockToken = "refresh-api-owner"
+ }
+ return true, c.lockToken, nil
}
-func (c *refreshAPICacheStub) ReleaseRefreshLock(context.Context, string) error {
+func (c *refreshAPICacheStub) ReleaseRefreshLock(_ context.Context, _ string, ownershipToken string) error {
c.releaseCalls++
+ c.releaseToken = ownershipToken
return nil
}
@@ -123,6 +132,7 @@ func TestRefreshIfNeeded_Success(t *testing.T) {
require.Equal(t, 1, repo.updateCalls) // DB updated
require.Equal(t, 1, repo.updateCredentialsCalls)
require.Equal(t, 1, cache.releaseCalls) // lock released
+ require.Equal(t, cache.lockToken, cache.releaseToken)
require.Equal(t, 1, executor.refreshCalls)
}
@@ -259,7 +269,7 @@ func TestRefreshIfNeeded_DBUpdateError(t *testing.T) {
require.Equal(t, 1, repo.updateCalls) // attempted
}
-func TestRefreshIfNeeded_DBRereadFails(t *testing.T) {
+func TestRefreshIfNeeded_DBRereadFailsClosed(t *testing.T) {
account := &Account{ID: 8, Platform: PlatformAnthropic, Type: AccountTypeOAuth}
repo := &refreshAPIAccountRepo{
account: nil, // GetByID returns nil
@@ -274,9 +284,48 @@ func TestRefreshIfNeeded_DBRereadFails(t *testing.T) {
api := NewOAuthRefreshAPI(repo, cache)
result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute)
- require.NoError(t, err)
- require.True(t, result.Refreshed)
- require.Equal(t, 1, executor.refreshCalls) // still refreshes using passed-in account
+ require.Error(t, err)
+ require.Nil(t, result)
+ require.Contains(t, err.Error(), "authoritative account")
+ require.Equal(t, 0, executor.refreshCalls)
+ require.Equal(t, 0, repo.updateCalls)
+ require.Equal(t, 1, cache.releaseCalls)
+}
+
+func TestRefreshIfNeeded_DBRereadReturnsNilFailsClosed(t *testing.T) {
+ account := &Account{ID: 81, Platform: PlatformAnthropic, Type: AccountTypeOAuth}
+ repo := &refreshAPIAccountRepo{account: nil}
+ cache := &refreshAPICacheStub{lockResult: true}
+ executor := &refreshAPIExecutorStub{
+ needsRefresh: true,
+ credentials: map[string]any{"access_token": "must-not-be-used"},
+ }
+
+ api := NewOAuthRefreshAPI(repo, cache)
+ result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute)
+
+ require.Error(t, err)
+ require.Nil(t, result)
+ require.Contains(t, err.Error(), "authoritative account")
+ require.Equal(t, 0, executor.refreshCalls)
+ require.Equal(t, 0, repo.updateCalls)
+ require.Equal(t, 1, cache.releaseCalls)
+}
+
+func TestRefreshIfNeeded_NilRepoFailsClosed(t *testing.T) {
+ account := &Account{ID: 82, Platform: PlatformAnthropic, Type: AccountTypeOAuth}
+ executor := &refreshAPIExecutorStub{
+ needsRefresh: true,
+ credentials: map[string]any{"access_token": "must-not-be-used"},
+ }
+
+ api := NewOAuthRefreshAPI(nil, nil)
+ result, err := api.RefreshIfNeeded(context.Background(), account, executor, 3*time.Minute)
+
+ require.Error(t, err)
+ require.Nil(t, result)
+ require.Contains(t, err.Error(), "authoritative account")
+ require.Equal(t, 0, executor.refreshCalls)
}
func TestRefreshIfNeeded_NilCredentials(t *testing.T) {
diff --git a/backend/internal/service/oauth_token_cache_consistency_security_test.go b/backend/internal/service/oauth_token_cache_consistency_security_test.go
new file mode 100644
index 00000000000..a9814e5555f
--- /dev/null
+++ b/backend/internal/service/oauth_token_cache_consistency_security_test.go
@@ -0,0 +1,491 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+type tokenVersionRepoErrorStub struct {
+ mockAccountRepoForGemini
+}
+
+func (r *tokenVersionRepoErrorStub) GetByID(context.Context, int64) (*Account, error) {
+ return nil, errors.New("authoritative account read failed")
+}
+
+type tokenVersionCacheStub struct {
+ setCalls atomic.Int32
+}
+
+type credentialBoundTokenCacheStub struct {
+ tokens map[string]string
+}
+
+type lockHeldStaleTokenCacheStub struct {
+ mu sync.Mutex
+ getCalls int
+ staleToken string
+}
+
+func (s *lockHeldStaleTokenCacheStub) GetAccessToken(context.Context, string) (string, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.getCalls++
+ if s.getCalls == 1 {
+ return "", nil
+ }
+ return s.staleToken, nil
+}
+
+func (s *lockHeldStaleTokenCacheStub) SetAccessToken(context.Context, string, string, time.Duration) error {
+ return nil
+}
+
+func (s *lockHeldStaleTokenCacheStub) DeleteAccessToken(context.Context, string) error {
+ return nil
+}
+
+func (s *lockHeldStaleTokenCacheStub) AcquireRefreshLock(context.Context, string, time.Duration) (bool, string, error) {
+ return false, "", nil
+}
+
+func (s *lockHeldStaleTokenCacheStub) ReleaseRefreshLock(context.Context, string, string) error {
+ return nil
+}
+
+func (s *credentialBoundTokenCacheStub) GetAccessToken(_ context.Context, cacheKey string) (string, error) {
+ return s.tokens[cacheKey], nil
+}
+
+func (s *credentialBoundTokenCacheStub) SetAccessToken(_ context.Context, cacheKey string, token string, _ time.Duration) error {
+ s.tokens[cacheKey] = token
+ return nil
+}
+
+func (s *credentialBoundTokenCacheStub) DeleteAccessToken(_ context.Context, cacheKey string) error {
+ delete(s.tokens, cacheKey)
+ return nil
+}
+
+func (s *credentialBoundTokenCacheStub) AcquireRefreshLock(context.Context, string, time.Duration) (bool, string, error) {
+ return true, "credential-bound-owner", nil
+}
+
+func (s *credentialBoundTokenCacheStub) ReleaseRefreshLock(context.Context, string, string) error {
+ return nil
+}
+
+func (s *tokenVersionCacheStub) GetAccessToken(context.Context, string) (string, error) {
+ return "", nil
+}
+
+func (s *tokenVersionCacheStub) SetAccessToken(context.Context, string, string, time.Duration) error {
+ s.setCalls.Add(1)
+ return nil
+}
+
+func (s *tokenVersionCacheStub) DeleteAccessToken(context.Context, string) error {
+ return nil
+}
+
+func (s *tokenVersionCacheStub) AcquireRefreshLock(context.Context, string, time.Duration) (bool, string, error) {
+ return false, "", nil
+}
+
+func (s *tokenVersionCacheStub) ReleaseRefreshLock(context.Context, string, string) error {
+ return nil
+}
+
+func TestTokenProvidersSkipCacheFillWhenAuthoritativeVersionReadFails(t *testing.T) {
+ expiresAt := time.Now().Add(time.Hour).Format(time.RFC3339)
+ repo := &tokenVersionRepoErrorStub{}
+
+ tests := []struct {
+ name string
+ account *Account
+ newProvider func(*tokenVersionCacheStub) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ }
+ }{
+ {
+ name: "gemini",
+ account: &Account{ID: 101, Platform: PlatformGemini, Type: AccountTypeOAuth, Credentials: map[string]any{
+ "access_token": "gemini-token", "expires_at": expiresAt, "project_id": "gemini-project",
+ }},
+ newProvider: func(cache *tokenVersionCacheStub) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ } {
+ return NewGeminiTokenProvider(repo, cache, nil)
+ },
+ },
+ {
+ name: "openai",
+ account: &Account{ID: 102, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{
+ "access_token": "openai-token", "expires_at": expiresAt,
+ }},
+ newProvider: func(cache *tokenVersionCacheStub) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ } {
+ return NewOpenAITokenProvider(repo, cache, nil)
+ },
+ },
+ {
+ name: "claude",
+ account: &Account{ID: 103, Platform: PlatformAnthropic, Type: AccountTypeOAuth, Credentials: map[string]any{
+ "access_token": "claude-token", "expires_at": expiresAt,
+ }},
+ newProvider: func(cache *tokenVersionCacheStub) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ } {
+ return NewClaudeTokenProvider(repo, cache, nil)
+ },
+ },
+ {
+ name: "antigravity",
+ account: &Account{ID: 104, Platform: PlatformAntigravity, Type: AccountTypeOAuth, Credentials: map[string]any{
+ "access_token": "antigravity-token", "expires_at": expiresAt, "project_id": "ag-project",
+ }},
+ newProvider: func(cache *tokenVersionCacheStub) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ } {
+ return NewAntigravityTokenProvider(repo, cache, nil)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cache := &tokenVersionCacheStub{}
+ provider := tt.newProvider(cache)
+ token, err := provider.GetAccessToken(context.Background(), tt.account)
+ require.NoError(t, err)
+ require.NotEmpty(t, token)
+ require.Zero(t, cache.setCalls.Load(), "an unverified token must not refill the shared cache")
+ })
+ }
+}
+
+func TestTokenProvidersDoNotReuseCacheEntryAfterOAuthCredentialReplacement(t *testing.T) {
+ expiresAt := time.Now().Add(time.Hour).Format(time.RFC3339)
+ tests := []struct {
+ name string
+ platform string
+ credentials map[string]any
+ key func(*Account) string
+ newProvider func(AccountRepository, GeminiTokenCache) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ }
+ }{
+ {
+ name: "gemini",
+ platform: PlatformGemini,
+ credentials: map[string]any{"project_id": "gemini-project"},
+ key: GeminiTokenCacheKey,
+ newProvider: func(repo AccountRepository, cache GeminiTokenCache) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ } {
+ return NewGeminiTokenProvider(repo, cache, nil)
+ },
+ },
+ {
+ name: "openai",
+ platform: PlatformOpenAI,
+ credentials: map[string]any{},
+ key: OpenAITokenCacheKey,
+ newProvider: func(repo AccountRepository, cache GeminiTokenCache) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ } {
+ return NewOpenAITokenProvider(repo, cache, nil)
+ },
+ },
+ {
+ name: "claude",
+ platform: PlatformAnthropic,
+ credentials: map[string]any{},
+ key: ClaudeTokenCacheKey,
+ newProvider: func(repo AccountRepository, cache GeminiTokenCache) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ } {
+ return NewClaudeTokenProvider(repo, cache, nil)
+ },
+ },
+ {
+ name: "antigravity",
+ platform: PlatformAntigravity,
+ credentials: map[string]any{"project_id": "ag-project"},
+ key: AntigravityTokenCacheKey,
+ newProvider: func(repo AccountRepository, cache GeminiTokenCache) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ } {
+ return NewAntigravityTokenProvider(repo, cache, nil)
+ },
+ },
+ }
+
+ for i, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ id := int64(200 + i)
+ oldCredentials := cloneCredentials(tt.credentials)
+ oldCredentials["access_token"] = "old-access-token-" + tt.name
+ oldCredentials["refresh_token"] = "old-refresh-token-" + tt.name
+ oldCredentials["expires_at"] = expiresAt
+ newCredentials := cloneCredentials(tt.credentials)
+ newCredentials["access_token"] = "new-access-token-" + tt.name
+ newCredentials["refresh_token"] = "new-refresh-token-" + tt.name
+ newCredentials["expires_at"] = expiresAt
+
+ oldAccount := &Account{ID: id, Platform: tt.platform, Type: AccountTypeOAuth, Credentials: oldCredentials}
+ newAccount := &Account{ID: id, Platform: tt.platform, Type: AccountTypeOAuth, Credentials: newCredentials}
+ oldKey := tt.key(oldAccount)
+ newKey := tt.key(newAccount)
+ require.NotEqual(t, oldKey, newKey, "cache key must be bound to the current credential generation")
+ require.False(t, strings.Contains(newKey, newAccount.GetCredential("access_token")))
+ require.False(t, strings.Contains(newKey, newAccount.GetCredential("refresh_token")))
+
+ cache := &credentialBoundTokenCacheStub{tokens: map[string]string{oldKey: "stale-cached-token"}}
+ repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{id: newAccount}}
+ provider := tt.newProvider(repo, cache)
+ // Simulate a scheduler/request path that still holds the pre-update
+ // account object while the database already contains the replacement.
+ token, err := provider.GetAccessToken(context.Background(), oldAccount)
+ require.NoError(t, err)
+ require.Equal(t, newAccount.GetCredential("access_token"), token)
+ require.NotEqual(t, "stale-cached-token", token)
+ })
+ }
+}
+
+func TestTokenProvidersRejectCacheHitWhenAuthoritativeVersionReadFails(t *testing.T) {
+ expiresAt := time.Now().Add(time.Hour).Format(time.RFC3339)
+ tests := []struct {
+ name string
+ platform string
+ credentials map[string]any
+ key func(*Account) string
+ newProvider func(AccountRepository, GeminiTokenCache) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ }
+ }{
+ {
+ name: "gemini",
+ platform: PlatformGemini,
+ credentials: map[string]any{"project_id": "gemini-project"},
+ key: GeminiTokenCacheKey,
+ newProvider: func(repo AccountRepository, cache GeminiTokenCache) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ } {
+ return NewGeminiTokenProvider(repo, cache, nil)
+ },
+ },
+ {
+ name: "openai",
+ platform: PlatformOpenAI,
+ key: OpenAITokenCacheKey,
+ newProvider: func(repo AccountRepository, cache GeminiTokenCache) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ } {
+ return NewOpenAITokenProvider(repo, cache, nil)
+ },
+ },
+ {
+ name: "claude",
+ platform: PlatformAnthropic,
+ key: ClaudeTokenCacheKey,
+ newProvider: func(repo AccountRepository, cache GeminiTokenCache) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ } {
+ return NewClaudeTokenProvider(repo, cache, nil)
+ },
+ },
+ {
+ name: "antigravity",
+ platform: PlatformAntigravity,
+ credentials: map[string]any{"project_id": "ag-project"},
+ key: AntigravityTokenCacheKey,
+ newProvider: func(repo AccountRepository, cache GeminiTokenCache) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ } {
+ return NewAntigravityTokenProvider(repo, cache, nil)
+ },
+ },
+ }
+
+ for i, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ credentials := cloneCredentials(tt.credentials)
+ credentials["access_token"] = "account-token-" + tt.name
+ credentials["refresh_token"] = "refresh-token-" + tt.name
+ credentials["expires_at"] = expiresAt
+ account := &Account{
+ ID: int64(300 + i),
+ Platform: tt.platform,
+ Type: AccountTypeOAuth,
+ Credentials: credentials,
+ }
+ cache := &credentialBoundTokenCacheStub{tokens: map[string]string{
+ tt.key(account): "unverified-cached-token",
+ }}
+ provider := tt.newProvider(&tokenVersionRepoErrorStub{}, cache)
+
+ token, err := provider.GetAccessToken(context.Background(), account)
+
+ require.Error(t, err)
+ require.Empty(t, token)
+ })
+ }
+}
+
+func TestTokenProvidersRejectStaleCacheHitAfterRefreshLockWait(t *testing.T) {
+ expiresAt := time.Now().Add(time.Minute).Format(time.RFC3339)
+ tests := []struct {
+ name string
+ platform string
+ credentials map[string]any
+ newProvider func(AccountRepository, GeminiTokenCache) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ SetRefreshAPI(*OAuthRefreshAPI, OAuthRefreshExecutor)
+ SetRefreshPolicy(ProviderRefreshPolicy)
+ }
+ }{
+ {
+ name: "gemini",
+ platform: PlatformGemini,
+ credentials: map[string]any{"project_id": "gemini-project"},
+ newProvider: func(repo AccountRepository, cache GeminiTokenCache) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ SetRefreshAPI(*OAuthRefreshAPI, OAuthRefreshExecutor)
+ SetRefreshPolicy(ProviderRefreshPolicy)
+ } {
+ return NewGeminiTokenProvider(repo, cache, nil)
+ },
+ },
+ {
+ name: "openai",
+ platform: PlatformOpenAI,
+ newProvider: func(repo AccountRepository, cache GeminiTokenCache) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ SetRefreshAPI(*OAuthRefreshAPI, OAuthRefreshExecutor)
+ SetRefreshPolicy(ProviderRefreshPolicy)
+ } {
+ return NewOpenAITokenProvider(repo, cache, nil)
+ },
+ },
+ {
+ name: "claude",
+ platform: PlatformAnthropic,
+ newProvider: func(repo AccountRepository, cache GeminiTokenCache) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ SetRefreshAPI(*OAuthRefreshAPI, OAuthRefreshExecutor)
+ SetRefreshPolicy(ProviderRefreshPolicy)
+ } {
+ return NewClaudeTokenProvider(repo, cache, nil)
+ },
+ },
+ {
+ name: "antigravity",
+ platform: PlatformAntigravity,
+ credentials: map[string]any{"project_id": "ag-project"},
+ newProvider: func(repo AccountRepository, cache GeminiTokenCache) interface {
+ GetAccessToken(context.Context, *Account) (string, error)
+ SetRefreshAPI(*OAuthRefreshAPI, OAuthRefreshExecutor)
+ SetRefreshPolicy(ProviderRefreshPolicy)
+ } {
+ return NewAntigravityTokenProvider(repo, cache, nil)
+ },
+ },
+ }
+
+ for i, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ id := int64(400 + i)
+ oldCredentials := cloneCredentials(tt.credentials)
+ oldCredentials["access_token"] = "old-lock-token-" + tt.name
+ oldCredentials["refresh_token"] = "old-lock-refresh-" + tt.name
+ oldCredentials["expires_at"] = expiresAt
+ newCredentials := cloneCredentials(tt.credentials)
+ newCredentials["access_token"] = "new-lock-token-" + tt.name
+ newCredentials["refresh_token"] = "new-lock-refresh-" + tt.name
+ newCredentials["expires_at"] = expiresAt
+
+ oldAccount := &Account{ID: id, Platform: tt.platform, Type: AccountTypeOAuth, Credentials: oldCredentials}
+ newAccount := &Account{ID: id, Platform: tt.platform, Type: AccountTypeOAuth, Credentials: newCredentials}
+ repo := &mockAccountRepoForGemini{accountsByID: map[int64]*Account{id: newAccount}}
+ cache := &lockHeldStaleTokenCacheStub{staleToken: "stale-lock-cache-token"}
+ provider := tt.newProvider(repo, cache)
+ provider.SetRefreshAPI(NewOAuthRefreshAPI(repo, cache), &refreshAPIExecutorStub{needsRefresh: true})
+ policy := ProviderRefreshPolicy{
+ OnRefreshError: ProviderRefreshErrorReturn,
+ OnLockHeld: ProviderLockHeldWaitForCache,
+ }
+ provider.SetRefreshPolicy(policy)
+
+ token, err := provider.GetAccessToken(context.Background(), oldAccount)
+
+ require.NoError(t, err)
+ require.Equal(t, newAccount.GetCredential("access_token"), token)
+ require.NotEqual(t, cache.staleToken, token)
+ })
+ }
+}
+
+func TestValidateOAuthTokenCacheHitRejectsRequestVersionAheadWithDifferentCredentials(t *testing.T) {
+ requestAccount := &Account{
+ ID: 901,
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Credentials: map[string]any{
+ "access_token": "request-old-token",
+ "refresh_token": "request-old-refresh",
+ "_token_version": int64(200),
+ },
+ }
+ latestAccount := &Account{
+ ID: requestAccount.ID,
+ Platform: requestAccount.Platform,
+ Type: requestAccount.Type,
+ Credentials: map[string]any{
+ "access_token": "authoritative-new-token",
+ "refresh_token": "authoritative-new-refresh",
+ "_token_version": int64(100),
+ },
+ }
+ repo := &tokenVersionAccountRepoStub{latest: latestAccount}
+
+ validatedAccount, cacheHitIsCurrent, err := validateOAuthTokenCacheHit(
+ context.Background(),
+ requestAccount,
+ repo,
+ )
+
+ require.Error(t, err)
+ require.Nil(t, validatedAccount)
+ require.False(t, cacheHitIsCurrent)
+}
+
+func TestAdvanceOAuthTokenVersionIsMonotonicAndOAuthOnly(t *testing.T) {
+ oauth := &Account{
+ Type: AccountTypeOAuth,
+ Credentials: map[string]any{
+ "_token_version": int64(9_999_999_999_999),
+ },
+ }
+ AdvanceOAuthTokenVersion(oauth)
+ require.EqualValues(t, 10_000_000_000_000, oauth.GetCredentialAsInt64("_token_version"))
+
+ withoutCredentials := &Account{Type: AccountTypeOAuth}
+ AdvanceOAuthTokenVersion(withoutCredentials)
+ require.Greater(t, withoutCredentials.GetCredentialAsInt64("_token_version"), int64(0))
+
+ apiKey := &Account{Type: AccountTypeAPIKey, Credentials: map[string]any{}}
+ AdvanceOAuthTokenVersion(apiKey)
+ require.NotContains(t, apiKey.Credentials, "_token_version")
+}
diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go
index 7a0a6636ec8..48fc82657a3 100644
--- a/backend/internal/service/openai_account_scheduler.go
+++ b/backend/internal/service/openai_account_scheduler.go
@@ -5,6 +5,7 @@ import (
"context"
"fmt"
"hash/fnv"
+ "log/slog"
"math"
"sort"
"strconv"
@@ -345,7 +346,7 @@ func (s *defaultOpenAIAccountScheduler) selectBySessionHash(
_ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash)
return nil, nil
}
- if !s.isAccountRequestCompatible(account, req) {
+ if !s.isAccountRequestCompatible(ctx, account, req) {
return nil, nil
}
if !s.isAccountTransportCompatible(account, req.RequiredTransport) {
@@ -548,11 +549,17 @@ func buildOpenAIWeightedSelectionOrder(
}
for i := range pool {
// 将 top-K 分值平移到正区间,避免“单一最高分账号”长期垄断。
- weight := (pool[i].score - minScore) + 1.0
- if math.IsNaN(weight) || math.IsInf(weight, 0) || weight <= 0 {
- weight = 1.0
+ baseWeight := (pool[i].score - minScore) + 1.0
+ if math.IsNaN(baseWeight) || math.IsInf(baseWeight, 0) || baseWeight <= 0 {
+ baseWeight = 1.0
}
- weights[i] = weight
+ // 按账号容量配比加权:Pro 20x 类大号配 LoadFactor=20,Plus 配 1,
+ // 抽样比例自动按 LoadFactor 比分摊流量,不再"单一高分号长期垄断"。
+ capacityMultiplier := float64(pool[i].account.EffectiveLoadFactor())
+ if capacityMultiplier <= 0 {
+ capacityMultiplier = 1.0
+ }
+ weights[i] = baseWeight * capacityMultiplier
}
order := make([]openAIAccountCandidateScore, 0, len(pool))
@@ -621,7 +628,7 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance(
fmt.Sprintf("Privacy not set, required by group [%s]", schedGroup.Name))
continue
}
- if !s.isAccountRequestCompatible(account, req) {
+ if !s.isAccountRequestCompatible(ctx, account, req) {
continue
}
if !s.isAccountTransportCompatible(account, req.RequiredTransport) {
@@ -822,11 +829,11 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance(
for i := 0; i < len(selectionOrder); i++ {
candidate := selectionOrder[i]
fresh := s.service.resolveFreshSchedulableOpenAIAccount(ctx, candidate.account, req.RequestedModel, false)
- if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(fresh, req) {
+ if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) {
continue
}
fresh = s.service.recheckSelectedOpenAIAccountFromDB(ctx, fresh, req.RequestedModel, false)
- if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(fresh, req) {
+ if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) {
continue
}
if req.RequireCompact && openAICompactSupportTier(fresh) == 0 {
@@ -853,11 +860,11 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance(
// WaitPlan.MaxConcurrency 使用 Concurrency(非 EffectiveLoadFactor),因为 WaitPlan 控制的是 Redis 实际并发槽位等待。
for _, candidate := range selectionOrder {
fresh := s.service.resolveFreshSchedulableOpenAIAccount(ctx, candidate.account, req.RequestedModel, false)
- if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(fresh, req) {
+ if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) {
continue
}
fresh = s.service.recheckSelectedOpenAIAccountFromDB(ctx, fresh, req.RequestedModel, false)
- if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(fresh, req) {
+ if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) {
continue
}
if req.RequireCompact && openAICompactSupportTier(fresh) == 0 {
@@ -888,13 +895,18 @@ func (s *defaultOpenAIAccountScheduler) isAccountTransportCompatible(account *Ac
return s.service.isOpenAIAccountTransportCompatible(account, requiredTransport)
}
-func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatible(account *Account, req OpenAIAccountScheduleRequest) bool {
+func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatible(ctx context.Context, account *Account, req OpenAIAccountScheduleRequest) bool {
if account == nil {
return false
}
if req.RequestedModel != "" && !account.IsModelSupported(req.RequestedModel) {
return false
}
+ if req.GroupID != nil && s != nil && s.service != nil &&
+ s.service.needsUpstreamChannelRestrictionCheck(ctx, req.GroupID) &&
+ s.service.isUpstreamModelRestrictedByChannel(ctx, *req.GroupID, account, req.RequestedModel, req.RequireCompact) {
+ return false
+ }
return account.SupportsOpenAIImageCapability(req.RequiredImageCapability)
}
@@ -1106,6 +1118,13 @@ func (s *OpenAIGatewayService) selectAccountWithScheduler(
}
}
+ if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) {
+ slog.Warn("channel pricing restriction blocked request",
+ "group_id", derefGroupID(groupID),
+ "model", requestedModel)
+ return nil, decision, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel)
+ }
+
var stickyAccountID int64
if sessionHash != "" && s.cache != nil {
if accountID, err := s.getStickySessionAccountID(ctx, groupID, sessionHash); err == nil && accountID > 0 {
diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go
index 0950ee54767..8b714eae89d 100644
--- a/backend/internal/service/openai_account_scheduler_test.go
+++ b/backend/internal/service/openai_account_scheduler_test.go
@@ -1424,3 +1424,91 @@ func TestDefaultOpenAIAccountScheduler_IsAccountTransportCompatible_Branches(t *
func int64PtrForTest(v int64) *int64 {
return &v
}
+
+// TestBuildOpenAIWeightedSelectionOrder_LoadFactorWeighting 验证当多个账号
+// 评分相同时,buildOpenAIWeightedSelectionOrder 会按 account.EffectiveLoadFactor()
+// 比例分配选中概率(Pro 20x 类大号 LoadFactor=15 配 9 个 Plus LoadFactor=1,
+// 期望 Pro 占比 ≈ 15/24 ≈ 62.5%,每个 Plus ≈ 4.17%)。
+func TestBuildOpenAIWeightedSelectionOrder_LoadFactorWeighting(t *testing.T) {
+ const (
+ proID = int64(12)
+ proLoadFactor = 15
+ plusCount = 9
+ iterations = 5000
+ )
+
+ candidates := []openAIAccountCandidateScore{
+ {
+ account: &Account{ID: proID, LoadFactor: intPtrForTest(proLoadFactor)},
+ loadInfo: &AccountLoadInfo{LoadRate: 10, WaitingCount: 0},
+ score: 1.0,
+ },
+ }
+ for i := 0; i < plusCount; i++ {
+ candidates = append(candidates, openAIAccountCandidateScore{
+ account: &Account{ID: int64(100 + i), LoadFactor: intPtrForTest(1)},
+ loadInfo: &AccountLoadInfo{LoadRate: 10, WaitingCount: 0},
+ score: 1.0,
+ })
+ }
+
+ hits := make(map[int64]int, len(candidates))
+ for i := 0; i < iterations; i++ {
+ req := OpenAIAccountScheduleRequest{
+ SessionHash: fmt.Sprintf("loadfactor_test_%d", i),
+ RequestedModel: "gpt-5.1",
+ }
+ order := buildOpenAIWeightedSelectionOrder(candidates, req)
+ require.Len(t, order, len(candidates))
+ hits[order[0].account.ID]++
+ }
+
+ totalWeight := float64(proLoadFactor + plusCount)
+ expectedProRatio := float64(proLoadFactor) / totalWeight
+ expectedPlusRatio := 1.0 / totalWeight
+
+ proRatio := float64(hits[proID]) / float64(iterations)
+ require.InDelta(t, expectedProRatio, proRatio, 0.05,
+ "Pro 20x (LoadFactor=%d) 实际占比 %.2f%% 与期望 %.2f%% 偏差超过 5%%",
+ proLoadFactor, proRatio*100, expectedProRatio*100)
+
+ for i := 0; i < plusCount; i++ {
+ plusID := int64(100 + i)
+ plusRatio := float64(hits[plusID]) / float64(iterations)
+ require.InDelta(t, expectedPlusRatio, plusRatio, 0.03,
+ "Plus 号 id=%d 实际占比 %.2f%% 与期望 %.2f%% 偏差超过 3%%",
+ plusID, plusRatio*100, expectedPlusRatio*100)
+ }
+}
+
+// TestBuildOpenAIWeightedSelectionOrder_LoadFactorNilFallsBackToConcurrency 验证
+// LoadFactor=nil 的旧账号会 fallback 到 Concurrency,不会被当作权重 0 而抽不到。
+func TestBuildOpenAIWeightedSelectionOrder_LoadFactorNilFallsBackToConcurrency(t *testing.T) {
+ candidates := []openAIAccountCandidateScore{
+ {
+ account: &Account{ID: 201, LoadFactor: nil, Concurrency: 5},
+ loadInfo: &AccountLoadInfo{LoadRate: 10, WaitingCount: 0},
+ score: 1.0,
+ },
+ {
+ account: &Account{ID: 202, LoadFactor: nil, Concurrency: 5},
+ loadInfo: &AccountLoadInfo{LoadRate: 10, WaitingCount: 0},
+ score: 1.0,
+ },
+ }
+
+ hits := make(map[int64]int)
+ for i := 0; i < 1000; i++ {
+ req := OpenAIAccountScheduleRequest{
+ SessionHash: fmt.Sprintf("nil_loadfactor_%d", i),
+ RequestedModel: "gpt-5.1",
+ }
+ order := buildOpenAIWeightedSelectionOrder(candidates, req)
+ require.Len(t, order, len(candidates))
+ hits[order[0].account.ID]++
+ }
+
+ // LoadFactor=nil 但 Concurrency 相等,应该接近 50:50。
+ require.InDelta(t, 0.5, float64(hits[201])/1000.0, 0.07)
+ require.InDelta(t, 0.5, float64(hits[202])/1000.0, 0.07)
+}
diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go
new file mode 100644
index 00000000000..a4eb9252099
--- /dev/null
+++ b/backend/internal/service/openai_apikey_responses_probe.go
@@ -0,0 +1,149 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
+)
+
+// openaiResponsesProbeTimeout 是探测请求的超时时长。
+// 探测必须快速失败——超时不应阻塞账号创建/更新流程。
+const openaiResponsesProbeTimeout = 8 * time.Second
+
+// openaiResponsesProbePayload 是探测使用的最小 Responses 请求体。
+// 仅作能力探测,不期望响应内容质量;Stream=false 减少 SSE 解析开销。
+//
+// 注意:探测的目标是区分"端点存在"与"端点不存在"——只要上游返回非 404 的
+// 4xx/5xx(如 400 invalid_request_error / 401 unauthorized / 422 等),
+// 都视为"端点存在 → 支持 Responses"。仅 404 / 405 视为"端点不存在"。
+func openaiResponsesProbePayload(modelID string) []byte {
+ if strings.TrimSpace(modelID) == "" {
+ modelID = openai.DefaultTestModel
+ }
+ body, _ := json.Marshal(map[string]any{
+ "model": modelID,
+ "input": []map[string]any{
+ {
+ "role": "user",
+ "content": []map[string]any{
+ {"type": "input_text", "text": "hi"},
+ },
+ },
+ },
+ "instructions": openai.DefaultInstructions,
+ "stream": false,
+ })
+ return body
+}
+
+// ProbeOpenAIAPIKeyResponsesSupport 探测 OpenAI APIKey 账号上游是否支持
+// /v1/responses 端点,并将结果持久化到 accounts.extra.openai_responses_supported。
+//
+// 调用时机:账号创建/更新后,且仅当 platform=openai && type=apikey 时。
+//
+// 探测策略(参见包文档 internal/pkg/openai_compat):
+// - 上游 404 / 405 → 不支持,写 false
+// - 上游 2xx / 其他 4xx(401/422/400 等)/ 5xx → 支持,写 true
+// - 网络层失败(连接错误、超时)→ 不写标记,保持 unknown
+// (后续请求仍按"现状即证据"默认走 Responses)
+//
+// 该方法是幂等的:重复调用会以最新探测结果覆盖标记。
+//
+// 关于失败处理:探测本身的失败不应阻塞账号创建——账号能创建/更新成功就够了,
+// 探测结果只影响后续路由优化。所有错误都仅记录日志,不向调用方传播。
+func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Context, accountID int64) {
+ account, err := s.accountRepo.GetByID(ctx, accountID)
+ if err != nil {
+ logger.LegacyPrintf("service.openai_probe", "probe_load_account_failed: account_id=%d err=%v", accountID, err)
+ return
+ }
+ if account.Platform != PlatformOpenAI || account.Type != AccountTypeAPIKey {
+ // 仅 OpenAI APIKey 账号需要探测;其他账号类型无能力差异。
+ return
+ }
+
+ apiKey := account.GetOpenAIApiKey()
+ if apiKey == "" {
+ logger.LegacyPrintf("service.openai_probe", "probe_skip_no_apikey: account_id=%d", accountID)
+ return
+ }
+ baseURL := account.GetOpenAIBaseURL()
+ if baseURL == "" {
+ baseURL = "https://api.openai.com"
+ }
+ normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL)
+ if err != nil {
+ logger.LegacyPrintf("service.openai_probe", "probe_invalid_baseurl: account_id=%d base_url=%q err=%v", accountID, baseURL, err)
+ return
+ }
+
+ probeURL := buildOpenAIResponsesURL(normalizedBaseURL)
+
+ probeCtx, cancel := context.WithTimeout(ctx, openaiResponsesProbeTimeout)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(probeCtx, http.MethodPost, probeURL, bytes.NewReader(openaiResponsesProbePayload("")))
+ if err != nil {
+ logger.LegacyPrintf("service.openai_probe", "probe_build_request_failed: account_id=%d err=%v", accountID, err)
+ return
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+apiKey)
+ req.Header.Set("Accept", "application/json")
+
+ proxyURL := ""
+ if account.ProxyID != nil && account.Proxy != nil {
+ proxyURL = account.Proxy.URL()
+ }
+
+ resp, err := s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
+ if err != nil {
+ // 网络层失败:不写标记,保持 unknown,下次重试或由网关 fallback 处理
+ logger.LegacyPrintf("service.openai_probe", "probe_request_failed: account_id=%d url=%s err=%v", accountID, probeURL, err)
+ return
+ }
+ defer func() {
+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
+ _ = resp.Body.Close()
+ }()
+
+ supported := isResponsesEndpointSupportedByStatus(resp.StatusCode)
+
+ if err := s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{
+ openai_compat.ExtraKeyResponsesSupported: supported,
+ }); err != nil {
+ logger.LegacyPrintf("service.openai_probe", "probe_persist_failed: account_id=%d supported=%v err=%v", accountID, supported, err)
+ return
+ }
+
+ logger.LegacyPrintf("service.openai_probe",
+ "probe_done: account_id=%d base_url=%s status=%d supported=%v",
+ accountID, normalizedBaseURL, resp.StatusCode, supported,
+ )
+}
+
+// isResponsesEndpointSupportedByStatus 根据探测响应的 HTTP 状态码判定上游
+// 是否暴露 /v1/responses 端点。
+//
+// 关键观察:第三方 OpenAI 兼容上游(DeepSeek/Kimi 等)对未知端点统一返回 404
+// 或 405;而 OpenAI 官方/有 Responses 实现的上游会因为请求体最简(缺字段)
+// 返回 400/422 等业务错误,但端点本身存在。
+//
+// 因此:仅 404 和 405 视为"端点不存在",其他 status 视为"端点存在"。
+//
+// 5xx 也视为"端点存在"——上游偶发故障不应误判为不支持。
+func isResponsesEndpointSupportedByStatus(status int) bool {
+ switch status {
+ case http.StatusNotFound, http.StatusMethodNotAllowed:
+ return false
+ }
+ return true
+}
diff --git a/backend/internal/service/openai_billing_identity.go b/backend/internal/service/openai_billing_identity.go
new file mode 100644
index 00000000000..3cfe10b07cd
--- /dev/null
+++ b/backend/internal/service/openai_billing_identity.go
@@ -0,0 +1,655 @@
+package service
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+)
+
+const (
+ PricingSourceBuiltinGPT56 = "builtin_gpt56"
+ PricingSourceBuiltinFallback = "builtin_fallback"
+ PricingSourceGroupImage = "group_image"
+ GPT56PricingRevision = "gpt56-policy-v1"
+ channelPricingRevision = "channel-effective-v1"
+ builtinFallbackRevision = "builtin-fallback-v1"
+ groupImagePricingRevision = "group-image-effective-v1"
+)
+
+var (
+ ErrOpenAIBillingPreflight = errors.New("openai billing preflight failed")
+ ErrOpenAIPricingUnavailable = errors.New("openai pricing unavailable")
+)
+
+type OpenAIBillingPreflightError struct {
+ Reason string
+ Cause error
+}
+
+func (e *OpenAIBillingPreflightError) Error() string {
+ if e == nil {
+ return ErrOpenAIBillingPreflight.Error()
+ }
+ if e.Cause != nil {
+ return fmt.Sprintf("%s: %s: %v", ErrOpenAIBillingPreflight, e.Reason, e.Cause)
+ }
+ return fmt.Sprintf("%s: %s", ErrOpenAIBillingPreflight, e.Reason)
+}
+
+func (e *OpenAIBillingPreflightError) Unwrap() error { return e.Cause }
+
+func (e *OpenAIBillingPreflightError) Is(target error) bool {
+ return target == ErrOpenAIBillingPreflight || (e != nil && e.Cause != nil && errors.Is(e.Cause, target))
+}
+
+type PricingEvidence struct {
+ Source string
+ Revision string
+ Hash string
+}
+
+type PricingQuote struct {
+ Resolved *ResolvedPricing
+ Evidence PricingEvidence
+}
+
+func (q *PricingQuote) CloneResolved() *ResolvedPricing {
+ if q == nil {
+ return nil
+ }
+ return cloneResolvedPricing(q.Resolved)
+}
+
+type ResolvedOpenAIBillingIdentity struct {
+ RequestedModel string
+ CompatModel string
+ DispatchModel string
+ ChannelMappedModel string
+ AccountMappedModel string
+ UpstreamModel string
+ BillingModel string
+ BillingModelSource string
+ ChannelID int64
+ ModelMappingChain string
+ Pricing *PricingQuote
+ TextBillingModel string
+ TextPricing *PricingQuote
+ AdmissionAttemptID string
+ AdmissionRef UsageBillingAdmissionAttemptRef
+}
+
+type OpenAIBillingIdentityInput struct {
+ RequestedModel string
+ CompatModel string
+ DispatchModel string
+ ChannelMappedModel string
+ ChannelMappingApplied bool
+ ChannelMappingExact bool
+ AccountMappedModel string
+ UpstreamModel string
+ BillingModelSource string
+ ChannelID int64
+ ModelMappingChain string
+ GroupID *int64
+}
+
+type OpenAIForwardOptions struct {
+ RequestedModel string
+ ChannelMapping ChannelMappingResult
+ GroupID *int64
+ ImagePriceConfig *ImagePriceConfig
+ RequirePricingPreflight bool
+ RequireBillingAdmission bool
+ UsageBilling *OpenAIUsageBillingAdmissionInput
+}
+
+func (s *OpenAIGatewayService) resolveForwardBillingIdentity(
+ ctx context.Context,
+ opts OpenAIForwardOptions,
+ originalModel string,
+ compatModel string,
+ accountMappedModel string,
+ upstreamModel string,
+) (*ResolvedOpenAIBillingIdentity, error) {
+ required := opts.RequirePricingPreflight
+ for _, model := range []string{originalModel, accountMappedModel, upstreamModel} {
+ if _, family := classifyOpenAIGPT56PreviewModel(model); family {
+ required = true
+ break
+ }
+ }
+ if !required {
+ return nil, nil
+ }
+ requested := firstNonEmptyModel(opts.RequestedModel, originalModel)
+ channelMapped := firstNonEmptyModel(opts.ChannelMapping.MappedModel, originalModel)
+ return s.ResolveOpenAIBillingIdentity(ctx, OpenAIBillingIdentityInput{
+ RequestedModel: requested, CompatModel: compatModel, DispatchModel: originalModel,
+ ChannelMappedModel: channelMapped, ChannelMappingApplied: opts.ChannelMapping.Mapped,
+ ChannelMappingExact: opts.ChannelMapping.MappingExact, AccountMappedModel: accountMappedModel,
+ UpstreamModel: upstreamModel, BillingModelSource: opts.ChannelMapping.BillingModelSource,
+ ChannelID: opts.ChannelMapping.ChannelID,
+ ModelMappingChain: opts.ChannelMapping.BuildModelMappingChain(requested, upstreamModel),
+ GroupID: opts.GroupID,
+ })
+}
+
+func attachOpenAIBillingIdentity(result *OpenAIForwardResult, identity *ResolvedOpenAIBillingIdentity) {
+ if result == nil || identity == nil {
+ return
+ }
+ result.BillingIdentity = identity
+ result.BillingModel = identity.BillingModel
+ result.UpstreamModel = identity.UpstreamModel
+}
+
+// ResolveOpenAIWSBillingIdentity creates one immutable quote for one WS turn.
+// Callers must resolve it again for every turn; a connection-level call is only
+// an early gate before token refresh/dial and must never be reused for billing.
+func (s *OpenAIGatewayService) ResolveOpenAIWSBillingIdentity(
+ ctx context.Context,
+ account *Account,
+ opts OpenAIForwardOptions,
+) (*ResolvedOpenAIBillingIdentity, error) {
+ return s.ResolveOpenAIWSBillingIdentityForPayload(ctx, account, opts, nil)
+}
+
+func (s *OpenAIGatewayService) ResolveOpenAIWSBillingIdentityForPayload(
+ ctx context.Context,
+ account *Account,
+ opts OpenAIForwardOptions,
+ payload []byte,
+) (*ResolvedOpenAIBillingIdentity, error) {
+ requested := strings.TrimSpace(opts.RequestedModel)
+ channelMapped := firstNonEmptyModel(opts.ChannelMapping.MappedModel, requested)
+ accountMapped := resolveOpenAIForwardModel(account, channelMapped, "")
+ upstream := normalizeOpenAIModelForUpstream(account, accountMapped)
+ if IsImageGenerationIntent(openAIResponsesEndpoint, requested, payload) {
+ imageModel, imageSizeTier, err := resolveOpenAIResponsesImageBillingConfigFromBody(payload, upstream)
+ if err != nil {
+ return nil, preflightError("invalid image billing configuration", err)
+ }
+ return s.ResolveOpenAIImageBillingIdentity(ctx, OpenAIBillingIdentityInput{
+ RequestedModel: requested, DispatchModel: channelMapped, ChannelMappedModel: channelMapped,
+ ChannelMappingApplied: opts.ChannelMapping.Mapped, ChannelMappingExact: opts.ChannelMapping.MappingExact,
+ AccountMappedModel: accountMapped, UpstreamModel: upstream,
+ BillingModelSource: BillingModelSourceUpstream, ChannelID: opts.ChannelMapping.ChannelID,
+ ModelMappingChain: opts.ChannelMapping.BuildModelMappingChain(requested, upstream), GroupID: opts.GroupID,
+ }, imageModel, imageSizeTier, opts.ImagePriceConfig)
+ }
+ return s.resolveForwardBillingIdentity(ctx, opts, channelMapped, channelMapped, accountMapped, upstream)
+}
+
+// AttachOpenAIBillingIdentity applies a preflight result to a completed turn.
+func AttachOpenAIBillingIdentity(result *OpenAIForwardResult, identity *ResolvedOpenAIBillingIdentity) {
+ if result != nil && result.ImageCount > 0 && pricingQuoteMode(identity) != BillingModeImage && pricingQuoteMode(identity) != BillingModePerRequest {
+ // Never replace an image result's billing model with a text-turn quote.
+ return
+ }
+ attachOpenAIBillingIdentity(result, identity)
+}
+
+func pricingQuoteMode(identity *ResolvedOpenAIBillingIdentity) BillingMode {
+ if identity == nil || identity.Pricing == nil || identity.Pricing.Resolved == nil {
+ return ""
+ }
+ return identity.Pricing.Resolved.Mode
+}
+
+// ResolveOpenAIImageBillingIdentity freezes the exact image schedule selected
+// before token acquisition or transport.
+func (s *OpenAIGatewayService) ResolveOpenAIImageBillingIdentity(
+ ctx context.Context,
+ input OpenAIBillingIdentityInput,
+ imageModel string,
+ imageSizeTier string,
+ groupConfig *ImagePriceConfig,
+) (*ResolvedOpenAIBillingIdentity, error) {
+ imageModel = strings.TrimSpace(imageModel)
+ if imageModel == "" {
+ return nil, preflightError("image billing model is empty", ErrOpenAIPricingUnavailable)
+ }
+ quote, err := s.ResolveOpenAIImagePricingQuote(ctx, imageModel, imageSizeTier, input.GroupID, groupConfig)
+ if err != nil {
+ return nil, preflightError("image billing model is not priceable", err)
+ }
+ requested := strings.TrimSpace(input.RequestedModel)
+ channelMapped := firstNonEmptyModel(input.ChannelMappedModel, input.DispatchModel, requested)
+ accountMapped := firstNonEmptyModel(input.AccountMappedModel, channelMapped)
+ upstream := firstNonEmptyModel(input.UpstreamModel, accountMapped)
+ identity := &ResolvedOpenAIBillingIdentity{
+ RequestedModel: requested, CompatModel: strings.TrimSpace(input.CompatModel),
+ DispatchModel: strings.TrimSpace(input.DispatchModel), ChannelMappedModel: channelMapped,
+ AccountMappedModel: accountMapped, UpstreamModel: upstream, BillingModel: imageModel,
+ BillingModelSource: BillingModelSourceUpstream, ChannelID: input.ChannelID,
+ ModelMappingChain: strings.TrimSpace(input.ModelMappingChain), Pricing: quote,
+ }
+ if !strings.EqualFold(strings.TrimSpace(upstream), imageModel) {
+ textIdentity, textErr := s.ResolveOpenAIBillingIdentity(ctx, input)
+ if textErr != nil {
+ return nil, preflightError("text fallback billing model is not priceable", textErr)
+ }
+ identity.TextBillingModel = textIdentity.BillingModel
+ identity.TextPricing = textIdentity.Pricing
+ }
+ return identity, nil
+}
+
+func (s *OpenAIGatewayService) ResolveOpenAIImagePricingQuote(
+ ctx context.Context,
+ model string,
+ imageSizeTier string,
+ groupID *int64,
+ groupConfig *ImagePriceConfig,
+) (*PricingQuote, error) {
+ model = strings.TrimSpace(model)
+ if model == "" {
+ return nil, fmt.Errorf("%w: image model is empty", ErrOpenAIPricingUnavailable)
+ }
+ resolver := s.resolver
+ if resolver == nil && s.billingService != nil {
+ resolver = NewModelPricingResolver(s.channelService, s.billingService)
+ }
+ if resolver != nil && groupID != nil {
+ resolved := resolver.Resolve(ctx, PricingInput{Model: model, GroupID: groupID})
+ if resolved != nil && (resolved.Mode == BillingModeImage || resolved.Mode == BillingModePerRequest) {
+ if !resolvedPricingIsUsable(resolved) {
+ return nil, fmt.Errorf("%w for image model %s", ErrOpenAIPricingUnavailable, model)
+ }
+ selectedPrice := resolver.GetRequestTierPrice(resolved, imageSizeTier)
+ if selectedPrice == 0 {
+ selectedPrice = resolved.DefaultPerRequestPrice
+ }
+ if !positiveFinitePrice(selectedPrice) {
+ return nil, fmt.Errorf("%w: no %s price for image model %s", ErrOpenAIPricingUnavailable, imageSizeTier, model)
+ }
+ selected := &ResolvedPricing{
+ Mode: resolved.Mode, Source: resolved.Source, Revision: resolved.Revision,
+ SourceExact: resolved.SourceExact, DefaultPerRequestPrice: selectedPrice,
+ RequestTiers: []PricingInterval{{TierLabel: imageSizeTier, PerRequestPrice: pricingFloat64Ptr(selectedPrice)}},
+ }
+ return freezeResolvedPricingQuote(selected)
+ }
+ }
+
+ basePrice := 0.0
+ source := ""
+ if s.billingService != nil && s.billingService.pricingService != nil {
+ if pricing := s.billingService.pricingService.GetModelPricing(model); pricing != nil && positiveFinitePrice(pricing.OutputCostPerImage) {
+ basePrice = pricing.OutputCostPerImage
+ source = PricingSourceLiteLLM
+ }
+ }
+ if basePrice == 0 && isOpenAIImageGenerationModel(model) {
+ basePrice = 0.134
+ source = PricingSourceBuiltinFallback
+ }
+ prices := map[string]float64{
+ "1K": basePrice,
+ "2K": basePrice * 1.5,
+ "4K": basePrice * 2,
+ }
+ imageSizeTier = strings.TrimSpace(imageSizeTier)
+ if imageSizeTier == "" {
+ imageSizeTier = "2K"
+ }
+ var groupPrice *float64
+ if groupConfig != nil {
+ switch imageSizeTier {
+ case "1K":
+ groupPrice = groupConfig.Price1K
+ case "2K":
+ groupPrice = groupConfig.Price2K
+ case "4K":
+ groupPrice = groupConfig.Price4K
+ }
+ }
+ if groupPrice != nil {
+ if !positiveFinitePrice(*groupPrice) {
+ return nil, fmt.Errorf("%w: invalid %s image price", ErrOpenAIPricingUnavailable, imageSizeTier)
+ }
+ prices[imageSizeTier] = *groupPrice
+ source = PricingSourceGroupImage
+ }
+ selectedPrice := prices[imageSizeTier]
+ if source == "" || !positiveFinitePrice(selectedPrice) {
+ return nil, fmt.Errorf("%w for image model %s", ErrOpenAIPricingUnavailable, model)
+ }
+ resolved := &ResolvedPricing{
+ Mode: BillingModeImage, Source: source, DefaultPerRequestPrice: selectedPrice, SourceExact: true,
+ RequestTiers: []PricingInterval{
+ {TierLabel: imageSizeTier, PerRequestPrice: pricingFloat64Ptr(selectedPrice)},
+ },
+ }
+ return freezeResolvedPricingQuote(resolved)
+}
+
+func (s *OpenAIGatewayService) ResolveOpenAIBillingIdentity(ctx context.Context, input OpenAIBillingIdentityInput) (*ResolvedOpenAIBillingIdentity, error) {
+ requested := strings.TrimSpace(input.RequestedModel)
+ channelMapped := firstNonEmptyModel(input.ChannelMappedModel, input.DispatchModel, requested)
+ accountMapped := firstNonEmptyModel(input.AccountMappedModel, channelMapped)
+ upstream := firstNonEmptyModel(input.UpstreamModel, accountMapped)
+ source := strings.TrimSpace(input.BillingModelSource)
+ if source == "" {
+ source = BillingModelSourceUpstream
+ }
+
+ billingModel := upstream
+ switch source {
+ case BillingModelSourceRequested:
+ billingModel = requested
+ case BillingModelSourceChannelMapped:
+ billingModel = channelMapped
+ case BillingModelSourceUpstream:
+ billingModel = upstream
+ default:
+ return nil, preflightError("unknown billing model source", nil)
+ }
+
+ stages := []string{requested, channelMapped, accountMapped, upstream, billingModel}
+ strictGPT56 := false
+ for _, model := range stages {
+ if _, family := classifyOpenAIGPT56PreviewModel(model); family {
+ strictGPT56 = true
+ break
+ }
+ }
+ if strictGPT56 {
+ if source == BillingModelSourceRequested {
+ return nil, preflightError("GPT-5.6 cannot use requested billing", nil)
+ }
+ if input.ChannelMappingApplied && !input.ChannelMappingExact {
+ return nil, preflightError("GPT-5.6 channel mapping must be exact", nil)
+ }
+ for i := 0; i+1 < len(stages); i++ {
+ if !ValidateOpenAIGPT56ModelTransition(stages[i], stages[i+1]) {
+ return nil, preflightError("invalid GPT-5.6 model transition", nil)
+ }
+ }
+ normalized, family := classifyOpenAIGPT56PreviewModel(billingModel)
+ if !family || normalized == "" {
+ return nil, preflightError("invalid GPT-5.6 billing model", nil)
+ }
+ billingModel = normalized
+ }
+ if strings.TrimSpace(billingModel) == "" {
+ return nil, preflightError("billing model is empty", nil)
+ }
+
+ resolver := s.resolver
+ if resolver == nil && s.billingService != nil {
+ resolver = NewModelPricingResolver(s.channelService, s.billingService)
+ }
+ if resolver == nil && strictGPT56 {
+ billing := NewBillingService(&config.Config{}, nil)
+ resolver = NewModelPricingResolver(nil, billing)
+ }
+ if resolver == nil {
+ return nil, preflightError("pricing resolver is unavailable", ErrOpenAIPricingUnavailable)
+ }
+ quote, err := resolver.ResolveQuote(ctx, PricingInput{Model: billingModel, GroupID: input.GroupID})
+ if err != nil {
+ return nil, preflightError("final billing model is not priceable", err)
+ }
+
+ return &ResolvedOpenAIBillingIdentity{
+ RequestedModel: requested, CompatModel: strings.TrimSpace(input.CompatModel),
+ DispatchModel: strings.TrimSpace(input.DispatchModel), ChannelMappedModel: channelMapped,
+ AccountMappedModel: accountMapped, UpstreamModel: upstream, BillingModel: billingModel,
+ BillingModelSource: source, ChannelID: input.ChannelID,
+ ModelMappingChain: strings.TrimSpace(input.ModelMappingChain), Pricing: quote,
+ }, nil
+}
+
+func preflightError(reason string, cause error) error {
+ return &OpenAIBillingPreflightError{Reason: reason, Cause: cause}
+}
+
+func firstNonEmptyModel(models ...string) string {
+ for _, model := range models {
+ if model = strings.TrimSpace(model); model != "" {
+ return model
+ }
+ }
+ return ""
+}
+
+func (r *ModelPricingResolver) ResolveQuote(ctx context.Context, input PricingInput) (*PricingQuote, error) {
+ if r == nil || r.billingService == nil {
+ return nil, fmt.Errorf("%w: pricing resolver is unavailable", ErrOpenAIPricingUnavailable)
+ }
+ resolved := r.Resolve(ctx, input)
+ if !resolvedPricingIsUsable(resolved) {
+ return nil, fmt.Errorf("%w for model %s", ErrOpenAIPricingUnavailable, strings.TrimSpace(input.Model))
+ }
+
+ if normalized, family := classifyOpenAIGPT56PreviewModel(input.Model); family {
+ if normalized == "" {
+ return nil, fmt.Errorf("%w for model %s", ErrOpenAIPricingUnavailable, strings.TrimSpace(input.Model))
+ }
+ if resolved.Source == PricingSourceChannel && !resolved.SourceExact {
+ return nil, fmt.Errorf("%w: GPT-5.6 channel pricing must be exact", ErrOpenAIPricingUnavailable)
+ }
+ if resolved.Source == PricingSourceBuiltinFallback {
+ resolved.Source = PricingSourceBuiltinGPT56
+ resolved.Revision = GPT56PricingRevision
+ }
+ }
+ return freezeResolvedPricingQuote(resolved)
+}
+
+func freezeResolvedPricingQuote(resolved *ResolvedPricing) (*PricingQuote, error) {
+ if resolved == nil || !resolvedPricingIsUsable(resolved) {
+ return nil, ErrOpenAIPricingUnavailable
+ }
+ if resolved.Revision == "" {
+ resolved.Revision = pricingRevisionForResolved(resolved)
+ }
+ snapshot := cloneResolvedPricing(resolved)
+ hash, err := hashResolvedPricing(snapshot)
+ if err != nil {
+ return nil, fmt.Errorf("hash pricing quote: %w", err)
+ }
+ if snapshot.Source == PricingSourceLiteLLM && snapshot.Revision == "" {
+ // localHash may be a remote synchronization anchor rather than the
+ // bytes currently loaded in memory. Bind the revision to the effective
+ // immutable schedule instead of asserting unverified file provenance.
+ snapshot.Revision = "effective-" + hash[:16]
+ }
+ return &PricingQuote{
+ Resolved: snapshot,
+ Evidence: PricingEvidence{Source: snapshot.Source, Revision: snapshot.Revision, Hash: hash},
+ }, nil
+}
+
+func pricingRevisionForResolved(resolved *ResolvedPricing) string {
+ switch resolved.Source {
+ case PricingSourceChannel:
+ return channelPricingRevision
+ case PricingSourceLiteLLM:
+ return ""
+ case PricingSourceBuiltinGPT56:
+ return GPT56PricingRevision
+ case PricingSourceGroupImage:
+ return groupImagePricingRevision
+ default:
+ return builtinFallbackRevision
+ }
+}
+
+func resolvedPricingIsUsable(resolved *ResolvedPricing) bool {
+ if resolved == nil {
+ return false
+ }
+ switch resolved.Mode {
+ case BillingModePerRequest, BillingModeImage:
+ if resolved.DefaultPerRequestPrice != 0 && !positiveFinitePrice(resolved.DefaultPerRequestPrice) {
+ return false
+ }
+ usable := positiveFinitePrice(resolved.DefaultPerRequestPrice)
+ for _, tier := range resolved.RequestTiers {
+ if tier.PerRequestPrice != nil {
+ if !positiveFinitePrice(*tier.PerRequestPrice) {
+ return false
+ }
+ usable = true
+ }
+ }
+ return usable
+ default:
+ usable, valid := usableModelPricing(resolved.BasePricing)
+ for _, interval := range resolved.Intervals {
+ intervalUsable := false
+ for _, price := range []*float64{interval.InputPrice, interval.OutputPrice, interval.CacheWritePrice, interval.CacheReadPrice} {
+ if price == nil {
+ continue
+ }
+ if !nonNegativeFinitePrice(*price) {
+ return false
+ }
+ intervalUsable = intervalUsable || *price > 0
+ }
+ if !intervalUsable {
+ return false
+ }
+ usable = true
+ }
+ return valid && usable
+ }
+}
+
+func usableModelPricing(pricing *ModelPricing) (usable bool, valid bool) {
+ if pricing == nil {
+ return false, true
+ }
+ valid = true
+ prices := []float64{
+ pricing.InputPricePerToken, pricing.InputPricePerTokenPriority,
+ pricing.OutputPricePerToken, pricing.OutputPricePerTokenPriority,
+ pricing.CacheCreationPricePerToken, pricing.CacheReadPricePerToken,
+ pricing.CacheReadPricePerTokenPriority, pricing.CacheCreation5mPrice,
+ pricing.CacheCreation1hPrice, pricing.ImageOutputPricePerToken,
+ }
+ for _, price := range prices {
+ if price < 0 || math.IsNaN(price) || math.IsInf(price, 0) {
+ return false, false
+ }
+ usable = usable || price > 0
+ }
+ return usable, valid
+}
+
+func positiveFinitePrice(price float64) bool {
+ return price > 0 && !math.IsNaN(price) && !math.IsInf(price, 0)
+}
+
+func nonNegativeFinitePrice(price float64) bool {
+ return price >= 0 && !math.IsNaN(price) && !math.IsInf(price, 0)
+}
+
+func pricingFloat64Ptr(price float64) *float64 {
+ return &price
+}
+
+func cloneResolvedPricing(in *ResolvedPricing) *ResolvedPricing {
+ if in == nil {
+ return nil
+ }
+ out := *in
+ if in.BasePricing != nil {
+ base := *in.BasePricing
+ out.BasePricing = &base
+ }
+ out.Intervals = clonePricingIntervals(in.Intervals)
+ out.RequestTiers = clonePricingIntervals(in.RequestTiers)
+ return &out
+}
+
+func clonePricingIntervals(in []PricingInterval) []PricingInterval {
+ if in == nil {
+ return nil
+ }
+ out := make([]PricingInterval, len(in))
+ for i := range in {
+ out[i] = in[i]
+ out[i].MaxTokens = cloneIntPtr(in[i].MaxTokens)
+ out[i].InputPrice = cloneFloat64Ptr(in[i].InputPrice)
+ out[i].OutputPrice = cloneFloat64Ptr(in[i].OutputPrice)
+ out[i].CacheWritePrice = cloneFloat64Ptr(in[i].CacheWritePrice)
+ out[i].CacheReadPrice = cloneFloat64Ptr(in[i].CacheReadPrice)
+ out[i].PerRequestPrice = cloneFloat64Ptr(in[i].PerRequestPrice)
+ }
+ return out
+}
+
+func cloneIntPtr(v *int) *int {
+ if v == nil {
+ return nil
+ }
+ copy := *v
+ return ©
+}
+
+func cloneFloat64Ptr(v *float64) *float64 {
+ if v == nil {
+ return nil
+ }
+ copy := *v
+ return ©
+}
+
+type canonicalResolvedPricing struct {
+ Mode BillingMode `json:"mode"`
+ Base *ModelPricing `json:"base,omitempty"`
+ Intervals []PricingInterval `json:"intervals,omitempty"`
+ RequestTiers []PricingInterval `json:"request_tiers,omitempty"`
+ DefaultPerRequestPrice float64 `json:"default_per_request_price"`
+ SupportsCacheBreakdown bool `json:"supports_cache_breakdown"`
+}
+
+func hashResolvedPricing(resolved *ResolvedPricing) (string, error) {
+ if resolved == nil {
+ return "", errors.New("resolved pricing is nil")
+ }
+ canonical := canonicalResolvedPricing{
+ Mode: resolved.Mode, Base: resolved.BasePricing,
+ Intervals: clonePricingIntervals(resolved.Intervals),
+ RequestTiers: clonePricingIntervals(resolved.RequestTiers),
+ DefaultPerRequestPrice: resolved.DefaultPerRequestPrice,
+ SupportsCacheBreakdown: resolved.SupportsCacheBreakdown,
+ }
+ sortPricingIntervals(canonical.Intervals)
+ sortPricingIntervals(canonical.RequestTiers)
+ data, err := json.Marshal(canonical)
+ if err != nil {
+ return "", err
+ }
+ sum := sha256.Sum256(data)
+ return hex.EncodeToString(sum[:]), nil
+}
+
+func sortPricingIntervals(intervals []PricingInterval) {
+ sort.SliceStable(intervals, func(i, j int) bool {
+ left, _ := json.Marshal(canonicalPricingInterval(intervals[i]))
+ right, _ := json.Marshal(canonicalPricingInterval(intervals[j]))
+ return string(left) < string(right)
+ })
+}
+
+func canonicalPricingInterval(iv PricingInterval) PricingInterval {
+ iv.ID = 0
+ iv.PricingID = 0
+ iv.SortOrder = 0
+ iv.CreatedAt = time.Time{}
+ iv.UpdatedAt = time.Time{}
+ return iv
+}
diff --git a/backend/internal/service/openai_billing_identity_test.go b/backend/internal/service/openai_billing_identity_test.go
new file mode 100644
index 00000000000..efbac9b8de9
--- /dev/null
+++ b/backend/internal/service/openai_billing_identity_test.go
@@ -0,0 +1,367 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestResolveOpenAIBillingIdentityGPT56Contract(t *testing.T) {
+ t.Parallel()
+
+ svc := newOpenAIBillingPreflightServiceForTest()
+ tests := []struct {
+ name string
+ input OpenAIBillingIdentityInput
+ wantErr bool
+ }{
+ {
+ name: "requested billing rejected even for same tier",
+ input: OpenAIBillingIdentityInput{
+ RequestedModel: "gpt-5.6-terra", ChannelMappedModel: "gpt-5.6-terra",
+ AccountMappedModel: "gpt-5.6-terra", UpstreamModel: "gpt-5.6-terra",
+ BillingModelSource: BillingModelSourceRequested,
+ },
+ wantErr: true,
+ },
+ {
+ name: "exact same tier channel mapping accepted",
+ input: OpenAIBillingIdentityInput{
+ RequestedModel: "gpt-5.6-terra", ChannelMappedModel: "openai/gpt-5.6-terra-high",
+ ChannelMappingExact: true, AccountMappedModel: "gpt-5.6-terra-high",
+ UpstreamModel: "gpt-5.6-terra-high", BillingModelSource: BillingModelSourceChannelMapped,
+ },
+ },
+ {
+ name: "wildcard channel mapping rejected",
+ input: OpenAIBillingIdentityInput{
+ RequestedModel: "gpt-5.6-terra", ChannelMappedModel: "gpt-5.6-terra",
+ ChannelMappingExact: false, ChannelMappingApplied: true,
+ AccountMappedModel: "gpt-5.6-terra", UpstreamModel: "gpt-5.6-terra",
+ BillingModelSource: BillingModelSourceChannelMapped,
+ },
+ wantErr: true,
+ },
+ {
+ name: "cross tier rejected",
+ input: OpenAIBillingIdentityInput{
+ RequestedModel: "gpt-5.6-terra", ChannelMappedModel: "gpt-5.6-sol",
+ ChannelMappingExact: true, ChannelMappingApplied: true,
+ AccountMappedModel: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol",
+ BillingModelSource: BillingModelSourceChannelMapped,
+ },
+ wantErr: true,
+ },
+ {
+ name: "unknown family rejected",
+ input: OpenAIBillingIdentityInput{
+ RequestedModel: "gpt-5.6-unknown", ChannelMappedModel: "gpt-5.6-unknown",
+ ChannelMappingExact: true, AccountMappedModel: "gpt-5.6-unknown",
+ UpstreamModel: "gpt-5.6-unknown", BillingModelSource: BillingModelSourceUpstream,
+ },
+ wantErr: true,
+ },
+ {
+ name: "legacy requested billing remains compatible",
+ input: OpenAIBillingIdentityInput{
+ RequestedModel: "gpt-5.4", ChannelMappedModel: "gpt-5.4",
+ AccountMappedModel: "gpt-5.4", UpstreamModel: "gpt-5.4",
+ BillingModelSource: BillingModelSourceRequested,
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ identity, err := svc.ResolveOpenAIBillingIdentity(context.Background(), tt.input)
+ if tt.wantErr {
+ require.Error(t, err)
+ require.ErrorIs(t, err, ErrOpenAIBillingPreflight)
+ require.Nil(t, identity)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, identity)
+ require.NotNil(t, identity.Pricing)
+ require.NotEmpty(t, identity.Pricing.Evidence.Hash)
+ })
+ }
+}
+
+func TestResolvePricingQuoteGPT56BuiltinEvidenceAndImmutability(t *testing.T) {
+ t.Parallel()
+
+ resolver := newOpenAIBillingPreflightServiceForTest().resolver
+ quote, err := resolver.ResolveQuote(context.Background(), PricingInput{Model: "gpt-5.6-sol"})
+ require.NoError(t, err)
+ require.Equal(t, PricingSourceBuiltinGPT56, quote.Evidence.Source)
+ require.Equal(t, GPT56PricingRevision, quote.Evidence.Revision)
+ require.Len(t, quote.Evidence.Hash, 64)
+
+ first := quote.CloneResolved()
+ require.NotNil(t, first)
+ require.NotNil(t, first.BasePricing)
+ wantInput := first.BasePricing.InputPricePerToken
+ first.BasePricing.InputPricePerToken = 999
+ require.Equal(t, wantInput, quote.CloneResolved().BasePricing.InputPricePerToken)
+
+ quote2, err := resolver.ResolveQuote(context.Background(), PricingInput{Model: "openai/gpt-5.6-sol-high"})
+ require.NoError(t, err)
+ require.Equal(t, quote.Evidence.Hash, quote2.Evidence.Hash)
+}
+
+func TestResolvePricingQuoteRemainsFrozenAfterPricingRefresh(t *testing.T) {
+ t.Parallel()
+
+ cfg := &config.Config{}
+ pricing := NewPricingService(cfg, nil)
+ pricing.pricingData["custom-priced"] = &LiteLLMModelPricing{
+ InputCostPerToken: 1e-6, OutputCostPerToken: 2e-6,
+ }
+ pricing.localHash = "revision-one"
+ billing := NewBillingService(cfg, pricing)
+ resolver := NewModelPricingResolver(nil, billing)
+
+ quote, err := resolver.ResolveQuote(context.Background(), PricingInput{Model: "custom-priced"})
+ require.NoError(t, err)
+ wantHash := quote.Evidence.Hash
+ wantInput := quote.CloneResolved().BasePricing.InputPricePerToken
+
+ pricing.mu.Lock()
+ pricing.pricingData = map[string]*LiteLLMModelPricing{
+ "custom-priced": {InputCostPerToken: 9e-6, OutputCostPerToken: 10e-6},
+ }
+ pricing.localHash = "revision-two"
+ pricing.mu.Unlock()
+
+ require.Equal(t, wantInput, quote.CloneResolved().BasePricing.InputPricePerToken)
+ require.Equal(t, wantHash, quote.Evidence.Hash)
+ refreshed, err := resolver.ResolveQuote(context.Background(), PricingInput{Model: "custom-priced"})
+ require.NoError(t, err)
+ require.NotEqual(t, wantHash, refreshed.Evidence.Hash)
+}
+
+func TestPricingEvidenceHashCanonicalAndSensitive(t *testing.T) {
+ t.Parallel()
+
+ one := 1e-6
+ two := 2e-6
+ a := &ResolvedPricing{
+ Mode: BillingModeToken,
+ BasePricing: &ModelPricing{InputPricePerToken: one, OutputPricePerToken: two},
+ Intervals: []PricingInterval{
+ {MinTokens: 100, InputPrice: &two},
+ {MinTokens: 0, InputPrice: &one},
+ },
+ Source: PricingSourceChannel,
+ }
+ oneCopy := one
+ twoCopy := two
+ b := &ResolvedPricing{
+ Mode: BillingModeToken,
+ BasePricing: &ModelPricing{InputPricePerToken: oneCopy, OutputPricePerToken: twoCopy},
+ Intervals: []PricingInterval{
+ {MinTokens: 0, InputPrice: &oneCopy},
+ {MinTokens: 100, InputPrice: &twoCopy},
+ },
+ Source: PricingSourceChannel,
+ }
+
+ hashA, err := hashResolvedPricing(a)
+ require.NoError(t, err)
+ hashB, err := hashResolvedPricing(b)
+ require.NoError(t, err)
+ require.Equal(t, hashA, hashB)
+
+ b.BasePricing.OutputPricePerToken = 3e-6
+ hashChanged, err := hashResolvedPricing(b)
+ require.NoError(t, err)
+ require.NotEqual(t, hashA, hashChanged)
+}
+
+func TestForwardWithOptionsUnpriceablePreflightNeverCallsUpstream(t *testing.T) {
+ body := []byte(`{"model":"custom-unpriceable","stream":false,"input":"hello"}`)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK}}
+ svc := newOpenAIBillingPreflightServiceForTest()
+ svc.httpUpstream = upstream
+ account := &Account{
+ ID: 1, Name: "preflight", Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "redacted-test-value",
+ "model_mapping": map[string]any{"custom-unpriceable": "custom-unpriceable"},
+ },
+ }
+
+ result, err := svc.ForwardWithOptions(context.Background(), c, account, body, OpenAIForwardOptions{
+ RequestedModel: "custom-unpriceable",
+ RequirePricingPreflight: true,
+ })
+ require.Error(t, err)
+ require.ErrorIs(t, err, ErrOpenAIPricingUnavailable)
+ require.Nil(t, result)
+ require.Nil(t, upstream.lastReq)
+}
+
+func TestOpenAISharedHTTPDispatchUnpriceablePreflightNeverCallsUpstream(t *testing.T) {
+
+ tests := []struct {
+ name string
+ path string
+ body []byte
+ accountExtra map[string]any
+ forward func(*OpenAIGatewayService, *gin.Context, *Account, []byte, OpenAIForwardOptions) (*OpenAIForwardResult, error)
+ }{
+ {
+ name: "chat responses conversion", path: "/v1/chat/completions",
+ body: []byte(`{"model":"custom-unpriceable","messages":[{"role":"user","content":"hello"}]}`),
+ forward: func(s *OpenAIGatewayService, c *gin.Context, a *Account, body []byte, opts OpenAIForwardOptions) (*OpenAIForwardResult, error) {
+ return s.ForwardAsChatCompletionsWithOptions(context.Background(), c, a, body, "", "", opts)
+ },
+ },
+ {
+ name: "raw chat", path: "/v1/chat/completions",
+ body: []byte(`{"model":"custom-unpriceable","messages":[{"role":"user","content":"hello"}]}`),
+ accountExtra: map[string]any{"openai_responses_supported": false},
+ forward: func(s *OpenAIGatewayService, c *gin.Context, a *Account, body []byte, opts OpenAIForwardOptions) (*OpenAIForwardResult, error) {
+ return s.ForwardAsChatCompletionsWithOptions(context.Background(), c, a, body, "", "", opts)
+ },
+ },
+ {
+ name: "messages compatibility", path: "/v1/messages",
+ body: []byte(`{"model":"claude-sonnet-4-6","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`),
+ forward: func(s *OpenAIGatewayService, c *gin.Context, a *Account, body []byte, opts OpenAIForwardOptions) (*OpenAIForwardResult, error) {
+ return s.ForwardAsAnthropicWithOptions(context.Background(), c, a, body, "", "custom-unpriceable", opts)
+ },
+ },
+ {
+ name: "responses passthrough", path: "/v1/responses",
+ body: []byte(`{"model":"custom-unpriceable","stream":false,"instructions":"hello","input":"hello"}`),
+ accountExtra: map[string]any{"openai_passthrough": true},
+ forward: func(s *OpenAIGatewayService, c *gin.Context, a *Account, body []byte, opts OpenAIForwardOptions) (*OpenAIForwardResult, error) {
+ return s.ForwardWithOptions(context.Background(), c, a, body, opts)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, tt.path, bytes.NewReader(tt.body))
+ c.Request.Header.Set("Content-Type", "application/json")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK}}
+ svc := newOpenAIBillingPreflightServiceForTest()
+ svc.httpUpstream = upstream
+ account := &Account{
+ ID: 1, Name: "preflight", Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "redacted-test-value",
+ "model_mapping": map[string]any{
+ "custom-unpriceable": "custom-unpriceable",
+ },
+ },
+ Extra: tt.accountExtra,
+ }
+ requested := "custom-unpriceable"
+ mapping := ChannelMappingResult{MappedModel: requested, MappingExact: true, BillingModelSource: BillingModelSourceUpstream}
+ if tt.name == "messages compatibility" {
+ requested = "claude-sonnet-4-6"
+ mapping = ChannelMappingResult{MappedModel: "custom-unpriceable", Mapped: true, MappingExact: true, BillingModelSource: BillingModelSourceUpstream}
+ }
+ result, err := tt.forward(svc, c, account, tt.body, OpenAIForwardOptions{
+ RequestedModel: requested, ChannelMapping: mapping, RequirePricingPreflight: true,
+ })
+ require.Error(t, err)
+ require.ErrorIs(t, err, ErrOpenAIPricingUnavailable)
+ require.Nil(t, result)
+ require.Nil(t, upstream.lastReq)
+ })
+ }
+}
+
+func TestOpenAIWSBillingPreflightRejectsUnpriceableBeforeTransport(t *testing.T) {
+ t.Parallel()
+
+ upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK}}
+ svc := newOpenAIBillingPreflightServiceForTest()
+ svc.httpUpstream = upstream
+ account := &Account{
+ ID: 1, Name: "ws-preflight", Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "redacted-test-value",
+ "model_mapping": map[string]any{"custom-unpriceable": "custom-unpriceable"},
+ },
+ }
+ identity, err := svc.ResolveOpenAIWSBillingIdentity(context.Background(), account, OpenAIForwardOptions{
+ RequestedModel: "custom-unpriceable",
+ ChannelMapping: ChannelMappingResult{
+ MappedModel: "custom-unpriceable", MappingExact: true,
+ BillingModelSource: BillingModelSourceUpstream,
+ },
+ RequirePricingPreflight: true,
+ })
+ require.Error(t, err)
+ require.ErrorIs(t, err, ErrOpenAIPricingUnavailable)
+ require.Nil(t, identity)
+ require.Nil(t, upstream.lastReq)
+}
+
+func TestOpenAIWSBillingIdentityResolvesFreshQuoteForEveryTurn(t *testing.T) {
+ t.Parallel()
+
+ cfg := &config.Config{}
+ pricing := NewPricingService(cfg, nil)
+ pricing.pricingData["custom-ws-priced"] = &LiteLLMModelPricing{InputCostPerToken: 1e-6, OutputCostPerToken: 2e-6}
+ billing := NewBillingService(cfg, pricing)
+ svc := &OpenAIGatewayService{cfg: cfg, billingService: billing, resolver: NewModelPricingResolver(nil, billing)}
+ account := &Account{
+ ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
+ Credentials: map[string]any{"model_mapping": map[string]any{"custom-ws-priced": "custom-ws-priced"}},
+ }
+ opts := OpenAIForwardOptions{
+ RequestedModel: "custom-ws-priced",
+ ChannelMapping: ChannelMappingResult{MappedModel: "custom-ws-priced", MappingExact: true, BillingModelSource: BillingModelSourceUpstream},
+ RequirePricingPreflight: true,
+ }
+
+ firstTurn, err := svc.ResolveOpenAIWSBillingIdentity(context.Background(), account, opts)
+ require.NoError(t, err)
+ pricing.mu.Lock()
+ pricing.pricingData = map[string]*LiteLLMModelPricing{
+ "custom-ws-priced": {InputCostPerToken: 7e-6, OutputCostPerToken: 8e-6},
+ }
+ pricing.mu.Unlock()
+ secondTurn, err := svc.ResolveOpenAIWSBillingIdentity(context.Background(), account, opts)
+ require.NoError(t, err)
+
+ require.NotEqual(t, firstTurn.Pricing.Evidence.Hash, secondTurn.Pricing.Evidence.Hash)
+ require.NotEqual(t, firstTurn.Pricing.Evidence.Revision, secondTurn.Pricing.Evidence.Revision)
+ require.Equal(t, 1e-6, firstTurn.Pricing.CloneResolved().BasePricing.InputPricePerToken)
+ require.Equal(t, 7e-6, secondTurn.Pricing.CloneResolved().BasePricing.InputPricePerToken)
+}
+
+func newOpenAIBillingPreflightServiceForTest() *OpenAIGatewayService {
+ cfg := &config.Config{}
+ pricing := NewPricingService(cfg, nil)
+ billing := NewBillingService(cfg, pricing)
+ resolver := NewModelPricingResolver(nil, billing)
+ return &OpenAIGatewayService{cfg: cfg, billingService: billing, resolver: resolver}
+}
+
+func TestOpenAIBillingPreflightErrorIsTyped(t *testing.T) {
+ t.Parallel()
+ err := &OpenAIBillingPreflightError{Reason: "test"}
+ require.True(t, errors.Is(err, ErrOpenAIBillingPreflight))
+}
diff --git a/backend/internal/service/openai_client_restriction_detector_test.go b/backend/internal/service/openai_client_restriction_detector_test.go
index 984b4ff6fa3..d818cf0ce69 100644
--- a/backend/internal/service/openai_client_restriction_detector_test.go
+++ b/backend/internal/service/openai_client_restriction_detector_test.go
@@ -24,7 +24,6 @@ func newCodexDetectorTestContext(ua string, originator string) *gin.Context {
}
func TestOpenAICodexClientRestrictionDetector_Detect(t *testing.T) {
- gin.SetMode(gin.TestMode)
t.Run("未开启开关时绕过", func(t *testing.T) {
detector := NewOpenAICodexClientRestrictionDetector(nil)
diff --git a/backend/internal/service/openai_client_transport_test.go b/backend/internal/service/openai_client_transport_test.go
index ef90e614518..146950d86e1 100644
--- a/backend/internal/service/openai_client_transport_test.go
+++ b/backend/internal/service/openai_client_transport_test.go
@@ -9,7 +9,6 @@ import (
)
func TestOpenAIClientTransport_SetAndGet(t *testing.T) {
- gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -23,7 +22,6 @@ func TestOpenAIClientTransport_SetAndGet(t *testing.T) {
}
func TestOpenAIClientTransport_GetNormalizesRawContextValue(t *testing.T) {
- gin.SetMode(gin.TestMode)
tests := []struct {
name string
@@ -76,7 +74,6 @@ func TestOpenAIClientTransport_NilAndUnknownInput(t *testing.T) {
SetOpenAIClientTransport(nil, OpenAIClientTransportHTTP)
require.Equal(t, OpenAIClientTransportUnknown, GetOpenAIClientTransport(nil))
- gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
diff --git a/backend/internal/service/openai_codex_instructions_template.go b/backend/internal/service/openai_codex_instructions_template.go
index 5588c73cbba..ed06001489c 100644
--- a/backend/internal/service/openai_codex_instructions_template.go
+++ b/backend/internal/service/openai_codex_instructions_template.go
@@ -2,11 +2,14 @@ package service
import (
"bytes"
+ "encoding/json"
"fmt"
"strings"
"text/template"
)
+const openAICompatKnowledgeCutoffGuardMarker = "HFC GPT compatibility note:"
+
type forcedCodexInstructionsTemplateData struct {
ExistingInstructions string
OriginalModel string
@@ -53,3 +56,78 @@ func renderForcedCodexInstructionsTemplate(
return strings.TrimSpace(buf.String()), nil
}
+
+func applyOpenAICompatKnowledgeCutoffGuardToResponsesBody(
+ body []byte,
+ originalModel string,
+ upstreamModel string,
+) ([]byte, bool, error) {
+ if !shouldApplyOpenAICompatKnowledgeCutoffGuard(originalModel, upstreamModel) {
+ return body, false, nil
+ }
+
+ var reqBody map[string]any
+ if err := json.Unmarshal(body, &reqBody); err != nil {
+ return nil, false, fmt.Errorf("unmarshal for gpt compatibility instructions guard: %w", err)
+ }
+ if !applyOpenAICompatKnowledgeCutoffGuard(reqBody, originalModel, upstreamModel) {
+ return body, false, nil
+ }
+
+ updated, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, false, fmt.Errorf("remarshal after gpt compatibility instructions guard: %w", err)
+ }
+ return updated, true, nil
+}
+
+func shouldApplyOpenAICompatKnowledgeCutoffGuard(originalModel string, upstreamModel string) bool {
+ original := normalizeBillingGuardModel(originalModel)
+ upstream := normalizeBillingGuardModel(upstreamModel)
+ return isClaudeCompatBillingModel(original) && strings.HasPrefix(upstream, "gpt")
+}
+
+func applyOpenAICompatKnowledgeCutoffGuard(
+ reqBody map[string]any,
+ originalModel string,
+ upstreamModel string,
+) bool {
+ if reqBody == nil {
+ return false
+ }
+
+ existing, _ := reqBody["instructions"].(string)
+ existing = strings.TrimSpace(existing)
+ if strings.Contains(existing, openAICompatKnowledgeCutoffGuardMarker) {
+ return false
+ }
+
+ guard := renderOpenAICompatKnowledgeCutoffGuard(originalModel, upstreamModel)
+ if guard == "" {
+ return false
+ }
+ if existing != "" {
+ reqBody["instructions"] = guard + "\n\n" + existing
+ } else {
+ reqBody["instructions"] = guard
+ }
+ return true
+}
+
+func renderOpenAICompatKnowledgeCutoffGuard(originalModel string, upstreamModel string) string {
+ original := strings.TrimSpace(originalModel)
+ upstream := strings.TrimSpace(upstreamModel)
+ if upstream == "" {
+ return ""
+ }
+ if original == "" {
+ original = "Claude-compatible"
+ }
+ return fmt.Sprintf(
+ "%s The client is using a Claude-compatible /v1/messages protocol model name (%s), but this request is executed by the upstream model %s. When asked about model identity, knowledge cutoff, training cutoff, or built-in knowledge date, use the actual upstream model %s. Do not infer identity or cutoff dates from the Claude-compatible request name. If the exact knowledge cutoff date is unavailable, say it cannot be confirmed instead of inventing a date.",
+ openAICompatKnowledgeCutoffGuardMarker,
+ original,
+ upstream,
+ upstream,
+ )
+}
diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go
index b256f1c757d..bd115e404a1 100644
--- a/backend/internal/service/openai_codex_transform.go
+++ b/backend/internal/service/openai_codex_transform.go
@@ -7,6 +7,24 @@ import (
)
var codexModelMap = map[string]string{
+ "gpt-5.6-sol": "gpt-5.6-sol",
+ "gpt-5.6-sol-none": "gpt-5.6-sol",
+ "gpt-5.6-sol-low": "gpt-5.6-sol",
+ "gpt-5.6-sol-medium": "gpt-5.6-sol",
+ "gpt-5.6-sol-high": "gpt-5.6-sol",
+ "gpt-5.6-sol-xhigh": "gpt-5.6-sol",
+ "gpt-5.6-terra": "gpt-5.6-terra",
+ "gpt-5.6-terra-none": "gpt-5.6-terra",
+ "gpt-5.6-terra-low": "gpt-5.6-terra",
+ "gpt-5.6-terra-medium": "gpt-5.6-terra",
+ "gpt-5.6-terra-high": "gpt-5.6-terra",
+ "gpt-5.6-terra-xhigh": "gpt-5.6-terra",
+ "gpt-5.6-luna": "gpt-5.6-luna",
+ "gpt-5.6-luna-none": "gpt-5.6-luna",
+ "gpt-5.6-luna-low": "gpt-5.6-luna",
+ "gpt-5.6-luna-medium": "gpt-5.6-luna",
+ "gpt-5.6-luna-high": "gpt-5.6-luna",
+ "gpt-5.6-luna-xhigh": "gpt-5.6-luna",
"gpt-5.5": "gpt-5.5",
"gpt-5.4": "gpt-5.4",
"gpt-5.4-mini": "gpt-5.4-mini",
@@ -38,6 +56,29 @@ var codexModelMap = map[string]string{
"gpt-5.2-medium": "gpt-5.2",
"gpt-5.2-high": "gpt-5.2",
"gpt-5.2-xhigh": "gpt-5.2",
+ "gpt-5": "gpt-5.4",
+ "gpt-5-mini": "gpt-5.4",
+ "gpt-5-nano": "gpt-5.4",
+ "gpt-5.1": "gpt-5.4",
+ "gpt-5.1-codex": "gpt-5.3-codex",
+ "gpt-5.1-codex-max": "gpt-5.3-codex",
+ "gpt-5.1-codex-mini": "gpt-5.3-codex",
+ "gpt-5.2-codex": "gpt-5.2",
+ "codex-mini-latest": "gpt-5.3-codex",
+ "gpt-5-codex": "gpt-5.3-codex",
+}
+
+var codexVersionModelPrefixes = []struct {
+ prefix string
+ target string
+}{
+ {prefix: "gpt-5.3-codex-spark", target: "gpt-5.3-codex-spark"},
+ {prefix: "gpt-5.3-codex", target: "gpt-5.3-codex"},
+ {prefix: "gpt-5.4-mini", target: "gpt-5.4-mini"},
+ {prefix: "gpt-5.4-nano", target: "gpt-5.4-nano"},
+ {prefix: "gpt-5.5", target: "gpt-5.5"},
+ {prefix: "gpt-5.4", target: "gpt-5.4"},
+ {prefix: "gpt-5.2", target: "gpt-5.2"},
}
type codexTransformResult struct {
@@ -46,6 +87,13 @@ type codexTransformResult struct {
PromptCacheKey string
}
+type codexOAuthTransformOptions struct {
+ IsCodexCLI bool
+ IsCompact bool
+ SkipDefaultInstructions bool
+ PreserveToolCallIDs bool
+}
+
const (
codexImageGenerationBridgeMarker = ""
codexImageGenerationBridgeText = codexImageGenerationBridgeMarker + "\nWhen the user asks for raster image generation or editing, use the OpenAI Responses native `image_generation` tool attached to this request. The local Codex client may not expose an `image_gen` namespace, but that does not mean image generation is unavailable. Do not ask the user to switch to CLI fallback solely because `image_gen` is absent.\n "
@@ -71,6 +119,13 @@ var openAICodexOAuthUnsupportedFields = append([]string{
}, openAIChatGPTInternalUnsupportedFields...)
func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact bool) codexTransformResult {
+ return applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{
+ IsCodexCLI: isCodexCLI,
+ IsCompact: isCompact,
+ })
+}
+
+func applyCodexOAuthTransformWithOptions(reqBody map[string]any, opts codexOAuthTransformOptions) codexTransformResult {
result := codexTransformResult{}
// 工具续链需求会影响存储策略与 input 过滤逻辑。
needsToolContinuation := NeedsToolContinuation(reqBody)
@@ -88,7 +143,7 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact
result.NormalizedModel = normalizedModel
}
- if isCompact {
+ if opts.IsCompact {
if _, ok := reqBody["store"]; ok {
delete(reqBody, "store")
result.Modified = true
@@ -160,6 +215,10 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact
if v, ok := reqBody["prompt_cache_key"].(string); ok {
result.PromptCacheKey = strings.TrimSpace(v)
+ if isOpenAICompatMessagesBridgeRequestBody(reqBody) {
+ delete(reqBody, "prompt_cache_key")
+ result.Modified = true
+ }
}
// 提取 input 中 role:"system" 消息至 instructions(OAuth 上游不支持 system role)。
@@ -168,7 +227,7 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact
}
// instructions 处理逻辑:根据是否是 Codex CLI 分别调用不同方法
- if applyInstructions(reqBody, isCodexCLI) {
+ if !opts.SkipDefaultInstructions && applyInstructions(reqBody, opts.IsCodexCLI) {
result.Modified = true
}
if isCodexSparkModel(normalizedModel) && applyCodexSparkImageUnsupportedInstructions(reqBody) {
@@ -185,7 +244,10 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact
input = normalizedInput
result.Modified = true
}
- input = filterCodexInput(input, needsToolContinuation)
+ input = filterCodexInputWithOptions(input, codexInputFilterOptions{
+ PreserveReferences: needsToolContinuation,
+ PreserveCallIDs: opts.PreserveToolCallIDs,
+ })
reqBody["input"] = input
result.Modified = true
} else if inputStr, ok := reqBody["input"].(string); ok {
@@ -447,51 +509,81 @@ func normalizeCodexModel(model string) string {
if model == "" {
return "gpt-5.4"
}
- if isOpenAIImageGenerationModel(model) {
- return model
+ if mapped, ok := normalizeKnownCodexModel(model); ok {
+ return mapped
}
+ return model
+}
- modelID := model
- if strings.Contains(modelID, "/") {
- parts := strings.Split(modelID, "/")
- modelID = parts[len(parts)-1]
+func normalizeKnownCodexModel(model string) (string, bool) {
+ model = strings.TrimSpace(model)
+ if model == "" {
+ return "", false
}
-
- if mapped := getNormalizedCodexModel(modelID); mapped != "" {
- return mapped
+ if isOpenAIImageGenerationModel(model) {
+ return model, true
}
- normalized := strings.ToLower(modelID)
+ modelID := lastOpenAIModelSegment(model)
- if strings.Contains(normalized, "gpt-5.5") || strings.Contains(normalized, "gpt 5.5") {
- return "gpt-5.5"
- }
- if strings.Contains(normalized, "gpt-5.4-mini") || strings.Contains(normalized, "gpt 5.4 mini") {
- return "gpt-5.4-mini"
+ if normalized := canonicalizeOpenAIModelAliasSpelling(modelID); normalized != "" {
+ modelID = normalized
}
- if strings.Contains(normalized, "gpt-5.4") || strings.Contains(normalized, "gpt 5.4") {
- return "gpt-5.4"
+ if mapped := normalizeKnownOpenAICodexModel(modelID); mapped != "" {
+ return mapped, true
}
- if strings.Contains(normalized, "gpt-5.2") || strings.Contains(normalized, "gpt 5.2") {
- return "gpt-5.2"
+ key := codexModelLookupKey(modelID)
+ if key == "" {
+ return "", false
}
- if strings.Contains(normalized, "gpt-5.3-codex-spark") || strings.Contains(normalized, "gpt 5.3 codex spark") {
- return "gpt-5.3-codex-spark"
+ if mapped := getNormalizedCodexModel(key); mapped != "" {
+ return mapped, true
}
- if strings.Contains(normalized, "gpt-5.3-codex") || strings.Contains(normalized, "gpt 5.3 codex") {
- return "gpt-5.3-codex"
+ for _, item := range codexVersionModelPrefixes {
+ if key == item.prefix {
+ return item.target, true
+ }
+ suffix, ok := strings.CutPrefix(key, item.prefix+"-")
+ if ok && isKnownCodexModelSuffix(suffix) {
+ return item.target, true
+ }
}
- if strings.Contains(normalized, "gpt-5.3") || strings.Contains(normalized, "gpt 5.3") {
- return "gpt-5.3-codex"
+ return "", false
+}
+
+func codexModelLookupKey(modelID string) string {
+ modelID = strings.TrimSpace(modelID)
+ if modelID == "" {
+ return ""
}
- if strings.Contains(normalized, "codex") {
- return "gpt-5.3-codex"
+ if strings.Contains(modelID, "/") {
+ parts := strings.Split(modelID, "/")
+ modelID = parts[len(parts)-1]
}
- if strings.Contains(normalized, "gpt-5") || strings.Contains(normalized, "gpt 5") {
- return "gpt-5.4"
+ return strings.ToLower(strings.Join(strings.Fields(modelID), "-"))
+}
+
+func isKnownCodexModelSuffix(suffix string) bool {
+ switch suffix {
+ case "none", "minimal", "low", "medium", "high", "xhigh":
+ return true
}
+ return isCodexDateSuffix(suffix)
+}
- return "gpt-5.4"
+func isCodexDateSuffix(suffix string) bool {
+ parts := strings.Split(suffix, "-")
+ if len(parts) != 3 || len(parts[0]) != 4 || len(parts[1]) != 2 || len(parts[2]) != 2 {
+ return false
+ }
+ for _, part := range parts {
+ for _, r := range part {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ }
+ return true
}
func isCodexSparkModel(model string) bool {
@@ -789,23 +881,18 @@ func SupportsVerbosity(model string) bool {
}
func getNormalizedCodexModel(modelID string) string {
- if modelID == "" {
+ key := codexModelLookupKey(modelID)
+ if key == "" {
return ""
}
- if mapped, ok := codexModelMap[modelID]; ok {
+ if mapped, ok := codexModelMap[key]; ok {
return mapped
}
- lower := strings.ToLower(modelID)
- for key, value := range codexModelMap {
- if strings.ToLower(key) == lower {
- return value
- }
- }
return ""
}
// extractTextFromContent extracts plain text from a content value that is either
-// a Go string or a []any of content-part maps with type:"text".
+// a Go string or a []any of text-like content-part maps.
func extractTextFromContent(content any) string {
switch v := content.(type) {
case string:
@@ -817,7 +904,8 @@ func extractTextFromContent(content any) string {
if !ok {
continue
}
- if t, _ := m["type"].(string); t == "text" {
+ switch t, _ := m["type"].(string); t {
+ case "text", "input_text", "output_text":
if text, ok := m["text"].(string); ok {
parts = append(parts, text)
}
@@ -871,6 +959,28 @@ func extractSystemMessagesFromInput(reqBody map[string]any) bool {
return true
}
+func extractPromptLikeInstructionsFromInput(reqBody map[string]any) string {
+ input, ok := reqBody["input"].([]any)
+ if !ok || len(input) == 0 {
+ return ""
+ }
+ var texts []string
+ for _, item := range input {
+ m, ok := item.(map[string]any)
+ if !ok {
+ continue
+ }
+ role, _ := m["role"].(string)
+ switch role {
+ case "developer", "system":
+ if text := strings.TrimSpace(extractTextFromContent(m["content"])); text != "" {
+ texts = append(texts, text)
+ }
+ }
+ }
+ return strings.Join(texts, "\n\n")
+}
+
// applyInstructions 处理 instructions 字段:仅在 instructions 为空时填充默认值。
func applyInstructions(reqBody map[string]any, isCodexCLI bool) bool {
if !isInstructionsEmpty(reqBody) {
@@ -897,9 +1007,20 @@ func isInstructionsEmpty(reqBody map[string]any) bool {
return strings.TrimSpace(str) == ""
}
+type codexInputFilterOptions struct {
+ PreserveReferences bool
+ PreserveCallIDs bool
+}
+
// filterCodexInput 按需过滤 item_reference 与 id。
// preserveReferences 为 true 时保持引用与 id,以满足续链请求对上下文的依赖。
func filterCodexInput(input []any, preserveReferences bool) []any {
+ return filterCodexInputWithOptions(input, codexInputFilterOptions{
+ PreserveReferences: preserveReferences,
+ })
+}
+
+func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []any {
filtered := make([]any, 0, len(input))
for _, item := range input {
m, ok := item.(map[string]any)
@@ -920,6 +1041,9 @@ func filterCodexInput(input []any, preserveReferences bool) []any {
// 仅修正真正的 tool/function call 标识,避免误改普通 message/reasoning id;
// 若 item_reference 指向 legacy call_* 标识,则仅修正该引用本身。
fixCallIDPrefix := func(id string) string {
+ if opts.PreserveCallIDs {
+ return id
+ }
if id == "" || strings.HasPrefix(id, "fc") {
return id
}
@@ -930,7 +1054,7 @@ func filterCodexInput(input []any, preserveReferences bool) []any {
}
if typ == "item_reference" {
- if !preserveReferences {
+ if !opts.PreserveReferences {
continue
}
newItem := make(map[string]any, len(m))
@@ -998,7 +1122,7 @@ func filterCodexInput(input []any, preserveReferences bool) []any {
}
}
- if !preserveReferences {
+ if !opts.PreserveReferences {
ensureCopy()
delete(newItem, "id")
}
diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go
index 87bb71628e2..705db21ea1e 100644
--- a/backend/internal/service/openai_codex_transform_test.go
+++ b/backend/internal/service/openai_codex_transform_test.go
@@ -44,6 +44,39 @@ func TestApplyCodexOAuthTransform_ToolContinuationPreservesInput(t *testing.T) {
require.Equal(t, "fc1", second["call_id"])
}
+func TestApplyCodexOAuthTransform_MessagesBridgePromptCacheKeyIsHeaderOnly(t *testing.T) {
+ reqBody := map[string]any{
+ "model": "gpt-5.5",
+ "prompt_cache_key": "anthropic-metadata-session-1",
+ "input": []any{
+ map[string]any{
+ "type": "message",
+ "role": "developer",
+ "content": []any{
+ map[string]any{
+ "type": "input_text",
+ "text": openAICompatClaudeCodeTodoGuardMarker,
+ },
+ },
+ },
+ map[string]any{
+ "type": "message",
+ "role": "user",
+ "content": "hello",
+ },
+ },
+ }
+
+ result := applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{
+ SkipDefaultInstructions: true,
+ PreserveToolCallIDs: true,
+ })
+
+ require.Equal(t, "anthropic-metadata-session-1", result.PromptCacheKey)
+ require.True(t, result.Modified)
+ require.NotContains(t, reqBody, "prompt_cache_key")
+}
+
func TestApplyCodexOAuthTransform_ToolContinuationPreservesNativeMessageAndReasoningIDs(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.2",
@@ -804,15 +837,25 @@ func TestApplyCodexOAuthTransform_EmptyInput(t *testing.T) {
func TestNormalizeCodexModel_Gpt53(t *testing.T) {
cases := map[string]string{
"gpt-5.4": "gpt-5.4",
+ "gpt5.5": "gpt-5.5",
+ "openai/gpt5.5": "gpt-5.5",
+ "gpt5.4": "gpt-5.4",
"gpt-5.4-high": "gpt-5.4",
"gpt-5.4-chat-latest": "gpt-5.4",
"gpt 5.4": "gpt-5.4",
"gpt-5.4-mini": "gpt-5.4-mini",
+ "gpt5.4-mini": "gpt-5.4-mini",
+ "gpt5.4mini": "gpt-5.4-mini",
"gpt 5.4 mini": "gpt-5.4-mini",
"gpt-5.3": "gpt-5.3-codex",
+ "gpt5.3": "gpt-5.3-codex",
"gpt-5.3-codex": "gpt-5.3-codex",
+ "gpt5.3-codex": "gpt-5.3-codex",
+ "gpt5.3codex": "gpt-5.3-codex",
"gpt-5.3-codex-xhigh": "gpt-5.3-codex",
"gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
+ "gpt5.3-codex-spark": "gpt-5.3-codex-spark",
+ "gpt5.3codexspark": "gpt-5.3-codex-spark",
"gpt 5.3 codex spark": "gpt-5.3-codex-spark",
"gpt-5.3-codex-spark-high": "gpt-5.3-codex-spark",
"gpt-5.3-codex-spark-xhigh": "gpt-5.3-codex-spark",
@@ -844,6 +887,18 @@ func TestNormalizeCodexModel_RemovedModelsFallbackToSupportedTargets(t *testing.
}
}
+func TestNormalizeCodexModel_GPT56ExactTierAliases(t *testing.T) {
+ t.Parallel()
+
+ for input, expected := range map[string]string{
+ "gpt-5.6-sol-high": "gpt-5.6-sol",
+ "openai/gpt-5.6-terra": "gpt-5.6-terra",
+ "OPENAI/GPT-5.6-LUNA-XHIGH": "gpt-5.6-luna",
+ } {
+ require.Equal(t, expected, normalizeCodexModel(input))
+ }
+}
+
func TestApplyCodexOAuthTransform_PreservesBareSparkModel(t *testing.T) {
reqBody := map[string]any{
"model": "gpt-5.3-codex-spark",
diff --git a/backend/internal/service/openai_compact_model_mapping_test.go b/backend/internal/service/openai_compact_model_mapping_test.go
index fc408e64af5..af826d11af0 100644
--- a/backend/internal/service/openai_compact_model_mapping_test.go
+++ b/backend/internal/service/openai_compact_model_mapping_test.go
@@ -15,7 +15,6 @@ import (
)
func TestOpenAIGatewayService_Forward_CompactOnlyModelMappingOverridesOAuthUpstreamModel(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -54,7 +53,6 @@ func TestOpenAIGatewayService_Forward_CompactOnlyModelMappingOverridesOAuthUpstr
}
func TestOpenAIGatewayService_Forward_NonCompactRequestIgnoresCompactOnlyModelMapping(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -93,7 +91,6 @@ func TestOpenAIGatewayService_Forward_NonCompactRequestIgnoresCompactOnlyModelMa
}
func TestOpenAIGatewayService_OAuthPassthrough_CompactOnlyModelMappingOverridesUpstreamModel(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -133,3 +130,139 @@ func TestOpenAIGatewayService_OAuthPassthrough_CompactOnlyModelMappingOverridesU
require.Equal(t, "gpt-5.4-openai-compact", gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "gpt-5.4", gjson.GetBytes(rec.Body.Bytes(), "model").String())
}
+
+func TestOpenAIGatewayService_Forward_CompactGPT56UnsafeMappingFailsBeforeUpstream(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"gpt-5.6-sol","stream":false,"instructions":"compact-test","input":"hello"}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(`{"id":"should_not_run","model":"gpt-5.4","usage":{"input_tokens":1,"output_tokens":1}}`)),
+ }}
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 10,
+ Name: "openai-api-key",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "test-key",
+ "base_url": "https://example.invalid",
+ "model_mapping": map[string]any{"gpt-5.6-sol": "gpt-5.6-sol"},
+ "compact_model_mapping": map[string]any{"gpt-5.6-sol": "gpt-5.4"},
+ },
+ }
+
+ result, err := svc.Forward(context.Background(), c, account, body)
+ require.Error(t, err)
+ require.Nil(t, result)
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+ require.Nil(t, upstream.lastReq)
+}
+
+func TestOpenAIGatewayService_Forward_LegacyMappingToGPT56WithoutEntitlementFailsBeforeUpstream(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"gpt-5.4","stream":false,"input":"hello"}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(`{"id":"should_not_run","model":"gpt-5.6-sol","usage":{"input_tokens":1,"output_tokens":1}}`)),
+ }}
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 12,
+ Name: "openai-api-key",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "test-key",
+ "base_url": "https://example.invalid",
+ "model_mapping": map[string]any{"gpt-5.4": "gpt-5.6-sol"},
+ },
+ }
+
+ result, err := svc.Forward(context.Background(), c, account, body)
+ require.Error(t, err)
+ require.Nil(t, result)
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+ require.Nil(t, upstream.lastReq)
+}
+
+func TestOpenAIGatewayService_Forward_CompactLegacyMappingToGPT56WithoutEntitlementFailsBeforeUpstream(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"gpt-5.4","stream":false,"input":"hello"}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(`{"id":"should_not_run","model":"gpt-5.6-luna","usage":{"input_tokens":1,"output_tokens":1}}`)),
+ }}
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 13,
+ Name: "openai-api-key-compact",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "test-key",
+ "base_url": "https://example.invalid",
+ "model_mapping": map[string]any{"gpt-5.4": "gpt-5.4"},
+ "compact_model_mapping": map[string]any{"gpt-5.4": "gpt-5.6-luna"},
+ },
+ }
+
+ result, err := svc.Forward(context.Background(), c, account, body)
+ require.Error(t, err)
+ require.Nil(t, result)
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+ require.Nil(t, upstream.lastReq)
+}
+
+func TestOpenAIGatewayService_Passthrough_CompactGPT56UnsafeMappingFailsBeforeUpstream(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"gpt-5.6-terra","stream":false,"instructions":"compact-test","input":"hello"}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(`{"id":"should_not_run","model":"gpt-5.6-sol","usage":{"input_tokens":1,"output_tokens":1}}`)),
+ }}
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 11,
+ Name: "openai-api-key-pass",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "test-key",
+ "base_url": "https://example.invalid",
+ "model_mapping": map[string]any{"gpt-5.6-terra": "gpt-5.6-terra"},
+ "compact_model_mapping": map[string]any{"gpt-5.6-terra": "gpt-5.6-sol"},
+ },
+ Extra: map[string]any{"openai_passthrough": true},
+ }
+
+ result, err := svc.Forward(context.Background(), c, account, body)
+ require.Error(t, err)
+ require.Nil(t, result)
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+ require.Nil(t, upstream.lastReq)
+}
diff --git a/backend/internal/service/openai_compat_model.go b/backend/internal/service/openai_compat_model.go
index 5f140d01099..c6774259212 100644
--- a/backend/internal/service/openai_compat_model.go
+++ b/backend/internal/service/openai_compat_model.go
@@ -4,6 +4,8 @@ import (
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
)
func NormalizeOpenAICompatRequestedModel(model string) string {
@@ -78,11 +80,22 @@ func splitOpenAICompatReasoningModel(model string) (normalizedModel string, reas
}
last := strings.NewReplacer("-", "", "_", "", " ", "").Replace(parts[len(parts)-1])
+ _, isGPT56Family := classifyOpenAIGPT56PreviewModel(trimmed)
switch last {
- case "none", "minimal":
+ case "none":
+ reasoningEffort = "none"
+ case "minimal":
+ if isGPT56Family {
+ return trimmed, "", false
+ }
case "low", "medium", "high":
reasoningEffort = last
- case "xhigh", "extrahigh":
+ case "xhigh":
+ reasoningEffort = "xhigh"
+ case "extrahigh":
+ if isGPT56Family {
+ return trimmed, "", false
+ }
reasoningEffort = "xhigh"
default:
return trimmed, "", false
@@ -91,8 +104,70 @@ func splitOpenAICompatReasoningModel(model string) (normalizedModel string, reas
return normalizeCodexModel(modelID), reasoningEffort, true
}
+func openAIGPT56ReasoningEffortFromModel(model string) (string, bool) {
+ normalized, isFamily := classifyOpenAIGPT56PreviewModel(model)
+ if !isFamily || normalized == "" {
+ return "", false
+ }
+ canonical := canonicalizeOpenAIModelAliasSpelling(model)
+ prefix := normalized + "-"
+ if !strings.HasPrefix(canonical, prefix) {
+ return "", false
+ }
+ switch effort := strings.TrimPrefix(canonical, prefix); effort {
+ case "none", "low", "medium", "high", "xhigh":
+ return effort, true
+ default:
+ return "", false
+ }
+}
+
+func injectOpenAIGPT56ReasoningEffort(body []byte, model, field string) ([]byte, bool, error) {
+ if len(body) == 0 || strings.TrimSpace(field) == "" {
+ return body, false, nil
+ }
+ if gjson.GetBytes(body, "reasoning.effort").Exists() || gjson.GetBytes(body, "reasoning_effort").Exists() {
+ return body, false, nil
+ }
+ effort, ok := openAIGPT56ReasoningEffortFromModel(model)
+ if !ok {
+ return body, false, nil
+ }
+ updated, err := sjson.SetBytes(body, field, effort)
+ if err != nil {
+ return body, false, err
+ }
+ return updated, true, nil
+}
+
+func injectOpenAIGPT56ReasoningEffortMap(reqBody map[string]any, model string) bool {
+ if reqBody == nil {
+ return false
+ }
+ if _, present := getOpenAIReasoningEffortFromReqBody(reqBody); present {
+ return false
+ }
+ effort, ok := openAIGPT56ReasoningEffortFromModel(model)
+ if !ok {
+ return false
+ }
+ reasoning, ok := reqBody["reasoning"].(map[string]any)
+ if !ok || reasoning == nil {
+ reasoning = make(map[string]any)
+ reqBody["reasoning"] = reasoning
+ }
+ reasoning["effort"] = effort
+ return true
+}
+
func openAIReasoningEffortToClaudeOutputEffort(effort string) string {
switch strings.TrimSpace(effort) {
+ case "none":
+ // Anthropic's public output_config does not document "none", but this
+ // request is converted locally to OpenAI Responses. Keeping the value
+ // in the compatibility struct prevents AnthropicToResponses from
+ // replacing an explicit GPT-5.6 "-none" suffix with its medium default.
+ return "none"
case "low", "medium", "high":
return effort
case "xhigh":
diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go
index 4396c15fd0b..83235b7db82 100644
--- a/backend/internal/service/openai_compat_model_test.go
+++ b/backend/internal/service/openai_compat_model_test.go
@@ -3,21 +3,71 @@ package service
import (
"bytes"
"context"
+ "errors"
+ "fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
+ "sync"
"testing"
+ "time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
)
+type openAICompatFailingWriter struct {
+ gin.ResponseWriter
+ failAfter int
+ writes int
+}
+
+func (w *openAICompatFailingWriter) Write(p []byte) (int, error) {
+ if w.writes >= w.failAfter {
+ return 0, errors.New("write failed: client disconnected")
+ }
+ w.writes++
+ return w.ResponseWriter.Write(p)
+}
+
+type openAICompatBlockingReadCloser struct {
+ data []byte
+ offset int
+ closed chan struct{}
+ closeOnce sync.Once
+}
+
+func newOpenAICompatBlockingReadCloser(data []byte) *openAICompatBlockingReadCloser {
+ return &openAICompatBlockingReadCloser{
+ data: data,
+ closed: make(chan struct{}),
+ }
+}
+
+func (r *openAICompatBlockingReadCloser) Read(p []byte) (int, error) {
+ if r.offset < len(r.data) {
+ n := copy(p, r.data[r.offset:])
+ r.offset += n
+ return n, nil
+ }
+ <-r.closed
+ return 0, io.EOF
+}
+
+func (r *openAICompatBlockingReadCloser) Close() error {
+ r.closeOnce.Do(func() {
+ close(r.closed)
+ })
+ return nil
+}
+
func TestNormalizeOpenAICompatRequestedModel(t *testing.T) {
t.Parallel()
@@ -39,6 +89,94 @@ func TestNormalizeOpenAICompatRequestedModel(t *testing.T) {
}
}
+func TestNormalizeOpenAICompatRequestedModel_GPT56ExactTiers(t *testing.T) {
+ t.Parallel()
+
+ for input, expected := range map[string]string{
+ "gpt-5.6-sol-none": "gpt-5.6-sol",
+ "gpt-5.6-terra-medium": "gpt-5.6-terra",
+ "openai/gpt-5.6-luna-xhigh": "gpt-5.6-luna",
+ } {
+ require.Equal(t, expected, NormalizeOpenAICompatRequestedModel(input))
+ }
+}
+
+func TestNormalizeOpenAICompatRequestedModel_GPT56RejectsUnsupportedSuffixAliases(t *testing.T) {
+ t.Parallel()
+
+ for _, model := range []string{
+ "gpt-5.6-sol-minimal",
+ "openai/gpt-5.6-terra-extrahigh",
+ } {
+ require.Equal(t, model, NormalizeOpenAICompatRequestedModel(model))
+
+ req := &apicompat.AnthropicRequest{Model: model}
+ applyOpenAICompatModelNormalization(req)
+ require.Equal(t, model, req.Model)
+ require.Nil(t, req.OutputConfig)
+ }
+}
+
+func TestOpenAIGatewayService_Forward_APIKeyGPT56SuffixUsesExactMappingAndReasoningEffort(t *testing.T) {
+
+ tests := []struct {
+ name string
+ explicitEffort string
+ wantEffort string
+ passthrough bool
+ }{
+ {name: "suffix derives effort", wantEffort: "high"},
+ {name: "explicit effort wins", explicitEffort: "low", wantEffort: "low"},
+ {name: "passthrough suffix uses exact account target", wantEffort: "high", passthrough: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"openai/gpt_5.6_sol_high","stream":false,"instructions":"test","input":"hello"}`)
+ if tt.explicitEffort != "" {
+ var err error
+ body, err = sjson.SetBytes(body, "reasoning.effort", tt.explicitEffort)
+ require.NoError(t, err)
+ }
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(`{"id":"resp_gpt56","model":"gpt-5.6-sol","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
+ }}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 21,
+ Name: "gpt56-api-key",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "test-key",
+ "base_url": "https://example.invalid",
+ "model_mapping": map[string]any{
+ "gpt-5.6-sol": "gpt-5.6-sol",
+ },
+ },
+ Extra: map[string]any{"openai_passthrough": tt.passthrough},
+ }
+
+ result, err := svc.Forward(context.Background(), c, account, body)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, "gpt-5.6-sol", result.UpstreamModel)
+ require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String())
+ require.Equal(t, tt.wantEffort, gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
+ })
+ }
+}
+
func TestApplyOpenAICompatModelNormalization(t *testing.T) {
t.Parallel()
@@ -75,9 +213,30 @@ func TestApplyOpenAICompatModelNormalization(t *testing.T) {
})
}
+func TestShouldApplyOpenAICompatKnowledgeCutoffGuard(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ originalModel string
+ upstreamModel string
+ want bool
+ }{
+ {name: "claude compat shell to gpt adds guard", originalModel: "claude-sonnet-4-6", upstreamModel: "gpt-5.4", want: true},
+ {name: "native gpt request skips guard", originalModel: "gpt-5.4", upstreamModel: "gpt-5.4", want: false},
+ {name: "real claude upstream skips guard", originalModel: "claude-sonnet-4-6", upstreamModel: "claude-sonnet-4-6", want: false},
+ {name: "empty upstream skips guard", originalModel: "claude-sonnet-4-6", upstreamModel: "", want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.Equal(t, tt.want, shouldApplyOpenAICompatKnowledgeCutoffGuard(tt.originalModel, tt.upstreamModel))
+ })
+ }
+}
+
func TestForwardAsAnthropic_NormalizesRoutingAndEffortForGpt54XHigh(t *testing.T) {
t.Parallel()
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -97,7 +256,10 @@ func TestForwardAsAnthropic_NormalizesRoutingAndEffortForGpt54XHigh(t *testing.T
Body: io.NopCloser(strings.NewReader(upstreamBody)),
}}
- svc := &OpenAIGatewayService{httpUpstream: upstream}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
account := &Account{
ID: 1,
Name: "openai-oauth",
@@ -131,85 +293,372 @@ func TestForwardAsAnthropic_NormalizesRoutingAndEffortForGpt54XHigh(t *testing.T
t.Logf("response body: %s", rec.Body.String())
}
-func TestForwardAsAnthropic_ForcedCodexInstructionsTemplatePrependsRenderedInstructions(t *testing.T) {
+func TestForwardAsAnthropic_GPT56NoneSuffixPreservedInFinalResponsesPayload(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"gpt-5.6-sol-none","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := strings.Join([]string{
+ `data: {"type":"response.completed","response":{"id":"resp_none","object":"response","model":"gpt-5.6-sol","status":"completed","output":[{"type":"message","id":"msg_none","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-api-key",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "test-key",
+ "model_mapping": map[string]any{
+ "gpt-5.6-sol": "gpt-5.6-sol",
+ },
+ },
+ }
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String())
+ require.Equal(t, "none", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
+ require.NotNil(t, result.ReasoningEffort)
+ require.Equal(t, "none", *result.ReasoningEffort)
+}
+
+func TestForwardAsAnthropic_GPT56DispatchTargetWinsOverConflictingClaudeAliasMapping(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"claude-sonnet-4-6","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := strings.Join([]string{
+ `data: {"type":"response.completed","response":{"id":"resp_dispatch","object":"response","model":"gpt-5.6-terra","status":"completed","output":[{"type":"message","id":"msg_dispatch","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 2,
+ Name: "openai-conflicting-alias",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "test-key",
+ "model_mapping": map[string]any{
+ "claude-sonnet-4-6": "gpt-5.4",
+ "gpt-5.6-terra": "gpt-5.6-terra",
+ },
+ },
+ }
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.6-terra")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, "gpt-5.6-terra", gjson.GetBytes(upstream.lastBody, "model").String())
+ require.Equal(t, "gpt-5.6-terra", result.BillingModel)
+ require.Equal(t, "gpt-5.6-terra", result.UpstreamModel)
+}
+
+func TestForwardAsAnthropic_StableDispatchIsAuthoritativeOverClaudeAliasGPT56Mapping(t *testing.T) {
+ body := []byte(`{"model":"claude-sonnet-4-6","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
+
+ tests := []struct {
+ name string
+ gpt56Entitled bool
+ wantErr bool
+ wantUpstreamModel string
+ }{
+ {name: "unauthorized alias target fails before upstream", wantErr: true},
+ {name: "authorized alias target cannot override stable dispatch", gpt56Entitled: true, wantUpstreamModel: "gpt-5.4"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ upstream := &httpUpstreamRecorder{resp: openAIIdentitySSE("gpt-5.4", false)}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ mapping := map[string]any{
+ "claude-sonnet-4-6": "gpt-5.6-sol",
+ "gpt-5.4": "gpt-5.4",
+ }
+ if tt.gpt56Entitled {
+ mapping["gpt-5.6-sol"] = "gpt-5.6-sol"
+ }
+ account := &Account{
+ ID: 3,
+ Name: "openai-stable-dispatch",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "test-key",
+ "model_mapping": mapping,
+ },
+ }
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4")
+ if tt.wantErr {
+ require.Error(t, err)
+ require.Nil(t, result)
+ require.Nil(t, upstream.lastReq)
+ return
+ }
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, tt.wantUpstreamModel, gjson.GetBytes(upstream.lastBody, "model").String())
+ require.Equal(t, tt.wantUpstreamModel, result.BillingModel)
+ require.Equal(t, tt.wantUpstreamModel, result.UpstreamModel)
+ })
+ }
+}
+
+func TestForwardAsAnthropic_InjectsPromptCacheKeyForAPIKeyMessagesDispatch(t *testing.T) {
t.Parallel()
- gin.SetMode(gin.TestMode)
- templateDir := t.TempDir()
- templatePath := filepath.Join(templateDir, "codex-instructions.md.tmpl")
- require.NoError(t, os.WriteFile(templatePath, []byte("server-prefix\n\n{{ .ExistingInstructions }}"), 0o644))
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"metadata":{"user_id":"claude-session-1"},"messages":[{"role":"user","content":"hello"}],"stream":false}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := strings.Join([]string{
+ `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.3-codex","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7,"input_tokens_details":{"cached_tokens":3}}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_cache_key"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-apikey",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "api_key": "sk-test",
+ "base_url": "https://api.openai.com/v1",
+ },
+ }
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "stable-cache-key", "gpt-5.3-codex")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, "stable-cache-key", gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String())
+ require.Equal(t, "gpt-5.3-codex", gjson.GetBytes(upstream.lastBody, "model").String())
+ instructions := gjson.GetBytes(upstream.lastBody, "instructions").String()
+ require.Contains(t, instructions, openAICompatKnowledgeCutoffGuardMarker)
+ require.Contains(t, instructions, "upstream model gpt-5.3-codex")
+ require.Contains(t, instructions, "cannot be confirmed")
+ require.Equal(t, 3, result.Usage.CacheReadInputTokens)
+}
+
+func TestForwardAsAnthropic_AutoDerivesPromptCacheKeyWhenMessagesDispatchHasNoSessionID(t *testing.T) {
+ t.Parallel()
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.4","max_tokens":16,"system":"client-system","messages":[{"role":"user","content":"hello"}],"stream":false}`)
+ body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"system":"You are helpful.","messages":[{"role":"user","content":"open repo"}],"stream":false}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstreamBody := strings.Join([]string{
- `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
+ `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.3-codex","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7,"input_tokens_details":{"cached_tokens":3}}}}`,
"",
"data: [DONE]",
"",
}, "\n")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_forced"}},
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_auto_cache_key"}},
Body: io.NopCloser(strings.NewReader(upstreamBody)),
}}
svc := &OpenAIGatewayService{
- cfg: &config.Config{Gateway: config.GatewayConfig{
- ForcedCodexInstructionsTemplateFile: templatePath,
- ForcedCodexInstructionsTemplate: "server-prefix\n\n{{ .ExistingInstructions }}",
- }},
httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
}
account := &Account{
ID: 1,
- Name: "openai-oauth",
+ Name: "openai-apikey",
Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
+ Type: AccountTypeAPIKey,
Concurrency: 1,
Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-acc",
+ "api_key": "sk-test",
+ "base_url": "https://api.openai.com/v1",
},
}
- result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.3-codex")
require.NoError(t, err)
require.NotNil(t, result)
- require.Equal(t, "server-prefix\n\nclient-system", gjson.GetBytes(upstream.lastBody, "instructions").String())
+ cacheKey := gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()
+ require.NotEmpty(t, cacheKey)
+ require.True(t, strings.HasPrefix(cacheKey, "anthropic-digest-"))
+ require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, cacheKey)), upstream.lastReq.Header.Get("session_id"))
}
-func TestForwardAsAnthropic_ForcedCodexInstructionsTemplateUsesCachedTemplateContent(t *testing.T) {
+func TestForwardAsAnthropic_DoesNotAutoDerivePromptCacheKeyForNonCodexModel(t *testing.T) {
t.Parallel()
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.4","max_tokens":16,"system":"client-system","messages":[{"role":"user","content":"hello"}],"stream":false}`)
+ body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstreamBody := strings.Join([]string{
- `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
+ `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-4o","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
"",
"data: [DONE]",
"",
}, "\n")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_forced_cached"}},
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_no_cache_key"}},
Body: io.NopCloser(strings.NewReader(upstreamBody)),
}}
svc := &OpenAIGatewayService{
- cfg: &config.Config{Gateway: config.GatewayConfig{
- ForcedCodexInstructionsTemplateFile: "/path/that/should/not/be/read.tmpl",
- ForcedCodexInstructionsTemplate: "cached-prefix\n\n{{ .ExistingInstructions }}",
- }},
httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-apikey",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "api_key": "sk-test",
+ "base_url": "https://api.openai.com/v1",
+ },
+ }
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-4o")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").Exists())
+ require.Empty(t, upstream.lastReq.Header.Get("session_id"))
+}
+
+func TestForwardAsAnthropic_TrimsFullReplayOnlyForCodexCompatModels(t *testing.T) {
+ t.Parallel()
+
+ messages := make([]string, 0, openAICompatAnthropicReplayMaxTailMessages+3)
+ for i := 0; i < openAICompatAnthropicReplayMaxTailMessages+3; i++ {
+ messages = append(messages, `{"role":"user","content":"message-`+fmt.Sprintf("%02d", i)+`"}`)
+ }
+ body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[` + strings.Join(messages, ",") + `],"stream":false}`)
+
+ run := func(t *testing.T, mappedModel string) []byte {
+ t.Helper()
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := strings.Join([]string{
+ `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"` + mappedModel + `","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_trim"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-apikey",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "api_key": "sk-test",
+ "base_url": "https://api.openai.com/v1",
+ },
+ }
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", mappedModel)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ return upstream.lastBody
+ }
+
+ codexBody := run(t, "gpt-5.3-codex")
+ require.Equal(t, int64(openAICompatAnthropicReplayMaxTailMessages+1), gjson.GetBytes(codexBody, "input.#").Int())
+ require.Equal(t, "developer", gjson.GetBytes(codexBody, "input.0.role").String())
+ require.Contains(t, gjson.GetBytes(codexBody, "input.0.content.0.text").String(), "")
+ require.Equal(t, "message-03", gjson.GetBytes(codexBody, "input.1.content.0.text").String())
+ require.Equal(t, "message-14", gjson.GetBytes(codexBody, "input.12.content.0.text").String())
+
+ nonCompatBody := run(t, "gpt-4o")
+ require.Equal(t, int64(openAICompatAnthropicReplayMaxTailMessages+3), gjson.GetBytes(nonCompatBody, "input.#").Int())
+ require.Equal(t, "message-00", gjson.GetBytes(nonCompatBody, "input.0.content.0.text").String())
+}
+
+func TestForwardAsAnthropic_OAuthCompatKeepsFullReplayForCacheGrowth(t *testing.T) {
+ t.Parallel()
+
+ messages := make([]string, 0, openAICompatAnthropicReplayMaxTailMessages+3)
+ for i := 0; i < openAICompatAnthropicReplayMaxTailMessages+3; i++ {
+ messages = append(messages, `{"role":"user","content":"message-`+fmt.Sprintf("%02d", i)+`"}`)
+ }
+ body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[` + strings.Join(messages, ",") + `],"stream":false}`)
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_oauth_trim", "gpt-5.4")}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
}
account := &Account{
ID: 1,
@@ -223,8 +672,1009 @@ func TestForwardAsAnthropic_ForcedCodexInstructionsTemplateUsesCachedTemplateCon
},
}
- result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4")
require.NoError(t, err)
require.NotNil(t, result)
- require.Equal(t, "cached-prefix\n\nclient-system", gjson.GetBytes(upstream.lastBody, "instructions").String())
+ require.Equal(t, int64(openAICompatAnthropicReplayMaxTailMessages+4), gjson.GetBytes(upstream.lastBody, "input.#").Int())
+ require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "input.0.role").String())
+ require.Contains(t, gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String(), "")
+ require.Equal(t, "message-00", gjson.GetBytes(upstream.lastBody, "input.1.content.0.text").String())
+ require.Equal(t, "message-14", gjson.GetBytes(upstream.lastBody, "input.15.content.0.text").String())
+ require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").Exists())
+}
+
+func TestForwardAsAnthropic_AttachesPreviousResponseIDForCompatContinuation(t *testing.T) {
+ t.Parallel()
+
+ upstream := &httpUpstreamRecorder{}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-apikey",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "api_key": "sk-test",
+ "base_url": "https://api.openai.com/v1",
+ },
+ }
+
+ firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"}],"stream":false}`)
+ upstream.resp = openAICompatSSECompletedResponse("resp_first", "gpt-5.3-codex")
+ firstRec := httptest.NewRecorder()
+ firstCtx, _ := gin.CreateTestContext(firstRec)
+ firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
+ firstCtx.Request.Header.Set("Content-Type", "application/json")
+
+ firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "stable-cache-key", "gpt-5.3-codex")
+ require.NoError(t, err)
+ require.NotNil(t, firstResult)
+ require.Equal(t, "resp_first", firstResult.ResponseID)
+ require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists())
+
+ secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
+ upstream.resp = openAICompatSSECompletedResponse("resp_second", "gpt-5.3-codex")
+ secondRec := httptest.NewRecorder()
+ secondCtx, _ := gin.CreateTestContext(secondRec)
+ secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
+ secondCtx.Request.Header.Set("Content-Type", "application/json")
+
+ secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "stable-cache-key", "gpt-5.3-codex")
+ require.NoError(t, err)
+ require.NotNil(t, secondResult)
+ require.Equal(t, "resp_second", secondResult.ResponseID)
+ require.Equal(t, "resp_first", gjson.GetBytes(upstream.lastBody, "previous_response_id").String())
+ require.Equal(t, int64(2), gjson.GetBytes(upstream.lastBody, "input.#").Int())
+ require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "input.0.role").String())
+ require.Contains(t, gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String(), "")
+ require.Equal(t, "second", gjson.GetBytes(upstream.lastBody, "input.1.content.0.text").String())
+}
+
+func TestForwardAsAnthropic_ReplaysWithoutContinuationWhenPreviousResponseMissing(t *testing.T) {
+ t.Parallel()
+
+ upstream := &httpUpstreamRecorder{}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-apikey",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "api_key": "sk-test",
+ "base_url": "https://api.openai.com/v1",
+ },
+ }
+
+ svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "stable-cache-key", "resp_missing")
+ secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
+ upstream.responses = []*http.Response{
+ {
+ StatusCode: http.StatusBadRequest,
+ Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_prev_missing"}},
+ Body: io.NopCloser(strings.NewReader(`{"error":{"code":"previous_response_not_found","message":"previous response not found"}}`)),
+ },
+ openAICompatSSECompletedResponse("resp_replayed", "gpt-5.3-codex"),
+ }
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, secondBody, "stable-cache-key", "gpt-5.3-codex")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, "resp_replayed", result.ResponseID)
+ require.Len(t, upstream.requests, 2)
+ require.Equal(t, "resp_missing", gjson.GetBytes(upstream.bodies[0], "previous_response_id").String())
+ require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
+ require.Equal(t, int64(4), gjson.GetBytes(upstream.bodies[1], "input.#").Int())
+ require.Equal(t, "developer", gjson.GetBytes(upstream.bodies[1], "input.0.role").String())
+ require.Contains(t, gjson.GetBytes(upstream.bodies[1], "input.0.content.0.text").String(), "")
+ require.Equal(t, "first", gjson.GetBytes(upstream.bodies[1], "input.1.content.0.text").String())
+ require.Equal(t, "second", gjson.GetBytes(upstream.bodies[1], "input.3.content.0.text").String())
+}
+
+func TestForwardAsAnthropic_DisablesAPIKeyContinuationWhenUpstreamRequiresWebSocketV2(t *testing.T) {
+ t.Parallel()
+
+ upstream := &httpUpstreamRecorder{}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-apikey",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "api_key": "sk-test",
+ "base_url": "https://api.openai.com/v1",
+ },
+ }
+
+ svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "stable-cache-key", "resp_http_unsupported")
+ body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
+ upstream.responses = []*http.Response{
+ {
+ StatusCode: http.StatusBadRequest,
+ Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_prev_http_unsupported"}},
+ Body: io.NopCloser(strings.NewReader(`{"error":{"message":"previous_response_id is only supported on Responses WebSocket v2","type":"invalid_request_error"}}`)),
+ },
+ openAICompatSSECompletedResponse("resp_replayed", "gpt-5.5"),
+ openAICompatSSECompletedResponse("resp_later", "gpt-5.5"),
+ }
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "stable-cache-key", "gpt-5.5")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, "resp_replayed", result.ResponseID)
+ require.Len(t, upstream.requests, 2)
+ require.Equal(t, "resp_http_unsupported", gjson.GetBytes(upstream.bodies[0], "previous_response_id").String())
+ require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
+
+ laterRec := httptest.NewRecorder()
+ laterCtx, _ := gin.CreateTestContext(laterRec)
+ laterCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ laterCtx.Request.Header.Set("Content-Type", "application/json")
+
+ laterResult, err := svc.ForwardAsAnthropic(context.Background(), laterCtx, account, body, "stable-cache-key", "gpt-5.5")
+ require.NoError(t, err)
+ require.NotNil(t, laterResult)
+ require.Equal(t, "resp_later", laterResult.ResponseID)
+ require.Len(t, upstream.requests, 3)
+ require.False(t, gjson.GetBytes(upstream.bodies[2], "previous_response_id").Exists())
+}
+
+func TestForwardAsAnthropic_APIKeyMetadataSessionSurvivesChangingCacheControlAnchorAfterContinuationDisabled(t *testing.T) {
+ t.Parallel()
+
+ metadata := `{"user_id":"{\"device_id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"account_uuid\":\"\",\"session_id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"}"}`
+ firstBody := []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":16,"metadata":` + metadata + `,"system":[{"type":"text","text":"project docs","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"first"}],"stream":false}`)
+ messages := make([]string, 0, openAICompatAnthropicReplayMaxTailMessages+4)
+ messages = append(messages, `{"role":"user","content":[{"type":"text","text":"rewritten context","cache_control":{"type":"ephemeral"}}]}`)
+ for i := 1; i < openAICompatAnthropicReplayMaxTailMessages+4; i++ {
+ messages = append(messages, `{"role":"user","content":"message-`+fmt.Sprintf("%02d", i)+`"}`)
+ }
+ secondBody := []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":16,"metadata":` + metadata + `,"messages":[` + strings.Join(messages, ",") + `],"stream":false}`)
+
+ upstream := &httpUpstreamRecorder{responses: []*http.Response{
+ openAICompatSSECompletedResponse("resp_first", "gpt-5.4-mini"),
+ openAICompatSSECompletedResponse("resp_second", "gpt-5.4-mini"),
+ }}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-apikey",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "api_key": "sk-test",
+ "base_url": "https://api.openai.com/v1",
+ },
+ }
+
+ firstRec := httptest.NewRecorder()
+ firstCtx, _ := gin.CreateTestContext(firstRec)
+ firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
+ firstCtx.Request.Header.Set("Content-Type", "application/json")
+
+ firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "", "gpt-5.4-mini")
+ require.NoError(t, err)
+ require.NotNil(t, firstResult)
+ firstKey := gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").String()
+ require.NotEmpty(t, firstKey)
+ require.True(t, strings.HasPrefix(firstKey, "anthropic-metadata-"))
+
+ svc.disableOpenAICompatSessionContinuation(context.Background(), nil, account, firstKey)
+
+ secondRec := httptest.NewRecorder()
+ secondCtx, _ := gin.CreateTestContext(secondRec)
+ secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
+ secondCtx.Request.Header.Set("Content-Type", "application/json")
+
+ secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "", "gpt-5.4-mini")
+ require.NoError(t, err)
+ require.NotNil(t, secondResult)
+ require.Len(t, upstream.requests, 2)
+ require.Equal(t, firstKey, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String())
+ require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
+ require.Equal(t, int64(openAICompatAnthropicReplayMaxTailMessages+5), gjson.GetBytes(upstream.bodies[1], "input.#").Int())
+ require.Equal(t, "developer", gjson.GetBytes(upstream.bodies[1], "input.0.role").String())
+ require.Contains(t, gjson.GetBytes(upstream.bodies[1], "input.0.content.0.text").String(), "")
+ require.Equal(t, "rewritten context", gjson.GetBytes(upstream.bodies[1], "input.1.content.0.text").String())
+ require.Equal(t, "message-15", gjson.GetBytes(upstream.bodies[1], "input.16.content.0.text").String())
+}
+
+func TestForwardAsAnthropic_DoesNotAttachPreviousResponseIDForOAuthCompat(t *testing.T) {
+ t.Parallel()
+
+ upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_oauth_next", "gpt-5.4")}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+ svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "stable-cache-key", "resp_oauth_prev")
+
+ body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "stable-cache-key", "gpt-5.4")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists())
+}
+
+func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) {
+ t.Parallel()
+
+ firstResp := openAICompatSSECompletedResponse("resp_oauth_first", "gpt-5.4")
+ firstResp.Header.Set("x-codex-turn-state", "turn_state_first")
+ upstream := &httpUpstreamRecorder{responses: []*http.Response{
+ firstResp,
+ openAICompatSSECompletedResponse("resp_oauth_second", "gpt-5.4"),
+ }}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"}],"stream":false}`)
+ firstRec := httptest.NewRecorder()
+ firstCtx, _ := gin.CreateTestContext(firstRec)
+ firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
+ firstCtx.Request.Header.Set("Content-Type", "application/json")
+
+ firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "stable-cache-key", "gpt-5.4")
+ require.NoError(t, err)
+ require.NotNil(t, firstResult)
+ require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state"))
+ require.Empty(t, upstream.requests[0].Header.Get("OpenAI-Beta"))
+ require.Empty(t, upstream.requests[0].Header.Get("originator"))
+
+ secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
+ secondRec := httptest.NewRecorder()
+ secondCtx, _ := gin.CreateTestContext(secondRec)
+ secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
+ secondCtx.Request.Header.Set("Content-Type", "application/json")
+
+ secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "stable-cache-key", "gpt-5.4")
+ require.NoError(t, err)
+ require.NotNil(t, secondResult)
+ require.Equal(t, "turn_state_first", upstream.requests[1].Header.Get("x-codex-turn-state"))
+ require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, "stable-cache-key")), upstream.requests[1].Header.Get("session_id"))
+ require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
+ require.Empty(t, upstream.requests[1].Header.Get("OpenAI-Beta"))
+ require.Empty(t, upstream.requests[1].Header.Get("originator"))
+ require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists())
+ require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
+}
+
+func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey(t *testing.T) {
+ t.Parallel()
+
+ firstResp := openAICompatSSECompletedResponse("resp_oauth_digest_first", "gpt-5.4")
+ firstResp.Header.Set("x-codex-turn-state", "turn_state_digest_first")
+ upstream := &httpUpstreamRecorder{responses: []*http.Response{
+ firstResp,
+ openAICompatSSECompletedResponse("resp_oauth_digest_second", "gpt-5.4"),
+ }}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"}],"stream":false}`)
+ firstRec := httptest.NewRecorder()
+ firstCtx, _ := gin.CreateTestContext(firstRec)
+ firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
+ firstCtx.Request.Header.Set("Content-Type", "application/json")
+
+ firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "", "gpt-5.4")
+ require.NoError(t, err)
+ require.NotNil(t, firstResult)
+ firstSessionID := upstream.requests[0].Header.Get("session_id")
+ require.NotEmpty(t, firstSessionID)
+ require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state"))
+ require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists())
+
+ secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
+ secondRec := httptest.NewRecorder()
+ secondCtx, _ := gin.CreateTestContext(secondRec)
+ secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
+ secondCtx.Request.Header.Set("Content-Type", "application/json")
+
+ secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "", "gpt-5.4")
+ require.NoError(t, err)
+ require.NotNil(t, secondResult)
+ require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session_id"))
+ require.Equal(t, "turn_state_digest_first", upstream.requests[1].Header.Get("x-codex-turn-state"))
+ require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
+ require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists())
+ require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
+}
+
+func TestForwardAsAnthropic_OAuthMetadataSessionSurvivesDigestPrefixRewrite(t *testing.T) {
+ t.Parallel()
+
+ firstResp := openAICompatSSECompletedResponse("resp_oauth_metadata_first", "gpt-5.5")
+ firstResp.Header.Set("x-codex-turn-state", "turn_state_metadata_first")
+ upstream := &httpUpstreamRecorder{responses: []*http.Response{
+ firstResp,
+ openAICompatSSECompletedResponse("resp_oauth_metadata_second", "gpt-5.5"),
+ }}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+ metadata := `{"user_id":"{\"device_id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"account_uuid\":\"\",\"session_id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"}"}`
+
+ firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"metadata":` + metadata + `,"messages":[{"role":"user","content":"first plan"}],"stream":false}`)
+ firstRec := httptest.NewRecorder()
+ firstCtx, _ := gin.CreateTestContext(firstRec)
+ firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
+ firstCtx.Request.Header.Set("Content-Type", "application/json")
+
+ firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "", "gpt-5.5")
+ require.NoError(t, err)
+ require.NotNil(t, firstResult)
+ firstSessionID := upstream.requests[0].Header.Get("session_id")
+ require.NotEmpty(t, firstSessionID)
+ require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state"))
+ require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists())
+
+ secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"metadata":` + metadata + `,"messages":[{"role":"user","content":"rewritten plan"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
+ secondRec := httptest.NewRecorder()
+ secondCtx, _ := gin.CreateTestContext(secondRec)
+ secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
+ secondCtx.Request.Header.Set("Content-Type", "application/json")
+
+ secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "", "gpt-5.5")
+ require.NoError(t, err)
+ require.NotNil(t, secondResult)
+ require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session_id"))
+ require.Equal(t, "turn_state_metadata_first", upstream.requests[1].Header.Get("x-codex-turn-state"))
+ require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
+ require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists())
+ require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
+}
+
+func TestForwardAsAnthropic_OAuthMetadataSessionSurvivesChangingCacheControlAnchor(t *testing.T) {
+ t.Parallel()
+
+ firstResp := openAICompatSSECompletedResponse("resp_oauth_cache_anchor_first", "gpt-5.5")
+ firstResp.Header.Set("x-codex-turn-state", "turn_state_cache_anchor_first")
+ upstream := &httpUpstreamRecorder{responses: []*http.Response{
+ firstResp,
+ openAICompatSSECompletedResponse("resp_oauth_cache_anchor_second", "gpt-5.5"),
+ }}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+ metadata := `{"user_id":"{\"device_id\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"account_uuid\":\"\",\"session_id\":\"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\"}"}`
+
+ firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"metadata":` + metadata + `,"system":[{"type":"text","text":"anchor one","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"first"}],"stream":false}`)
+ firstRec := httptest.NewRecorder()
+ firstCtx, _ := gin.CreateTestContext(firstRec)
+ firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
+ firstCtx.Request.Header.Set("Content-Type", "application/json")
+
+ firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "", "gpt-5.5")
+ require.NoError(t, err)
+ require.NotNil(t, firstResult)
+ firstSessionID := upstream.requests[0].Header.Get("session_id")
+ require.NotEmpty(t, firstSessionID)
+ require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state"))
+ require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists())
+
+ secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"metadata":` + metadata + `,"system":[{"type":"text","text":"anchor two","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
+ secondRec := httptest.NewRecorder()
+ secondCtx, _ := gin.CreateTestContext(secondRec)
+ secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
+ secondCtx.Request.Header.Set("Content-Type", "application/json")
+
+ secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "", "gpt-5.5")
+ require.NoError(t, err)
+ require.NotNil(t, secondResult)
+ require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session_id"))
+ require.Equal(t, "turn_state_cache_anchor_first", upstream.requests[1].Header.Get("x-codex-turn-state"))
+ require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
+ require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists())
+ require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
+}
+
+func TestForwardAsAnthropic_OAuthKeepsSystemAsDeveloperInput(t *testing.T) {
+ t.Parallel()
+
+ upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_oauth_system", "gpt-5.4")}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"system":[{"type":"text","text":"project instructions","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"first"}],"stream":false}`)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "input.0.role").String())
+ require.Equal(t, "input_text", gjson.GetBytes(upstream.lastBody, "input.0.content.0.type").String())
+ require.Equal(t, "project instructions", gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String())
+ instructions := gjson.GetBytes(upstream.lastBody, "instructions")
+ require.True(t, instructions.Exists())
+ require.Contains(t, instructions.String(), openAICompatKnowledgeCutoffGuardMarker)
+ require.Contains(t, instructions.String(), "upstream model gpt-5.4")
+ require.Contains(t, instructions.String(), "cannot be confirmed")
+ require.NotContains(t, instructions.String(), "project instructions")
+ require.Empty(t, upstream.requests[0].Header.Get("OpenAI-Beta"))
+ require.Empty(t, upstream.requests[0].Header.Get("originator"))
+}
+
+func TestForwardAsAnthropic_OAuthAddsClaudeCodeTodoGuardForCompatModel(t *testing.T) {
+ t.Parallel()
+
+ upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_oauth_todo_guard", "gpt-5.5")}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"system":"project instructions","messages":[{"role":"user","content":"review files"}],"stream":false}`)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.5")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "input.0.role").String())
+ require.Equal(t, "project instructions", gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String())
+ require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "input.1.role").String())
+ require.Contains(t, gjson.GetBytes(upstream.lastBody, "input.1.content.0.text").String(), "")
+ require.Equal(t, "user", gjson.GetBytes(upstream.lastBody, "input.2.role").String())
+}
+
+func TestForwardAsAnthropic_OAuthPreservesClaudeCodeToolCallID(t *testing.T) {
+ t.Parallel()
+
+ upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_oauth_tool", "gpt-5.4")}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"list files"},{"role":"assistant","content":[{"type":"tool_use","id":"toolu_123","name":"Bash","input":{"command":"ls"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_123","content":"ok"}]}],"tools":[{"name":"Bash","description":"run shell","input_schema":{"type":"object","properties":{"command":{"type":"string"}}}}],"stream":false}`)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "stable-cache-key", "gpt-5.4")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, "toolu_123", gjson.GetBytes(upstream.lastBody, `input.#(type=="function_call").call_id`).String())
+ require.Equal(t, "toolu_123", gjson.GetBytes(upstream.lastBody, `input.#(type=="function_call_output").call_id`).String())
+ require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool())
+ require.Equal(t, "medium", gjson.GetBytes(upstream.lastBody, "text.verbosity").String())
+ require.False(t, gjson.GetBytes(upstream.lastBody, "tools.0.strict").Bool())
+}
+
+func TestForwardAsAnthropic_StoresStreamingResponseIDWithoutUsage(t *testing.T) {
+ t.Parallel()
+
+ upstream := &httpUpstreamRecorder{}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-apikey",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "api_key": "sk-test",
+ "base_url": "https://api.openai.com/v1",
+ },
+ }
+
+ firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"}],"stream":true}`)
+ upstream.resp = openAICompatSSEResponseWithoutUsage("resp_stream_first", "gpt-5.3-codex")
+ firstRec := httptest.NewRecorder()
+ firstCtx, _ := gin.CreateTestContext(firstRec)
+ firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
+ firstCtx.Request.Header.Set("Content-Type", "application/json")
+
+ firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "stable-cache-key", "gpt-5.3-codex")
+ require.NoError(t, err)
+ require.NotNil(t, firstResult)
+ require.Equal(t, "resp_stream_first", firstResult.ResponseID)
+
+ secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
+ upstream.resp = openAICompatSSECompletedResponse("resp_stream_second", "gpt-5.3-codex")
+ secondRec := httptest.NewRecorder()
+ secondCtx, _ := gin.CreateTestContext(secondRec)
+ secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
+ secondCtx.Request.Header.Set("Content-Type", "application/json")
+
+ secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "stable-cache-key", "gpt-5.3-codex")
+ require.NoError(t, err)
+ require.NotNil(t, secondResult)
+ require.Equal(t, "resp_stream_first", gjson.GetBytes(upstream.lastBody, "previous_response_id").String())
+}
+
+func openAICompatSSECompletedResponse(responseID, model string) *http.Response {
+ body := strings.Join([]string{
+ `data: {"type":"response.completed","response":{"id":"` + responseID + `","object":"response","model":"` + model + `","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_continuation"}},
+ Body: io.NopCloser(strings.NewReader(body)),
+ }
+}
+
+func openAICompatSSEResponseWithoutUsage(responseID, model string) *http.Response {
+ body := strings.Join([]string{
+ `data: {"type":"response.completed","response":{"id":"` + responseID + `","object":"response","model":"` + model + `","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}]}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_" + responseID}},
+ Body: io.NopCloser(strings.NewReader(body)),
+ }
+}
+
+func TestForwardAsAnthropic_ForcedCodexInstructionsTemplatePrependsRenderedInstructions(t *testing.T) {
+ t.Parallel()
+
+ templateDir := t.TempDir()
+ templatePath := filepath.Join(templateDir, "codex-instructions.md.tmpl")
+ require.NoError(t, os.WriteFile(templatePath, []byte("server-prefix\n\n{{ .ExistingInstructions }}"), 0o644))
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"gpt-5.4","max_tokens":16,"system":"client-system","messages":[{"role":"user","content":"hello"}],"stream":false}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := strings.Join([]string{
+ `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_forced"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{Gateway: config.GatewayConfig{
+ ForcedCodexInstructionsTemplateFile: templatePath,
+ ForcedCodexInstructionsTemplate: "server-prefix\n\n{{ .ExistingInstructions }}",
+ }},
+ httpUpstream: upstream,
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, "server-prefix\n\nclient-system", gjson.GetBytes(upstream.lastBody, "instructions").String())
+}
+
+func TestForwardAsAnthropic_ForcedCodexInstructionsTemplateUsesCachedTemplateContent(t *testing.T) {
+ t.Parallel()
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"gpt-5.4","max_tokens":16,"system":"client-system","messages":[{"role":"user","content":"hello"}],"stream":false}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := strings.Join([]string{
+ `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_forced_cached"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{Gateway: config.GatewayConfig{
+ ForcedCodexInstructionsTemplateFile: "/path/that/should/not/be/read.tmpl",
+ ForcedCodexInstructionsTemplate: "cached-prefix\n\n{{ .ExistingInstructions }}",
+ }},
+ httpUpstream: upstream,
+ }
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, "cached-prefix\n\nclient-system", gjson.GetBytes(upstream.lastBody, "instructions").String())
+}
+
+func TestForwardAsAnthropic_ClientDisconnectDrainsUpstreamUsage(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Writer = &openAICompatFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
+ body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := strings.Join([]string{
+ `data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5.4","status":"in_progress","output":[]}}`,
+ "",
+ `data: {"type":"response.output_text.delta","delta":"ok"}`,
+ "",
+ `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":9,"output_tokens":4,"total_tokens":13,"input_tokens_details":{"cached_tokens":3}}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_disconnect"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, 9, result.Usage.InputTokens)
+ require.Equal(t, 4, result.Usage.OutputTokens)
+ require.Equal(t, 3, result.Usage.CacheReadInputTokens)
+}
+
+func TestForwardAsAnthropic_TerminalUsageWithoutUpstreamCloseReturns(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Writer = &openAICompatFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
+ body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := []byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":15,"output_tokens":6,"total_tokens":21,"input_tokens_details":{"cached_tokens":5}}}}` + "\n\n")
+ upstreamStream := newOpenAICompatBlockingReadCloser(upstreamBody)
+ defer func() {
+ require.NoError(t, upstreamStream.Close())
+ }()
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_terminal_no_close"}},
+ Body: upstreamStream,
+ }}
+
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ type forwardResult struct {
+ result *OpenAIForwardResult
+ err error
+ }
+ resultCh := make(chan forwardResult, 1)
+ go func() {
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
+ resultCh <- forwardResult{result: result, err: err}
+ }()
+
+ select {
+ case got := <-resultCh:
+ require.NoError(t, got.err)
+ require.NotNil(t, got.result)
+ require.Equal(t, 15, got.result.Usage.InputTokens)
+ require.Equal(t, 6, got.result.Usage.OutputTokens)
+ require.Equal(t, 5, got.result.Usage.CacheReadInputTokens)
+ case <-time.After(time.Second):
+ require.Fail(t, "ForwardAsAnthropic should return after terminal usage event even if upstream keeps the connection open")
+ }
+}
+
+func TestForwardAsAnthropic_BufferedTerminalWithoutUpstreamCloseReturns(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := []byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":15,"output_tokens":6,"total_tokens":21,"input_tokens_details":{"cached_tokens":5}}}}` + "\n\n")
+ upstreamStream := newOpenAICompatBlockingReadCloser(upstreamBody)
+ defer func() {
+ require.NoError(t, upstreamStream.Close())
+ }()
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_buffered_terminal_no_close"}},
+ Body: upstreamStream,
+ }}
+
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ type forwardResult struct {
+ result *OpenAIForwardResult
+ err error
+ }
+ resultCh := make(chan forwardResult, 1)
+ go func() {
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
+ resultCh <- forwardResult{result: result, err: err}
+ }()
+
+ select {
+ case got := <-resultCh:
+ require.NoError(t, got.err)
+ require.NotNil(t, got.result)
+ require.Equal(t, 15, got.result.Usage.InputTokens)
+ require.Equal(t, 6, got.result.Usage.OutputTokens)
+ require.Equal(t, 5, got.result.Usage.CacheReadInputTokens)
+ require.Contains(t, rec.Body.String(), `"stop_reason":"end_turn"`)
+ case <-time.After(time.Second):
+ require.Fail(t, "ForwardAsAnthropic buffered response should return after terminal usage event even if upstream keeps the connection open")
+ }
+}
+
+func TestForwardAsAnthropic_DoneSentinelWithoutTerminalReturnsError(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := "data: [DONE]\n\n"
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_missing_terminal"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "missing terminal event")
+ require.NotNil(t, result)
+ require.Zero(t, result.Usage.InputTokens)
+ require.Zero(t, result.Usage.OutputTokens)
+}
+
+func TestForwardAsAnthropic_UpstreamRequestIgnoresClientCancel(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ reqCtx, cancel := context.WithCancel(context.Background())
+ body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)).WithContext(reqCtx)
+ c.Request.Header.Set("Content-Type", "application/json")
+ cancel()
+
+ upstreamBody := strings.Join([]string{
+ `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_ctx"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ result, err := svc.ForwardAsAnthropic(reqCtx, c, account, body, "", "gpt-5.1")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.NotNil(t, upstream.lastReq)
+ require.NoError(t, upstream.lastReq.Context().Err())
}
diff --git a/backend/internal/service/openai_compat_prompt_cache_key.go b/backend/internal/service/openai_compat_prompt_cache_key.go
index fcd27f1921b..de227ff17ab 100644
--- a/backend/internal/service/openai_compat_prompt_cache_key.go
+++ b/backend/internal/service/openai_compat_prompt_cache_key.go
@@ -1,7 +1,9 @@
package service
import (
+ "crypto/sha256"
"encoding/json"
+ "fmt"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
@@ -16,12 +18,8 @@ func shouldAutoInjectPromptCacheKeyForCompat(model string) bool {
if !strings.Contains(trimmed, "gpt-5") && !strings.Contains(trimmed, "codex") {
return false
}
- switch normalizeCodexModel(trimmed) {
- case "gpt-5.4", "gpt-5.3-codex", "gpt-5.3-codex-spark":
- return true
- default:
- return false
- }
+ normalized := strings.TrimSpace(strings.ToLower(normalizeCodexModel(trimmed)))
+ return strings.HasPrefix(normalized, "gpt-5") || strings.Contains(normalized, "codex")
}
func deriveCompatPromptCacheKey(req *apicompat.ChatCompletionsRequest, mappedModel string) string {
@@ -71,6 +69,102 @@ func deriveCompatPromptCacheKey(req *apicompat.ChatCompletionsRequest, mappedMod
return compatPromptCacheKeyPrefix + hashSensitiveValueForLog(strings.Join(seedParts, "|"))
}
+func deriveAnthropicCompatPromptCacheKey(req *apicompat.AnthropicRequest, mappedModel string) string {
+ if req == nil {
+ return ""
+ }
+ if anchorKey := deriveAnthropicCacheControlPromptCacheKey(req); anchorKey != "" {
+ return anchorKey
+ }
+
+ normalizedModel := normalizeCodexModel(strings.TrimSpace(mappedModel))
+ if normalizedModel == "" {
+ normalizedModel = normalizeCodexModel(strings.TrimSpace(req.Model))
+ }
+ if normalizedModel == "" {
+ normalizedModel = strings.TrimSpace(req.Model)
+ }
+
+ seedParts := []string{"model=" + normalizedModel}
+ if req.OutputConfig != nil && strings.TrimSpace(req.OutputConfig.Effort) != "" {
+ seedParts = append(seedParts, "effort="+strings.TrimSpace(req.OutputConfig.Effort))
+ }
+ if len(req.ToolChoice) > 0 {
+ seedParts = append(seedParts, "tool_choice="+normalizeCompatSeedJSON(req.ToolChoice))
+ }
+ if len(req.Tools) > 0 {
+ if raw, err := json.Marshal(req.Tools); err == nil {
+ seedParts = append(seedParts, "tools="+normalizeCompatSeedJSON(raw))
+ }
+ }
+ if len(req.System) > 0 {
+ seedParts = append(seedParts, "system="+normalizeCompatSeedJSON(req.System))
+ }
+
+ firstUserCaptured := false
+ for _, msg := range req.Messages {
+ if strings.TrimSpace(msg.Role) != "user" || firstUserCaptured {
+ continue
+ }
+ seedParts = append(seedParts, "first_user="+normalizeCompatSeedJSON(msg.Content))
+ firstUserCaptured = true
+ }
+
+ return compatPromptCacheKeyPrefix + hashSensitiveValueForLog(strings.Join(seedParts, "|"))
+}
+
+func deriveAnthropicCacheControlPromptCacheKey(req *apicompat.AnthropicRequest) string {
+ if req == nil {
+ return ""
+ }
+
+ var parts []string
+ var systemBlocks []apicompat.AnthropicContentBlock
+ if len(req.System) > 0 && json.Unmarshal(req.System, &systemBlocks) == nil {
+ for _, block := range systemBlocks {
+ if block.Type == "text" &&
+ block.CacheControl != nil &&
+ strings.TrimSpace(block.CacheControl.Type) == "ephemeral" &&
+ strings.TrimSpace(block.Text) != "" {
+ parts = append(parts, "system:"+strings.TrimSpace(block.Text))
+ }
+ }
+ }
+
+ firstUserAnchor := ""
+ for _, msg := range req.Messages {
+ var blocks []apicompat.AnthropicContentBlock
+ if len(msg.Content) == 0 || json.Unmarshal(msg.Content, &blocks) != nil {
+ continue
+ }
+ role := strings.TrimSpace(msg.Role)
+ for _, block := range blocks {
+ if block.Type != "text" ||
+ block.CacheControl == nil ||
+ strings.TrimSpace(block.CacheControl.Type) != "ephemeral" ||
+ strings.TrimSpace(block.Text) == "" {
+ continue
+ }
+ switch role {
+ case "user":
+ if firstUserAnchor == "" {
+ firstUserAnchor = strings.TrimSpace(block.Text)
+ }
+ case "assistant":
+ parts = append(parts, "assistant:"+strings.TrimSpace(block.Text))
+ }
+ }
+ }
+ if firstUserAnchor != "" {
+ parts = append(parts, "user_anchor:"+firstUserAnchor)
+ }
+ if len(parts) == 0 {
+ return ""
+ }
+ sum := sha256.Sum256([]byte("anthropic-cache:" + strings.Join(parts, "\n")))
+ return fmt.Sprintf("anthropic-cache-%x", sum[:16])
+}
+
func normalizeCompatSeedJSON(v json.RawMessage) string {
if len(v) == 0 {
return ""
diff --git a/backend/internal/service/openai_compat_prompt_cache_key_test.go b/backend/internal/service/openai_compat_prompt_cache_key_test.go
index 6ca3e85cd3b..3fe7db6ef69 100644
--- a/backend/internal/service/openai_compat_prompt_cache_key_test.go
+++ b/backend/internal/service/openai_compat_prompt_cache_key_test.go
@@ -2,6 +2,7 @@ package service
import (
"encoding/json"
+ "strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
@@ -14,7 +15,10 @@ func mustRawJSON(t *testing.T, s string) json.RawMessage {
}
func TestShouldAutoInjectPromptCacheKeyForCompat(t *testing.T) {
+ require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.5"))
require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.4"))
+ require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.4-mini"))
+ require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.2"))
require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.3"))
require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.3-codex"))
require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.3-codex-spark"))
@@ -77,3 +81,57 @@ func TestDeriveCompatPromptCacheKey_UsesResolvedSparkFamily(t *testing.T) {
require.NotEmpty(t, k1)
require.Equal(t, k1, k2, "resolved spark family should derive a stable compat cache key")
}
+
+func TestDeriveAnthropicCompatPromptCacheKey_StableAcrossLaterTurns(t *testing.T) {
+ base := &apicompat.AnthropicRequest{
+ Model: "claude-sonnet-4-5",
+ System: mustRawJSON(t, `"You are helpful."`),
+ Messages: []apicompat.AnthropicMessage{
+ {Role: "user", Content: mustRawJSON(t, `"Open repo"`)},
+ },
+ }
+ extended := &apicompat.AnthropicRequest{
+ Model: "claude-sonnet-4-5",
+ System: mustRawJSON(t, `"You are helpful."`),
+ Messages: []apicompat.AnthropicMessage{
+ {Role: "user", Content: mustRawJSON(t, `"Open repo"`)},
+ {Role: "assistant", Content: mustRawJSON(t, `"Opened."`)},
+ {Role: "user", Content: mustRawJSON(t, `"Run tests"`)},
+ },
+ }
+
+ k1 := deriveAnthropicCompatPromptCacheKey(base, "gpt-5.3-codex")
+ k2 := deriveAnthropicCompatPromptCacheKey(extended, "gpt-5.3-codex")
+ require.NotEmpty(t, k1)
+ require.Equal(t, k1, k2, "cache key should stay stable as later Claude Code turns append history")
+}
+
+func TestDeriveAnthropicCompatPromptCacheKey_UsesCacheControlAnchors(t *testing.T) {
+ base := &apicompat.AnthropicRequest{
+ Model: "claude-sonnet-4-5",
+ System: mustRawJSON(t, `[
+ {"type":"text","text":"project instructions","cache_control":{"type":"ephemeral"}}
+ ]`),
+ Messages: []apicompat.AnthropicMessage{
+ {Role: "user", Content: mustRawJSON(t, `[
+ {"type":"text","text":"repo anchor","cache_control":{"type":"ephemeral"}}
+ ]`)},
+ },
+ }
+ extended := &apicompat.AnthropicRequest{
+ Model: base.Model,
+ System: base.System,
+ Messages: []apicompat.AnthropicMessage{
+ base.Messages[0],
+ {Role: "assistant", Content: mustRawJSON(t, `[{"type":"text","text":"Opened."}]`)},
+ {Role: "user", Content: mustRawJSON(t, `[{"type":"text","text":"Run tests"}]`)},
+ },
+ }
+
+ k1 := deriveAnthropicCompatPromptCacheKey(base, "gpt-5.4")
+ k2 := deriveAnthropicCompatPromptCacheKey(extended, "gpt-5.4")
+ require.NotEmpty(t, k1)
+ require.Equal(t, k1, k2)
+ require.True(t, strings.HasPrefix(k1, "anthropic-cache-"))
+ require.False(t, strings.HasPrefix(k1, compatPromptCacheKeyPrefix))
+}
diff --git a/backend/internal/service/openai_fast_policy_ws_test.go b/backend/internal/service/openai_fast_policy_ws_test.go
index 3316a242c9f..fc464ccbd6b 100644
--- a/backend/internal/service/openai_fast_policy_ws_test.go
+++ b/backend/internal/service/openai_fast_policy_ws_test.go
@@ -282,6 +282,34 @@ func TestPolicyEnforcingFrameConn_FollowupFrameWithoutModelUsesCapturedModel(t *
require.Equal(t, "response.create", gjson.GetBytes(payload, "type").String())
}
+func TestRewriteOpenAIWSPassthroughMappedModel_GPT56SuffixAndEffort(t *testing.T) {
+ t.Parallel()
+
+ account := &Account{
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "model_mapping": map[string]any{"gpt-5.6-luna": "gpt-5.6-luna"},
+ },
+ }
+
+ for _, tt := range []struct {
+ name string
+ payload string
+ wantEffort string
+ }{
+ {name: "suffix derives effort", payload: `{"type":"response.create","model":"openai/gpt_5.6_luna_high"}`, wantEffort: "high"},
+ {name: "explicit effort wins", payload: `{"type":"response.create","model":"gpt-5.6-luna-xhigh","reasoning":{"effort":"low"}}`, wantEffort: "low"},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := rewriteOpenAIWSPassthroughMappedModel(account, []byte(tt.payload))
+ require.NoError(t, err)
+ require.Equal(t, "gpt-5.6-luna", gjson.GetBytes(got, "model").String())
+ require.Equal(t, tt.wantEffort, gjson.GetBytes(got, "reasoning.effort").String())
+ })
+ }
+}
+
// TestPolicyEnforcingFrameConn_WithoutCapturedFallbackPolicyMisses pins the
// inverse: when the wrapper has NO capturedSessionModel fallback (model is
// empty per-frame and no fallback is wired up), the policy fails to match
@@ -318,7 +346,6 @@ func TestPolicyEnforcingFrameConn_WithoutCapturedFallbackPolicyMisses(t *testing
// is normalized + filtered out before being written upstream. This is the
// integration flavour of TestWSResponseCreate_FilterStripsServiceTier.
func TestWSResponseCreate_IngressFiltersServiceTierBeforeUpstream(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -440,7 +467,6 @@ func TestWSResponseCreate_IngressFiltersServiceTierBeforeUpstream(t *testing.T)
// asserts that with a custom block rule, the client receives a Realtime-style
// error event AND the upstream FrameConn never receives the offending frame.
func TestWSResponseCreate_IngressBlockSendsErrorEventAndSkipsUpstream(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -667,39 +693,31 @@ func TestForwardAsAnthropicMessages_BetaFastModeTriggersOpenAIFastPolicy(t *test
require.NotContains(t, string(upstreamBody), `"service_tier"`, "default policy 命中 gpt-5.5 priority 应当 filter 掉 service_tier")
}
-// --- Fix1: passthrough capturedSessionModel must follow session.update ---
-
-// TestPolicyEnforcingFrameConn_SessionUpdateRotatesCapturedModel covers the
-// fix1 bypass: client opens with a whitelist-miss model (gpt-4o → pass under
-// gpt-5.5 whitelist), rotates to gpt-5.5 via session.update, then sends
-// response.create without "model". Without the session.update sniffing the
-// follow-up frame would fall back to the stale gpt-4o capture and pass — the
-// fix updates capturedSessionModel from session.* events so the fallback now
-// resolves to gpt-5.5 and the policy filters service_tier.
-func TestPolicyEnforcingFrameConn_SessionUpdateRotatesCapturedModel(t *testing.T) {
+func TestPolicyEnforcingFrameConn_RejectsSessionModelRotation(t *testing.T) {
svc := newOpenAIGatewayServiceWithSettings(t, gpt55WhitelistFastPolicy())
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
- // Frame 1: response.create with whitelist-miss model — under default
- // rule fallback=pass, service_tier stays.
first := []byte(`{"type":"response.create","model":"gpt-4o","service_tier":"priority"}`)
- // Frame 2: session.update rotates the session model to gpt-5.5.
rotate := []byte(`{"type":"session.update","session":{"model":"gpt-5.5"}}`)
- // Frame 3: response.create WITHOUT model — must inherit gpt-5.5.
- followup := []byte(`{"type":"response.create","service_tier":"priority"}`)
+ inner := &fakePassthroughFrameConn{reads: [][]byte{first, rotate}}
- inner := &fakePassthroughFrameConn{reads: [][]byte{first, rotate, followup}}
-
- // Replicate the production wiring in openai_ws_v2_passthrough_adapter.go
- // so capturedSessionModel state is shared across frames.
capturedSessionModel := openAIWSPassthroughPolicyModelForFrame(account, first)
require.Equal(t, "gpt-4o", capturedSessionModel)
+ initialCanonical, ok := resolveOpenAIWSSessionCanonicalModel(account, "gpt-4o")
+ require.True(t, ok)
wrapper := &openAIWSPolicyEnforcingFrameConn{
inner: inner,
filter: func(msgType coderws.MessageType, payload []byte) ([]byte, *OpenAIFastBlockedError, error) {
if msgType != coderws.MessageText {
return payload, nil, nil
}
+ candidateModel := openAIWSPassthroughRequestModelForFrame(payload)
+ if candidateModel == "" {
+ candidateModel = openAIWSPassthroughRequestModelFromSessionFrame(payload)
+ }
+ if err := validateOpenAIWSSessionModel(account, initialCanonical, candidateModel); err != nil {
+ return payload, nil, err
+ }
if updated := openAIWSPassthroughPolicyModelFromSessionFrame(account, payload); updated != "" {
capturedSessionModel = updated
}
@@ -711,23 +729,15 @@ func TestPolicyEnforcingFrameConn_SessionUpdateRotatesCapturedModel(t *testing.T
},
}
- // Frame 1: gpt-4o miss whitelist → pass (service_tier preserved).
_, payload1, err := wrapper.ReadFrame(context.Background())
require.NoError(t, err)
- require.Contains(t, string(payload1), `"service_tier"`, "frame1: gpt-4o miss whitelist → pass keeps service_tier")
-
- // Frame 2: session.update — not response.create, untouched, but its
- // side effect updates capturedSessionModel to gpt-5.5.
- _, payload2, err := wrapper.ReadFrame(context.Background())
- require.NoError(t, err)
- require.Equal(t, string(rotate), string(payload2), "session.update frame is forwarded verbatim")
- require.Equal(t, "gpt-5.5", capturedSessionModel, "fix1: session.update must rotate capturedSessionModel")
-
- // Frame 3: empty model + new captured gpt-5.5 → matches whitelist → filter.
- _, payload3, err := wrapper.ReadFrame(context.Background())
- require.NoError(t, err)
- require.NotContains(t, string(payload3), `"service_tier"`,
- "fix1: post-rotate response.create without model must use refreshed capturedSessionModel and trigger filter")
+ require.Contains(t, string(payload1), `"service_tier"`)
+ _, _, err = wrapper.ReadFrame(context.Background())
+ require.Error(t, err)
+ var closeErr *OpenAIWSClientCloseError
+ require.ErrorAs(t, err, &closeErr)
+ require.Equal(t, coderws.StatusPolicyViolation, closeErr.StatusCode())
+ require.Equal(t, "gpt-4o", capturedSessionModel)
}
// TestPolicyModelFromSessionFrame_OnlySessionUpdate covers the negative
@@ -972,6 +982,164 @@ func TestPassthroughBilling_MultiTurnServiceTierFollowsFilteredFrames(t *testing
"turn 3: response.create without service_tier overwrites billing to nil to match upstream default")
}
+func TestPassthroughUsageMeta_TracksReasoningEffortAcrossTurns(t *testing.T) {
+ svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings())
+ account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
+
+ firstFrame := []byte(`{"type":"response.create","model":"gpt-5.5","reasoning":{"effort":"medium"},"service_tier":"priority"}`)
+ meta := newOpenAIWSPassthroughUsageMeta("", firstFrame)
+ capturedSessionModel := openAIWSPassthroughPolicyModelForFrame(account, firstFrame)
+ firstOut, firstBlocked, firstErr := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, capturedSessionModel, firstFrame)
+ require.NoError(t, firstErr)
+ require.Nil(t, firstBlocked)
+ meta.initFromFirstFrame(firstOut)
+ require.NotNil(t, meta.reasoningEffort.Load())
+ require.Equal(t, "medium", *meta.reasoningEffort.Load())
+
+ process := func(payload []byte) ([]byte, *OpenAIFastBlockedError, error) {
+ if updated := openAIWSPassthroughPolicyModelFromSessionFrame(account, payload); updated != "" {
+ capturedSessionModel = updated
+ }
+ meta.updateSessionRequestModel(payload)
+ requestModelForThisFrame := meta.requestModelForFrame(payload)
+ model := openAIWSPassthroughPolicyModelForFrame(account, payload)
+ if model == "" {
+ model = capturedSessionModel
+ }
+ out, blocked, policyErr := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, model, payload)
+ if policyErr == nil && blocked == nil &&
+ strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" {
+ meta.updateFromResponseCreate(out, requestModelForThisFrame)
+ }
+ return out, blocked, policyErr
+ }
+
+ _, blockedSession, errSession := process([]byte(`{"type":"session.update","session":{"model":"gpt-5-high"}}`))
+ require.NoError(t, errSession)
+ require.Nil(t, blockedSession)
+ require.NotNil(t, meta.reasoningEffort.Load())
+ require.Equal(t, "medium", *meta.reasoningEffort.Load(), "session.update 只刷新后续 fallback model,不覆盖当前 turn metadata")
+
+ _, blockedCancel, errCancel := process([]byte(`{"type":"response.cancel","reasoning_effort":"x-high"}`))
+ require.NoError(t, errCancel)
+ require.Nil(t, blockedCancel)
+ require.NotNil(t, meta.reasoningEffort.Load())
+ require.Equal(t, "medium", *meta.reasoningEffort.Load(), "非 response.create 帧不能污染当前 turn metadata")
+
+ _, blockedFlat, errFlat := process([]byte(`{"type":"response.create","reasoning_effort":"x-high"}`))
+ require.NoError(t, errFlat)
+ require.Nil(t, blockedFlat)
+ require.NotNil(t, meta.reasoningEffort.Load())
+ require.Equal(t, "xhigh", *meta.reasoningEffort.Load(), "flat reasoning_effort 必须进入 passthrough usage metadata")
+
+ _, blockedClear, errClear := process([]byte(`{"type":"response.create","model":"gpt-4o"}`))
+ require.NoError(t, errClear)
+ require.Nil(t, blockedClear)
+ require.Nil(t, meta.reasoningEffort.Load(), "新的 response.create 无 effort 且无可推导后缀时必须清空旧值")
+}
+
+func TestWSPassthroughSessionUpdateGPT56SuffixAppliesToModelLessResponseCreate(t *testing.T) {
+ account := &Account{
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "model_mapping": map[string]any{"gpt-5.6-luna": "gpt-5.6-luna"},
+ },
+ }
+ meta := newOpenAIWSPassthroughUsageMeta("gpt-5.6-luna", nil)
+ sessionFrame := []byte(`{"type":"session.update","session":{"model":"gpt-5.6-luna-xhigh"}}`)
+ meta.updateSessionRequestModel(sessionFrame)
+
+ rewrittenSession, err := rewriteOpenAIWSPassthroughMappedModel(account, sessionFrame)
+ require.NoError(t, err)
+ require.Equal(t, "gpt-5.6-luna", gjson.GetBytes(rewrittenSession, "session.model").String())
+
+ responseCreate := []byte(`{"type":"response.create","input":[]}`)
+ effectiveModel := meta.requestModelForFrame(responseCreate)
+ rewrittenCreate, err := rewriteOpenAIWSPassthroughMappedModelForRequest(account, responseCreate, effectiveModel)
+ require.NoError(t, err)
+ require.Equal(t, "xhigh", gjson.GetBytes(rewrittenCreate, "reasoning.effort").String())
+
+ meta.updateFromResponseCreate(rewrittenCreate, "")
+ require.NotNil(t, meta.reasoningEffort.Load())
+ require.Equal(t, "xhigh", *meta.reasoningEffort.Load())
+}
+
+func TestOpenAIWSSessionCanonicalModelGuard(t *testing.T) {
+ account := &Account{
+ Platform: PlatformOpenAI,
+ Credentials: map[string]any{"model_mapping": map[string]any{
+ "gpt-5.4": "gpt-5.4",
+ "gpt-5.6-luna": "gpt-5.6-luna",
+ }},
+ }
+
+ initial, ok := resolveOpenAIWSSessionCanonicalModel(account, "gpt-5.4")
+ require.True(t, ok)
+ require.Equal(t, "gpt-5.4", initial)
+ require.NoError(t, validateOpenAIWSSessionModel(account, initial, "gpt-5.4-high"))
+ require.Error(t, validateOpenAIWSSessionModel(account, initial, "gpt-5.6-luna"))
+
+ luna, ok := resolveOpenAIWSSessionCanonicalModel(account, "gpt-5.6-luna-low")
+ require.True(t, ok)
+ require.NoError(t, validateOpenAIWSSessionModel(account, luna, "gpt-5.6-luna-xhigh"))
+ require.Error(t, validateOpenAIWSSessionModel(account, luna, "gpt-5.6-sol"))
+
+ unauthorized := &Account{
+ Platform: PlatformOpenAI,
+ Credentials: map[string]any{"model_mapping": map[string]any{
+ "gpt-5.4": "gpt-5.6-luna",
+ }},
+ }
+ _, ok = resolveOpenAIWSSessionCanonicalModel(unauthorized, "gpt-5.4")
+ require.False(t, ok)
+ _, err := rewriteOpenAIWSPassthroughMappedModel(unauthorized, []byte(`{"type":"session.update","session":{"model":"gpt-5.4"}}`))
+ require.Error(t, err)
+}
+
+func TestOpenAIWSSessionCanonicalModelGuard_PreservesUnknownProviderNamespace(t *testing.T) {
+ account := &Account{
+ Platform: PlatformOpenAI,
+ Credentials: map[string]any{"model_mapping": map[string]any{
+ "provider-a/gpt-custom": "provider-a/gpt-custom",
+ "provider-b/gpt-custom": "provider-b/gpt-custom",
+ "provider-a/gpt-5.4": "provider-a/gpt-5.4",
+ "provider-b/gpt-5.4": "provider-b/gpt-5.4",
+ "provider-a/gpt-5.8": "provider-a/gpt-5.8",
+ "provider-a/gpt-5.9": "provider-a/gpt-5.9",
+ }},
+ }
+
+ initial, ok := resolveOpenAIWSSessionCanonicalModel(account, "provider-a/gpt-custom")
+ require.True(t, ok)
+ require.Equal(t, "provider-a/gpt-custom", initial)
+ require.NoError(t, validateOpenAIWSSessionModel(account, initial, "provider-a/gpt-custom"))
+ require.Error(t, validateOpenAIWSSessionModel(account, initial, "provider-b/gpt-custom"))
+
+ known, ok := resolveOpenAIWSSessionCanonicalModel(account, "provider-a/gpt-5.4")
+ require.True(t, ok)
+ require.Equal(t, "provider-a/gpt-5.4", known)
+ require.Error(t, validateOpenAIWSSessionModel(account, known, "provider-b/gpt-5.4"))
+
+ future, ok := resolveOpenAIWSSessionCanonicalModel(account, "provider-a/gpt-5.8")
+ require.True(t, ok)
+ require.Equal(t, "provider-a/gpt-5.8", future)
+ require.Error(t, validateOpenAIWSSessionModel(account, future, "provider-a/gpt-5.9"))
+}
+
+func TestOpenAIWSSessionFrameModelGuard_RejectsBinaryBypass(t *testing.T) {
+ account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
+ initial, ok := resolveOpenAIWSSessionCanonicalModel(account, "gpt-5.4")
+ require.True(t, ok)
+
+ require.NoError(t, validateOpenAIWSSessionFrameModel(account, initial, coderws.MessageBinary,
+ []byte(`{"type":"response.create","model":"gpt-5.4-high"}`)))
+ require.Error(t, validateOpenAIWSSessionFrameModel(account, initial, coderws.MessageBinary,
+ []byte(`{"type":"response.create","model":"gpt-5.5"}`)))
+ require.Error(t, validateOpenAIWSSessionFrameModel(account, initial, coderws.MessageBinary,
+ []byte{0xff, 0x00, 0x01}))
+}
+
// TestPassthroughBilling_BlockedFrameDoesNotMutateServiceTier locks in the
// "block keeps previous" semantic: when policy returns block on a
// response.create frame, that frame is never sent upstream, so billing tier
diff --git a/backend/internal/service/openai_gateway_403_reset_test.go b/backend/internal/service/openai_gateway_403_reset_test.go
index c6805464955..440b94a9f92 100644
--- a/backend/internal/service/openai_gateway_403_reset_test.go
+++ b/backend/internal/service/openai_gateway_403_reset_test.go
@@ -20,20 +20,29 @@ func (s *openAI403CounterResetStub) ResetOpenAI403Count(_ context.Context, accou
return nil
}
-func TestOpenAIGatewayServiceRecordUsage_ResetsOpenAI403CounterBeforeZeroUsageReturn(t *testing.T) {
+func TestOpenAIGatewayServiceRecordUsage_ResetsOpenAI403CounterForZeroUsage(t *testing.T) {
counter := &openAI403CounterResetStub{}
rateLimitSvc := NewRateLimitService(nil, nil, nil, nil, nil)
rateLimitSvc.SetOpenAI403CounterCache(counter)
- svc := &OpenAIGatewayService{
- rateLimitService: rateLimitSvc,
- }
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ billingRepo := &openAIRecordUsageBillingRepoStub{result: &UsageBillingApplyResult{Applied: true}}
+ userRepo := &openAIRecordUsageUserRepoStub{}
+ subRepo := &openAIRecordUsageSubRepoStub{}
+ svc := newOpenAIRecordUsageServiceWithBillingRepoForTest(usageRepo, billingRepo, userRepo, subRepo, nil)
+ svc.rateLimitService = rateLimitSvc
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
- Result: &OpenAIForwardResult{},
+ Result: &OpenAIForwardResult{
+ RequestID: "resp_zero_usage_reset_403",
+ Model: "gpt-5.1",
+ },
+ APIKey: &APIKey{ID: 1001, Group: &Group{RateMultiplier: 1}},
+ User: &User{ID: 2001},
Account: &Account{ID: 777, Platform: PlatformOpenAI},
})
require.NoError(t, err)
require.Equal(t, []int64{777}, counter.resetCalls)
+ require.Equal(t, 1, usageRepo.calls)
}
diff --git a/backend/internal/service/openai_gateway_buffered_fallback.go b/backend/internal/service/openai_gateway_buffered_fallback.go
new file mode 100644
index 00000000000..1271972157b
--- /dev/null
+++ b/backend/internal/service/openai_gateway_buffered_fallback.go
@@ -0,0 +1,100 @@
+package service
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
+)
+
+func synthesizeBufferedResponsesResponse(acc *apicompat.BufferedResponseAccumulator, requestID, model string) *apicompat.ResponsesResponse {
+ if acc == nil || !acc.HasContent() || acc.HasFunctionCalls() {
+ return nil
+ }
+
+ id := strings.TrimSpace(acc.ResponseID())
+ if id == "" {
+ id = strings.TrimSpace(requestID)
+ }
+ if id == "" {
+ id = fmt.Sprintf("resp_%d", time.Now().UnixNano())
+ }
+ if observedModel := strings.TrimSpace(acc.ResponseModel()); observedModel != "" {
+ model = observedModel
+ }
+
+ return &apicompat.ResponsesResponse{
+ ID: id,
+ Object: "response",
+ Model: model,
+ Status: "completed",
+ Output: acc.BuildOutput(),
+ }
+}
+
+func bufferedMissingTerminalDetail(component string, eventsSeen int, lastEventType string, terminalEventSeen, terminalResponsePresent bool, acc *apicompat.BufferedResponseAccumulator, scanErr error) string {
+ scannerErr := ""
+ if scanErr != nil {
+ scannerErr = scanErr.Error()
+ }
+ hasContent := acc != nil && acc.HasContent()
+ hasFunctionCalls := acc != nil && acc.HasFunctionCalls()
+ return fmt.Sprintf(
+ "%s upstream stream ended without terminal response payload: events_seen=%d last_event_type=%q terminal_event_seen=%t terminal_response_present=%t accumulated_content=%t accumulated_function_calls=%t scanner_err=%q",
+ component,
+ eventsSeen,
+ lastEventType,
+ terminalEventSeen,
+ terminalResponsePresent,
+ hasContent,
+ hasFunctionCalls,
+ scannerErr,
+ )
+}
+
+func newBufferedMissingTerminalFailover(resp *http.Response, detail string) *UpstreamFailoverError {
+ headers := http.Header(nil)
+ if resp != nil && resp.Header != nil {
+ headers = resp.Header.Clone()
+ }
+ return &UpstreamFailoverError{
+ StatusCode: http.StatusBadGateway,
+ ResponseBody: []byte(detail),
+ ResponseHeaders: headers,
+ RetryableOnSameAccount: true,
+ }
+}
+
+func newBufferedReadFailover(resp *http.Response, component string, err error) *UpstreamFailoverError {
+ headers := http.Header(nil)
+ if resp != nil && resp.Header != nil {
+ headers = resp.Header.Clone()
+ }
+ msg := "upstream stream read failed"
+ if err != nil {
+ msg = sanitizeUpstreamErrorMessage(strings.TrimSpace(err.Error()))
+ if msg == "" {
+ msg = "upstream stream read failed"
+ }
+ }
+ component = strings.TrimSpace(component)
+ if component == "" {
+ component = "openai buffered"
+ }
+ detail := fmt.Sprintf("%s upstream stream read failed before buffered response was written: scanner_err=%q", component, msg)
+ body, _ := json.Marshal(map[string]any{
+ "error": map[string]string{
+ "type": "upstream_error",
+ "message": detail,
+ },
+ })
+ return &UpstreamFailoverError{
+ StatusCode: http.StatusBadGateway,
+ ResponseBody: body,
+ ResponseHeaders: headers,
+ RetryableOnSameAccount: false,
+ }
+}
diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go
index 5822ae4ccf8..ba19f1fdba1 100644
--- a/backend/internal/service/openai_gateway_chat_completions.go
+++ b/backend/internal/service/openai_gateway_chat_completions.go
@@ -10,10 +10,12 @@ import (
"io"
"net/http"
"strings"
+ "sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
@@ -39,9 +41,18 @@ var cursorResponsesUnsupportedFields = []string{
// ForwardAsChatCompletions accepts a Chat Completions request body, converts it
// to OpenAI Responses API format, forwards to the OpenAI upstream, and converts
-// the response back to Chat Completions format. All account types (OAuth and API
-// Key) go through the Responses API conversion path since the upstream only
-// exposes the /v1/responses endpoint.
+// the response back to Chat Completions format.
+//
+// 历史背景:该函数原本对所有 OpenAI 账号无差别走 CC→Responses 转换 + /v1/responses
+// 端点——这在 OAuth(ChatGPT 内部 API 仅支持 Responses)和官方 APIKey 账号上是
+// 正确的,但 sub2api 接入 DeepSeek/Kimi/GLM 等第三方 OpenAI 兼容上游后假设破裂:
+// 这些上游普遍只支持 /v1/chat/completions,无 /v1/responses 端点。
+//
+// 当前路由策略(基于账号探测标记,详见 openai_compat.ShouldUseResponsesAPI):
+// - APIKey 账号 + 探测确认不支持 Responses → 走 forwardAsRawChatCompletions
+// 直转上游 /v1/chat/completions,不做协议转换
+// - 其他所有情况(OAuth、APIKey 探测确认支持、未探测)→ 走原有 CC→Responses
+// 转换路径(保留旧行为,存量未探测账号零兼容破坏)
func (s *OpenAIGatewayService) ForwardAsChatCompletions(
ctx context.Context,
c *gin.Context,
@@ -50,6 +61,35 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
promptCacheKey string,
defaultMappedModel string,
) (*OpenAIForwardResult, error) {
+ return s.ForwardAsChatCompletionsWithOptions(ctx, c, account, body, promptCacheKey, defaultMappedModel, OpenAIForwardOptions{})
+}
+
+func (s *OpenAIGatewayService) ForwardAsChatCompletionsWithOptions(
+ ctx context.Context,
+ c *gin.Context,
+ account *Account,
+ body []byte,
+ promptCacheKey string,
+ defaultMappedModel string,
+ opts OpenAIForwardOptions,
+) (*OpenAIForwardResult, error) {
+ reasoningField := "reasoning_effort"
+ if !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists() {
+ reasoningField = "reasoning.effort"
+ }
+ requestModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
+ if injectedBody, _, injectErr := injectOpenAIGPT56ReasoningEffort(body, requestModel, reasoningField); injectErr != nil {
+ return nil, fmt.Errorf("inject GPT-5.6 reasoning effort: %w", injectErr)
+ } else {
+ body = injectedBody
+ }
+
+ // 入口分流:APIKey 账号 + 已探测且确认上游不支持 Responses,走 CC 直转。
+ // 标记缺失(未探测)按"现状即证据"原则继续走下方原 Responses 转换路径。
+ if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) {
+ return s.forwardAsRawChatCompletionsWithOptions(ctx, c, account, body, defaultMappedModel, opts)
+ }
+
startTime := time.Now()
// 1. Parse Chat Completions request
@@ -182,6 +222,15 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
}
responsesBody = updatedBody
+ billingIdentity, err := s.resolveForwardBillingIdentity(ctx, opts, originalModel, originalModel, billingModel, upstreamModel)
+ if err != nil {
+ return nil, err
+ }
+ setOpenAIUsageBillingReservationBody(opts.UsageBilling, responsesBody)
+ if err := s.prepareOpenAIForwardUsageBilling(ctx, account, billingIdentity, opts); err != nil {
+ return nil, err
+ }
+
// 5. Get access token
token, _, err := s.GetAccessToken(ctx, account)
if err != nil {
@@ -189,7 +238,9 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
}
// 6. Build upstream request
- upstreamReq, err := s.buildUpstreamRequest(ctx, c, account, responsesBody, token, true, promptCacheKey, false)
+ upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
+ upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, responsesBody, token, true, promptCacheKey, false)
+ releaseUpstreamCtx()
if err != nil {
return nil, fmt.Errorf("build upstream request: %w", err)
}
@@ -247,16 +298,14 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
Message: upstreamMsg,
Detail: upstreamDetail,
})
- if s.rateLimitService != nil {
- s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
- }
+ s.handleOpenAIUpstreamError(ctx, account, upstreamModel, resp.StatusCode, resp.Header, respBody)
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
RetryableOnSameAccount: account.IsPoolMode() && (isPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
}
}
- return s.handleChatCompletionsErrorResponse(resp, c, account)
+ return s.handleChatCompletionsErrorResponse(resp, c, account, upstreamModel)
}
// 9. Handle normal response
@@ -270,6 +319,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
// Propagate ServiceTier and ReasoningEffort to result for billing
if handleErr == nil && result != nil {
+ attachOpenAIBillingIdentity(result, billingIdentity)
if responsesReq.ServiceTier != "" {
st := responsesReq.ServiceTier
result.ServiceTier = &st
@@ -331,8 +381,9 @@ func (s *OpenAIGatewayService) handleChatCompletionsErrorResponse(
resp *http.Response,
c *gin.Context,
account *Account,
+ model string,
) (*OpenAIForwardResult, error) {
- return s.handleCompatErrorResponse(resp, c, account, writeChatCompletionsError)
+ return s.handleCompatErrorResponse(resp, c, account, model, writeChatCompletionsError)
}
// handleChatBufferedStreamingResponse reads all Responses SSE events from the
@@ -348,66 +399,28 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse(
) (*OpenAIForwardResult, error) {
requestID := resp.Header.Get("x-request-id")
- scanner := bufio.NewScanner(resp.Body)
- maxLineSize := defaultMaxLineSize
- if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.cfg.Gateway.MaxLineSize
+ finalResponse, usage, acc, err := s.readOpenAICompatBufferedTerminal(resp, "openai chat_completions buffered", requestID)
+ if err != nil {
+ return nil, err
}
- scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
-
- var finalResponse *apicompat.ResponsesResponse
- var usage OpenAIUsage
- acc := apicompat.NewBufferedResponseAccumulator()
- for scanner.Scan() {
- line := scanner.Text()
- if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
- continue
- }
- payload := line[6:]
-
- var event apicompat.ResponsesStreamEvent
- if err := json.Unmarshal([]byte(payload), &event); err != nil {
- logger.L().Warn("openai chat_completions buffered: failed to parse event",
- zap.Error(err),
+ if finalResponse == nil {
+ if synthetic := synthesizeBufferedResponsesResponse(acc, requestID, upstreamModel); synthetic != nil {
+ logger.L().Warn("openai chat_completions buffered: synthesized response after missing terminal event",
zap.String("request_id", requestID),
+ zap.String("response_id", synthetic.ID),
)
- continue
- }
-
- // Accumulate delta content for fallback when terminal output is empty.
- acc.ProcessEvent(&event)
-
- if (event.Type == "response.completed" || event.Type == "response.done" ||
- event.Type == "response.incomplete" || event.Type == "response.failed") &&
- event.Response != nil {
- finalResponse = event.Response
- if event.Response.Usage != nil {
- usage = OpenAIUsage{
- InputTokens: event.Response.Usage.InputTokens,
- OutputTokens: event.Response.Usage.OutputTokens,
- }
- if event.Response.Usage.InputTokensDetails != nil {
- usage.CacheReadInputTokens = event.Response.Usage.InputTokensDetails.CachedTokens
- }
- }
- }
- }
-
- if err := scanner.Err(); err != nil {
- if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
- logger.L().Warn("openai chat_completions buffered: read error",
- zap.Error(err),
+ finalResponse = synthetic
+ } else {
+ detail := bufferedMissingTerminalDetail("openai chat_completions buffered", 0, "", false, false, acc, nil)
+ logger.L().Warn("openai chat_completions buffered: missing terminal response event",
zap.String("request_id", requestID),
+ zap.String("detail", detail),
)
+ return nil, newBufferedMissingTerminalFailover(resp, detail)
}
}
- if finalResponse == nil {
- writeChatCompletionsError(c, http.StatusBadGateway, "api_error", "Upstream stream ended without a terminal response event")
- return nil, fmt.Errorf("upstream stream ended without terminal event")
- }
-
// When the terminal event has an empty output array, reconstruct from
// accumulated delta events so the client receives the full content.
acc.SupplementResponseOutput(finalResponse)
@@ -459,14 +472,27 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
var usage OpenAIUsage
var firstTokenMs *int
firstChunk := true
+ clientDisconnected := false
+ var compatibilityErr error
scanner := bufio.NewScanner(resp.Body)
- maxLineSize := defaultMaxLineSize
- if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.cfg.Gateway.MaxLineSize
- }
+ maxLineSize := resolveGatewayMaxLineSize(s.cfg)
scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
+ streamInterval := time.Duration(0)
+ if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 {
+ streamInterval = time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second
+ }
+ var intervalTicker *time.Ticker
+ if streamInterval > 0 {
+ intervalTicker = time.NewTicker(streamInterval)
+ defer intervalTicker.Stop()
+ }
+ var intervalCh <-chan time.Time
+ if intervalTicker != nil {
+ intervalCh = intervalTicker.C
+ }
+
resultWithUsage := func() *OpenAIForwardResult {
return &OpenAIForwardResult{
RequestID: requestID,
@@ -496,54 +522,77 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
return false
}
- // Extract usage from completion events
- if (event.Type == "response.completed" || event.Type == "response.incomplete" || event.Type == "response.failed") &&
- event.Response != nil && event.Response.Usage != nil {
- usage = OpenAIUsage{
- InputTokens: event.Response.Usage.InputTokens,
- OutputTokens: event.Response.Usage.OutputTokens,
- }
- if event.Response.Usage.InputTokensDetails != nil {
- usage.CacheReadInputTokens = event.Response.Usage.InputTokensDetails.CachedTokens
- }
+ // 仅按兼容转换器支持的终止事件提取 usage,避免无意扩大事件语义。
+ isTerminalEvent := isOpenAICompatResponsesTerminalEvent(event.Type)
+ if isTerminalEvent && event.Response != nil && event.Response.Usage != nil {
+ usage = copyOpenAIUsageFromResponsesUsage(event.Response.Usage)
}
chunks := apicompat.ResponsesEventToChatChunks(&event, state)
- for _, chunk := range chunks {
- sse, err := apicompat.ChatChunkToSSE(chunk)
- if err != nil {
- logger.L().Warn("openai chat_completions stream: failed to marshal chunk",
- zap.Error(err),
- zap.String("request_id", requestID),
- )
- continue
- }
- if _, err := fmt.Fprint(c.Writer, sse); err != nil {
- logger.L().Info("openai chat_completions stream: client disconnected",
- zap.String("request_id", requestID),
- )
- return true
+ if err := state.Err(); err != nil {
+ compatibilityErr = err
+ logger.L().Warn("openai chat_completions stream: compatibility resource limit exceeded",
+ zap.Error(err),
+ zap.String("request_id", requestID),
+ )
+ return true
+ }
+ if !clientDisconnected {
+ for _, chunk := range chunks {
+ sse, err := apicompat.ChatChunkToSSE(chunk)
+ if err != nil {
+ logger.L().Warn("openai chat_completions stream: failed to marshal chunk",
+ zap.Error(err),
+ zap.String("request_id", requestID),
+ )
+ continue
+ }
+ if _, err := fmt.Fprint(c.Writer, sse); err != nil {
+ clientDisconnected = true
+ logger.L().Info("openai chat_completions stream: client disconnected, continuing to drain upstream for billing",
+ zap.String("request_id", requestID),
+ )
+ break
+ }
}
}
- if len(chunks) > 0 {
+ if len(chunks) > 0 && !clientDisconnected {
c.Writer.Flush()
}
- return false
+ return isTerminalEvent
}
finalizeStream := func() (*OpenAIForwardResult, error) {
- if finalChunks := apicompat.FinalizeResponsesChatStream(state); len(finalChunks) > 0 {
+ if compatibilityErr != nil {
+ return resultWithUsage(), compatibilityErr
+ }
+ if finalChunks := apicompat.FinalizeResponsesChatStream(state); len(finalChunks) > 0 && !clientDisconnected {
for _, chunk := range finalChunks {
sse, err := apicompat.ChatChunkToSSE(chunk)
if err != nil {
continue
}
- fmt.Fprint(c.Writer, sse) //nolint:errcheck
+ if _, err := fmt.Fprint(c.Writer, sse); err != nil {
+ clientDisconnected = true
+ logger.L().Info("openai chat_completions stream: client disconnected during final flush",
+ zap.String("request_id", requestID),
+ )
+ break
+ }
}
}
// Send [DONE] sentinel
- fmt.Fprint(c.Writer, "data: [DONE]\n\n") //nolint:errcheck
- c.Writer.Flush()
+ if !clientDisconnected {
+ if _, err := fmt.Fprint(c.Writer, "data: [DONE]\n\n"); err != nil {
+ clientDisconnected = true
+ logger.L().Info("openai chat_completions stream: client disconnected during done flush",
+ zap.String("request_id", requestID),
+ )
+ }
+ }
+ if !clientDisconnected {
+ c.Writer.Flush()
+ }
return resultWithUsage(), nil
}
@@ -555,6 +604,9 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
)
}
}
+ missingTerminalErr := func() (*OpenAIForwardResult, error) {
+ return resultWithUsage(), fmt.Errorf("stream usage incomplete: missing terminal event")
+ }
// Determine keepalive interval
keepaliveInterval := time.Duration(0)
@@ -563,18 +615,25 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
}
// No keepalive: fast synchronous path
- if keepaliveInterval <= 0 {
+ if streamInterval <= 0 && keepaliveInterval <= 0 {
for scanner.Scan() {
line := scanner.Text()
- if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
+ payload, ok := extractOpenAISSEDataLine(line)
+ if !ok {
continue
}
- if processDataLine(line[6:]) {
- return resultWithUsage(), nil
+ if strings.TrimSpace(payload) == "[DONE]" {
+ return missingTerminalErr()
+ }
+ if processDataLine(payload) {
+ return finalizeStream()
}
}
- handleScanErr(scanner.Err())
- return finalizeStream()
+ if err := scanner.Err(); err != nil {
+ handleScanErr(err)
+ return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", err)
+ }
+ return missingTerminalErr()
}
// With keepalive: goroutine + channel + select
@@ -584,6 +643,8 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
}
events := make(chan scanEvent, 16)
done := make(chan struct{})
+ var lastReadAt int64
+ atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
sendEvent := func(ev scanEvent) bool {
select {
case events <- ev:
@@ -595,6 +656,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
go func() {
defer close(events)
for scanner.Scan() {
+ atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
if !sendEvent(scanEvent{line: scanner.Text()}) {
return
}
@@ -605,30 +667,59 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
}()
defer close(done)
- keepaliveTicker := time.NewTicker(keepaliveInterval)
- defer keepaliveTicker.Stop()
+ var keepaliveTicker *time.Ticker
+ if keepaliveInterval > 0 {
+ keepaliveTicker = time.NewTicker(keepaliveInterval)
+ defer keepaliveTicker.Stop()
+ }
+ var keepaliveCh <-chan time.Time
+ if keepaliveTicker != nil {
+ keepaliveCh = keepaliveTicker.C
+ }
lastDataAt := time.Now()
for {
select {
case ev, ok := <-events:
if !ok {
- return finalizeStream()
+ return missingTerminalErr()
}
if ev.err != nil {
handleScanErr(ev.err)
- return finalizeStream()
+ return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", ev.err)
}
lastDataAt = time.Now()
line := ev.line
- if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
+ payload, ok := extractOpenAISSEDataLine(line)
+ if !ok {
+ continue
+ }
+ if strings.TrimSpace(payload) == "[DONE]" {
+ return missingTerminalErr()
+ }
+ if processDataLine(payload) {
+ return finalizeStream()
+ }
+
+ case <-intervalCh:
+ lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
+ if time.Since(lastRead) < streamInterval {
continue
}
- if processDataLine(line[6:]) {
- return resultWithUsage(), nil
+ if clientDisconnected {
+ return resultWithUsage(), fmt.Errorf("stream usage incomplete after timeout")
}
+ logger.L().Warn("openai chat_completions stream: data interval timeout",
+ zap.String("request_id", requestID),
+ zap.String("model", originalModel),
+ zap.Duration("interval", streamInterval),
+ )
+ return resultWithUsage(), fmt.Errorf("stream data interval timeout")
- case <-keepaliveTicker.C:
+ case <-keepaliveCh:
+ if clientDisconnected {
+ continue
+ }
if time.Since(lastDataAt) < keepaliveInterval {
continue
}
@@ -637,7 +728,8 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
logger.L().Info("openai chat_completions stream: client disconnected during keepalive",
zap.String("request_id", requestID),
)
- return resultWithUsage(), nil
+ clientDisconnected = true
+ continue
}
c.Writer.Flush()
}
diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go
new file mode 100644
index 00000000000..bdf2422e471
--- /dev/null
+++ b/backend/internal/service/openai_gateway_chat_completions_raw.go
@@ -0,0 +1,458 @@
+package service
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
+ "github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
+ "github.com/gin-gonic/gin"
+ "github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
+ "go.uber.org/zap"
+)
+
+// openaiCCRawAllowedHeaders 是 CC 直转路径专用的客户端 header 透传白名单。
+//
+// **关键**:不能复用 openaiAllowedHeaders——后者含 Codex 客户端专属 header
+// (originator / session_id / x-codex-turn-state / x-codex-turn-metadata / conversation_id),
+// 这些在 ChatGPT OAuth 上游是必需的,但透传给 DeepSeek/Kimi/GLM 等第三方
+// OpenAI 兼容上游会造成:
+// - 完全忽略(多数友好厂商)——隐性污染上游统计
+// - 400 "unknown parameter"(严格上游)——可见错误
+//
+// 这里仅放行通用 HTTP header;content-type / authorization / accept 由上下文
+// 显式设置,不依赖透传。
+//
+// 参见决策记录:
+// pensieve/short-term/maxims/dont-reuse-shared-headers-whitelist-across-different-upstream-trust-domains
+var openaiCCRawAllowedHeaders = map[string]bool{
+ "accept-language": true,
+ "user-agent": true,
+}
+
+// forwardAsRawChatCompletions 直转客户端的 Chat Completions 请求到上游
+// `{base_url}/v1/chat/completions`,**不**做 CC↔Responses 协议转换。
+//
+// 适用场景:account.platform=openai && account.type=apikey && 上游已被探测确认
+// 不支持 /v1/responses 端点(如 DeepSeek/Kimi/GLM/Qwen 等第三方 OpenAI 兼容上游)。
+//
+// 与 ForwardAsChatCompletions 的关键差异:
+//
+// - 不调用 apicompat.ChatCompletionsToResponses,body 仅做模型 ID 改写
+// - 上游 URL 拼到 /v1/chat/completions 而非 /v1/responses
+// - 流式响应 SSE 直接透传给客户端(上游 chunk 已是 CC 格式)
+// - 非流式响应 JSON 直接透传,仅按需提取 usage
+// - 不应用 codex OAuth transform(APIKey 路径无 OAuth)
+// - 不注入 prompt_cache_key(OAuth 专属机制)
+//
+// 调用入口:openai_gateway_chat_completions.go::ForwardAsChatCompletions
+// 在函数顶部按 openai_compat.ShouldUseResponsesAPI 分流。
+func (s *OpenAIGatewayService) forwardAsRawChatCompletions(
+ ctx context.Context,
+ c *gin.Context,
+ account *Account,
+ body []byte,
+ defaultMappedModel string,
+) (*OpenAIForwardResult, error) {
+ return s.forwardAsRawChatCompletionsWithOptions(ctx, c, account, body, defaultMappedModel, OpenAIForwardOptions{})
+}
+
+func (s *OpenAIGatewayService) forwardAsRawChatCompletionsWithOptions(
+ ctx context.Context,
+ c *gin.Context,
+ account *Account,
+ body []byte,
+ defaultMappedModel string,
+ opts OpenAIForwardOptions,
+) (*OpenAIForwardResult, error) {
+ startTime := time.Now()
+
+ // 1. Parse minimal fields needed for routing/billing
+ originalModel := gjson.GetBytes(body, "model").String()
+ if originalModel == "" {
+ writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "model is required")
+ return nil, fmt.Errorf("missing model in request")
+ }
+ clientStream := gjson.GetBytes(body, "stream").Bool()
+
+ // 1b. Extract reasoning effort and service tier from the raw body before any transformation.
+ reasoningEffort := extractOpenAIReasoningEffortFromBody(body, originalModel)
+ serviceTier := extractOpenAIServiceTierFromBody(body)
+
+ // 2. Resolve model mapping (same as ForwardAsChatCompletions)
+ billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel)
+ upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel)
+
+ // 3. Rewrite model in body (no protocol conversion)
+ upstreamBody := body
+ if upstreamModel != originalModel {
+ upstreamBody = ReplaceModelInBody(body, upstreamModel)
+ }
+
+ // 4. Apply OpenAI fast policy on the CC body
+ updatedBody, policyErr := s.applyOpenAIFastPolicyToBody(ctx, account, upstreamModel, upstreamBody)
+ if policyErr != nil {
+ var blocked *OpenAIFastBlockedError
+ if errors.As(policyErr, &blocked) {
+ writeChatCompletionsError(c, http.StatusForbidden, "permission_error", blocked.Message)
+ }
+ return nil, policyErr
+ }
+ upstreamBody = updatedBody
+ if clientStream {
+ var usageErr error
+ upstreamBody, usageErr = ensureOpenAIChatStreamUsage(upstreamBody)
+ if usageErr != nil {
+ return nil, fmt.Errorf("enable stream usage: %w", usageErr)
+ }
+ }
+
+ logger.L().Debug("openai chat_completions raw: forwarding without protocol conversion",
+ zap.Int64("account_id", account.ID),
+ zap.String("original_model", originalModel),
+ zap.String("billing_model", billingModel),
+ zap.String("upstream_model", upstreamModel),
+ zap.Bool("stream", clientStream),
+ )
+
+ billingIdentity, err := s.resolveForwardBillingIdentity(ctx, opts, originalModel, originalModel, billingModel, upstreamModel)
+ if err != nil {
+ return nil, err
+ }
+ setOpenAIUsageBillingReservationBody(opts.UsageBilling, upstreamBody)
+ if err := s.prepareOpenAIForwardUsageBilling(ctx, account, billingIdentity, opts); err != nil {
+ return nil, err
+ }
+
+ // 5. Build upstream request
+ apiKey := account.GetOpenAIApiKey()
+ if apiKey == "" {
+ return nil, fmt.Errorf("account %d missing api_key", account.ID)
+ }
+ baseURL := account.GetOpenAIBaseURL()
+ if baseURL == "" {
+ baseURL = "https://api.openai.com"
+ }
+ validatedURL, err := s.validateUpstreamBaseURL(baseURL)
+ if err != nil {
+ return nil, fmt.Errorf("invalid base_url: %w", err)
+ }
+ targetURL := buildOpenAIChatCompletionsURL(validatedURL)
+
+ upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
+ upstreamReq, err := http.NewRequestWithContext(upstreamCtx, http.MethodPost, targetURL, bytes.NewReader(upstreamBody))
+ releaseUpstreamCtx()
+ if err != nil {
+ return nil, fmt.Errorf("build upstream request: %w", err)
+ }
+ upstreamReq.Header.Set("Content-Type", "application/json")
+ upstreamReq.Header.Set("Authorization", "Bearer "+apiKey)
+ if clientStream {
+ upstreamReq.Header.Set("Accept", "text/event-stream")
+ } else {
+ upstreamReq.Header.Set("Accept", "application/json")
+ }
+
+ // 透传白名单中的客户端 header。详见 openaiCCRawAllowedHeaders 的设计说明。
+ for key, values := range c.Request.Header {
+ lowerKey := strings.ToLower(key)
+ if openaiCCRawAllowedHeaders[lowerKey] {
+ for _, v := range values {
+ upstreamReq.Header.Add(key, v)
+ }
+ }
+ }
+ customUA := account.GetOpenAIUserAgent()
+ if customUA != "" {
+ upstreamReq.Header.Set("user-agent", customUA)
+ }
+
+ // 6. Send request
+ proxyURL := ""
+ if account.Proxy != nil {
+ proxyURL = account.Proxy.URL()
+ }
+ resp, err := s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency)
+ if err != nil {
+ safeErr := sanitizeUpstreamErrorMessage(err.Error())
+ setOpsUpstreamError(c, 0, safeErr, "")
+ appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
+ Platform: account.Platform,
+ AccountID: account.ID,
+ AccountName: account.Name,
+ UpstreamStatusCode: 0,
+ Kind: "request_error",
+ Message: safeErr,
+ })
+ writeChatCompletionsError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
+ return nil, fmt.Errorf("upstream request failed: %s", safeErr)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // 7. Handle error response with failover
+ if resp.StatusCode >= 400 {
+ respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
+ _ = resp.Body.Close()
+ resp.Body = io.NopCloser(bytes.NewReader(respBody))
+
+ upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
+ upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
+ if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) {
+ upstreamDetail := ""
+ if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
+ maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
+ if maxBytes <= 0 {
+ maxBytes = 2048
+ }
+ upstreamDetail = truncateString(string(respBody), maxBytes)
+ }
+ appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
+ Platform: account.Platform,
+ AccountID: account.ID,
+ AccountName: account.Name,
+ UpstreamStatusCode: resp.StatusCode,
+ UpstreamRequestID: resp.Header.Get("x-request-id"),
+ Kind: "failover",
+ Message: upstreamMsg,
+ Detail: upstreamDetail,
+ })
+ s.handleOpenAIUpstreamError(ctx, account, upstreamModel, resp.StatusCode, resp.Header, respBody)
+ return nil, &UpstreamFailoverError{
+ StatusCode: resp.StatusCode,
+ ResponseBody: respBody,
+ RetryableOnSameAccount: account.IsPoolMode() && (isPoolModeRetryableStatus(resp.StatusCode) || isOpenAITransientProcessingError(resp.StatusCode, upstreamMsg, respBody)),
+ }
+ }
+ return s.handleChatCompletionsErrorResponse(resp, c, account, upstreamModel)
+ }
+
+ // 8. Forward response
+ var result *OpenAIForwardResult
+ if clientStream {
+ result, err = s.streamRawChatCompletions(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
+ } else {
+ result, err = s.bufferRawChatCompletions(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime)
+ }
+ if err == nil {
+ attachOpenAIBillingIdentity(result, billingIdentity)
+ }
+ return result, err
+}
+
+// streamRawChatCompletions 透传上游 CC SSE 流到客户端,并提取 usage(包括
+// 末尾 [DONE] 之前的 chunk 中的 usage 字段,按 OpenAI CC 协议)。
+//
+// usage 字段仅在客户端请求 stream_options.include_usage=true 时出现于上游响应中。
+// 网关会对上游强制打开 include_usage 以保证计费完整,并原样向下游透传 usage,
+// 让级联代理或下游计费系统也能拿到完整用量。
+func (s *OpenAIGatewayService) streamRawChatCompletions(
+ c *gin.Context,
+ resp *http.Response,
+ originalModel string,
+ billingModel string,
+ upstreamModel string,
+ reasoningEffort *string,
+ serviceTier *string,
+ startTime time.Time,
+) (*OpenAIForwardResult, error) {
+ requestID := resp.Header.Get("x-request-id")
+
+ if s.responseHeaderFilter != nil {
+ responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
+ }
+ c.Writer.Header().Set("Content-Type", "text/event-stream")
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.Header().Set("X-Accel-Buffering", "no")
+ c.Writer.WriteHeader(http.StatusOK)
+
+ scanner := bufio.NewScanner(resp.Body)
+ maxLineSize := resolveGatewayMaxLineSize(s.cfg)
+ scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
+
+ var usage OpenAIUsage
+ var firstTokenMs *int
+ clientDisconnected := false
+
+ for scanner.Scan() {
+ line := scanner.Text()
+ if payload, ok := extractOpenAISSEDataLine(line); ok {
+ trimmedPayload := strings.TrimSpace(payload)
+ if trimmedPayload != "[DONE]" {
+ usageOnlyChunk := isOpenAIChatUsageOnlyStreamChunk(payload)
+ if u := extractCCStreamUsage(payload); u != nil {
+ usage = *u
+ }
+ if firstTokenMs == nil && !usageOnlyChunk {
+ elapsed := int(time.Since(startTime).Milliseconds())
+ firstTokenMs = &elapsed
+ }
+ }
+ }
+
+ if !clientDisconnected {
+ if _, werr := c.Writer.WriteString(line + "\n"); werr != nil {
+ clientDisconnected = true
+ logger.L().Debug("openai chat_completions raw: client disconnected, continuing to drain upstream for billing",
+ zap.Error(werr),
+ zap.String("request_id", requestID),
+ )
+ }
+ }
+ if line == "" {
+ if !clientDisconnected {
+ c.Writer.Flush()
+ }
+ continue
+ }
+ if !clientDisconnected {
+ c.Writer.Flush()
+ }
+ }
+
+ if err := scanner.Err(); err != nil {
+ if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
+ logger.L().Warn("openai chat_completions raw: stream read error",
+ zap.Error(err),
+ zap.String("request_id", requestID),
+ )
+ }
+ }
+
+ return &OpenAIForwardResult{
+ RequestID: requestID,
+ Usage: usage,
+ Model: originalModel,
+ BillingModel: billingModel,
+ UpstreamModel: upstreamModel,
+ ReasoningEffort: reasoningEffort,
+ ServiceTier: serviceTier,
+ Stream: true,
+ Duration: time.Since(startTime),
+ FirstTokenMs: firstTokenMs,
+ }, nil
+}
+
+// ensureOpenAIChatStreamUsage 确保 raw Chat Completions 流式请求会让上游返回 usage。
+// usage 也会继续向下游透传,支持级联代理和下游计费系统。
+func ensureOpenAIChatStreamUsage(body []byte) ([]byte, error) {
+ updated, err := sjson.SetBytes(body, "stream_options.include_usage", true)
+ if err != nil {
+ return body, err
+ }
+ return updated, nil
+}
+
+func isOpenAIChatUsageOnlyStreamChunk(payload string) bool {
+ if strings.TrimSpace(payload) == "" {
+ return false
+ }
+ if !gjson.Get(payload, "usage").Exists() {
+ return false
+ }
+ choices := gjson.Get(payload, "choices")
+ return choices.Exists() && choices.IsArray() && len(choices.Array()) == 0
+}
+
+// extractCCStreamUsage 从单个 CC 流式 chunk 的 payload 中提取 usage 字段。
+// CC 协议中 usage 仅出现在末尾 chunk(且仅当 include_usage 生效时),
+// 但上游可能在多个 chunk 中重复——总是用最新值。
+func extractCCStreamUsage(payload string) *OpenAIUsage {
+ usageResult := gjson.Get(payload, "usage")
+ if !usageResult.Exists() || !usageResult.IsObject() {
+ return nil
+ }
+ u := OpenAIUsage{
+ InputTokens: int(gjson.Get(payload, "usage.prompt_tokens").Int()),
+ OutputTokens: int(gjson.Get(payload, "usage.completion_tokens").Int()),
+ }
+ if cached := gjson.Get(payload, "usage.prompt_tokens_details.cached_tokens"); cached.Exists() {
+ u.CacheReadInputTokens = int(cached.Int())
+ }
+ return &u
+}
+
+// bufferRawChatCompletions 透传上游 CC 非流式 JSON 响应。
+func (s *OpenAIGatewayService) bufferRawChatCompletions(
+ c *gin.Context,
+ resp *http.Response,
+ originalModel string,
+ billingModel string,
+ upstreamModel string,
+ reasoningEffort *string,
+ serviceTier *string,
+ startTime time.Time,
+) (*OpenAIForwardResult, error) {
+ requestID := resp.Header.Get("x-request-id")
+
+ respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
+ if err != nil {
+ if !errors.Is(err, ErrUpstreamResponseBodyTooLarge) {
+ writeChatCompletionsError(c, http.StatusBadGateway, "api_error", "Failed to read upstream response")
+ }
+ return nil, fmt.Errorf("read upstream body: %w", err)
+ }
+
+ var ccResp apicompat.ChatCompletionsResponse
+ var usage OpenAIUsage
+ if err := json.Unmarshal(respBody, &ccResp); err == nil && ccResp.Usage != nil {
+ usage = OpenAIUsage{
+ InputTokens: ccResp.Usage.PromptTokens,
+ OutputTokens: ccResp.Usage.CompletionTokens,
+ }
+ if ccResp.Usage.PromptTokensDetails != nil {
+ usage.CacheReadInputTokens = ccResp.Usage.PromptTokensDetails.CachedTokens
+ }
+ }
+
+ if s.responseHeaderFilter != nil {
+ responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
+ }
+ if ct := resp.Header.Get("Content-Type"); ct != "" {
+ c.Writer.Header().Set("Content-Type", ct)
+ } else {
+ c.Writer.Header().Set("Content-Type", "application/json")
+ }
+ c.Writer.WriteHeader(http.StatusOK)
+ _, _ = c.Writer.Write(respBody)
+
+ return &OpenAIForwardResult{
+ RequestID: requestID,
+ Usage: usage,
+ Model: originalModel,
+ BillingModel: billingModel,
+ UpstreamModel: upstreamModel,
+ ReasoningEffort: reasoningEffort,
+ ServiceTier: serviceTier,
+ Stream: false,
+ Duration: time.Since(startTime),
+ }, nil
+}
+
+// buildOpenAIChatCompletionsURL 拼接上游 Chat Completions 端点 URL。
+//
+// - base 已是 /chat/completions:原样返回
+// - base 以 /v1 结尾:追加 /chat/completions
+// - 其他情况:追加 /v1/chat/completions
+//
+// 与 buildOpenAIResponsesURL 是姐妹函数。
+func buildOpenAIChatCompletionsURL(base string) string {
+ normalized := strings.TrimRight(strings.TrimSpace(base), "/")
+ if strings.HasSuffix(normalized, "/chat/completions") {
+ return normalized
+ }
+ if strings.HasSuffix(normalized, "/v1") {
+ return normalized + "/chat/completions"
+ }
+ return normalized + "/v1/chat/completions"
+}
diff --git a/backend/internal/service/openai_gateway_chat_completions_raw_test.go b/backend/internal/service/openai_gateway_chat_completions_raw_test.go
new file mode 100644
index 00000000000..ddb9864e77f
--- /dev/null
+++ b/backend/internal/service/openai_gateway_chat_completions_raw_test.go
@@ -0,0 +1,256 @@
+//go:build unit
+
+package service
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+func TestBuildOpenAIChatCompletionsURL(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ base string
+ want string
+ }{
+ // 已是 /chat/completions:原样返回
+ {"already chat/completions", "https://api.openai.com/v1/chat/completions", "https://api.openai.com/v1/chat/completions"},
+ // 以 /v1 结尾:追加 /chat/completions
+ {"bare /v1", "https://api.openai.com/v1", "https://api.openai.com/v1/chat/completions"},
+ // 其他情况:追加 /v1/chat/completions
+ {"bare domain", "https://api.openai.com", "https://api.openai.com/v1/chat/completions"},
+ {"domain with trailing slash", "https://api.openai.com/", "https://api.openai.com/v1/chat/completions"},
+ // 第三方上游常见形式
+ {"third-party bare domain", "https://api.deepseek.com", "https://api.deepseek.com/v1/chat/completions"},
+ {"third-party with path prefix", "https://api.gptgod.online/api", "https://api.gptgod.online/api/v1/chat/completions"},
+ // 带空白字符
+ {"whitespace trimmed", " https://api.openai.com/v1 ", "https://api.openai.com/v1/chat/completions"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := buildOpenAIChatCompletionsURL(tt.base)
+ require.Equal(t, tt.want, got)
+ })
+ }
+}
+
+// TestBuildOpenAIResponsesURL_ProbeURL 锁定 probe/测试端点使用的 URL 构建逻辑,
+// 确保 buildOpenAIResponsesURL 对标准 OpenAI base_url 格式均拼出 `/v1/responses`。
+func TestBuildOpenAIResponsesURL_ProbeURL(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ base string
+ want string
+ }{
+ {"bare domain", "https://api.openai.com", "https://api.openai.com/v1/responses"},
+ {"domain trailing slash", "https://api.openai.com/", "https://api.openai.com/v1/responses"},
+ {"bare /v1", "https://api.openai.com/v1", "https://api.openai.com/v1/responses"},
+ {"already /responses", "https://api.openai.com/v1/responses", "https://api.openai.com/v1/responses"},
+ {"third-party bare domain", "https://api.deepseek.com", "https://api.deepseek.com/v1/responses"},
+ {"only domain, no scheme", "api.gptgod.online", "api.gptgod.online/v1/responses"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ got := buildOpenAIResponsesURL(tt.base)
+ require.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestForwardAsRawChatCompletions_ForcesStreamUsageUpstreamAndPassesUsageDownstream(t *testing.T) {
+
+ body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := strings.Join([]string{
+ `data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"content":"ok"}}]}`,
+ "",
+ `data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-5.4","choices":[],"usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13,"prompt_tokens_details":{"cached_tokens":3}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_raw_usage"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+
+ svc := &OpenAIGatewayService{
+ cfg: rawChatCompletionsTestConfig(),
+ httpUpstream: upstream,
+ }
+ account := rawChatCompletionsTestAccount()
+
+ result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, 9, result.Usage.InputTokens)
+ require.Equal(t, 4, result.Usage.OutputTokens)
+ require.Equal(t, 3, result.Usage.CacheReadInputTokens)
+ require.NotNil(t, upstream.lastReq)
+ require.NoError(t, upstream.lastReq.Context().Err())
+ require.True(t, gjson.GetBytes(upstream.lastBody, "stream_options.include_usage").Bool())
+ require.Contains(t, rec.Body.String(), `"usage"`)
+ require.Contains(t, rec.Body.String(), "data: [DONE]")
+}
+
+func TestForwardAsRawChatCompletions_ClientDisconnectDrainsUsage(t *testing.T) {
+
+ body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Writer = &openAIChatFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := strings.Join([]string{
+ `data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"content":"ok"}}]}`,
+ "",
+ `data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-5.4","choices":[],"usage":{"prompt_tokens":17,"completion_tokens":8,"total_tokens":25,"prompt_tokens_details":{"cached_tokens":6}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_raw_disconnect"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+
+ svc := &OpenAIGatewayService{
+ cfg: rawChatCompletionsTestConfig(),
+ httpUpstream: upstream,
+ }
+ account := rawChatCompletionsTestAccount()
+
+ result, err := svc.forwardAsRawChatCompletions(context.Background(), c, account, body, "")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, 17, result.Usage.InputTokens)
+ require.Equal(t, 8, result.Usage.OutputTokens)
+ require.Equal(t, 6, result.Usage.CacheReadInputTokens)
+ require.True(t, gjson.GetBytes(upstream.lastBody, "stream_options.include_usage").Bool())
+}
+
+func TestForwardAsRawChatCompletions_UpstreamRequestIgnoresClientCancel(t *testing.T) {
+
+ reqCtx, cancel := context.WithCancel(context.Background())
+ body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)).WithContext(reqCtx)
+ c.Request.Header.Set("Content-Type", "application/json")
+ cancel()
+
+ upstreamBody := strings.Join([]string{
+ `data: {"id":"chatcmpl_1","object":"chat.completion.chunk","model":"gpt-5.4","choices":[],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_raw_ctx"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+
+ svc := &OpenAIGatewayService{
+ cfg: rawChatCompletionsTestConfig(),
+ httpUpstream: upstream,
+ }
+ account := rawChatCompletionsTestAccount()
+
+ result, err := svc.forwardAsRawChatCompletions(reqCtx, c, account, body, "")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.NotNil(t, upstream.lastReq)
+ require.NoError(t, upstream.lastReq.Context().Err())
+}
+
+func TestIsOpenAIChatUsageOnlyStreamChunk(t *testing.T) {
+ t.Parallel()
+
+ require.True(t, isOpenAIChatUsageOnlyStreamChunk(`{"choices":[],"usage":{"prompt_tokens":1,"completion_tokens":2}}`))
+ require.False(t, isOpenAIChatUsageOnlyStreamChunk(`{"choices":[{"index":0}],"usage":{"prompt_tokens":1,"completion_tokens":2}}`))
+ require.False(t, isOpenAIChatUsageOnlyStreamChunk(`{"choices":[]}`))
+ require.False(t, isOpenAIChatUsageOnlyStreamChunk(``))
+}
+
+func TestEnsureOpenAIChatStreamUsage(t *testing.T) {
+ t.Parallel()
+
+ body, err := ensureOpenAIChatStreamUsage([]byte(`{"model":"gpt-5.4"}`))
+ require.NoError(t, err)
+ require.True(t, gjson.GetBytes(body, "stream_options.include_usage").Bool())
+
+ body, err = ensureOpenAIChatStreamUsage([]byte(`{"model":"gpt-5.4","stream_options":{"include_usage":false}}`))
+ require.NoError(t, err)
+ require.True(t, gjson.GetBytes(body, "stream_options.include_usage").Bool())
+}
+
+func TestBufferRawChatCompletions_RejectsOversizedResponse(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader("toolong")),
+ }
+ svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig()}
+ svc.cfg.Gateway.UpstreamResponseReadMaxBytes = 3
+
+ result, err := svc.bufferRawChatCompletions(c, resp, "gpt-5.4", "gpt-5.4", "gpt-5.4", nil, nil, time.Now())
+ require.ErrorIs(t, err, ErrUpstreamResponseBodyTooLarge)
+ require.Nil(t, result)
+ require.Equal(t, http.StatusBadGateway, rec.Code)
+}
+
+func rawChatCompletionsTestConfig() *config.Config {
+ return &config.Config{
+ Security: config.SecurityConfig{
+ URLAllowlist: config.URLAllowlistConfig{
+ Enabled: false,
+ AllowInsecureHTTP: true,
+ },
+ },
+ }
+}
+
+func rawChatCompletionsTestAccount() *Account {
+ return &Account{
+ ID: 101,
+ Name: "raw-openai-apikey",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "api_key": "sk-test",
+ "base_url": "http://upstream.example",
+ },
+ }
+}
diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go
index 6846e03adc5..7c7777125d6 100644
--- a/backend/internal/service/openai_gateway_chat_completions_test.go
+++ b/backend/internal/service/openai_gateway_chat_completions_test.go
@@ -1,13 +1,37 @@
package service
import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
"testing"
+ "time"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
+ "github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
)
+type openAIChatFailingWriter struct {
+ gin.ResponseWriter
+ failAfter int
+ writes int
+}
+
+func (w *openAIChatFailingWriter) Write(p []byte) (int, error) {
+ if w.writes >= w.failAfter {
+ return 0, errors.New("write failed: client disconnected")
+ }
+ w.writes++
+ return w.ResponseWriter.Write(p)
+}
+
func TestNormalizeResponsesRequestServiceTier(t *testing.T) {
t.Parallel()
@@ -73,3 +97,362 @@ func TestNormalizeResponsesBodyServiceTier(t *testing.T) {
require.Empty(t, tier)
require.False(t, gjson.GetBytes(body, "service_tier").Exists())
}
+
+func TestForwardAsChatCompletions_UnknownModelDoesNotUseDefaultMappedModel(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"gpt6","messages":[{"role":"user","content":"hello"}],"stream":false}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusBadRequest,
+ Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_chat_unknown_model"}},
+ Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"model not found"}}`)),
+ }}
+
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4")
+ require.Error(t, err)
+ require.Nil(t, result)
+ require.Equal(t, "gpt6", gjson.GetBytes(upstream.lastBody, "model").String())
+ require.NotEqual(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String())
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+}
+
+func TestForwardAsChatCompletions_APIKeyGPT56SuffixUsesExactMappingAndReasoningEffort(t *testing.T) {
+
+ tests := []struct {
+ name string
+ explicitEffort string
+ wantEffort string
+ }{
+ {name: "suffix derives effort", wantEffort: "xhigh"},
+ {name: "explicit effort wins", explicitEffort: "medium", wantEffort: "medium"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"openai/gpt-5.6-terra-xhigh","messages":[{"role":"user","content":"hello"}],"stream":false}`)
+ if tt.explicitEffort != "" {
+ var err error
+ body, err = sjson.SetBytes(body, "reasoning_effort", tt.explicitEffort)
+ require.NoError(t, err)
+ }
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := strings.Join([]string{
+ `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.6-terra","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_gpt56_chat"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 22,
+ Name: "gpt56-chat-api-key",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "test-key",
+ "base_url": "https://example.invalid",
+ "model_mapping": map[string]any{
+ "gpt-5.6-terra": "gpt-5.6-terra",
+ },
+ },
+ }
+
+ result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, "gpt-5.6-terra", result.UpstreamModel)
+ require.Equal(t, "gpt-5.6-terra", gjson.GetBytes(upstream.lastBody, "model").String())
+ require.Equal(t, tt.wantEffort, gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
+ })
+ }
+}
+
+func TestForwardAsChatCompletions_ClientDisconnectDrainsUpstreamUsage(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Writer = &openAIChatFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
+ body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := strings.Join([]string{
+ `data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5.4","status":"in_progress","output":[]}}`,
+ "",
+ `data: {"type":"response.output_text.delta","delta":"ok"}`,
+ "",
+ `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":11,"output_tokens":5,"total_tokens":16,"input_tokens_details":{"cached_tokens":4}}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_disconnect"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.1")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, 11, result.Usage.InputTokens)
+ require.Equal(t, 5, result.Usage.OutputTokens)
+ require.Equal(t, 4, result.Usage.CacheReadInputTokens)
+}
+
+func TestForwardAsChatCompletions_TerminalUsageWithoutUpstreamCloseReturns(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Writer = &openAIChatFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
+ body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := []byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":17,"output_tokens":8,"total_tokens":25,"input_tokens_details":{"cached_tokens":6}}}}` + "\n\n")
+ upstreamStream := newOpenAICompatBlockingReadCloser(upstreamBody)
+ defer func() {
+ require.NoError(t, upstreamStream.Close())
+ }()
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_terminal_no_close"}},
+ Body: upstreamStream,
+ }}
+
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ type forwardResult struct {
+ result *OpenAIForwardResult
+ err error
+ }
+ resultCh := make(chan forwardResult, 1)
+ go func() {
+ result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.1")
+ resultCh <- forwardResult{result: result, err: err}
+ }()
+
+ select {
+ case got := <-resultCh:
+ require.NoError(t, got.err)
+ require.NotNil(t, got.result)
+ require.Equal(t, 17, got.result.Usage.InputTokens)
+ require.Equal(t, 8, got.result.Usage.OutputTokens)
+ require.Equal(t, 6, got.result.Usage.CacheReadInputTokens)
+ case <-time.After(time.Second):
+ require.Fail(t, "ForwardAsChatCompletions should return after terminal usage event even if upstream keeps the connection open")
+ }
+}
+
+func TestForwardAsChatCompletions_BufferedTerminalWithoutUpstreamCloseReturns(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := []byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":17,"output_tokens":8,"total_tokens":25,"input_tokens_details":{"cached_tokens":6}}}}` + "\n\n")
+ upstreamStream := newOpenAICompatBlockingReadCloser(upstreamBody)
+ defer func() {
+ require.NoError(t, upstreamStream.Close())
+ }()
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_buffered_terminal_no_close"}},
+ Body: upstreamStream,
+ }}
+
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ type forwardResult struct {
+ result *OpenAIForwardResult
+ err error
+ }
+ resultCh := make(chan forwardResult, 1)
+ go func() {
+ result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.1")
+ resultCh <- forwardResult{result: result, err: err}
+ }()
+
+ select {
+ case got := <-resultCh:
+ require.NoError(t, got.err)
+ require.NotNil(t, got.result)
+ require.Equal(t, 17, got.result.Usage.InputTokens)
+ require.Equal(t, 8, got.result.Usage.OutputTokens)
+ require.Equal(t, 6, got.result.Usage.CacheReadInputTokens)
+ require.Contains(t, rec.Body.String(), `"finish_reason":"stop"`)
+ case <-time.After(time.Second):
+ require.Fail(t, "ForwardAsChatCompletions buffered response should return after terminal usage event even if upstream keeps the connection open")
+ }
+}
+
+func TestForwardAsChatCompletions_BufferedReadErrorReturnsFailover(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
+
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{
+ "Content-Type": []string{"text/event-stream"},
+ "X-Request-Id": []string{"rid_chat_http2_reset"},
+ },
+ Body: errReadCloser{err: errors.New("stream error: stream ID 49; INTERNAL_ERROR; received from peer")},
+ }
+
+ svc := &OpenAIGatewayService{}
+ result, err := svc.handleChatBufferedStreamingResponse(resp, c, "gpt-5.4", "gpt-5.4", "gpt-5.4", time.Now())
+
+ require.Nil(t, result)
+ require.Error(t, err)
+ var failoverErr *UpstreamFailoverError
+ require.ErrorAs(t, err, &failoverErr)
+ require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
+ require.False(t, failoverErr.RetryableOnSameAccount)
+ require.Equal(t, "rid_chat_http2_reset", failoverErr.ResponseHeaders.Get("x-request-id"))
+ require.False(t, c.Writer.Written())
+ require.Empty(t, rec.Body.String())
+ require.Contains(t, string(failoverErr.ResponseBody), "stream error")
+}
+
+func TestForwardAsChatCompletions_DoneSentinelWithoutTerminalReturnsError(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := "data: [DONE]\n\n"
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_missing_terminal"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.1")
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "missing terminal event")
+ require.NotNil(t, result)
+ require.Zero(t, result.Usage.InputTokens)
+ require.Zero(t, result.Usage.OutputTokens)
+}
+
+func TestForwardAsChatCompletions_UpstreamRequestIgnoresClientCancel(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ reqCtx, cancel := context.WithCancel(context.Background())
+ body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)).WithContext(reqCtx)
+ c.Request.Header.Set("Content-Type", "application/json")
+ cancel()
+
+ upstreamBody := strings.Join([]string{
+ `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_ctx"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ result, err := svc.ForwardAsChatCompletions(reqCtx, c, account, body, "", "gpt-5.1")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.NotNil(t, upstream.lastReq)
+ require.NoError(t, upstream.lastReq.Context().Err())
+}
diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go
index 4e0ebb2e9a5..3e8089c874a 100644
--- a/backend/internal/service/openai_gateway_messages.go
+++ b/backend/internal/service/openai_gateway_messages.go
@@ -10,6 +10,7 @@ import (
"io"
"net/http"
"strings"
+ "sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
@@ -31,6 +32,18 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
body []byte,
promptCacheKey string,
defaultMappedModel string,
+) (*OpenAIForwardResult, error) {
+ return s.ForwardAsAnthropicWithOptions(ctx, c, account, body, promptCacheKey, defaultMappedModel, OpenAIForwardOptions{})
+}
+
+func (s *OpenAIGatewayService) ForwardAsAnthropicWithOptions(
+ ctx context.Context,
+ c *gin.Context,
+ account *Account,
+ body []byte,
+ promptCacheKey string,
+ defaultMappedModel string,
+ opts OpenAIForwardOptions,
) (*OpenAIForwardResult, error) {
startTime := time.Now()
@@ -39,12 +52,57 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
if err := json.Unmarshal(body, &anthropicReq); err != nil {
return nil, fmt.Errorf("parse anthropic request: %w", err)
}
+ anthropicDigestReq := cloneAnthropicRequestForDigest(&anthropicReq)
originalModel := anthropicReq.Model
applyOpenAICompatModelNormalization(&anthropicReq)
normalizedModel := anthropicReq.Model
clientStream := anthropicReq.Stream // client's original stream preference
- // 2. Convert Anthropic → Responses
+ // 2. Model mapping
+ billingModel := resolveOpenAIForwardModel(account, normalizedModel, defaultMappedModel)
+ upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel)
+ if strings.TrimSpace(billingModel) == "" || strings.TrimSpace(upstreamModel) == "" {
+ return nil, errors.New("invalid or unsupported OpenAI model mapping")
+ }
+ promptCacheKey = strings.TrimSpace(promptCacheKey)
+ apiKeyID := getAPIKeyIDFromContext(c)
+ anthropicDigestChain := ""
+ anthropicMatchedDigestChain := ""
+ compatPromptCacheInjected := false
+ if promptCacheKey == "" && shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) {
+ promptCacheKey = promptCacheKeyFromAnthropicMetadataSession(&anthropicReq)
+ if promptCacheKey == "" {
+ promptCacheKey = deriveAnthropicCacheControlPromptCacheKey(&anthropicReq)
+ }
+ if promptCacheKey == "" {
+ anthropicDigestChain = buildOpenAICompatAnthropicDigestChain(anthropicDigestReq)
+ if reusedKey, matchedChain := s.findOpenAICompatAnthropicDigestPromptCacheKey(account, apiKeyID, anthropicDigestChain); reusedKey != "" {
+ promptCacheKey = reusedKey
+ anthropicMatchedDigestChain = matchedChain
+ } else {
+ promptCacheKey = promptCacheKeyFromAnthropicDigest(anthropicDigestChain)
+ }
+ }
+ compatPromptCacheInjected = promptCacheKey != ""
+ }
+ compatReplayTrimmed := false
+ compatReplayGuardEnabled := shouldAutoInjectPromptCacheKeyForCompat(upstreamModel)
+ compatContinuationEnabled := openAICompatContinuationEnabled(account, upstreamModel)
+ previousResponseID := ""
+ if compatContinuationEnabled {
+ previousResponseID = s.getOpenAICompatSessionResponseID(ctx, c, account, promptCacheKey)
+ }
+ compatContinuationDisabled := compatContinuationEnabled &&
+ s.isOpenAICompatSessionContinuationDisabled(ctx, c, account, promptCacheKey)
+ compatTurnState := ""
+ // OAuth/Plus relies on session_id + x-codex-turn-state; trimming to a
+ // sliding 12-message window makes the cached prefix stall at system/tools.
+ // Keep full replay there so upstream prompt caching can grow turn by turn.
+ if compatReplayGuardEnabled && account.Type != AccountTypeOAuth && previousResponseID == "" && !compatContinuationDisabled {
+ compatReplayTrimmed = applyAnthropicCompatFullReplayGuard(&anthropicReq)
+ }
+
+ // 3. Convert Anthropic → Responses after compatibility-only replay guard.
responsesReq, err := apicompat.AnthropicToResponses(&anthropicReq)
if err != nil {
return nil, fmt.Errorf("convert anthropic to responses: %w", err)
@@ -55,24 +113,50 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
responsesReq.Stream = true
isStream := true
- // 2b. Handle BetaFastMode → service_tier: "priority"
+ // 3b. Handle BetaFastMode → service_tier: "priority"
if containsBetaToken(c.GetHeader("anthropic-beta"), claude.BetaFastMode) {
responsesReq.ServiceTier = "priority"
}
- // 3. Model mapping
- billingModel := resolveOpenAIForwardModel(account, normalizedModel, defaultMappedModel)
- upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel)
responsesReq.Model = upstreamModel
+ if previousResponseID != "" {
+ responsesReq.PreviousResponseID = previousResponseID
+ trimAnthropicCompatResponsesInputToLatestTurn(responsesReq)
+ }
+ if compatReplayGuardEnabled && account.Type != AccountTypeOAuth {
+ appendOpenAICompatClaudeCodeTodoGuard(responsesReq)
+ }
- logger.L().Debug("openai messages: model mapping applied",
+ logFields := []zap.Field{
zap.Int64("account_id", account.ID),
zap.String("original_model", originalModel),
zap.String("normalized_model", normalizedModel),
zap.String("billing_model", billingModel),
zap.String("upstream_model", upstreamModel),
zap.Bool("stream", isStream),
- )
+ }
+ if compatPromptCacheInjected {
+ logFields = append(logFields,
+ zap.Bool("compat_prompt_cache_key_injected", true),
+ zap.String("compat_prompt_cache_key_sha256", hashSensitiveValueForLog(promptCacheKey)),
+ )
+ }
+ if compatReplayTrimmed {
+ logFields = append(logFields,
+ zap.Bool("compat_full_replay_trimmed", true),
+ zap.Int("compat_messages_after_trim", len(anthropicReq.Messages)),
+ )
+ }
+ if previousResponseID != "" {
+ logFields = append(logFields,
+ zap.Bool("compat_previous_response_id_attached", true),
+ zap.String("compat_previous_response_id", truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen)),
+ )
+ }
+ if compatTurnState != "" {
+ logFields = append(logFields, zap.Bool("compat_turn_state_attached", true))
+ }
+ logger.L().Debug("openai messages: model mapping applied", logFields...)
// 4. Marshal Responses request body, then apply OAuth codex transform
responsesBody, err := json.Marshal(responsesReq)
@@ -85,7 +169,10 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
if err := json.Unmarshal(responsesBody, &reqBody); err != nil {
return nil, fmt.Errorf("unmarshal for codex transform: %w", err)
}
- codexResult := applyCodexOAuthTransform(reqBody, false, false)
+ codexResult := applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{
+ SkipDefaultInstructions: true,
+ PreserveToolCallIDs: true,
+ })
forcedTemplateText := ""
if s.cfg != nil {
forcedTemplateText = s.cfg.Gateway.ForcedCodexInstructionsTemplate
@@ -95,6 +182,9 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
templateUpstreamModel = codexResult.NormalizedModel
}
existingInstructions, _ := reqBody["instructions"].(string)
+ if strings.TrimSpace(existingInstructions) == "" {
+ existingInstructions = extractPromptLikeInstructionsFromInput(reqBody)
+ }
if _, err := applyForcedCodexInstructionsTemplate(reqBody, forcedTemplateText, forcedCodexInstructionsTemplateData{
ExistingInstructions: strings.TrimSpace(existingInstructions),
OriginalModel: originalModel,
@@ -104,13 +194,19 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
}); err != nil {
return nil, err
}
+ ensureCodexOAuthInstructionsField(reqBody)
+ if shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) {
+ appendOpenAICompatClaudeCodeTodoGuardToRequestBody(reqBody)
+ }
if codexResult.NormalizedModel != "" {
upstreamModel = codexResult.NormalizedModel
}
if codexResult.PromptCacheKey != "" {
promptCacheKey = codexResult.PromptCacheKey
- } else if promptCacheKey != "" {
- reqBody["prompt_cache_key"] = promptCacheKey
+ }
+ delete(reqBody, "prompt_cache_key")
+ if shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) {
+ compatTurnState = s.getOpenAICompatSessionTurnState(ctx, c, account, promptCacheKey)
}
// OAuth codex transform forces stream=true upstream, so always use
// the streaming response handler regardless of what the client asked.
@@ -121,6 +217,12 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
}
}
+ if updatedBody, applied, err := applyOpenAICompatKnowledgeCutoffGuardToResponsesBody(responsesBody, originalModel, upstreamModel); err != nil {
+ return nil, err
+ } else if applied {
+ responsesBody = updatedBody
+ }
+
// For API key accounts (including OpenAI-compatible upstream gateways),
// ensure promptCacheKey is also propagated via the request body so that
// upstreams using the Responses API can derive a stable session identifier
@@ -156,6 +258,15 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
}
responsesBody = updatedBody
+ billingIdentity, err := s.resolveForwardBillingIdentity(ctx, opts, originalModel, normalizedModel, billingModel, upstreamModel)
+ if err != nil {
+ return nil, err
+ }
+ setOpenAIUsageBillingReservationBody(opts.UsageBilling, responsesBody)
+ if err := s.prepareOpenAIForwardUsageBilling(ctx, account, billingIdentity, opts); err != nil {
+ return nil, err
+ }
+
// 5. Get access token
token, _, err := s.GetAccessToken(ctx, account)
if err != nil {
@@ -163,7 +274,9 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
}
// 6. Build upstream request
- upstreamReq, err := s.buildUpstreamRequest(ctx, c, account, responsesBody, token, isStream, promptCacheKey, false)
+ upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
+ upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, responsesBody, token, isStream, promptCacheKey, false)
+ releaseUpstreamCtx()
if err != nil {
return nil, fmt.Errorf("build upstream request: %w", err)
}
@@ -171,8 +284,25 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
// Override session_id with a deterministic UUID derived from the isolated
// session key, ensuring different API keys produce different upstream sessions.
if promptCacheKey != "" {
- apiKeyID := getAPIKeyIDFromContext(c)
- upstreamReq.Header.Set("session_id", generateSessionUUID(isolateOpenAISessionID(apiKeyID, promptCacheKey)))
+ isolatedSessionID := generateSessionUUID(isolateOpenAISessionID(apiKeyID, promptCacheKey))
+ upstreamReq.Header.Set("session_id", isolatedSessionID)
+ if upstreamReq.Header.Get("conversation_id") != "" {
+ upstreamReq.Header.Set("conversation_id", isolatedSessionID)
+ }
+ }
+ if account.Type == AccountTypeOAuth {
+ // Anthropic Messages compatibility uses the ChatGPT Codex SSE endpoint.
+ // Match airgate-openai's request shape: the SSE endpoint does not need
+ // the Responses experimental beta header, and forcing originator can make
+ // ChatGPT select a different internal continuation path.
+ upstreamReq.Header.Del("OpenAI-Beta")
+ upstreamReq.Header.Del("originator")
+ }
+ if account.Type == AccountTypeOAuth && promptCacheKey != "" && strings.TrimSpace(c.GetHeader("conversation_id")) == "" {
+ upstreamReq.Header.Del("conversation_id")
+ }
+ if compatTurnState != "" && upstreamReq.Header.Get("x-codex-turn-state") == "" {
+ upstreamReq.Header.Set("x-codex-turn-state", compatTurnState)
}
// 7. Send request
@@ -205,6 +335,19 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody))
upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
+ if previousResponseID != "" && (isOpenAICompatPreviousResponseNotFound(resp.StatusCode, upstreamMsg, respBody) || isOpenAICompatPreviousResponseUnsupported(resp.StatusCode, upstreamMsg, respBody)) {
+ if isOpenAICompatPreviousResponseUnsupported(resp.StatusCode, upstreamMsg, respBody) {
+ s.disableOpenAICompatSessionContinuation(ctx, c, account, promptCacheKey)
+ } else {
+ s.deleteOpenAICompatSessionResponseID(ctx, c, account, promptCacheKey)
+ }
+ logger.L().Info("openai messages: previous_response_id unavailable, retrying without continuation",
+ zap.Int64("account_id", account.ID),
+ zap.String("previous_response_id", truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen)),
+ zap.String("upstream_model", upstreamModel),
+ )
+ return s.ForwardAsAnthropicWithOptions(ctx, c, account, body, promptCacheKey, defaultMappedModel, opts)
+ }
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) {
upstreamDetail := ""
if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
@@ -224,9 +367,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
Message: upstreamMsg,
Detail: upstreamDetail,
})
- if s.rateLimitService != nil {
- s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
- }
+ s.handleOpenAIUpstreamError(ctx, account, upstreamModel, resp.StatusCode, resp.Header, respBody)
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
@@ -234,7 +375,13 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
}
}
// Non-failover error: return Anthropic-formatted error to client
- return s.handleAnthropicErrorResponse(resp, c, account)
+ return s.handleAnthropicErrorResponse(resp, c, account, upstreamModel)
+ }
+
+ if account.Type == AccountTypeOAuth && promptCacheKey != "" {
+ if turnState := strings.TrimSpace(resp.Header.Get("x-codex-turn-state")); turnState != "" {
+ s.bindOpenAICompatSessionTurnState(ctx, c, account, promptCacheKey, turnState)
+ }
}
// 9. Handle normal response
@@ -250,6 +397,13 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
// Propagate ServiceTier and ReasoningEffort to result for billing
if handleErr == nil && result != nil {
+ attachOpenAIBillingIdentity(result, billingIdentity)
+ if compatContinuationEnabled && promptCacheKey != "" && result.ResponseID != "" {
+ s.bindOpenAICompatSessionResponseID(ctx, c, account, promptCacheKey, result.ResponseID)
+ }
+ if promptCacheKey != "" && anthropicDigestChain != "" {
+ s.bindOpenAICompatAnthropicDigestPromptCacheKey(account, apiKeyID, anthropicDigestChain, promptCacheKey, anthropicMatchedDigestChain)
+ }
if responsesReq.ServiceTier != "" {
st := responsesReq.ServiceTier
result.ServiceTier = &st
@@ -270,14 +424,28 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
return result, handleErr
}
+func ensureCodexOAuthInstructionsField(reqBody map[string]any) {
+ if reqBody == nil {
+ return
+ }
+ if value, ok := reqBody["instructions"]; !ok || value == nil {
+ reqBody["instructions"] = ""
+ return
+ }
+ if _, ok := reqBody["instructions"].(string); !ok {
+ reqBody["instructions"] = ""
+ }
+}
+
// handleAnthropicErrorResponse reads an upstream error and returns it in
// Anthropic error format.
func (s *OpenAIGatewayService) handleAnthropicErrorResponse(
resp *http.Response,
c *gin.Context,
account *Account,
+ model string,
) (*OpenAIForwardResult, error) {
- return s.handleCompatErrorResponse(resp, c, account, writeAnthropicError)
+ return s.handleCompatErrorResponse(resp, c, account, model, writeAnthropicError)
}
// handleAnthropicBufferedStreamingResponse reads all Responses SSE events from
@@ -296,71 +464,43 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
) (*OpenAIForwardResult, error) {
requestID := resp.Header.Get("x-request-id")
- scanner := bufio.NewScanner(resp.Body)
- maxLineSize := defaultMaxLineSize
- if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.cfg.Gateway.MaxLineSize
+ finalResponse, usage, acc, err := s.readOpenAICompatBufferedTerminal(resp, "openai messages buffered", requestID)
+ if err != nil {
+ return nil, err
}
- scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
-
- var finalResponse *apicompat.ResponsesResponse
- var usage OpenAIUsage
- acc := apicompat.NewBufferedResponseAccumulator()
-
- for scanner.Scan() {
- line := scanner.Text()
- if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
- continue
- }
- payload := line[6:]
-
- var event apicompat.ResponsesStreamEvent
- if err := json.Unmarshal([]byte(payload), &event); err != nil {
- logger.L().Warn("openai messages buffered: failed to parse event",
- zap.Error(err),
+ if finalResponse == nil {
+ if synthetic := synthesizeBufferedResponsesResponse(acc, requestID, upstreamModel); synthetic != nil {
+ logger.L().Warn("openai messages buffered: synthesized response after missing terminal event",
zap.String("request_id", requestID),
+ zap.String("response_id", synthetic.ID),
)
- continue
- }
-
- // Accumulate delta content for fallback when terminal output is empty.
- acc.ProcessEvent(&event)
-
- // Terminal events carry the complete ResponsesResponse with output + usage.
- if (event.Type == "response.completed" || event.Type == "response.done" ||
- event.Type == "response.incomplete" || event.Type == "response.failed") &&
- event.Response != nil {
- finalResponse = event.Response
- if event.Response.Usage != nil {
- usage = OpenAIUsage{
- InputTokens: event.Response.Usage.InputTokens,
- OutputTokens: event.Response.Usage.OutputTokens,
- }
- if event.Response.Usage.InputTokensDetails != nil {
- usage.CacheReadInputTokens = event.Response.Usage.InputTokensDetails.CachedTokens
- }
- }
- }
- }
-
- if err := scanner.Err(); err != nil {
- if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
- logger.L().Warn("openai messages buffered: read error",
- zap.Error(err),
+ finalResponse = synthetic
+ } else {
+ detail := bufferedMissingTerminalDetail("openai messages buffered", 0, "", false, false, acc, nil)
+ logger.L().Warn("openai messages buffered: missing terminal response event",
zap.String("request_id", requestID),
+ zap.String("detail", detail),
)
+ return nil, newBufferedMissingTerminalFailover(resp, detail)
}
}
- if finalResponse == nil {
- writeAnthropicError(c, http.StatusBadGateway, "api_error", "Upstream stream ended without a terminal response event")
- return nil, fmt.Errorf("upstream stream ended without terminal event")
- }
-
// When the terminal event has an empty output array, reconstruct from
// accumulated delta events so the client receives the full content.
acc.SupplementResponseOutput(finalResponse)
+ if openAICompatResponseIsEmptyTerminal(finalResponse, usage, false) {
+ message := openAICompatEmptyTerminalMessage(finalResponse)
+ logger.L().Warn("openai_messages.empty_terminal_response",
+ zap.String("request_id", requestID),
+ zap.String("response_id", strings.TrimSpace(finalResponse.ID)),
+ zap.String("original_model", originalModel),
+ zap.String("upstream_model", upstreamModel),
+ zap.Bool("stream", false),
+ zap.Bool("usage_empty", openAICompatUsageIsZero(usage)),
+ )
+ return nil, newOpenAICompatEmptyTerminalFailover(resp, message)
+ }
anthropicResp := apicompat.ResponsesToAnthropic(finalResponse, originalModel)
@@ -371,6 +511,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
return &OpenAIForwardResult{
RequestID: requestID,
+ ResponseID: finalResponse.ID,
Usage: usage,
Model: originalModel,
BillingModel: billingModel,
@@ -380,6 +521,297 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
}, nil
}
+func isOpenAICompatResponsesTerminalEvent(eventType string) bool {
+ switch strings.TrimSpace(eventType) {
+ case "response.completed", "response.done", "response.incomplete", "response.failed":
+ return true
+ default:
+ return false
+ }
+}
+
+func isOpenAICompatDoneSentinelLine(line string) bool {
+ payload, ok := extractOpenAISSEDataLine(line)
+ return ok && strings.TrimSpace(payload) == "[DONE]"
+}
+
+func openAICompatUsageIsZero(usage OpenAIUsage) bool {
+ return usage.InputTokens == 0 &&
+ usage.OutputTokens == 0 &&
+ usage.CacheCreationInputTokens == 0 &&
+ usage.CacheReadInputTokens == 0 &&
+ usage.ImageOutputTokens == 0
+}
+
+func openAICompatResponseHasDeliverableOutput(resp *apicompat.ResponsesResponse) bool {
+ if resp == nil {
+ return false
+ }
+ for _, item := range resp.Output {
+ switch item.Type {
+ case "message":
+ for _, part := range item.Content {
+ if part.Type == "output_text" && strings.TrimSpace(part.Text) != "" {
+ return true
+ }
+ }
+ case "reasoning":
+ for _, summary := range item.Summary {
+ if summary.Type == "summary_text" && strings.TrimSpace(summary.Text) != "" {
+ return true
+ }
+ }
+ case "function_call":
+ if strings.TrimSpace(item.CallID) != "" ||
+ strings.TrimSpace(item.Name) != "" ||
+ strings.TrimSpace(item.Arguments) != "" {
+ return true
+ }
+ case "web_search_call":
+ if strings.TrimSpace(item.ID) != "" {
+ return true
+ }
+ if item.Action != nil && strings.TrimSpace(item.Action.Query) != "" {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func openAICompatResponseIsEmptyTerminal(resp *apicompat.ResponsesResponse, usage OpenAIUsage, deliveredContent bool) bool {
+ if deliveredContent {
+ return false
+ }
+ if openAICompatResponseHasDeliverableOutput(resp) {
+ return false
+ }
+ if resp != nil && strings.TrimSpace(resp.Status) == "failed" {
+ return true
+ }
+ return openAICompatUsageIsZero(usage)
+}
+
+func openAICompatAnthropicEventsHaveDeliverableContent(events []apicompat.AnthropicStreamEvent) bool {
+ for _, evt := range events {
+ switch evt.Type {
+ case "content_block_start":
+ if evt.ContentBlock == nil {
+ continue
+ }
+ switch evt.ContentBlock.Type {
+ case "tool_use", "server_tool_use", "web_search_tool_result":
+ return true
+ }
+ case "content_block_delta":
+ if evt.Delta == nil {
+ continue
+ }
+ if strings.TrimSpace(evt.Delta.Text) != "" ||
+ strings.TrimSpace(evt.Delta.PartialJSON) != "" ||
+ strings.TrimSpace(evt.Delta.Thinking) != "" ||
+ strings.TrimSpace(evt.Delta.Signature) != "" {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func openAICompatEmptyTerminalMessage(resp *apicompat.ResponsesResponse) string {
+ if resp != nil && resp.Error != nil {
+ if msg := sanitizeUpstreamErrorMessage(strings.TrimSpace(resp.Error.Message)); msg != "" {
+ return msg
+ }
+ }
+ return "Upstream returned an empty response; please retry"
+}
+
+func newOpenAICompatEmptyTerminalFailover(resp *http.Response, message string) *UpstreamFailoverError {
+ headers := http.Header(nil)
+ if resp != nil && resp.Header != nil {
+ headers = resp.Header.Clone()
+ }
+ message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message))
+ if message == "" {
+ message = "Upstream returned an empty response; please retry"
+ }
+ body, _ := json.Marshal(map[string]any{
+ "type": "error",
+ "error": map[string]string{
+ "type": "upstream_empty_response",
+ "message": message,
+ },
+ })
+ return &UpstreamFailoverError{
+ StatusCode: http.StatusBadGateway,
+ ResponseBody: body,
+ ResponseHeaders: headers,
+ RetryableOnSameAccount: true,
+ }
+}
+
+func writeOpenAICompatAnthropicStreamError(c *gin.Context, errType, message string) error {
+ message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message))
+ if message == "" {
+ message = errType
+ }
+ body, err := json.Marshal(map[string]any{
+ "type": "error",
+ "error": map[string]string{
+ "type": errType,
+ "message": message,
+ },
+ })
+ if err != nil {
+ body = []byte(fmt.Sprintf(`{"type":"error","error":{"type":%q,"message":%q}}`, errType, message))
+ }
+ if _, err := fmt.Fprintf(c.Writer, "event: error\ndata: %s\n\n", body); err != nil {
+ return err
+ }
+ c.Writer.Flush()
+ return nil
+}
+
+func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal(
+ resp *http.Response,
+ logPrefix string,
+ requestID string,
+) (*apicompat.ResponsesResponse, OpenAIUsage, *apicompat.BufferedResponseAccumulator, error) {
+ acc := apicompat.NewBufferedResponseAccumulator()
+ var usage OpenAIUsage
+ if resp == nil || resp.Body == nil {
+ return nil, usage, acc, errors.New("upstream response body is nil")
+ }
+
+ scanner := bufio.NewScanner(resp.Body)
+ maxLineSize := resolveGatewayMaxLineSize(s.cfg)
+ scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
+
+ streamInterval := time.Duration(0)
+ if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 {
+ streamInterval = time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second
+ }
+ var timeoutCh <-chan time.Time
+ var timeoutTimer *time.Timer
+ resetTimeout := func() {
+ if streamInterval <= 0 {
+ return
+ }
+ if timeoutTimer == nil {
+ timeoutTimer = time.NewTimer(streamInterval)
+ timeoutCh = timeoutTimer.C
+ return
+ }
+ if !timeoutTimer.Stop() {
+ select {
+ case <-timeoutTimer.C:
+ default:
+ }
+ }
+ timeoutTimer.Reset(streamInterval)
+ }
+ stopTimeout := func() {
+ if timeoutTimer == nil {
+ return
+ }
+ if !timeoutTimer.Stop() {
+ select {
+ case <-timeoutTimer.C:
+ default:
+ }
+ }
+ }
+ resetTimeout()
+ defer stopTimeout()
+
+ type scanEvent struct {
+ line string
+ err error
+ }
+ events := make(chan scanEvent, 16)
+ done := make(chan struct{})
+ go func() {
+ defer close(events)
+ for scanner.Scan() {
+ select {
+ case events <- scanEvent{line: scanner.Text()}:
+ case <-done:
+ return
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ select {
+ case events <- scanEvent{err: err}:
+ case <-done:
+ }
+ }
+ }()
+ defer close(done)
+
+ for {
+ select {
+ case ev, ok := <-events:
+ if !ok {
+ return nil, usage, acc, nil
+ }
+ resetTimeout()
+ if ev.err != nil {
+ isContextErr := errors.Is(ev.err, context.Canceled) || errors.Is(ev.err, context.DeadlineExceeded)
+ isScannerLimitErr := errors.Is(ev.err, bufio.ErrTooLong)
+ if !isContextErr && !isScannerLimitErr {
+ logger.L().Warn(logPrefix+": read error",
+ zap.Error(ev.err),
+ zap.String("request_id", requestID),
+ )
+ return nil, usage, acc, newBufferedReadFailover(resp, logPrefix, ev.err)
+ }
+ return nil, usage, acc, ev.err
+ }
+
+ if isOpenAICompatDoneSentinelLine(ev.line) {
+ return nil, usage, acc, nil
+ }
+ payload, ok := extractOpenAISSEDataLine(ev.line)
+ if !ok || payload == "" {
+ continue
+ }
+
+ var event apicompat.ResponsesStreamEvent
+ if err := json.Unmarshal([]byte(payload), &event); err != nil {
+ logger.L().Warn(logPrefix+": failed to parse event",
+ zap.Error(err),
+ zap.String("request_id", requestID),
+ )
+ continue
+ }
+
+ if err := acc.ProcessEvent(&event); err != nil {
+ logger.L().Warn(logPrefix+": compatibility response limit exceeded",
+ zap.Error(err),
+ zap.String("request_id", requestID),
+ )
+ return nil, usage, acc, err
+ }
+
+ if isOpenAICompatResponsesTerminalEvent(event.Type) && event.Response != nil {
+ if event.Response.Usage != nil {
+ usage = copyOpenAIUsageFromResponsesUsage(event.Response.Usage)
+ }
+ return event.Response, usage, acc, nil
+ }
+
+ case <-timeoutCh:
+ _ = resp.Body.Close()
+ logger.L().Warn(logPrefix+": data interval timeout",
+ zap.String("request_id", requestID),
+ zap.Duration("interval", streamInterval),
+ )
+ return nil, usage, acc, fmt.Errorf("stream data interval timeout")
+ }
+ }
+}
+
// handleAnthropicStreamingResponse reads Responses SSE events from upstream,
// converts each to Anthropic SSE events, and writes them to the client.
// When StreamKeepaliveInterval is configured, it uses a goroutine + channel
@@ -395,32 +827,57 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
) (*OpenAIForwardResult, error) {
requestID := resp.Header.Get("x-request-id")
- if s.responseHeaderFilter != nil {
- responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
+ headersCommitted := false
+ commitHeaders := func() {
+ if headersCommitted {
+ return
+ }
+ if s.responseHeaderFilter != nil {
+ responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
+ }
+ c.Writer.Header().Set("Content-Type", "text/event-stream")
+ c.Writer.Header().Set("Cache-Control", "no-cache")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.Header().Set("X-Accel-Buffering", "no")
+ c.Writer.WriteHeader(http.StatusOK)
+ headersCommitted = true
}
- c.Writer.Header().Set("Content-Type", "text/event-stream")
- c.Writer.Header().Set("Cache-Control", "no-cache")
- c.Writer.Header().Set("Connection", "keep-alive")
- c.Writer.Header().Set("X-Accel-Buffering", "no")
- c.Writer.WriteHeader(http.StatusOK)
state := apicompat.NewResponsesEventToAnthropicState()
state.Model = originalModel
var usage OpenAIUsage
+ responseID := ""
var firstTokenMs *int
firstChunk := true
+ clientDisconnected := false
+ deliveredContent := false
+ pendingSSE := make([]string, 0, 4)
+ pendingSSEBytes := 0
+ const precommitMaxBytes = 64 * 1024
scanner := bufio.NewScanner(resp.Body)
- maxLineSize := defaultMaxLineSize
- if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.cfg.Gateway.MaxLineSize
- }
+ maxLineSize := resolveGatewayMaxLineSize(s.cfg)
scanner.Buffer(make([]byte, 0, 64*1024), maxLineSize)
+ streamInterval := time.Duration(0)
+ if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 {
+ streamInterval = time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second
+ }
+ var intervalTicker *time.Ticker
+ if streamInterval > 0 {
+ intervalTicker = time.NewTicker(streamInterval)
+ defer intervalTicker.Stop()
+ }
+ var intervalCh <-chan time.Time
+ if intervalTicker != nil {
+ intervalCh = intervalTicker.C
+ }
+
// resultWithUsage builds the final result snapshot.
resultWithUsage := func() *OpenAIForwardResult {
return &OpenAIForwardResult{
RequestID: requestID,
+ ResponseID: responseID,
Usage: usage,
Model: originalModel,
BillingModel: billingModel,
@@ -431,9 +888,74 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
}
}
+ flushPendingSSE := func() {
+ if clientDisconnected || len(pendingSSE) == 0 {
+ return
+ }
+ commitHeaders()
+ for _, sse := range pendingSSE {
+ if _, err := fmt.Fprint(c.Writer, sse); err != nil {
+ clientDisconnected = true
+ logger.L().Info("openai messages stream: client disconnected, continuing to drain upstream for billing",
+ zap.String("request_id", requestID),
+ )
+ break
+ }
+ }
+ pendingSSE = pendingSSE[:0]
+ pendingSSEBytes = 0
+ if !clientDisconnected {
+ c.Writer.Flush()
+ }
+ }
+
+ writeOrBufferSSE := func(sse string) {
+ if clientDisconnected {
+ return
+ }
+ if !headersCommitted {
+ pendingSSE = append(pendingSSE, sse)
+ pendingSSEBytes += len(sse)
+ if pendingSSEBytes >= precommitMaxBytes {
+ flushPendingSSE()
+ }
+ return
+ }
+ if _, err := fmt.Fprint(c.Writer, sse); err != nil {
+ clientDisconnected = true
+ logger.L().Info("openai messages stream: client disconnected, continuing to drain upstream for billing",
+ zap.String("request_id", requestID),
+ )
+ }
+ }
+
+ emptyTerminalErr := func(respPayload *apicompat.ResponsesResponse) (*OpenAIForwardResult, error) {
+ message := openAICompatEmptyTerminalMessage(respPayload)
+ logger.L().Warn("openai_messages.empty_terminal_response",
+ zap.String("request_id", requestID),
+ zap.String("response_id", responseID),
+ zap.String("original_model", originalModel),
+ zap.String("upstream_model", upstreamModel),
+ zap.Bool("stream", true),
+ zap.Bool("usage_empty", openAICompatUsageIsZero(usage)),
+ )
+ if !headersCommitted && !c.Writer.Written() {
+ return nil, newOpenAICompatEmptyTerminalFailover(resp, message)
+ }
+ commitHeaders()
+ if !clientDisconnected {
+ if err := writeOpenAICompatAnthropicStreamError(c, "upstream_empty_response", message); err != nil {
+ clientDisconnected = true
+ logger.L().Info("openai messages stream: client disconnected during empty-terminal error",
+ zap.String("request_id", requestID),
+ )
+ }
+ }
+ return resultWithUsage(), fmt.Errorf("openai messages empty terminal response")
+ }
+
// processDataLine handles a single "data: ..." SSE line from upstream.
- // Returns (clientDisconnected bool).
- processDataLine := func(payload string) bool {
+ processDataLine := func(payload string) (bool, *OpenAIForwardResult, error) {
if firstChunk {
firstChunk = false
ms := int(time.Since(startTime).Milliseconds())
@@ -446,55 +968,62 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
zap.Error(err),
zap.String("request_id", requestID),
)
- return false
+ return false, nil, nil
}
- // Extract usage from completion events
- if (event.Type == "response.completed" || event.Type == "response.incomplete" || event.Type == "response.failed") &&
- event.Response != nil && event.Response.Usage != nil {
- usage = OpenAIUsage{
- InputTokens: event.Response.Usage.InputTokens,
- OutputTokens: event.Response.Usage.OutputTokens,
+ // 仅按兼容转换器支持的终止事件提取 usage,避免无意扩大事件语义。
+ isTerminalEvent := isOpenAICompatResponsesTerminalEvent(event.Type)
+ if isTerminalEvent && event.Response != nil {
+ if id := strings.TrimSpace(event.Response.ID); id != "" {
+ responseID = id
}
- if event.Response.Usage.InputTokensDetails != nil {
- usage.CacheReadInputTokens = event.Response.Usage.InputTokensDetails.CachedTokens
+ if event.Response.Usage != nil {
+ usage = copyOpenAIUsageFromResponsesUsage(event.Response.Usage)
}
}
+ if isTerminalEvent && openAICompatResponseIsEmptyTerminal(event.Response, usage, deliveredContent) {
+ result, err := emptyTerminalErr(event.Response)
+ return true, result, err
+ }
// Convert to Anthropic events
events := apicompat.ResponsesEventToAnthropicEvents(&event, state)
- for _, evt := range events {
- sse, err := apicompat.ResponsesAnthropicEventToSSE(evt)
- if err != nil {
- logger.L().Warn("openai messages stream: failed to marshal event",
- zap.Error(err),
- zap.String("request_id", requestID),
- )
- continue
- }
- if _, err := fmt.Fprint(c.Writer, sse); err != nil {
- logger.L().Info("openai messages stream: client disconnected",
- zap.String("request_id", requestID),
- )
- return true
+ if openAICompatAnthropicEventsHaveDeliverableContent(events) {
+ deliveredContent = true
+ }
+ if !clientDisconnected {
+ for _, evt := range events {
+ sse, err := apicompat.ResponsesAnthropicEventToSSE(evt)
+ if err != nil {
+ logger.L().Warn("openai messages stream: failed to marshal event",
+ zap.Error(err),
+ zap.String("request_id", requestID),
+ )
+ continue
+ }
+ writeOrBufferSSE(sse)
}
}
- if len(events) > 0 {
- c.Writer.Flush()
+ if deliveredContent || headersCommitted {
+ flushPendingSSE()
}
- return false
+ return isTerminalEvent, nil, nil
}
// finalizeStream sends any remaining Anthropic events and returns the result.
finalizeStream := func() (*OpenAIForwardResult, error) {
- if finalEvents := apicompat.FinalizeResponsesAnthropicStream(state); len(finalEvents) > 0 {
+ if finalEvents := apicompat.FinalizeResponsesAnthropicStream(state); len(finalEvents) > 0 && !clientDisconnected {
for _, evt := range finalEvents {
sse, err := apicompat.ResponsesAnthropicEventToSSE(evt)
if err != nil {
continue
}
- fmt.Fprint(c.Writer, sse) //nolint:errcheck
+ writeOrBufferSSE(sse)
}
+ }
+ if len(pendingSSE) > 0 && !clientDisconnected {
+ flushPendingSSE()
+ } else if headersCommitted && !clientDisconnected {
c.Writer.Flush()
}
return resultWithUsage(), nil
@@ -509,6 +1038,9 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
)
}
}
+ missingTerminalErr := func() (*OpenAIForwardResult, error) {
+ return resultWithUsage(), fmt.Errorf("stream usage incomplete: missing terminal event")
+ }
// ── Determine keepalive interval ──
keepaliveInterval := time.Duration(0)
@@ -517,18 +1049,27 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
}
// ── No keepalive: fast synchronous path (no goroutine overhead) ──
- if keepaliveInterval <= 0 {
+ if streamInterval <= 0 && keepaliveInterval <= 0 {
for scanner.Scan() {
line := scanner.Text()
- if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
+ if isOpenAICompatDoneSentinelLine(line) {
+ return missingTerminalErr()
+ }
+ payload, ok := extractOpenAISSEDataLine(line)
+ if !ok {
continue
}
- if processDataLine(line[6:]) {
- return resultWithUsage(), nil
+ if terminal, result, err := processDataLine(payload); err != nil {
+ return result, err
+ } else if terminal {
+ return finalizeStream()
}
}
- handleScanErr(scanner.Err())
- return finalizeStream()
+ if err := scanner.Err(); err != nil {
+ handleScanErr(err)
+ return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", err)
+ }
+ return missingTerminalErr()
}
// ── With keepalive: goroutine + channel + select ──
@@ -538,6 +1079,8 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
}
events := make(chan scanEvent, 16)
done := make(chan struct{})
+ var lastReadAt int64
+ atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
sendEvent := func(ev scanEvent) bool {
select {
case events <- ev:
@@ -549,6 +1092,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
go func() {
defer close(events)
for scanner.Scan() {
+ atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
if !sendEvent(scanEvent{line: scanner.Text()}) {
return
}
@@ -559,8 +1103,15 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
}()
defer close(done)
- keepaliveTicker := time.NewTicker(keepaliveInterval)
- defer keepaliveTicker.Stop()
+ var keepaliveTicker *time.Ticker
+ if keepaliveInterval > 0 {
+ keepaliveTicker = time.NewTicker(keepaliveInterval)
+ defer keepaliveTicker.Stop()
+ }
+ var keepaliveCh <-chan time.Time
+ if keepaliveTicker != nil {
+ keepaliveCh = keepaliveTicker.C
+ }
lastDataAt := time.Now()
for {
@@ -568,32 +1119,58 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
case ev, ok := <-events:
if !ok {
// Upstream closed
- return finalizeStream()
+ return missingTerminalErr()
}
if ev.err != nil {
handleScanErr(ev.err)
- return finalizeStream()
+ return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", ev.err)
}
lastDataAt = time.Now()
line := ev.line
- if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
+ if isOpenAICompatDoneSentinelLine(line) {
+ return missingTerminalErr()
+ }
+ payload, ok := extractOpenAISSEDataLine(line)
+ if !ok {
continue
}
- if processDataLine(line[6:]) {
- return resultWithUsage(), nil
+ if terminal, result, err := processDataLine(payload); err != nil {
+ return result, err
+ } else if terminal {
+ return finalizeStream()
}
- case <-keepaliveTicker.C:
+ case <-intervalCh:
+ lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
+ if time.Since(lastRead) < streamInterval {
+ continue
+ }
+ if clientDisconnected {
+ return resultWithUsage(), fmt.Errorf("stream usage incomplete after timeout")
+ }
+ logger.L().Warn("openai messages stream: data interval timeout",
+ zap.String("request_id", requestID),
+ zap.String("model", originalModel),
+ zap.Duration("interval", streamInterval),
+ )
+ return resultWithUsage(), fmt.Errorf("stream data interval timeout")
+
+ case <-keepaliveCh:
+ if clientDisconnected {
+ continue
+ }
if time.Since(lastDataAt) < keepaliveInterval {
continue
}
// Send Anthropic-format ping event
+ commitHeaders()
if _, err := fmt.Fprint(c.Writer, "event: ping\ndata: {\"type\":\"ping\"}\n\n"); err != nil {
// Client disconnected
logger.L().Info("openai messages stream: client disconnected during keepalive",
zap.String("request_id", requestID),
)
- return resultWithUsage(), nil
+ clientDisconnected = true
+ continue
}
c.Writer.Flush()
}
@@ -610,3 +1187,17 @@ func writeAnthropicError(c *gin.Context, statusCode int, errType, message string
},
})
}
+
+func copyOpenAIUsageFromResponsesUsage(usage *apicompat.ResponsesUsage) OpenAIUsage {
+ if usage == nil {
+ return OpenAIUsage{}
+ }
+ result := OpenAIUsage{
+ InputTokens: usage.InputTokens,
+ OutputTokens: usage.OutputTokens,
+ }
+ if usage.InputTokensDetails != nil {
+ result.CacheReadInputTokens = usage.InputTokensDetails.CachedTokens
+ }
+ return result
+}
diff --git a/backend/internal/service/openai_gateway_messages_buffered_test.go b/backend/internal/service/openai_gateway_messages_buffered_test.go
new file mode 100644
index 00000000000..8cfc4488525
--- /dev/null
+++ b/backend/internal/service/openai_gateway_messages_buffered_test.go
@@ -0,0 +1,292 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+func responsesFunctionCallStream(count int) string {
+ var body strings.Builder
+ for i := 0; i < count; i++ {
+ index := strconv.Itoa(i)
+ body.WriteString(`data: {"type":"response.output_item.added","output_index":`)
+ body.WriteString(index)
+ body.WriteString(`,"item":{"type":"function_call","call_id":"call_`)
+ body.WriteString(index)
+ body.WriteString(`","name":"tool"}}`)
+ body.WriteString("\n\n")
+ }
+ return body.String()
+}
+
+func TestReadOpenAICompatBufferedTerminalRejectsExcessiveFunctionCalls(t *testing.T) {
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(responsesFunctionCallStream(129))),
+ }
+ svc := &OpenAIGatewayService{}
+
+ terminal, _, acc, err := svc.readOpenAICompatBufferedTerminal(resp, "test", "rid_limit")
+
+ require.Nil(t, terminal)
+ require.ErrorIs(t, err, apicompat.ErrResponsesCompatibilityResourceLimit)
+ require.ErrorIs(t, acc.Err(), apicompat.ErrResponsesCompatibilityResourceLimit)
+}
+
+func TestHandleChatStreamingResponseStopsOnCompatibilityLimit(t *testing.T) {
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(responsesFunctionCallStream(129))),
+ }
+ svc := &OpenAIGatewayService{}
+
+ result, err := svc.handleChatStreamingResponse(
+ resp,
+ c,
+ "gpt-test",
+ "gpt-test",
+ "gpt-test",
+ false,
+ time.Now(),
+ )
+
+ require.NotNil(t, result)
+ require.ErrorIs(t, err, apicompat.ErrResponsesCompatibilityResourceLimit)
+}
+
+func TestForwardAsAnthropic_BufferedMissingTerminalWithTextSynthesizesResponse(t *testing.T) {
+ t.Parallel()
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := strings.Join([]string{
+ `data: {"type":"response.created","response":{"id":"resp_missing_terminal","object":"response","model":"gpt-5.4","status":"in_progress"}}`,
+ "",
+ `data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"Recovered answer"}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_missing_terminal"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.Equal(t, "resp_missing_terminal", gjson.GetBytes(rec.Body.Bytes(), "id").String())
+ require.Equal(t, "claude-haiku-4-5-20251001", gjson.GetBytes(rec.Body.Bytes(), "model").String())
+ require.Equal(t, "Recovered answer", gjson.GetBytes(rec.Body.Bytes(), "content.0.text").String())
+ require.Equal(t, "end_turn", gjson.GetBytes(rec.Body.Bytes(), "stop_reason").String())
+}
+
+func TestHandleChatBufferedStreamingResponse_MissingTerminalWithTextSynthesizesResponse(t *testing.T) {
+ t.Parallel()
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
+
+ upstreamBody := strings.Join([]string{
+ `data: {"type":"response.created","response":{"id":"resp_chat_missing_terminal","object":"response","model":"gpt-5.4","status":"in_progress"}}`,
+ "",
+ `data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"Recovered chat answer"}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_missing_terminal"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }
+
+ svc := &OpenAIGatewayService{}
+
+ result, err := svc.handleChatBufferedStreamingResponse(resp, c, "gpt-5.4", "gpt-5.4", "gpt-5.4", time.Now())
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.Equal(t, "resp_chat_missing_terminal", gjson.GetBytes(rec.Body.Bytes(), "id").String())
+ require.Equal(t, "Recovered chat answer", gjson.GetBytes(rec.Body.Bytes(), "choices.0.message.content").String())
+ require.Equal(t, "stop", gjson.GetBytes(rec.Body.Bytes(), "choices.0.finish_reason").String())
+}
+
+func TestForwardAsAnthropic_BufferedMissingTerminalWithToolCallReturnsFailover(t *testing.T) {
+ t.Parallel()
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := strings.Join([]string{
+ `data: {"type":"response.created","response":{"id":"resp_tool_missing_terminal","object":"response","model":"gpt-5.4","status":"in_progress"}}`,
+ "",
+ `data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"Read"}}`,
+ "",
+ `data: {"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"file_path\":"}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_tool_missing_terminal"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4")
+ require.Nil(t, result)
+ require.Error(t, err)
+ var failoverErr *UpstreamFailoverError
+ require.True(t, errors.As(err, &failoverErr))
+ require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
+ require.True(t, failoverErr.RetryableOnSameAccount)
+ require.False(t, c.Writer.Written())
+ require.Empty(t, rec.Body.String())
+}
+
+func TestForwardAsAnthropic_StreamingEmptyTerminalReturnsFailoverBeforeOutput(t *testing.T) {
+ t.Parallel()
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := strings.Join([]string{
+ `data: {"type":"response.completed","response":{"id":"resp_empty_terminal","object":"response","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_empty_terminal"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4")
+ require.Nil(t, result)
+ require.Error(t, err)
+ var failoverErr *UpstreamFailoverError
+ require.True(t, errors.As(err, &failoverErr))
+ require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
+ require.True(t, failoverErr.RetryableOnSameAccount)
+ require.Contains(t, string(failoverErr.ResponseBody), "upstream_empty_response")
+ require.False(t, c.Writer.Written())
+ require.Empty(t, rec.Body.String())
+}
+
+func TestForwardAsAnthropic_BufferedEmptyTerminalReturnsFailoverBeforeOutput(t *testing.T) {
+ t.Parallel()
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstreamBody := strings.Join([]string{
+ `data: {"type":"response.completed","response":{"id":"resp_empty_terminal_buffered","object":"response","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n")
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_empty_terminal_buffered"}},
+ Body: io.NopCloser(strings.NewReader(upstreamBody)),
+ }}
+
+ svc := &OpenAIGatewayService{httpUpstream: upstream}
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ }
+
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4")
+ require.Nil(t, result)
+ require.Error(t, err)
+ var failoverErr *UpstreamFailoverError
+ require.True(t, errors.As(err, &failoverErr))
+ require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
+ require.True(t, failoverErr.RetryableOnSameAccount)
+ require.Contains(t, string(failoverErr.ResponseBody), "upstream_empty_response")
+ require.False(t, c.Writer.Written())
+ require.Empty(t, rec.Body.String())
+}
diff --git a/backend/internal/service/openai_gateway_messages_identity_test.go b/backend/internal/service/openai_gateway_messages_identity_test.go
new file mode 100644
index 00000000000..6fd47233e73
--- /dev/null
+++ b/backend/internal/service/openai_gateway_messages_identity_test.go
@@ -0,0 +1,157 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+func TestForwardAsAnthropic_ModelIdentityMatrix(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ accountType string
+ requestedModel string
+ mappedModel string
+ stream bool
+ toolResponse bool
+ }{
+ {name: "api_key_native_sol_buffered", accountType: AccountTypeAPIKey, requestedModel: "gpt-5.6-sol", mappedModel: "gpt-5.6-sol"},
+ {name: "api_key_native_terra_stream", accountType: AccountTypeAPIKey, requestedModel: "gpt-5.6-terra", mappedModel: "gpt-5.6-terra", stream: true},
+ {name: "api_key_native_luna_buffered", accountType: AccountTypeAPIKey, requestedModel: "gpt-5.6-luna", mappedModel: "gpt-5.6-luna"},
+ {name: "oauth_native_sol_stream", accountType: AccountTypeOAuth, requestedModel: "gpt-5.6-sol", mappedModel: "gpt-5.6-sol", stream: true},
+ {name: "oauth_native_terra_buffered", accountType: AccountTypeOAuth, requestedModel: "gpt-5.6-terra", mappedModel: "gpt-5.6-terra"},
+ {name: "oauth_native_luna_stream", accountType: AccountTypeOAuth, requestedModel: "gpt-5.6-luna", mappedModel: "gpt-5.6-luna", stream: true},
+ {name: "api_key_legacy_alias_buffered", accountType: AccountTypeAPIKey, requestedModel: "claude-sonnet-4-6", mappedModel: "gpt-5.6-terra", toolResponse: true},
+ {name: "api_key_legacy_alias_stream", accountType: AccountTypeAPIKey, requestedModel: "claude-sonnet-4-6", mappedModel: "gpt-5.6-terra", stream: true, toolResponse: true},
+ {name: "oauth_legacy_alias_buffered", accountType: AccountTypeOAuth, requestedModel: "claude-sonnet-4-6", mappedModel: "gpt-5.6-terra", toolResponse: true},
+ {name: "oauth_legacy_alias_stream", accountType: AccountTypeOAuth, requestedModel: "claude-sonnet-4-6", mappedModel: "gpt-5.6-terra", stream: true, toolResponse: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ body := []byte(fmt.Sprintf(`{"model":%q,"max_tokens":64,"messages":[{"role":"user","content":"local identity test"}],"stream":%t}`, tt.requestedModel, tt.stream))
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstream := &httpUpstreamRecorder{resp: openAIIdentitySSE(tt.mappedModel, tt.toolResponse)}
+ svc := &OpenAIGatewayService{
+ httpUpstream: upstream,
+ cfg: &config.Config{Security: config.SecurityConfig{
+ URLAllowlist: config.URLAllowlistConfig{Enabled: false},
+ }},
+ }
+ account := openAIIdentityTestAccount(tt.accountType, tt.requestedModel, tt.mappedModel)
+
+ defaultMappedModel := "gpt-5.4"
+ if strings.HasPrefix(tt.requestedModel, "claude-") {
+ defaultMappedModel = ""
+ }
+ result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "local-identity-session", defaultMappedModel)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ require.Equal(t, tt.requestedModel, result.Model, "product-visible result identity must match the client request")
+ require.Equal(t, tt.mappedModel, result.BillingModel, "billing identity must match the selected GPT tier")
+ require.Equal(t, tt.mappedModel, result.UpstreamModel, "upstream identity must match the exact GPT tier")
+ require.Equal(t, tt.stream, result.Stream)
+ require.Equal(t, tt.mappedModel, gjson.GetBytes(upstream.lastBody, "model").String(), "upstream JSON must retain the exact GPT tier")
+ require.NotEqual(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String(), "GPT-5.6 must not be downgraded by the OAuth transform")
+
+ if tt.accountType == AccountTypeOAuth {
+ require.Equal(t, "Bearer oauth-local-test", upstream.lastReq.Header.Get("Authorization"))
+ require.Equal(t, "https://chatgpt.com/backend-api/codex/responses", upstream.lastReq.URL.String())
+ } else {
+ require.Equal(t, "Bearer sk-local-test", upstream.lastReq.Header.Get("Authorization"))
+ require.Equal(t, "https://api.openai.com/v1/responses", upstream.lastReq.URL.String())
+ }
+
+ if tt.stream {
+ require.Contains(t, rec.Body.String(), `"type":"message_start"`)
+ require.Contains(t, rec.Body.String(), fmt.Sprintf(`"model":%q`, tt.requestedModel))
+ } else {
+ require.Equal(t, tt.requestedModel, gjson.GetBytes(rec.Body.Bytes(), "model").String())
+ }
+
+ if tt.toolResponse {
+ if tt.stream {
+ require.Contains(t, rec.Body.String(), `"type":"tool_use"`)
+ require.Contains(t, rec.Body.String(), `"name":"Read"`)
+ require.Contains(t, rec.Body.String(), `\"file_path\":\"README.md\"`)
+ } else {
+ require.Equal(t, "tool_use", gjson.GetBytes(rec.Body.Bytes(), "content.0.type").String())
+ require.Equal(t, "Read", gjson.GetBytes(rec.Body.Bytes(), "content.0.name").String())
+ require.Equal(t, "README.md", gjson.GetBytes(rec.Body.Bytes(), "content.0.input.file_path").String())
+ }
+ }
+ })
+ }
+}
+
+func openAIIdentityTestAccount(accountType, requestedModel, mappedModel string) *Account {
+ mapping := map[string]any{requestedModel: mappedModel}
+ if tier, isGPT56Family := classifyOpenAIGPT56PreviewModel(mappedModel); isGPT56Family && tier != "" {
+ mapping[tier] = tier
+ }
+ credentials := map[string]any{"model_mapping": mapping}
+ if accountType == AccountTypeOAuth {
+ credentials["access_token"] = "oauth-local-test"
+ credentials["chatgpt_account_id"] = "chatgpt-local-test"
+ } else {
+ credentials["api_key"] = "sk-local-test"
+ }
+ return &Account{
+ ID: 9401,
+ Name: "local-openai-identity",
+ Platform: PlatformOpenAI,
+ Type: accountType,
+ Concurrency: 1,
+ Credentials: credentials,
+ Status: StatusActive,
+ Schedulable: true,
+ }
+}
+
+func openAIIdentitySSE(model string, toolResponse bool) *http.Response {
+ var events []string
+ if toolResponse {
+ events = []string{
+ fmt.Sprintf(`data: {"type":"response.created","response":{"id":"resp_identity","object":"response","model":%q,"status":"in_progress"}}`, model),
+ `data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","call_id":"call_identity","name":"Read","status":"in_progress"}}`,
+ `data: {"type":"response.function_call_arguments.done","output_index":0,"arguments":"{\"file_path\":\"README.md\",\"pages\":\"\"}"}`,
+ `data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","call_id":"call_identity","name":"Read","arguments":"{\"file_path\":\"README.md\",\"pages\":\"\"}","status":"completed"}}`,
+ fmt.Sprintf(`data: {"type":"response.completed","response":{"id":"resp_identity","object":"response","model":%q,"status":"completed","output":[{"type":"function_call","call_id":"call_identity","name":"Read","arguments":"{\"file_path\":\"README.md\",\"pages\":\"\"}","status":"completed"}],"usage":{"input_tokens":7,"output_tokens":3,"total_tokens":10}}}`, model),
+ }
+ } else {
+ events = []string{
+ fmt.Sprintf(`data: {"type":"response.created","response":{"id":"resp_identity","object":"response","model":%q,"status":"in_progress"}}`, model),
+ `data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"local identity ok"}`,
+ `data: {"type":"response.output_text.done","output_index":0,"content_index":0,"text":"local identity ok"}`,
+ fmt.Sprintf(`data: {"type":"response.completed","response":{"id":"resp_identity","object":"response","model":%q,"status":"completed","output":[{"type":"message","id":"msg_identity","role":"assistant","status":"completed","content":[{"type":"output_text","text":"local identity ok"}]}],"usage":{"input_tokens":7,"output_tokens":3,"total_tokens":10}}}`, model),
+ }
+ }
+ events = append(events, "data: [DONE]", "")
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{
+ "Content-Type": []string{"text/event-stream"},
+ "x-request-id": []string{"rid_identity"},
+ },
+ Body: io.NopCloser(strings.NewReader(strings.Join(events, "\n\n"))),
+ }
+}
diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go
index 47ff4e3b6eb..650967ca800 100644
--- a/backend/internal/service/openai_gateway_record_usage_test.go
+++ b/backend/internal/service/openai_gateway_record_usage_test.go
@@ -52,6 +52,129 @@ func (s *openAIRecordUsageBillingRepoStub) Apply(ctx context.Context, cmd *Usage
return &UsageBillingApplyResult{Applied: true}, nil
}
+func TestOpenAIGatewayServiceRecordUsage_RejectsNilInput(t *testing.T) {
+ svc := &OpenAIGatewayService{}
+ require.Error(t, svc.RecordUsage(context.Background(), nil))
+ require.Error(t, svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{}))
+}
+
+func TestOpenAIGatewayServiceRecordUsageUsesImmutablePreflightQuoteAndEvidence(t *testing.T) {
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ billingRepo := &openAIRecordUsageBillingRepoStub{result: &UsageBillingApplyResult{Applied: true}}
+ svc := newOpenAIRecordUsageServiceWithBillingRepoForTest(
+ usageRepo,
+ billingRepo,
+ &openAIRecordUsageUserRepoStub{},
+ &openAIRecordUsageSubRepoStub{},
+ nil,
+ )
+ svc.resolver = NewModelPricingResolver(nil, svc.billingService)
+ quote := &PricingQuote{
+ Resolved: &ResolvedPricing{
+ Mode: BillingModeToken,
+ BasePricing: &ModelPricing{InputPricePerToken: 0.01, OutputPricePerToken: 0.02},
+ Source: PricingSourceLiteLLM,
+ },
+ Evidence: PricingEvidence{Source: PricingSourceLiteLLM, Revision: "effective-test", Hash: strings.Repeat("c", 64)},
+ }
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp-immutable-quote", Model: "custom-unpriceable", UpstreamModel: "custom-unpriceable",
+ Usage: OpenAIUsage{InputTokens: 10, OutputTokens: 2}, Duration: time.Second,
+ BillingIdentity: &ResolvedOpenAIBillingIdentity{
+ RequestedModel: "custom-unpriceable", UpstreamModel: "custom-unpriceable",
+ BillingModel: "custom-unpriceable", BillingModelSource: BillingModelSourceUpstream,
+ Pricing: quote,
+ },
+ },
+ APIKey: &APIKey{ID: 100, Group: &Group{ID: 10, RateMultiplier: 1}},
+ User: &User{ID: 200}, Account: &Account{ID: 300, Type: AccountTypeAPIKey},
+ APIKeyService: &openAIRecordUsageAPIKeyQuotaStub{},
+ })
+ require.NoError(t, err, "recording must use the quote even though the model cannot be re-resolved")
+ require.NotNil(t, usageRepo.lastLog)
+ require.InDelta(t, 0.10, usageRepo.lastLog.InputCost, 1e-12)
+ require.InDelta(t, 0.04, usageRepo.lastLog.OutputCost, 1e-12)
+ require.Equal(t, "custom-unpriceable", *usageRepo.lastLog.BillingModel)
+ require.Equal(t, PricingSourceLiteLLM, *usageRepo.lastLog.PricingSource)
+ require.Equal(t, "effective-test", *usageRepo.lastLog.PricingRevision)
+ require.Equal(t, strings.Repeat("c", 64), *usageRepo.lastLog.PricingHash)
+}
+
+func TestOpenAIGatewayServiceRecordUsage_ImageIntentWithoutOutputUsesFrozenTextQuote(t *testing.T) {
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ billingRepo := &openAIRecordUsageBillingRepoStub{result: &UsageBillingApplyResult{Applied: true}}
+ svc := newOpenAIRecordUsageServiceWithBillingRepoForTest(
+ usageRepo, billingRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil,
+ )
+ svc.resolver = NewModelPricingResolver(nil, svc.billingService)
+ identity := &ResolvedOpenAIBillingIdentity{
+ BillingModel: "gpt-image-2",
+ Pricing: &PricingQuote{
+ Resolved: &ResolvedPricing{Mode: BillingModeImage, DefaultPerRequestPrice: 0.25, Source: PricingSourceBuiltinFallback},
+ Evidence: PricingEvidence{Source: PricingSourceBuiltinFallback, Revision: "image", Hash: strings.Repeat("i", 64)},
+ },
+ TextBillingModel: "custom-text-priced",
+ TextPricing: &PricingQuote{
+ Resolved: &ResolvedPricing{Mode: BillingModeToken, BasePricing: &ModelPricing{InputPricePerToken: 0.01, OutputPricePerToken: 0.02}, Source: PricingSourceLiteLLM},
+ Evidence: PricingEvidence{Source: PricingSourceLiteLLM, Revision: "text", Hash: strings.Repeat("t", 64)},
+ },
+ }
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp-image-no-output", Model: "custom-text-priced", UpstreamModel: "custom-text-priced",
+ BillingModel: "gpt-image-2", BillingIdentity: identity,
+ Usage: OpenAIUsage{InputTokens: 10, OutputTokens: 2}, Duration: time.Second,
+ },
+ APIKey: &APIKey{ID: 101, Group: &Group{ID: 11, RateMultiplier: 1}},
+ User: &User{ID: 201}, Account: &Account{ID: 301, Type: AccountTypeAPIKey},
+ APIKeyService: &openAIRecordUsageAPIKeyQuotaStub{},
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, usageRepo.lastLog)
+ require.InDelta(t, 0.10, usageRepo.lastLog.InputCost, 1e-12)
+ require.InDelta(t, 0.04, usageRepo.lastLog.OutputCost, 1e-12)
+ require.Equal(t, "custom-text-priced", *usageRepo.lastLog.BillingModel)
+ require.Equal(t, PricingSourceLiteLLM, *usageRepo.lastLog.PricingSource)
+ require.Equal(t, "text", *usageRepo.lastLog.PricingRevision)
+ require.Equal(t, strings.Repeat("t", 64), *usageRepo.lastLog.PricingHash)
+}
+
+func TestOpenAIGatewayServiceRecordUsage_DedicatedImageWithoutOutputChargesZero(t *testing.T) {
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ billingRepo := &openAIRecordUsageBillingRepoStub{result: &UsageBillingApplyResult{Applied: true}}
+ svc := newOpenAIRecordUsageServiceWithBillingRepoForTest(
+ usageRepo, billingRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil,
+ )
+ svc.resolver = NewModelPricingResolver(nil, svc.billingService)
+ identity := &ResolvedOpenAIBillingIdentity{
+ BillingModel: "gpt-image-2",
+ Pricing: &PricingQuote{
+ Resolved: &ResolvedPricing{Mode: BillingModeImage, DefaultPerRequestPrice: 0.25, Source: PricingSourceBuiltinFallback},
+ Evidence: PricingEvidence{Source: PricingSourceBuiltinFallback, Revision: "image-zero", Hash: strings.Repeat("z", 64)},
+ },
+ }
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp-dedicated-image-zero", Model: "gpt-image-2", UpstreamModel: "gpt-image-2",
+ BillingModel: "gpt-image-2", BillingIdentity: identity, ImageCount: 0, Duration: time.Second,
+ },
+ APIKey: &APIKey{ID: 102, Group: &Group{ID: 12, RateMultiplier: 1}},
+ User: &User{ID: 202}, Account: &Account{ID: 302, Type: AccountTypeAPIKey},
+ APIKeyService: &openAIRecordUsageAPIKeyQuotaStub{},
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, usageRepo.lastLog)
+ require.Zero(t, usageRepo.lastLog.ImageCount)
+ require.Zero(t, usageRepo.lastLog.TotalCost)
+ require.Zero(t, usageRepo.lastLog.ActualCost)
+}
+
type openAIRecordUsageUserRepoStub struct {
UserRepository
@@ -73,11 +196,13 @@ type openAIRecordUsageSubRepoStub struct {
incrementCalls int
incrementErr error
+ lastAmount float64
lastCtxErr error
}
func (s *openAIRecordUsageSubRepoStub) IncrementUsage(ctx context.Context, id int64, costUSD float64) error {
s.incrementCalls++
+ s.lastAmount = costUSD
s.lastCtxErr = ctx.Err()
return s.incrementErr
}
@@ -134,6 +259,7 @@ func newOpenAIRecordUsageServiceForTest(usageRepo UsageLogRepository, userRepo U
nil,
userRepo,
subRepo,
+ nil, // walletRepo
rateRepo,
nil,
cfg,
@@ -149,7 +275,11 @@ func newOpenAIRecordUsageServiceForTest(usageRepo UsageLogRepository, userRepo U
nil,
nil,
nil,
+ nil,
+ nil,
+ nil,
)
+ svc.requireUsageBillingOutbox = false
svc.userGroupRateResolver = newUserGroupRateResolver(
rateRepo,
nil,
@@ -186,6 +316,56 @@ func max(a, b int) int {
return b
}
+func TestOpenAIGatewayServiceRecordUsage_ZeroUsageStillWritesUsageLog(t *testing.T) {
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ billingRepo := &openAIRecordUsageBillingRepoStub{result: &UsageBillingApplyResult{Applied: true}}
+ userRepo := &openAIRecordUsageUserRepoStub{}
+ subRepo := &openAIRecordUsageSubRepoStub{}
+ quotaSvc := &openAIRecordUsageAPIKeyQuotaStub{}
+ svc := newOpenAIRecordUsageServiceWithBillingRepoForTest(usageRepo, billingRepo, userRepo, subRepo, nil)
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp_zero_usage",
+ Usage: OpenAIUsage{},
+ Model: "gpt-5.1",
+ Duration: time.Second,
+ },
+ APIKey: &APIKey{ID: 1000, Quota: 100, Group: &Group{RateMultiplier: 1}},
+ User: &User{ID: 2000},
+ Account: &Account{ID: 3000, Type: AccountTypeAPIKey},
+ APIKeyService: quotaSvc,
+ })
+
+ require.NoError(t, err)
+ require.Equal(t, 1, billingRepo.calls)
+ require.Equal(t, 1, usageRepo.calls)
+ require.Equal(t, 0, userRepo.deductCalls)
+ require.Equal(t, 0, subRepo.incrementCalls)
+ require.Equal(t, 0, quotaSvc.quotaCalls)
+ require.Equal(t, 0, quotaSvc.rateLimitCalls)
+
+ require.NotNil(t, usageRepo.lastLog)
+ require.Equal(t, "resp_zero_usage", usageRepo.lastLog.RequestID)
+ require.Zero(t, usageRepo.lastLog.InputTokens)
+ require.Zero(t, usageRepo.lastLog.OutputTokens)
+ require.Zero(t, usageRepo.lastLog.CacheCreationTokens)
+ require.Zero(t, usageRepo.lastLog.CacheReadTokens)
+ require.Zero(t, usageRepo.lastLog.ImageOutputTokens)
+ require.Zero(t, usageRepo.lastLog.ImageCount)
+ require.Zero(t, usageRepo.lastLog.InputCost)
+ require.Zero(t, usageRepo.lastLog.OutputCost)
+ require.Zero(t, usageRepo.lastLog.TotalCost)
+ require.Zero(t, usageRepo.lastLog.ActualCost)
+
+ require.NotNil(t, billingRepo.lastCmd)
+ require.Zero(t, billingRepo.lastCmd.BalanceCost)
+ require.Zero(t, billingRepo.lastCmd.SubscriptionCost)
+ require.Zero(t, billingRepo.lastCmd.APIKeyQuotaCost)
+ require.Zero(t, billingRepo.lastCmd.APIKeyRateLimitCost)
+ require.Zero(t, billingRepo.lastCmd.AccountQuotaCost)
+}
+
func TestOpenAIGatewayServiceRecordUsage_UsesUserSpecificGroupRate(t *testing.T) {
groupID := int64(11)
groupRate := 1.4
@@ -230,6 +410,58 @@ func TestOpenAIGatewayServiceRecordUsage_UsesUserSpecificGroupRate(t *testing.T)
require.Equal(t, 1, userRepo.deductCalls)
}
+func TestOpenAIGatewayServiceRecordUsage_LockedRatesOverrideUserSpecificGroupRate(t *testing.T) {
+ groupID := int64(11)
+ groupRate := 0.7
+ userRate := 0.3
+ lockedRate := 3.5
+ usage := OpenAIUsage{InputTokens: 20, OutputTokens: 5, CacheReadInputTokens: 4}
+
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ userRepo := &openAIRecordUsageUserRepoStub{}
+ subRepo := &openAIRecordUsageSubRepoStub{}
+ rateRepo := &openAIUserGroupRateRepoStub{rate: &userRate}
+ svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, rateRepo)
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp_locked_rate",
+ Usage: usage,
+ Model: "gpt-5.1",
+ Duration: time.Second,
+ },
+ APIKey: &APIKey{
+ ID: 10011,
+ GroupID: i64p(groupID),
+ Group: &Group{
+ ID: groupID,
+ RateMultiplier: groupRate,
+ },
+ },
+ User: &User{ID: 20011},
+ Account: &Account{
+ ID: 30011,
+ },
+ Subscription: &UserSubscription{
+ ID: 9011,
+ UserID: 20011,
+ LockedRates: map[string]float64{"11": lockedRate},
+ },
+ })
+
+ require.NoError(t, err)
+ require.Equal(t, 0, rateRepo.calls, "locked_rates should bypass user_group_rate lookup")
+ require.NotNil(t, usageRepo.lastLog)
+ require.Equal(t, lockedRate, usageRepo.lastLog.RateMultiplier)
+ require.Equal(t, int8(BillingTypeSubscription), usageRepo.lastLog.BillingType)
+
+ expected := expectedOpenAICost(t, svc, "gpt-5.1", usage, lockedRate)
+ require.InDelta(t, expected.ActualCost, usageRepo.lastLog.ActualCost, 1e-12)
+ require.InDelta(t, expected.ActualCost, subRepo.lastAmount, 1e-12)
+ require.Equal(t, 1, subRepo.incrementCalls)
+ require.Equal(t, 0, userRepo.deductCalls)
+}
+
func TestOpenAIGatewayServiceRecordUsage_IncludesEndpointMetadata(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
userRepo := &openAIRecordUsageUserRepoStub{}
@@ -896,7 +1128,7 @@ func TestOpenAIGatewayServiceRecordUsage_UsesRequestedModelAndUpstreamModelMetad
require.NoError(t, err)
require.NotNil(t, usageRepo.lastLog)
- require.Equal(t, "gpt-5.1", usageRepo.lastLog.Model)
+ require.Equal(t, "gpt-5.1-codex", usageRepo.lastLog.Model)
require.Equal(t, "gpt-5.1", usageRepo.lastLog.RequestedModel)
require.NotNil(t, usageRepo.lastLog.UpstreamModel)
require.Equal(t, "gpt-5.1-codex", *usageRepo.lastLog.UpstreamModel)
@@ -943,7 +1175,7 @@ func TestOpenAIGatewayServiceRecordUsage_BillsMappedRequestsUsingRequestedModel(
require.NoError(t, err)
require.NotNil(t, usageRepo.lastLog)
- require.Equal(t, "gpt-5.1", usageRepo.lastLog.Model)
+ require.Equal(t, "gpt-5.1-codex", usageRepo.lastLog.Model)
require.Equal(t, expectedCost.ActualCost, usageRepo.lastLog.ActualCost)
require.Equal(t, expectedCost.TotalCost, usageRepo.lastLog.TotalCost)
require.Equal(t, expectedCost.ActualCost, userRepo.lastAmount)
@@ -956,9 +1188,8 @@ func TestOpenAIGatewayServiceRecordUsage_ChannelMappedDoesNotOverrideBillingMode
svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
usage := OpenAIUsage{InputTokens: 20, OutputTokens: 10}
- // When channel did NOT map the model (ChannelMappedModel == OriginalModel),
- // billing should use result.BillingModel (the actual model used after group
- // DefaultMappedModel resolution), not the unmapped original model.
+ // 渠道未发生模型映射时,应使用 result.BillingModel 中记录的实际上游计费模型,
+ // 而不是未映射的原始请求模型。
expectedCost, err := svc.billingService.CalculateCost("gpt-5.1", UsageTokens{
InputTokens: 20,
OutputTokens: 10,
@@ -1032,6 +1263,178 @@ func TestOpenAIGatewayServiceRecordUsage_ChannelMappedOverridesBillingModelWhenM
require.True(t, usageRepo.lastLog.ActualCost > 0, "cost must not be zero")
}
+func TestOpenAIGatewayServiceRecordUsage_BillsCompactOpenAIModelAlias(t *testing.T) {
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ userRepo := &openAIRecordUsageUserRepoStub{}
+ subRepo := &openAIRecordUsageSubRepoStub{}
+ svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
+ usage := OpenAIUsage{InputTokens: 20, OutputTokens: 10}
+
+ expectedCost, err := svc.billingService.CalculateCost("gpt-5.5", UsageTokens{
+ InputTokens: 20,
+ OutputTokens: 10,
+ }, 1.1)
+ require.NoError(t, err)
+
+ err = svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp_compact_openai_alias",
+ Model: "gpt5.5",
+ UpstreamModel: "gpt-5.4",
+ Usage: usage,
+ Duration: time.Second,
+ },
+ APIKey: &APIKey{ID: 10},
+ User: &User{ID: 20},
+ Account: &Account{ID: 30},
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, usageRepo.lastLog)
+ require.Equal(t, "gpt-5.4", usageRepo.lastLog.Model)
+ require.NotNil(t, usageRepo.lastLog.UpstreamModel)
+ require.Equal(t, "gpt-5.4", *usageRepo.lastLog.UpstreamModel)
+ require.InDelta(t, expectedCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12)
+ require.True(t, usageRepo.lastLog.ActualCost > 0, "cost must not be zero")
+ require.InDelta(t, expectedCost.ActualCost, userRepo.lastAmount, 1e-12)
+}
+
+func TestOpenAIGatewayServiceRecordUsage_ClaudeSelectorRequestedSourceStoresMappedGPTModel(t *testing.T) {
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ userRepo := &openAIRecordUsageUserRepoStub{}
+ subRepo := &openAIRecordUsageSubRepoStub{}
+ svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
+ usage := OpenAIUsage{InputTokens: 18, OutputTokens: 11}
+
+ claudeCost, err := svc.billingService.CalculateCost("claude-haiku-4-5", UsageTokens{
+ InputTokens: 18,
+ OutputTokens: 11,
+ }, 1.1)
+ require.NoError(t, err)
+ expectedGPTCost, err := svc.billingService.CalculateCost("gpt-5.4-mini", UsageTokens{
+ InputTokens: 18,
+ OutputTokens: 11,
+ }, 1.1)
+ require.NoError(t, err)
+
+ err = svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp_claude_requested_source_mapped_gpt",
+ Model: "claude-haiku-4-5",
+ BillingModel: "gpt-5.4-mini",
+ UpstreamModel: "gpt-5.4-mini",
+ Usage: usage,
+ Duration: time.Second,
+ },
+ APIKey: &APIKey{ID: 10},
+ User: &User{ID: 20},
+ Account: &Account{ID: 30},
+ ChannelUsageFields: ChannelUsageFields{
+ OriginalModel: "claude-haiku-4-5",
+ ChannelMappedModel: "gpt-5.4-mini",
+ BillingModelSource: BillingModelSourceRequested,
+ },
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, usageRepo.lastLog)
+ require.Equal(t, "gpt-5.4-mini", usageRepo.lastLog.Model)
+ require.Equal(t, "claude-haiku-4-5", usageRepo.lastLog.RequestedModel)
+ require.NotNil(t, usageRepo.lastLog.UpstreamModel)
+ require.Equal(t, "gpt-5.4-mini", *usageRepo.lastLog.UpstreamModel)
+ require.InDelta(t, expectedGPTCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12)
+ require.NotEqual(t, claudeCost.ActualCost, usageRepo.lastLog.ActualCost)
+}
+
+func TestOpenAIGatewayServiceRecordUsage_FallsBackToUpstreamModelWhenPrimaryUnpriceable(t *testing.T) {
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ userRepo := &openAIRecordUsageUserRepoStub{}
+ subRepo := &openAIRecordUsageSubRepoStub{}
+ svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
+ usage := OpenAIUsage{InputTokens: 20, OutputTokens: 10}
+
+ expectedCost, err := svc.billingService.CalculateCost("gpt-5.4", UsageTokens{
+ InputTokens: 20,
+ OutputTokens: 10,
+ }, 1.1)
+ require.NoError(t, err)
+
+ err = svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp_unpriceable_primary_upstream_fallback",
+ Model: "not-priceable-alias",
+ BillingModel: "not-priceable-alias",
+ UpstreamModel: "gpt-5.4",
+ Usage: usage,
+ Duration: time.Second,
+ },
+ APIKey: &APIKey{ID: 10},
+ User: &User{ID: 20},
+ Account: &Account{ID: 30},
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, usageRepo.lastLog)
+ require.NotNil(t, usageRepo.lastLog.BillingModel)
+ require.Equal(t, "gpt-5.4", *usageRepo.lastLog.BillingModel)
+ require.InDelta(t, expectedCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12)
+ require.True(t, usageRepo.lastLog.ActualCost > 0, "cost must not be zero")
+ require.InDelta(t, expectedCost.ActualCost, userRepo.lastAmount, 1e-12)
+}
+
+func TestOpenAIGatewayServiceRecordUsage_PersistsExactNativeGPT56BillingTier(t *testing.T) {
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ svc := newOpenAIRecordUsageServiceForTest(
+ usageRepo,
+ &openAIRecordUsageUserRepoStub{},
+ &openAIRecordUsageSubRepoStub{},
+ nil,
+ )
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp_native_gpt56_terra",
+ Model: "gpt-5.6-terra",
+ UpstreamModel: "gpt-5.6-terra",
+ Usage: OpenAIUsage{InputTokens: 20, OutputTokens: 10},
+ Duration: time.Second,
+ },
+ APIKey: &APIKey{ID: 10},
+ User: &User{ID: 20},
+ Account: &Account{ID: 30},
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, usageRepo.lastLog)
+ require.NotNil(t, usageRepo.lastLog.BillingModel)
+ require.Equal(t, "gpt-5.6-terra", *usageRepo.lastLog.BillingModel)
+}
+
+func TestOpenAIGatewayServiceRecordUsage_ReturnsErrorWhenTokenModelCannotBePriced(t *testing.T) {
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ userRepo := &openAIRecordUsageUserRepoStub{}
+ subRepo := &openAIRecordUsageSubRepoStub{}
+ svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp_unpriceable_without_upstream",
+ Model: "not-priceable-alias",
+ Usage: OpenAIUsage{InputTokens: 20, OutputTokens: 10},
+ Duration: time.Second,
+ },
+ APIKey: &APIKey{ID: 10},
+ User: &User{ID: 20},
+ Account: &Account{ID: 30},
+ })
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "calculate OpenAI usage cost failed")
+ require.Equal(t, 0, usageRepo.calls)
+ require.Equal(t, 0, userRepo.deductCalls)
+ require.Equal(t, 0, subRepo.incrementCalls)
+}
+
func TestOpenAIGatewayServiceRecordUsage_SubscriptionBillingSetsSubscriptionFields(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
userRepo := &openAIRecordUsageUserRepoStub{}
@@ -1061,6 +1464,52 @@ func TestOpenAIGatewayServiceRecordUsage_SubscriptionBillingSetsSubscriptionFiel
require.Equal(t, 0, userRepo.deductCalls)
}
+func TestOpenAIGatewayServiceRecordUsage_WalletModeStandardGroupBillsWallet(t *testing.T) {
+ groupID := int64(3)
+ subID := int64(45)
+ walletBal := 100.0
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ billingRepo := &openAIRecordUsageBillingRepoStub{result: &UsageBillingApplyResult{Applied: true}}
+ svc := newOpenAIRecordUsageServiceWithBillingRepoForTest(
+ usageRepo,
+ billingRepo,
+ &openAIRecordUsageUserRepoStub{},
+ &openAIRecordUsageSubRepoStub{},
+ nil,
+ )
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp_wallet_standard_group",
+ Usage: OpenAIUsage{InputTokens: 100, OutputTokens: 50},
+ Model: "claude-sonnet-4",
+ Duration: time.Second,
+ },
+ APIKey: &APIKey{
+ ID: 100,
+ GroupID: &groupID,
+ Group: &Group{ID: groupID, RateMultiplier: 1.0},
+ },
+ User: &User{ID: 200},
+ Account: &Account{ID: 300},
+ Subscription: &UserSubscription{
+ ID: subID,
+ WalletBalanceUSD: &walletBal,
+ },
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, usageRepo.lastLog)
+ require.Equal(t, BillingTypeSubscription, usageRepo.lastLog.BillingType)
+ require.NotNil(t, usageRepo.lastLog.SubscriptionID)
+ require.Equal(t, subID, *usageRepo.lastLog.SubscriptionID)
+ require.NotNil(t, billingRepo.lastCmd)
+ require.Equal(t, subID, *billingRepo.lastCmd.SubscriptionID)
+ require.Greater(t, billingRepo.lastCmd.WalletCost, 0.0)
+ require.Zero(t, billingRepo.lastCmd.BalanceCost)
+ require.Zero(t, billingRepo.lastCmd.SubscriptionCost)
+}
+
func TestOpenAIGatewayServiceRecordUsage_SimpleModeSkipsBillingAfterPersist(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
userRepo := &openAIRecordUsageUserRepoStub{}
@@ -1112,6 +1561,73 @@ func TestOpenAIGatewayServiceRecordUsage_ImageOnlyUsageStillPersists(t *testing.
require.Equal(t, "1K", *usageRepo.lastLog.ImageSize)
require.NotNil(t, usageRepo.lastLog.BillingMode)
require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode)
+ require.NotNil(t, usageRepo.lastLog.BillingModel)
+ require.Equal(t, "gpt-image-2", *usageRepo.lastLog.BillingModel)
+}
+
+func TestOpenAIGatewayServiceRecordUsage_ImageEmptyBillingCandidatesReturnErrorWithoutWriteOrDeduct(t *testing.T) {
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ userRepo := &openAIRecordUsageUserRepoStub{}
+ subRepo := &openAIRecordUsageSubRepoStub{}
+ svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil)
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp_image_empty_billing_candidates",
+ Model: " \t ",
+ BillingModel: " ",
+ UpstreamModel: "\n",
+ ImageCount: 1,
+ ImageSize: "1K",
+ Duration: time.Second,
+ },
+ APIKey: &APIKey{ID: 10071},
+ User: &User{ID: 20071},
+ Account: &Account{ID: 30071},
+ })
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "billing model is empty")
+ require.Zero(t, usageRepo.calls)
+ require.Zero(t, userRepo.deductCalls)
+ require.Zero(t, subRepo.incrementCalls)
+}
+
+func TestOpenAIGatewayServiceRecordUsage_ImageSelectsExplicitUpstreamAfterInvalidPrimary(t *testing.T) {
+ groupID := int64(127)
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ svc := newOpenAIRecordUsageServiceForTest(
+ usageRepo,
+ &openAIRecordUsageUserRepoStub{},
+ &openAIRecordUsageSubRepoStub{},
+ nil,
+ )
+ svc.resolver = newOpenAIImageChannelPricingResolverForTest(t, groupID, "gpt-image-2", 0.25)
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp_image_explicit_upstream_candidate",
+ Model: "not-priceable-image-alias",
+ BillingModel: "not-priceable-image-alias",
+ UpstreamModel: "gpt-image-2",
+ ImageCount: 2,
+ ImageSize: "1K",
+ Duration: time.Second,
+ },
+ APIKey: &APIKey{
+ ID: 10072,
+ GroupID: &groupID,
+ Group: &Group{ID: groupID, RateMultiplier: 1},
+ },
+ User: &User{ID: 20072},
+ Account: &Account{ID: 30072},
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, usageRepo.lastLog)
+ require.NotNil(t, usageRepo.lastLog.BillingModel)
+ require.Equal(t, "gpt-image-2", *usageRepo.lastLog.BillingModel)
+ require.InDelta(t, 0.5, usageRepo.lastLog.TotalCost, 1e-12)
}
func TestOpenAIGatewayServiceRecordUsage_ImageUsesPerImageBillingEvenWithUsageTokens(t *testing.T) {
@@ -1160,3 +1676,289 @@ func TestOpenAIGatewayServiceRecordUsage_ImageUsesPerImageBillingEvenWithUsageTo
require.InDelta(t, 0.0, usageRepo.lastLog.OutputCost, 1e-12)
require.InDelta(t, 0.0, usageRepo.lastLog.ImageOutputCost, 1e-12)
}
+
+func TestOpenAIGatewayServiceRecordUsage_ImageSharedMultiplierPreservesExistingBehavior(t *testing.T) {
+ imagePrice := 0.2
+ groupID := int64(121)
+
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp_image_shared_multiplier",
+ Model: "gpt-image-2",
+ ImageCount: 1,
+ ImageSize: "1K",
+ Duration: time.Second,
+ },
+ APIKey: &APIKey{
+ ID: 10121,
+ GroupID: i64p(groupID),
+ Group: &Group{
+ ID: groupID,
+ RateMultiplier: 0.15,
+ ImageRateIndependent: false,
+ ImageRateMultiplier: 1,
+ ImagePrice1K: &imagePrice,
+ },
+ },
+ User: &User{ID: 20121},
+ Account: &Account{ID: 30121},
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, usageRepo.lastLog)
+ require.InDelta(t, 0.2, usageRepo.lastLog.TotalCost, 1e-12)
+ require.InDelta(t, 0.03, usageRepo.lastLog.ActualCost, 1e-12)
+ require.InDelta(t, 0.15, usageRepo.lastLog.RateMultiplier, 1e-12)
+ require.NotNil(t, usageRepo.lastLog.BillingMode)
+ require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode)
+}
+
+func TestOpenAIGatewayServiceRecordUsage_ImageSharedMultiplierUsesUserGroupOverride(t *testing.T) {
+ imagePrice := 0.5
+ userRate := 0.2
+ groupID := int64(125)
+
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ svc := newOpenAIRecordUsageServiceForTest(
+ usageRepo,
+ &openAIRecordUsageUserRepoStub{},
+ &openAIRecordUsageSubRepoStub{},
+ &openAIUserGroupRateRepoStub{rate: &userRate},
+ )
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp_image_user_group_override",
+ Model: "gpt-image-2",
+ ImageCount: 1,
+ ImageSize: "1K",
+ Duration: time.Second,
+ },
+ APIKey: &APIKey{
+ ID: 10125,
+ GroupID: i64p(groupID),
+ Group: &Group{
+ ID: groupID,
+ RateMultiplier: 0.15,
+ ImageRateIndependent: false,
+ ImageRateMultiplier: 1,
+ ImagePrice1K: &imagePrice,
+ },
+ },
+ User: &User{ID: 20125},
+ Account: &Account{ID: 30125},
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, usageRepo.lastLog)
+ require.InDelta(t, 0.5, usageRepo.lastLog.TotalCost, 1e-12)
+ require.InDelta(t, 0.1, usageRepo.lastLog.ActualCost, 1e-12)
+ require.InDelta(t, 0.2, usageRepo.lastLog.RateMultiplier, 1e-12)
+}
+
+func TestOpenAIGatewayServiceRecordUsage_ImageIndependentMultiplierUsesImageRate(t *testing.T) {
+ imagePrice := 0.2
+ groupID := int64(122)
+
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp_image_independent_multiplier",
+ Model: "gpt-image-2",
+ ImageCount: 1,
+ ImageSize: "1K",
+ Duration: time.Second,
+ },
+ APIKey: &APIKey{
+ ID: 10122,
+ GroupID: i64p(groupID),
+ Group: &Group{
+ ID: groupID,
+ RateMultiplier: 0.15,
+ ImageRateIndependent: true,
+ ImageRateMultiplier: 1,
+ ImagePrice1K: &imagePrice,
+ },
+ },
+ User: &User{ID: 20122},
+ Account: &Account{ID: 30122},
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, usageRepo.lastLog)
+ require.InDelta(t, 0.2, usageRepo.lastLog.TotalCost, 1e-12)
+ require.InDelta(t, 0.2, usageRepo.lastLog.ActualCost, 1e-12)
+ require.InDelta(t, 1.0, usageRepo.lastLog.RateMultiplier, 1e-12)
+ require.NotNil(t, usageRepo.lastLog.BillingMode)
+ require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode)
+}
+
+func TestOpenAIGatewayServiceRecordUsage_ChannelImageBillingUsesImageCountAndSharedMultiplier(t *testing.T) {
+ groupID := int64(123)
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
+ // Live pricing intentionally differs from the frozen preflight quote.
+ svc.resolver = newOpenAIImageChannelPricingResolverForTest(t, groupID, "gpt-image-2", 0.80)
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp_image_channel_shared",
+ Model: "gpt-image-2",
+ ImageCount: 3,
+ ImageSize: "1K",
+ Duration: time.Second,
+ BillingIdentity: &ResolvedOpenAIBillingIdentity{
+ BillingModel: "gpt-image-2",
+ Pricing: &PricingQuote{
+ Resolved: &ResolvedPricing{Mode: BillingModeImage, DefaultPerRequestPrice: 0.25, Source: PricingSourceChannel},
+ Evidence: PricingEvidence{Source: PricingSourceChannel, Revision: "image-effective-test", Hash: strings.Repeat("f", 64)},
+ },
+ },
+ },
+ APIKey: &APIKey{
+ ID: 10123,
+ GroupID: i64p(groupID),
+ Group: &Group{
+ ID: groupID,
+ RateMultiplier: 0.15,
+ ImageRateIndependent: false,
+ ImageRateMultiplier: 1,
+ },
+ },
+ User: &User{ID: 20123},
+ Account: &Account{ID: 30123},
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, usageRepo.lastLog)
+ require.InDelta(t, 0.75, usageRepo.lastLog.TotalCost, 1e-12)
+ require.InDelta(t, 0.1125, usageRepo.lastLog.ActualCost, 1e-12)
+ require.InDelta(t, 0.15, usageRepo.lastLog.RateMultiplier, 1e-12)
+ require.Equal(t, 3, usageRepo.lastLog.ImageCount)
+ require.NotNil(t, usageRepo.lastLog.BillingMode)
+ require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode)
+ require.Equal(t, PricingSourceChannel, *usageRepo.lastLog.PricingSource)
+ require.Equal(t, "image-effective-test", *usageRepo.lastLog.PricingRevision)
+ require.Equal(t, strings.Repeat("f", 64), *usageRepo.lastLog.PricingHash)
+}
+
+func TestOpenAIGatewayServiceRecordUsage_ChannelImageBillingUsesImageCountAndIndependentMultiplier(t *testing.T) {
+ groupID := int64(124)
+ usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
+ svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
+ svc.resolver = newOpenAIImageChannelPricingResolverForTest(t, groupID, "gpt-image-2", 0.25)
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "resp_image_channel_independent",
+ Model: "gpt-image-2",
+ ImageCount: 3,
+ ImageSize: "1K",
+ Duration: time.Second,
+ },
+ APIKey: &APIKey{
+ ID: 10124,
+ GroupID: i64p(groupID),
+ Group: &Group{
+ ID: groupID,
+ RateMultiplier: 0.15,
+ ImageRateIndependent: true,
+ ImageRateMultiplier: 1,
+ },
+ },
+ User: &User{ID: 20124},
+ Account: &Account{ID: 30124},
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, usageRepo.lastLog)
+ require.InDelta(t, 0.75, usageRepo.lastLog.TotalCost, 1e-12)
+ require.InDelta(t, 0.75, usageRepo.lastLog.ActualCost, 1e-12)
+ require.InDelta(t, 1.0, usageRepo.lastLog.RateMultiplier, 1e-12)
+ require.Equal(t, 3, usageRepo.lastLog.ImageCount)
+ require.NotNil(t, usageRepo.lastLog.BillingMode)
+ require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode)
+}
+
+func newOpenAIImageChannelPricingResolverForTest(t *testing.T, groupID int64, model string, price float64) *ModelPricingResolver {
+ t.Helper()
+ cache := newEmptyChannelCache()
+ cache.pricingByGroupModel[channelModelKey{groupID: groupID, model: model}] = &ChannelModelPricing{
+ BillingMode: BillingModeImage,
+ PerRequestPrice: &price,
+ }
+ cache.channelByGroupID[groupID] = &Channel{ID: groupID, Status: StatusActive}
+ cache.groupPlatform[groupID] = ""
+ cache.loadedAt = time.Now()
+ cs := &ChannelService{}
+ cs.cache.Store(cache)
+ return NewModelPricingResolver(cs, NewBillingService(&config.Config{}, nil))
+}
+
+func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesImageCount(t *testing.T) {
+ groupID := int64(126)
+ billingService := NewBillingService(&config.Config{}, nil)
+ svc := &GatewayService{
+ billingService: billingService,
+ resolver: newOpenAIImageChannelPricingResolverForTest(t, groupID, "gemini-image", 0.25),
+ }
+
+ cost := svc.calculateRecordUsageCost(
+ context.Background(),
+ &ForwardResult{Model: "gemini-image", ImageCount: 2, ImageSize: "1K"},
+ &APIKey{GroupID: i64p(groupID), Group: &Group{ID: groupID}},
+ "gemini-image",
+ 0.15,
+ 1.0,
+ nil,
+ )
+
+ require.NotNil(t, cost)
+ require.Equal(t, string(BillingModeImage), cost.BillingMode)
+ require.InDelta(t, 0.5, cost.TotalCost, 1e-12)
+ require.InDelta(t, 0.5, cost.ActualCost, 1e-12)
+}
+
+func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesSizeTier(t *testing.T) {
+ groupID := int64(127)
+ defaultPrice := 0.10
+ price4K := 0.40
+ cache := newEmptyChannelCache()
+ cache.pricingByGroupModel[channelModelKey{groupID: groupID, model: "gemini-image"}] = &ChannelModelPricing{
+ BillingMode: BillingModeImage,
+ PerRequestPrice: &defaultPrice,
+ Intervals: []PricingInterval{{
+ TierLabel: "4K",
+ PerRequestPrice: &price4K,
+ }},
+ }
+ cache.channelByGroupID[groupID] = &Channel{ID: groupID, Status: StatusActive}
+ cache.loadedAt = time.Now()
+ channelService := &ChannelService{}
+ channelService.cache.Store(cache)
+
+ svc := &GatewayService{
+ billingService: NewBillingService(&config.Config{}, nil),
+ resolver: NewModelPricingResolver(channelService, NewBillingService(&config.Config{}, nil)),
+ }
+
+ cost := svc.calculateRecordUsageCost(
+ context.Background(),
+ &ForwardResult{Model: "gemini-image", ImageCount: 2, ImageSize: "4K"},
+ &APIKey{GroupID: i64p(groupID), Group: &Group{ID: groupID}},
+ "gemini-image",
+ 1.0,
+ 1.0,
+ nil,
+ )
+
+ require.NotNil(t, cost)
+ require.Equal(t, string(BillingModeImage), cost.BillingMode)
+ require.InDelta(t, 0.80, cost.TotalCost, 1e-12)
+ require.InDelta(t, 0.80, cost.ActualCost, 1e-12)
+}
diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go
index ed69730c23f..14fcad85dcc 100644
--- a/backend/internal/service/openai_gateway_service.go
+++ b/backend/internal/service/openai_gateway_service.go
@@ -25,7 +25,6 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
- "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
"github.com/cespare/xxhash/v2"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -211,14 +210,17 @@ type OpenAIUsage struct {
// OpenAIForwardResult represents the result of forwarding
type OpenAIForwardResult struct {
- RequestID string
- Usage OpenAIUsage
- Model string // 原始模型(用于响应和日志显示)
+ RequestID string
+ ResponseID string
+ Usage OpenAIUsage
+ Model string // Client-facing original model returned for protocol compatibility.
// BillingModel is the model used for cost calculation.
// When non-empty, CalculateCost uses this instead of Model.
// This is set by the Anthropic Messages conversion path where
// the mapped upstream model differs from the client-facing model.
BillingModel string
+ // BillingIdentity is the immutable preflight snapshot used for settlement.
+ BillingIdentity *ResolvedOpenAIBillingIdentity
// UpstreamModel is the actual model sent to the upstream provider after mapping.
// Empty when no mapping was applied (requested model was used as-is).
UpstreamModel string
@@ -310,31 +312,41 @@ var defaultOpenAICodexSnapshotPersistThrottle = newAccountWriteThrottle(openAICo
// support but no compatible account is available.
var ErrNoAvailableCompactAccounts = errors.New("no available OpenAI accounts support /responses/compact")
+// ErrNoAvailableOpenAIAccounts is the typed OpenAI selection sentinel used by
+// protocol handlers to distinguish model availability from repository or
+// scheduler failures.
+var ErrNoAvailableOpenAIAccounts = errors.New("no available OpenAI accounts")
+
// OpenAIGatewayService handles OpenAI API gateway operations
type OpenAIGatewayService struct {
- accountRepo AccountRepository
- usageLogRepo UsageLogRepository
- usageBillingRepo UsageBillingRepository
- userRepo UserRepository
- userSubRepo UserSubscriptionRepository
- cache GatewayCache
- cfg *config.Config
- codexDetector CodexClientRestrictionDetector
- schedulerSnapshot *SchedulerSnapshotService
- concurrencyService *ConcurrencyService
- billingService *BillingService
- rateLimitService *RateLimitService
- billingCacheService *BillingCacheService
- userGroupRateResolver *userGroupRateResolver
- httpUpstream HTTPUpstream
- deferredService *DeferredService
- openAITokenProvider *OpenAITokenProvider
- toolCorrector *CodexToolCorrector
- openaiWSResolver OpenAIWSProtocolResolver
- resolver *ModelPricingResolver
- channelService *ChannelService
- balanceNotifyService *BalanceNotifyService
- settingService *SettingService
+ accountRepo AccountRepository
+ usageLogRepo UsageLogRepository
+ usageBillingRepo UsageBillingRepository
+ usageBillingOutboxRepo UsageBillingOutboxRepository
+ usageBillingOutboxWake UsageBillingOutboxWaker
+ usageBillingAdmissionRepo UsageBillingAdmissionRepository
+ requireUsageBillingOutbox bool
+ userRepo UserRepository
+ userSubRepo UserSubscriptionRepository
+ walletRepo WalletRepository
+ cache GatewayCache
+ cfg *config.Config
+ codexDetector CodexClientRestrictionDetector
+ schedulerSnapshot *SchedulerSnapshotService
+ concurrencyService *ConcurrencyService
+ billingService *BillingService
+ rateLimitService *RateLimitService
+ billingCacheService *BillingCacheService
+ userGroupRateResolver *userGroupRateResolver
+ httpUpstream HTTPUpstream
+ deferredService *DeferredService
+ openAITokenProvider *OpenAITokenProvider
+ toolCorrector *CodexToolCorrector
+ openaiWSResolver OpenAIWSProtocolResolver
+ resolver *ModelPricingResolver
+ channelService *ChannelService
+ balanceNotifyService *BalanceNotifyService
+ settingService *SettingService
openaiWSPoolOnce sync.Once
openaiWSStateStoreOnce sync.Once
@@ -346,10 +358,12 @@ type OpenAIGatewayService struct {
openaiWSPassthroughDialer openAIWSClientDialer
openaiAccountStats *openAIAccountRuntimeStats
- openaiWSFallbackUntil sync.Map // key: int64(accountID), value: time.Time
- openaiWSRetryMetrics openAIWSRetryMetrics
- responseHeaderFilter *responseheaders.CompiledHeaderFilter
- codexSnapshotThrottle *accountWriteThrottle
+ openaiWSFallbackUntil sync.Map // key: int64(accountID), value: time.Time
+ openaiWSRetryMetrics openAIWSRetryMetrics
+ responseHeaderFilter *responseheaders.CompiledHeaderFilter
+ codexSnapshotThrottle *accountWriteThrottle
+ openaiCompatSessionResponses sync.Map
+ openaiCompatAnthropicDigestSessions sync.Map
}
// NewOpenAIGatewayService creates a new OpenAIGatewayService
@@ -359,6 +373,7 @@ func NewOpenAIGatewayService(
usageBillingRepo UsageBillingRepository,
userRepo UserRepository,
userSubRepo UserSubscriptionRepository,
+ walletRepo WalletRepository,
userGroupRateRepo UserGroupRateRepository,
cache GatewayCache,
cfg *config.Config,
@@ -374,21 +389,29 @@ func NewOpenAIGatewayService(
channelService *ChannelService,
balanceNotifyService *BalanceNotifyService,
settingService *SettingService,
+ usageBillingOutboxRepo UsageBillingOutboxRepository,
+ usageBillingOutboxWorker *UsageBillingOutboxWorker,
+ usageBillingAdmissionRepo UsageBillingAdmissionRepository,
) *OpenAIGatewayService {
svc := &OpenAIGatewayService{
- accountRepo: accountRepo,
- usageLogRepo: usageLogRepo,
- usageBillingRepo: usageBillingRepo,
- userRepo: userRepo,
- userSubRepo: userSubRepo,
- cache: cache,
- cfg: cfg,
- codexDetector: NewOpenAICodexClientRestrictionDetector(cfg),
- schedulerSnapshot: schedulerSnapshot,
- concurrencyService: concurrencyService,
- billingService: billingService,
- rateLimitService: rateLimitService,
- billingCacheService: billingCacheService,
+ accountRepo: accountRepo,
+ usageLogRepo: usageLogRepo,
+ usageBillingRepo: usageBillingRepo,
+ usageBillingOutboxRepo: usageBillingOutboxRepo,
+ usageBillingOutboxWake: usageBillingOutboxWorker,
+ usageBillingAdmissionRepo: usageBillingAdmissionRepo,
+ requireUsageBillingOutbox: true,
+ userRepo: userRepo,
+ userSubRepo: userSubRepo,
+ walletRepo: walletRepo,
+ cache: cache,
+ cfg: cfg,
+ codexDetector: NewOpenAICodexClientRestrictionDetector(cfg),
+ schedulerSnapshot: schedulerSnapshot,
+ concurrencyService: concurrencyService,
+ billingService: billingService,
+ rateLimitService: rateLimitService,
+ billingCacheService: billingCacheService,
userGroupRateResolver: newUserGroupRateResolver(
userGroupRateRepo,
nil,
@@ -412,6 +435,21 @@ func NewOpenAIGatewayService(
return svc
}
+func (s *OpenAIGatewayService) AdmitUsageBillingRequest(
+ ctx context.Context,
+ apiKey *APIKey,
+ user *User,
+ account *Account,
+ subscription *UserSubscription,
+ quote UsageBillingReservationQuote,
+) (context.Context, *UsageBillingAdmissionSession, error) {
+ return admitUsageBillingRequest(ctx, s.cfg, s.requireUsageBillingOutbox, s.usageBillingAdmissionRepo, apiKey, user, account, subscription, quote)
+}
+
+func (s *OpenAIGatewayService) AbandonUsageBillingRequest(ctx context.Context, session *UsageBillingAdmissionSession) error {
+ return abandonUsageBillingRequest(ctx, s.usageBillingAdmissionRepo, session)
+}
+
// ResolveChannelMapping 解析渠道级模型映射(代理到 ChannelService)
func (s *OpenAIGatewayService) ResolveChannelMapping(ctx context.Context, groupID int64, model string) ChannelMappingResult {
if s.channelService == nil {
@@ -437,6 +475,21 @@ func (s *OpenAIGatewayService) ResolveChannelMappingAndRestrict(ctx context.Cont
return s.channelService.ResolveChannelMappingAndRestrict(ctx, groupID, model)
}
+func (s *OpenAIGatewayService) isCodexImageGenerationBridgeEnabled(ctx context.Context, account *Account, apiKey *APIKey) bool {
+ if override := account.CodexImageGenerationBridgeOverride(); override != nil {
+ return *override
+ }
+ if s != nil && s.channelService != nil && apiKey != nil && apiKey.GroupID != nil {
+ ch, err := s.channelService.GetChannelForGroup(ctx, *apiKey.GroupID)
+ if err != nil {
+ slog.Warn("failed to resolve codex image generation bridge channel override", "group_id", *apiKey.GroupID, "error", err)
+ } else if override := ch.CodexImageGenerationBridgeOverride(PlatformOpenAI); override != nil {
+ return *override
+ }
+ }
+ return s != nil && s.cfg != nil && s.cfg.Gateway.CodexImageGenerationBridgeEnabled
+}
+
func (s *OpenAIGatewayService) checkChannelPricingRestriction(ctx context.Context, groupID *int64, requestedModel string) bool {
if groupID == nil || s.channelService == nil || requestedModel == "" {
return false
@@ -492,6 +545,7 @@ func (s *OpenAIGatewayService) billingDeps() *billingDeps {
accountRepo: s.accountRepo,
userRepo: s.userRepo,
userSubRepo: s.userSubRepo,
+ walletRepo: s.walletRepo,
billingCacheService: s.billingCacheService,
deferredService: s.deferredService,
balanceNotifyService: s.balanceNotifyService,
@@ -1248,9 +1302,9 @@ func noAvailableOpenAISelectionError(requestedModel string, compactBlocked bool)
return ErrNoAvailableCompactAccounts
}
if requestedModel != "" {
- return fmt.Errorf("no available OpenAI accounts supporting model: %s", requestedModel)
+ return fmt.Errorf("%w supporting model: %s", ErrNoAvailableOpenAIAccounts, requestedModel)
}
- return errors.New("no available OpenAI accounts")
+ return ErrNoAvailableOpenAIAccounts
}
// openAICompactSupportTier classifies an OpenAI account by compact capability.
@@ -1281,6 +1335,12 @@ func isOpenAIAccountEligibleForRequest(account *Account, requestedModel string,
if requireCompact && openAICompactSupportTier(account) == 0 {
return false
}
+ if requireCompact {
+ baseModel := resolveOpenAIForwardModel(account, requestedModel, "")
+ if _, valid := resolveOpenAICompactForwardModelWithValidity(account, baseModel); !valid {
+ return false
+ }
+ }
return true
}
@@ -1320,7 +1380,11 @@ func resolveOpenAIAccountUpstreamModelForRequest(account *Account, requestedMode
return ""
}
if requireCompact {
- return resolveOpenAICompactForwardModel(account, upstreamModel)
+ compactModel, valid := resolveOpenAICompactForwardModelWithValidity(account, upstreamModel)
+ if !valid {
+ return ""
+ }
+ return compactModel
}
return upstreamModel
}
@@ -1357,7 +1421,7 @@ func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.C
// 4. 设置粘性会话绑定
// Set sticky session binding
if sessionHash != "" {
- _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, selected.ID, openaiStickySessionTTL)
+ _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, selected.ID, s.openAIWSSessionStickyTTL())
}
return s.hydrateSelectedAccount(ctx, selected)
@@ -1416,7 +1480,7 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID
// 刷新会话 TTL 并返回账号
// Refresh session TTL and return account
- _ = s.refreshStickySessionTTL(ctx, groupID, sessionHash, openaiStickySessionTTL)
+ _ = s.refreshStickySessionTTL(ctx, groupID, sessionHash, s.openAIWSSessionStickyTTL())
return account
}
@@ -1462,15 +1526,11 @@ func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, groupID *i
}
}
- // 选择优先级最高且最久未使用的账号
- // Select highest priority and least recently used
if selected == nil {
selected = fresh
selectedCompactTier = compactTier
continue
}
-
- // compact 模式下高 tier 优先;同 tier 内才比较 priority/LRU。
if requireCompact && compactTier != selectedCompactTier {
if compactTier > selectedCompactTier {
selected = fresh
@@ -1478,7 +1538,6 @@ func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, groupID *i
}
continue
}
-
if s.isBetterAccount(fresh, selected) {
selected = fresh
selectedCompactTier = compactTier
@@ -1521,6 +1580,93 @@ func (s *OpenAIGatewayService) isBetterAccount(candidate, current *Account) bool
}
}
+// weightedShuffleByLoadFactorWithinSortGroups 对已经按 (Priority, LoadRate) 排过序的
+// accountWithLoad 切片进行原地加权洗牌:
+// - 分组键只用 Priority,不再绑定 LoadRate 或 LastUsedAt。LoadRate>=100 的满载
+// 账号已在上层过滤;剩余健康账号应由 LoadFactor 决定长期容量占比;
+// - 每组内做加权 Fisher–Yates,权重取 account.EffectiveLoadFactor()。
+//
+// 选第一位的概率 ≈ w_i / Σw_j(与号池其它账号一致),用于多账号场景下的「按容量分流」。
+// 该函数仅在 OpenAI 选号路径中调用,不影响其它平台的 shuffleWithinSortGroups 行为。
+func weightedShuffleByLoadFactorWithinSortGroups(accounts []accountWithLoad) {
+ if len(accounts) <= 1 {
+ return
+ }
+ i := 0
+ for i < len(accounts) {
+ j := i + 1
+ for j < len(accounts) && samePriorityAndLoadRateGroup(accounts[i], accounts[j]) {
+ j++
+ }
+ if j-i > 1 {
+ weightedShuffleSegmentByLoadFactor(accounts[i:j])
+ }
+ i = j
+ }
+}
+
+func samePriorityAndLoadRateGroup(a, b accountWithLoad) bool {
+ return a.account.Priority == b.account.Priority
+}
+
+// weightedShuffleSegmentByLoadFactor 在 seg 上做加权 Fisher–Yates:
+// 每轮从 seg[i:] 中按 EffectiveLoadFactor 抽 1 个,交换到位置 i。
+// 池规模通常 < 100,O(n^2) 完全够用。
+//
+// 当所有账号的 EffectiveLoadFactor 都相等(典型的旧部署:全部 LoadFactor=nil 且
+// Concurrency 相同)时,回退到 shuffleWithinSortGroups 的旧逻辑——按 LastUsedAt
+// 子组打散,保留 LRU/never-used 优先的语义;仅在权重存在差异时才用加权抽样。
+func weightedShuffleSegmentByLoadFactor(seg []accountWithLoad) {
+ if !hasNonUniformLoadFactor(seg) {
+ shuffleWithinSortGroups(seg)
+ return
+ }
+ n := len(seg)
+ for i := 0; i < n-1; i++ {
+ var sumW float64
+ for k := i; k < n; k++ {
+ w := float64(seg[k].account.EffectiveLoadFactor())
+ if w <= 0 {
+ w = 1
+ }
+ sumW += w
+ }
+ if sumW <= 0 {
+ return
+ }
+ r := rand.Float64() * sumW
+ var cum float64
+ chosen := n - 1
+ for k := i; k < n; k++ {
+ w := float64(seg[k].account.EffectiveLoadFactor())
+ if w <= 0 {
+ w = 1
+ }
+ cum += w
+ if r < cum {
+ chosen = k
+ break
+ }
+ }
+ if chosen != i {
+ seg[i], seg[chosen] = seg[chosen], seg[i]
+ }
+ }
+}
+
+func hasNonUniformLoadFactor(seg []accountWithLoad) bool {
+ if len(seg) <= 1 {
+ return false
+ }
+ first := seg[0].account.EffectiveLoadFactor()
+ for k := 1; k < len(seg); k++ {
+ if seg[k].account.EffectiveLoadFactor() != first {
+ return true
+ }
+ }
+ return false
+}
+
// SelectAccountWithLoadAwareness selects an account with load-awareness and wait plan.
func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*AccountSelectionResult, error) {
return s.selectAccountWithLoadAwareness(ctx, groupID, sessionHash, requestedModel, excludedIDs, false)
@@ -1605,7 +1751,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
} else {
result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency)
if err == nil && result.Acquired {
- _ = s.refreshStickySessionTTL(ctx, groupID, sessionHash, openaiStickySessionTTL)
+ _ = s.refreshStickySessionTTL(ctx, groupID, sessionHash, s.openAIWSSessionStickyTTL())
return s.newSelectionResult(ctx, account, true, result.ReleaseFunc, nil)
}
@@ -1682,7 +1828,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
result, err := s.tryAcquireAccountSlot(ctx, fresh.ID, fresh.Concurrency)
if err == nil && result.Acquired {
if sessionHash != "" {
- _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, openaiStickySessionTTL)
+ _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, s.openAIWSSessionStickyTTL())
}
return s.newSelectionResult(ctx, fresh, true, result.ReleaseFunc, nil)
}
@@ -1722,7 +1868,10 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
return a.account.LastUsedAt.Before(*b.account.LastUsedAt)
}
})
- shuffleWithinSortGroups(available)
+ // loadfactor-v3: OpenAI 路径下用按 EffectiveLoadFactor 加权的 Fisher–Yates,
+ // 让 Pro 20x(load_factor=15)按比例占 ≈15/(15+9) ≈62.5% 流量;
+ // 不再让 LastUsedAt(LRU)和 0% LoadRate 平均化把高容量账号拉回 1/N 份额。
+ weightedShuffleByLoadFactorWithinSortGroups(available)
selectionOrder := make([]accountWithLoad, 0, len(available))
if requireCompact {
@@ -1758,7 +1907,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
result, err := s.tryAcquireAccountSlot(ctx, fresh.ID, fresh.Concurrency)
if err == nil && result.Acquired {
if sessionHash != "" {
- _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, openaiStickySessionTTL)
+ _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, fresh.ID, s.openAIWSSessionStickyTTL())
}
return s.newSelectionResult(ctx, fresh, true, result.ReleaseFunc, nil)
}
@@ -1967,13 +2116,33 @@ func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode i
return isOpenAITransientProcessingError(statusCode, upstreamMsg, upstreamBody)
}
-func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account) {
+func (s *OpenAIGatewayService) handleOpenAIUpstreamError(
+ ctx context.Context,
+ account *Account,
+ model string,
+ statusCode int,
+ headers http.Header,
+ body []byte,
+) bool {
+ if s == nil || s.rateLimitService == nil {
+ return false
+ }
+ return s.rateLimitService.HandleUpstreamErrorForModel(ctx, account, model, statusCode, headers, body)
+}
+
+func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account, model string) {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
- s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body)
+ s.handleOpenAIUpstreamError(ctx, account, model, resp.StatusCode, resp.Header, body)
}
// Forward forwards request to OpenAI API
func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
+ return s.ForwardWithOptions(ctx, c, account, body, OpenAIForwardOptions{})
+}
+
+// ForwardWithOptions enables request handlers to supply the pre-account channel
+// identity while preserving Forward as a compatibility wrapper.
+func (s *OpenAIGatewayService) ForwardWithOptions(ctx context.Context, c *gin.Context, account *Account, body []byte, opts OpenAIForwardOptions) (*OpenAIForwardResult, error) {
startTime := time.Now()
restrictionResult := s.detectCodexClientRestriction(c, account)
@@ -1989,9 +2158,20 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
return nil, errors.New("codex_cli_only restriction: only codex official clients are allowed")
}
- originalBody := body
reqModel, reqStream, promptCacheKey := extractOpenAIRequestMetaFromBody(body)
originalModel := reqModel
+ compatMessagesBridge := isOpenAICompatMessagesBridgeBody(body)
+ setOpenAICompatMessagesBridgeContext(c, compatMessagesBridge)
+ if reqModel != "" && !account.IsModelSupported(reqModel) {
+ err := errors.New("model is not supported by selected account")
+ setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
+ c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
+ "type": "invalid_request_error",
+ "message": err.Error(),
+ "param": "model",
+ }})
+ return nil, err
+ }
isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI)
wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account)
@@ -2027,9 +2207,14 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
passthroughEnabled := account.IsOpenAIPassthroughEnabled()
if passthroughEnabled {
+ var injectErr error
+ body, _, injectErr = injectOpenAIGPT56ReasoningEffort(body, reqModel, "reasoning.effort")
+ if injectErr != nil {
+ return nil, fmt.Errorf("inject GPT-5.6 reasoning effort: %w", injectErr)
+ }
// 透传分支只需要轻量提取字段,避免热路径全量 Unmarshal。
reasoningEffort := extractOpenAIReasoningEffortFromBody(body, reqModel)
- return s.forwardOpenAIPassthrough(ctx, c, account, originalBody, reqModel, reasoningEffort, reqStream, startTime)
+ return s.forwardOpenAIPassthrough(ctx, c, account, body, reqModel, reasoningEffort, reqStream, startTime, opts)
}
reqBody, err := getOpenAIRequestBodyMap(c, body)
@@ -2049,11 +2234,28 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
promptCacheKey = strings.TrimSpace(v)
}
}
+ apiKey := getAPIKeyFromContext(c)
+ imageGenerationAllowed := GroupAllowsImageGeneration(nil)
+ if apiKey != nil {
+ imageGenerationAllowed = GroupAllowsImageGeneration(apiKey.Group)
+ }
+ codexImageGenerationBridgeEnabled := isCodexCLI && imageGenerationAllowed && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey)
+ if IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, reqBody) && !imageGenerationAllowed {
+ setOpsUpstreamError(c, http.StatusForbidden, ImageGenerationPermissionMessage(), "")
+ c.JSON(http.StatusForbidden, gin.H{
+ "error": gin.H{
+ "type": "permission_error",
+ "message": ImageGenerationPermissionMessage(),
+ },
+ })
+ return nil, errors.New("image generation disabled for group")
+ }
// Track if body needs re-serialization
- bodyModified := false
+ reasoningEffortInjected := injectOpenAIGPT56ReasoningEffortMap(reqBody, reqModel)
+ bodyModified := reasoningEffortInjected
// 单字段补丁快速路径:只要整个变更集最终可归约为同一路径的 set/delete,就避免全量 Marshal。
- patchDisabled := false
+ patchDisabled := reasoningEffortInjected
patchHasOp := false
patchDelete := false
patchPath := ""
@@ -2102,13 +2304,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
// 非透传模式下,instructions 为空时注入默认指令。
- if isInstructionsEmpty(reqBody) {
+ if isInstructionsEmpty(reqBody) && !compatMessagesBridge {
reqBody["instructions"] = "You are a helpful coding assistant."
bodyModified = true
markPatchSet("instructions", "You are a helpful coding assistant.")
}
- if isCodexCLI && ensureOpenAIResponsesImageGenerationTool(reqBody) {
+ if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationTool(reqBody) {
bodyModified = true
disablePatch()
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Injected /responses image_generation tool for Codex client")
@@ -2119,7 +2321,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
disablePatch()
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image_generation tool payload")
}
- if isCodexCLI && applyCodexImageGenerationBridgeInstructions(reqBody) {
+ if codexImageGenerationBridgeEnabled && applyCodexImageGenerationBridgeInstructions(reqBody) {
bodyModified = true
disablePatch()
logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Added Codex image_generation bridge instructions")
@@ -2134,7 +2336,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
markPatchSet("model", billingModel)
}
upstreamModel := billingModel
- if normalizeOpenAIResponsesImageOnlyModel(reqBody) {
+ if imageGenerationAllowed && normalizeOpenAIResponsesImageOnlyModel(reqBody) {
bodyModified = true
disablePatch()
if model, ok := reqBody["model"].(string); ok {
@@ -2185,7 +2387,17 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
isCompactRequest := isOpenAIResponsesCompactPath(c)
compactMapped := false
if isCompactRequest {
- compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel)
+ compactMappedModel, compactMappingValid := resolveOpenAICompactForwardModelWithValidity(account, billingModel)
+ if !compactMappingValid {
+ err := errors.New("invalid GPT-5.6 compact model mapping")
+ setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
+ c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
+ "type": "invalid_request_error",
+ "message": err.Error(),
+ "param": "model",
+ }})
+ return nil, err
+ }
if compactMappedModel != "" && compactMappedModel != billingModel {
compactMapped = true
upstreamModel = compactMappedModel
@@ -2231,7 +2443,20 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
if account.Type == AccountTypeOAuth {
- codexResult := applyCodexOAuthTransform(reqBody, isCodexCLI, isCompactRequest)
+ codexResult := codexTransformResult{}
+ if compatMessagesBridge {
+ codexResult = applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{
+ IsCodexCLI: isCodexCLI,
+ IsCompact: isCompactRequest,
+ SkipDefaultInstructions: true,
+ PreserveToolCallIDs: true,
+ })
+ ensureCodexOAuthInstructionsField(reqBody)
+ bodyModified = true
+ disablePatch()
+ } else {
+ codexResult = applyCodexOAuthTransform(reqBody, isCodexCLI, isCompactRequest)
+ }
if codexResult.Modified {
bodyModified = true
disablePatch()
@@ -2355,6 +2580,34 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
}
+ if IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, reqBody) && !imageGenerationAllowed {
+ setOpsUpstreamError(c, http.StatusForbidden, ImageGenerationPermissionMessage(), "")
+ c.JSON(http.StatusForbidden, gin.H{
+ "error": gin.H{
+ "type": "permission_error",
+ "message": ImageGenerationPermissionMessage(),
+ },
+ })
+ return nil, errors.New("image generation disabled for group")
+ }
+ imageBillingModel := ""
+ imageSizeTier := ""
+ if IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, reqBody) {
+ var imageCfgErr error
+ imageBillingModel, imageSizeTier, imageCfgErr = resolveOpenAIResponsesImageBillingConfig(reqBody, billingModel)
+ if imageCfgErr != nil {
+ setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "")
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": gin.H{
+ "type": "invalid_request_error",
+ "message": imageCfgErr.Error(),
+ "param": "size",
+ },
+ })
+ return nil, imageCfgErr
+ }
+ }
+
// Re-serialize body only if modified
if bodyModified {
serializedByPatch := false
@@ -2378,6 +2631,41 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
}
+ requestedForIdentity := firstNonEmptyModel(opts.RequestedModel, originalModel)
+ channelMappedForIdentity := firstNonEmptyModel(opts.ChannelMapping.MappedModel, originalModel)
+ identityRequired := opts.RequirePricingPreflight
+ if _, family := classifyOpenAIGPT56PreviewModel(upstreamModel); family {
+ identityRequired = true
+ }
+ var billingIdentity *ResolvedOpenAIBillingIdentity
+ if identityRequired {
+ identityInput := OpenAIBillingIdentityInput{
+ RequestedModel: requestedForIdentity,
+ DispatchModel: originalModel,
+ ChannelMappedModel: channelMappedForIdentity,
+ ChannelMappingApplied: opts.ChannelMapping.Mapped,
+ ChannelMappingExact: opts.ChannelMapping.MappingExact,
+ AccountMappedModel: billingModel,
+ UpstreamModel: upstreamModel,
+ BillingModelSource: opts.ChannelMapping.BillingModelSource,
+ ChannelID: opts.ChannelMapping.ChannelID,
+ ModelMappingChain: opts.ChannelMapping.BuildModelMappingChain(requestedForIdentity, upstreamModel),
+ GroupID: opts.GroupID,
+ }
+ if imageBillingModel != "" {
+ billingIdentity, err = s.ResolveOpenAIImageBillingIdentity(ctx, identityInput, imageBillingModel, imageSizeTier, opts.ImagePriceConfig)
+ } else {
+ billingIdentity, err = s.ResolveOpenAIBillingIdentity(ctx, identityInput)
+ }
+ if err != nil {
+ return nil, err
+ }
+ }
+ setOpenAIUsageBillingReservationBody(opts.UsageBilling, body)
+ if err := s.prepareOpenAIForwardUsageBilling(ctx, account, billingIdentity, opts); err != nil {
+ return nil, err
+ }
+
// Get access token
token, _, err := s.GetAccessToken(ctx, account)
if err != nil {
@@ -2592,6 +2880,14 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
wsAttempts,
)
wsResult.UpstreamModel = upstreamModel
+ wsResult.BillingIdentity = billingIdentity
+ if billingIdentity != nil {
+ wsResult.BillingModel = billingIdentity.BillingModel
+ }
+ if wsResult.ImageCount > 0 {
+ wsResult.ImageSize = imageSizeTier
+ wsResult.BillingModel = imageBillingModel
+ }
return wsResult, nil
}
s.writeOpenAIWSFallbackErrorResponse(c, account, wsErr)
@@ -2601,7 +2897,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
httpInvalidEncryptedContentRetryTried := false
for {
// Build upstream request
- upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream)
+ upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, body, token, reqStream, promptCacheKey, isCodexCLI)
releaseUpstreamCtx()
if err != nil {
@@ -2681,7 +2977,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
Detail: upstreamDetail,
})
- s.handleFailoverSideEffects(ctx, resp, account)
+ s.handleFailoverSideEffects(ctx, resp, account, upstreamModel)
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
@@ -2695,6 +2991,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
// Handle normal response
var usage *OpenAIUsage
var firstTokenMs *int
+ imageCount := 0
if reqStream {
streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, upstreamModel)
if err != nil {
@@ -2702,11 +2999,14 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
usage = streamResult.usage
firstTokenMs = streamResult.firstTokenMs
+ imageCount = streamResult.imageCount
} else {
- usage, err = s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel)
+ nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel)
if err != nil {
return nil, err
}
+ usage = nonStreamResult.usage
+ imageCount = nonStreamResult.imageCount
}
// Extract and save Codex usage snapshot from response headers (for OAuth accounts)
@@ -2723,7 +3023,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
reasoningEffort := extractOpenAIReasoningEffort(reqBody, originalModel)
serviceTier := extractOpenAIServiceTier(reqBody)
- return &OpenAIForwardResult{
+ forwardResult := &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"),
Usage: *usage,
Model: originalModel,
@@ -2734,7 +3034,17 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
OpenAIWSMode: false,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
- }, nil
+ BillingIdentity: billingIdentity,
+ }
+ if billingIdentity != nil {
+ forwardResult.BillingModel = billingIdentity.BillingModel
+ }
+ if imageCount > 0 {
+ forwardResult.ImageCount = imageCount
+ forwardResult.ImageSize = imageSizeTier
+ forwardResult.BillingModel = imageBillingModel
+ }
+ return forwardResult, nil
}
}
@@ -2747,11 +3057,49 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
reasoningEffort *string,
reqStream bool,
startTime time.Time,
+ opts OpenAIForwardOptions,
) (*OpenAIForwardResult, error) {
upstreamPassthroughModel := ""
+ compactBaseModel := reqModel
+ if _, isGPT56Family := classifyOpenAIGPT56PreviewModel(reqModel); isGPT56Family {
+ if account == nil || !account.IsModelSupported(reqModel) {
+ err := errors.New("invalid GPT-5.6 passthrough model mapping")
+ setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
+ c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
+ "type": "invalid_request_error",
+ "message": err.Error(),
+ "param": "model",
+ }})
+ return nil, err
+ }
+ mappedModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(reqModel))
+ if mappedModel == "" {
+ return nil, errors.New("empty GPT-5.6 passthrough mapped model")
+ }
+ compactBaseModel = mappedModel
+ upstreamPassthroughModel = mappedModel
+ if mappedModel != reqModel {
+ nextBody, setErr := sjson.SetBytes(body, "model", mappedModel)
+ if setErr != nil {
+ return nil, fmt.Errorf("set GPT-5.6 passthrough model: %w", setErr)
+ }
+ body = nextBody
+ upstreamPassthroughModel = mappedModel
+ }
+ }
if isOpenAIResponsesCompactPath(c) {
- compactMappedModel := resolveOpenAICompactForwardModel(account, reqModel)
- if compactMappedModel != "" && compactMappedModel != reqModel {
+ compactMappedModel, compactMappingValid := resolveOpenAICompactForwardModelWithValidity(account, compactBaseModel)
+ if !compactMappingValid {
+ err := errors.New("invalid GPT-5.6 compact model mapping")
+ setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
+ c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
+ "type": "invalid_request_error",
+ "message": err.Error(),
+ "param": "model",
+ }})
+ return nil, err
+ }
+ if compactMappedModel != "" && compactMappedModel != compactBaseModel {
nextBody, setErr := sjson.SetBytes(body, "model", compactMappedModel)
if setErr != nil {
return nil, fmt.Errorf("set compact passthrough model: %w", setErr)
@@ -2823,6 +3171,35 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
}
body = updatedBody
+ apiKey := getAPIKeyFromContext(c)
+ if IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) && !GroupAllowsImageGeneration(apiKeyGroup(apiKey)) {
+ setOpsUpstreamError(c, http.StatusForbidden, ImageGenerationPermissionMessage(), "")
+ c.JSON(http.StatusForbidden, gin.H{
+ "error": gin.H{
+ "type": "permission_error",
+ "message": ImageGenerationPermissionMessage(),
+ },
+ })
+ return nil, errors.New("image generation disabled for group")
+ }
+ imageBillingModel := ""
+ imageSizeTier := ""
+ if IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) {
+ var imageCfgErr error
+ imageBillingModel, imageSizeTier, imageCfgErr = resolveOpenAIResponsesImageBillingConfigFromBody(body, reqModel)
+ if imageCfgErr != nil {
+ setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "")
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": gin.H{
+ "type": "invalid_request_error",
+ "message": imageCfgErr.Error(),
+ "param": "size",
+ },
+ })
+ return nil, imageCfgErr
+ }
+ }
+
logger.LegacyPrintf("service.openai_gateway",
"[OpenAI 自动透传] 命中自动透传分支: account=%d name=%s type=%s model=%s stream=%v",
account.ID,
@@ -2846,13 +3223,48 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
}
}
+ finalUpstreamModel := firstNonEmptyModel(upstreamPassthroughModel, policyModel)
+ requestedForIdentity := firstNonEmptyModel(opts.RequestedModel, reqModel)
+ identityRequired := opts.RequirePricingPreflight
+ if _, family := classifyOpenAIGPT56PreviewModel(finalUpstreamModel); family {
+ identityRequired = true
+ }
+ var billingIdentity *ResolvedOpenAIBillingIdentity
+ if identityRequired {
+ identityInput := OpenAIBillingIdentityInput{
+ RequestedModel: requestedForIdentity,
+ DispatchModel: reqModel,
+ ChannelMappedModel: firstNonEmptyModel(opts.ChannelMapping.MappedModel, reqModel),
+ ChannelMappingApplied: opts.ChannelMapping.Mapped,
+ ChannelMappingExact: opts.ChannelMapping.MappingExact,
+ AccountMappedModel: compactBaseModel,
+ UpstreamModel: finalUpstreamModel,
+ BillingModelSource: opts.ChannelMapping.BillingModelSource,
+ ChannelID: opts.ChannelMapping.ChannelID,
+ ModelMappingChain: opts.ChannelMapping.BuildModelMappingChain(requestedForIdentity, finalUpstreamModel),
+ GroupID: opts.GroupID,
+ }
+ if imageBillingModel != "" {
+ billingIdentity, err = s.ResolveOpenAIImageBillingIdentity(ctx, identityInput, imageBillingModel, imageSizeTier, opts.ImagePriceConfig)
+ } else {
+ billingIdentity, err = s.ResolveOpenAIBillingIdentity(ctx, identityInput)
+ }
+ if err != nil {
+ return nil, err
+ }
+ }
+ setOpenAIUsageBillingReservationBody(opts.UsageBilling, body)
+ if err := s.prepareOpenAIForwardUsageBilling(ctx, account, billingIdentity, opts); err != nil {
+ return nil, err
+ }
+
// Get access token
token, _, err := s.GetAccessToken(ctx, account)
if err != nil {
return nil, err
}
- upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream)
+ upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
upstreamReq, err := s.buildUpstreamRequestOpenAIPassthrough(upstreamCtx, c, account, body, token)
releaseUpstreamCtx()
if err != nil {
@@ -2905,6 +3317,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
var usage *OpenAIUsage
var firstTokenMs *int
+ imageCount := 0
if reqStream {
result, err := s.handleStreamingResponsePassthrough(ctx, resp, c, account, startTime, reqModel, upstreamPassthroughModel)
if err != nil {
@@ -2912,11 +3325,14 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
}
usage = result.usage
firstTokenMs = result.firstTokenMs
+ imageCount = result.imageCount
} else {
- usage, err = s.handleNonStreamingResponsePassthrough(ctx, resp, c, reqModel, upstreamPassthroughModel)
+ result, err := s.handleNonStreamingResponsePassthrough(ctx, resp, c, reqModel, upstreamPassthroughModel)
if err != nil {
return nil, err
}
+ usage = result.usage
+ imageCount = result.imageCount
}
if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil {
@@ -2927,7 +3343,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
usage = &OpenAIUsage{}
}
- return &OpenAIForwardResult{
+ forwardResult := &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"),
Usage: *usage,
Model: reqModel,
@@ -2938,7 +3354,17 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
OpenAIWSMode: false,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
- }, nil
+ BillingIdentity: billingIdentity,
+ }
+ if billingIdentity != nil {
+ forwardResult.BillingModel = billingIdentity.BillingModel
+ }
+ if imageCount > 0 {
+ forwardResult.ImageCount = imageCount
+ forwardResult.ImageSize = imageSizeTier
+ forwardResult.BillingModel = imageBillingModel
+ }
+ return forwardResult, nil
}
func logOpenAIPassthroughInstructionsRejected(
@@ -3113,9 +3539,8 @@ func (s *OpenAIGatewayService) handleFailoverErrorResponsePassthrough(
}
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body)
- if s.rateLimitService != nil {
- _ = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body)
- }
+ requestModel, _, _ := extractOpenAIRequestMetaFromBody(requestBody)
+ _ = s.handleOpenAIUpstreamError(ctx, account, requestModel, resp.StatusCode, resp.Header, body)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
@@ -3156,12 +3581,11 @@ func (s *OpenAIGatewayService) handleErrorResponsePassthrough(
}
setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body)
- if s.rateLimitService != nil {
- // Passthrough mode preserves the raw upstream error response, but runtime
- // account state still needs to be updated so sticky routing can stop
- // reusing a freshly rate-limited account.
- _ = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body)
- }
+ // Passthrough mode preserves the raw upstream error response, but runtime
+ // account state still needs to be updated so sticky routing can stop
+ // reusing a freshly rate-limited account.
+ requestModel, _, _ := extractOpenAIRequestMetaFromBody(requestBody)
+ _ = s.handleOpenAIUpstreamError(ctx, account, requestModel, resp.StatusCode, resp.Header, body)
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
@@ -3233,6 +3657,13 @@ func collectOpenAIPassthroughTimeoutHeaders(h http.Header) []string {
type openaiStreamingResultPassthrough struct {
usage *OpenAIUsage
firstTokenMs *int
+ imageCount int
+}
+
+type openaiNonStreamingResultPassthrough struct {
+ *OpenAIUsage
+ usage *OpenAIUsage
+ imageCount int
}
func openAIStreamClientOutputStarted(c *gin.Context, localStarted bool) bool {
@@ -3369,6 +3800,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
}
usage := &OpenAIUsage{}
+ imageCounter := newOpenAIImageOutputCounter()
var firstTokenMs *int
clientDisconnected := false
sawDone := false
@@ -3391,15 +3823,15 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
}
scanner := bufio.NewScanner(resp.Body)
- maxLineSize := defaultMaxLineSize
- if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.cfg.Gateway.MaxLineSize
- }
+ maxLineSize := resolveGatewayMaxLineSize(s.cfg)
scanBuf := getSSEScannerBuf64K()
scanner.Buffer(scanBuf[:0], maxLineSize)
defer putSSEScannerBuf64K(scanBuf)
needModelReplace := strings.TrimSpace(originalModel) != "" && strings.TrimSpace(mappedModel) != "" && strings.TrimSpace(originalModel) != strings.TrimSpace(mappedModel)
+ resultWithUsage := func() *openaiStreamingResultPassthrough {
+ return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs, imageCount: imageCounter.Count()}
+ }
for scanner.Scan() {
line := scanner.Text()
@@ -3419,7 +3851,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
if eventType == "response.failed" {
failedMessage = extractOpenAISSEErrorMessage(dataBytes)
if !openAIStreamClientOutputStarted(c, clientOutputStarted) && openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) {
- return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs},
+ return resultWithUsage(),
s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, failedMessage)
}
forceFlushFailedEvent = true
@@ -3431,6 +3863,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
if openAIStreamEventIsTerminal(trimmedData) {
sawTerminalEvent = true
}
+ imageCounter.AddSSEData(dataBytes)
lineStartsClientOutput = forceFlushFailedEvent || openAIStreamDataStartsClientOutput(trimmedData, eventType)
if firstTokenMs == nil && lineStartsClientOutput && trimmedData != "[DONE]" {
ms := int(time.Since(startTime).Milliseconds())
@@ -3460,28 +3893,28 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
}
if err := scanner.Err(); err != nil {
if sawTerminalEvent && !sawFailedEvent {
- return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, nil
+ return resultWithUsage(), nil
}
if sawFailedEvent {
- return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("upstream response failed: %s", failedMessage)
+ return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage)
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
- return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream usage incomplete: %w", err)
+ return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", err)
}
if errors.Is(err, bufio.ErrTooLong) {
logger.LegacyPrintf("service.openai_gateway", "[OpenAI passthrough] SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, err)
- return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, err
+ return resultWithUsage(), err
}
if !openAIStreamClientOutputStarted(c, clientOutputStarted) {
msg := "OpenAI stream disconnected before completion"
if errText := strings.TrimSpace(err.Error()); errText != "" {
msg += ": " + errText
}
- return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs},
+ return resultWithUsage(),
s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, nil, msg)
}
if clientDisconnected {
- return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream usage incomplete after disconnect: %w", err)
+ return resultWithUsage(), fmt.Errorf("stream usage incomplete after disconnect: %w", err)
}
logger.LegacyPrintf("service.openai_gateway",
"[OpenAI passthrough] 流读取异常中断: account=%d request_id=%s err=%v",
@@ -3489,10 +3922,10 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
upstreamRequestID,
err,
)
- return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream read error: %w", err)
+ return resultWithUsage(), fmt.Errorf("stream read error: %w", err)
}
if sawFailedEvent {
- return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("upstream response failed: %s", failedMessage)
+ return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage)
}
if !clientDisconnected && !sawDone && !sawTerminalEvent && ctx.Err() == nil {
logger.FromContext(ctx).With(
@@ -3501,13 +3934,13 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
zap.String("upstream_request_id", upstreamRequestID),
).Info("OpenAI passthrough 上游流在未收到 [DONE] 时结束,疑似断流")
if !openAIStreamClientOutputStarted(c, clientOutputStarted) {
- return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs},
+ return resultWithUsage(),
s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, nil, "OpenAI stream ended before a terminal event")
}
- return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, errors.New("stream usage incomplete: missing terminal event")
+ return resultWithUsage(), errors.New("stream usage incomplete: missing terminal event")
}
- return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, nil
+ return resultWithUsage(), nil
}
func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
@@ -3516,7 +3949,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
c *gin.Context,
originalModel string,
mappedModel string,
-) (*OpenAIUsage, error) {
+) (*openaiNonStreamingResultPassthrough, error) {
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
if err != nil {
return nil, err
@@ -3553,14 +3986,18 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
}
c.Data(resp.StatusCode, contentType, body)
- return usage, nil
+ return &openaiNonStreamingResultPassthrough{
+ OpenAIUsage: usage,
+ usage: usage,
+ imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body),
+ }, nil
}
// handlePassthroughSSEToJSON converts an SSE response body into a JSON
// response for the passthrough path. It mirrors handleSSEToJSON while
// preserving passthrough payloads, except compact-only model remapping may
// rewrite model fields back to the original requested model.
-func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel string, mappedModel string) (*OpenAIUsage, error) {
+func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel string, mappedModel string) (*openaiNonStreamingResultPassthrough, error) {
bodyText := string(body)
finalResponse, ok := extractCodexFinalResponse(bodyText)
@@ -3611,7 +4048,11 @@ func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c
}
c.Data(resp.StatusCode, contentType, body)
- return usage, nil
+ return &openaiNonStreamingResultPassthrough{
+ OpenAIUsage: usage,
+ usage: usage,
+ imageCount: countOpenAIImageOutputsFromSSEBody(bodyText),
+ }, nil
}
func writeOpenAIPassthroughResponseHeaders(dst http.Header, src http.Header, filter *responseheaders.CompiledHeaderFilter) {
@@ -3715,12 +4156,19 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
}
}
if account.Type == AccountTypeOAuth {
+ compatMessagesBridge := isOpenAICompatMessagesBridgeContext(c) || isOpenAICompatMessagesBridgeBody(body)
// 清除客户端透传的 session 头,后续用隔离后的值重新设置,防止跨用户会话碰撞。
+ clientConversationID := strings.TrimSpace(req.Header.Get("conversation_id"))
req.Header.Del("conversation_id")
req.Header.Del("session_id")
- req.Header.Set("OpenAI-Beta", "responses=experimental")
- req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI))
+ if compatMessagesBridge {
+ req.Header.Del("OpenAI-Beta")
+ req.Header.Del("originator")
+ } else {
+ req.Header.Set("OpenAI-Beta", "responses=experimental")
+ req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI))
+ }
apiKeyID := getAPIKeyIDFromContext(c)
if isOpenAIResponsesCompactPath(c) {
req.Header.Set("accept", "application/json")
@@ -3734,8 +4182,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
}
if promptCacheKey != "" {
isolated := isolateOpenAISessionID(apiKeyID, promptCacheKey)
- req.Header.Set("conversation_id", isolated)
req.Header.Set("session_id", isolated)
+ if !compatMessagesBridge || clientConversationID != "" {
+ req.Header.Set("conversation_id", isolated)
+ }
}
}
@@ -3842,9 +4292,8 @@ func (s *OpenAIGatewayService) handleErrorResponse(
// Handle upstream error (mark account status)
shouldDisable := false
- if s.rateLimitService != nil {
- shouldDisable = s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, body)
- }
+ requestModel, _, _ := extractOpenAIRequestMetaFromBody(requestBody)
+ shouldDisable = s.handleOpenAIUpstreamError(ctx, account, requestModel, resp.StatusCode, resp.Header, body)
kind := "http_error"
if shouldDisable {
kind = "failover"
@@ -3920,6 +4369,7 @@ func (s *OpenAIGatewayService) handleCompatErrorResponse(
resp *http.Response,
c *gin.Context,
account *Account,
+ model string,
writeError compatErrorWriter,
) (*OpenAIForwardResult, error) {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
@@ -3977,11 +4427,9 @@ func (s *OpenAIGatewayService) handleCompatErrorResponse(
// Track rate limits and decide whether to trigger secondary failover.
shouldDisable := false
- if s.rateLimitService != nil {
- shouldDisable = s.rateLimitService.HandleUpstreamError(
- c.Request.Context(), account, resp.StatusCode, resp.Header, body,
- )
- }
+ shouldDisable = s.handleOpenAIUpstreamError(
+ c.Request.Context(), account, model, resp.StatusCode, resp.Header, body,
+ )
kind := "http_error"
if shouldDisable {
kind = "failover"
@@ -4025,6 +4473,13 @@ func (s *OpenAIGatewayService) handleCompatErrorResponse(
type openaiStreamingResult struct {
usage *OpenAIUsage
firstTokenMs *int
+ imageCount int
+}
+
+type openaiNonStreamingResult struct {
+ *OpenAIUsage
+ usage *OpenAIUsage
+ imageCount int
}
func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*openaiStreamingResult, error) {
@@ -4058,12 +4513,10 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
}
usage := &OpenAIUsage{}
+ imageCounter := newOpenAIImageOutputCounter()
var firstTokenMs *int
scanner := bufio.NewScanner(resp.Body)
- maxLineSize := defaultMaxLineSize
- if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
- maxLineSize = s.cfg.Gateway.MaxLineSize
- }
+ maxLineSize := resolveGatewayMaxLineSize(s.cfg)
scanBuf := getSSEScannerBuf64K()
scanner.Buffer(scanBuf[:0], maxLineSize)
@@ -4136,7 +4589,7 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
needModelReplace := originalModel != mappedModel
resultWithUsage := func() *openaiStreamingResult {
- return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs}
+ return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs, imageCount: imageCounter.Count()}
}
finalizeStream := func() (*openaiStreamingResult, error) {
if !sawTerminalEvent {
@@ -4231,6 +4684,7 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
forceFlushFailedEvent = true
sawFailedEvent = true
}
+ imageCounter.AddSSEData(dataBytes)
// Correct Codex tool calls if needed (apply_patch -> edit, etc.)
if correctedData, corrected := s.toolCorrector.CorrectToolCallsInSSEBytes(dataBytes); corrected {
@@ -4496,7 +4950,7 @@ func extractOpenAIUsageFromJSONBytes(body []byte) (OpenAIUsage, bool) {
}, true
}
-func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*OpenAIUsage, error) {
+func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*openaiNonStreamingResult, error) {
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
if err != nil {
return nil, err
@@ -4542,7 +4996,11 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r
c.Data(resp.StatusCode, contentType, body)
- return usage, nil
+ return &openaiNonStreamingResult{
+ OpenAIUsage: usage,
+ usage: usage,
+ imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body),
+ }, nil
}
func isEventStreamResponse(header http.Header) bool {
@@ -4550,7 +5008,7 @@ func isEventStreamResponse(header http.Header) bool {
return strings.Contains(contentType, "text/event-stream")
}
-func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel, mappedModel string) (*OpenAIUsage, error) {
+func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel, mappedModel string) (*openaiNonStreamingResult, error) {
bodyText := string(body)
finalResponse, ok := extractCodexFinalResponse(bodyText)
@@ -4602,21 +5060,29 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte
}
c.Data(resp.StatusCode, contentType, body)
- return usage, nil
+ return &openaiNonStreamingResult{
+ OpenAIUsage: usage,
+ usage: usage,
+ imageCount: countOpenAIImageOutputsFromSSEBody(bodyText),
+ }, nil
}
func extractOpenAISSETerminalEvent(body string) (string, []byte, bool) {
- lines := strings.Split(body, "\n")
- for _, line := range lines {
- data, ok := extractOpenAISSEDataLine(line)
- if !ok || data == "" || data == "[DONE]" {
- continue
+ var terminalType string
+ var terminalPayload []byte
+ forEachOpenAISSEDataPayload(body, func(data []byte) {
+ if terminalPayload != nil {
+ return
}
- eventType := strings.TrimSpace(gjson.Get(data, "type").String())
+ eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String())
switch eventType {
case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled":
- return eventType, []byte(data), true
+ terminalType = eventType
+ terminalPayload = append([]byte(nil), data...)
}
+ })
+ if terminalPayload != nil {
+ return terminalType, terminalPayload, true
}
return "", nil, false
}
@@ -4651,21 +5117,20 @@ func (s *OpenAIGatewayService) writeOpenAINonStreamingProtocolError(resp *http.R
}
func extractCodexFinalResponse(body string) ([]byte, bool) {
- lines := strings.Split(body, "\n")
- for _, line := range lines {
- data, ok := extractOpenAISSEDataLine(line)
- if !ok {
- continue
- }
- if data == "" || data == "[DONE]" {
- continue
+ var finalResponse []byte
+ forEachOpenAISSEDataPayload(body, func(data []byte) {
+ if finalResponse != nil {
+ return
}
- eventType := gjson.Get(data, "type").String()
+ eventType := gjson.GetBytes(data, "type").String()
if eventType == "response.done" || eventType == "response.completed" {
- if response := gjson.Get(data, "response"); response.Exists() && response.Type == gjson.JSON && response.Raw != "" {
- return []byte(response.Raw), true
+ if response := gjson.GetBytes(data, "response"); response.Exists() && response.Type == gjson.JSON && response.Raw != "" {
+ finalResponse = []byte(response.Raw)
}
}
+ })
+ if finalResponse != nil {
+ return finalResponse, true
}
return nil, false
}
@@ -4677,20 +5142,23 @@ func reconstructResponseOutputFromSSE(bodyText string) ([]byte, bool) {
acc := apicompat.NewBufferedResponseAccumulator()
imageOutputs := make([]json.RawMessage, 0, 1)
seenImages := make(map[string]struct{})
- lines := strings.Split(bodyText, "\n")
- for _, line := range lines {
- data, ok := extractOpenAISSEDataLine(line)
- if !ok || data == "" || data == "[DONE]" {
- continue
+ limitExceeded := false
+ forEachOpenAISSEDataPayload(bodyText, func(data []byte) {
+ if limitExceeded {
+ return
}
- if imageOutput, ok := extractImageGenerationOutputFromSSEData([]byte(data), seenImages); ok {
+ if imageOutput, ok := extractImageGenerationOutputFromSSEData(data, seenImages); ok {
imageOutputs = append(imageOutputs, imageOutput)
}
var event apicompat.ResponsesStreamEvent
- if err := json.Unmarshal([]byte(data), &event); err != nil {
- continue
+ if err := json.Unmarshal(data, &event); err == nil {
+ if err := acc.ProcessEvent(&event); err != nil {
+ limitExceeded = true
+ }
}
- acc.ProcessEvent(&event)
+ })
+ if limitExceeded {
+ return nil, false
}
if !acc.HasContent() && len(imageOutputs) == 0 {
return nil, false
@@ -4744,17 +5212,9 @@ func extractImageGenerationOutputFromSSEData(data []byte, seen map[string]struct
func (s *OpenAIGatewayService) parseSSEUsageFromBody(body string) *OpenAIUsage {
usage := &OpenAIUsage{}
- lines := strings.Split(body, "\n")
- for _, line := range lines {
- data, ok := extractOpenAISSEDataLine(line)
- if !ok {
- continue
- }
- if data == "" || data == "[DONE]" {
- continue
- }
- s.parseSSEUsageBytes([]byte(data), usage)
- }
+ forEachOpenAISSEDataPayload(body, func(data []byte) {
+ s.parseSSEUsageBytes(data, usage)
+ })
return usage
}
@@ -4770,22 +5230,7 @@ func (s *OpenAIGatewayService) replaceModelInSSEBody(body, fromModel, toModel st
}
func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, error) {
- if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
- normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
- if err != nil {
- return "", fmt.Errorf("invalid base_url: %w", err)
- }
- return normalized, nil
- }
- normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
- AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
- RequireAllowlist: true,
- AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
- })
- if err != nil {
- return "", fmt.Errorf("invalid base_url: %w", err)
- }
- return normalized, nil
+ return validateUpstreamBaseURLFormat(raw, s.cfg)
}
// buildOpenAIResponsesURL 组装 OpenAI Responses 端点。
@@ -5036,22 +5481,25 @@ type OpenAIRecordUsageInput struct {
// RecordUsage records usage and deducts balance
func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRecordUsageInput) error {
+ if input == nil {
+ return errors.New("openai usage input is nil")
+ }
result := input.Result
- if s.rateLimitService != nil && input != nil && input.Account != nil && input.Account.Platform == PlatformOpenAI {
- s.rateLimitService.ResetOpenAI403Counter(ctx, input.Account.ID)
+ if result == nil {
+ return errors.New("openai usage result is nil")
}
-
- // 跳过所有 token 均为零的用量记录——上游未返回 usage 时不应写入数据库
- if result.Usage.InputTokens == 0 && result.Usage.OutputTokens == 0 &&
- result.Usage.CacheCreationInputTokens == 0 && result.Usage.CacheReadInputTokens == 0 &&
- result.Usage.ImageOutputTokens == 0 && result.ImageCount == 0 {
- return nil
+ if s.rateLimitService != nil && input.Account != nil && input.Account.Platform == PlatformOpenAI {
+ s.rateLimitService.ResetOpenAI403Counter(ctx, input.Account.ID)
}
apiKey := input.APIKey
user := input.User
account := input.Account
subscription := input.Subscription
+ if s.requireUsageBillingOutbox && subscription != nil && subscription.IsWalletMode() &&
+ (result.BillingIdentity == nil || !validFenceToken(result.BillingIdentity.AdmissionAttemptID) || result.BillingIdentity.AdmissionRef.Validate() != nil) {
+ return ErrUsageBillingLifecycleContractInvalid
+ }
// 计算实际的新输入token(减去缓存读取的token)
// 因为 input_tokens 包含了 cache_read_tokens,而缓存读取的token不应按输入价格计费
@@ -5069,22 +5517,24 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
ImageOutputTokens: result.Usage.ImageOutputTokens,
}
- // Get rate multiplier
- multiplier := 1.0
+ // Get rate multiplier(订阅锁定倍率 > 用户专属 > 分组默认 > 系统默认)
+ systemDefault := 1.0
if s.cfg != nil {
- multiplier = s.cfg.Default.RateMultiplier
+ systemDefault = s.cfg.Default.RateMultiplier
}
- if apiKey.GroupID != nil && apiKey.Group != nil {
- resolver := s.userGroupRateResolver
- if resolver == nil {
- resolver = newUserGroupRateResolver(nil, nil, resolveUserGroupRateCacheTTL(s.cfg), nil, "service.openai_gateway")
- }
- multiplier = resolver.Resolve(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplier)
+ resolver := s.userGroupRateResolver
+ if resolver == nil {
+ resolver = newUserGroupRateResolver(nil, nil, resolveUserGroupRateCacheTTL(s.cfg), nil, "service.openai_gateway")
}
+ multiplier := canonicalUsageBillingRate(resolveEffectiveRateMultiplier(ctx, resolver, user.ID, apiKey.GroupID, apiKey.Group, subscription, systemDefault).Multiplier)
+ imageMultiplier := canonicalUsageBillingRate(resolveImageRateMultiplier(apiKey, multiplier))
var cost *CostBreakdown
var err error
billingModel := forwardResultBillingModel(result.Model, result.UpstreamModel)
+ if result.BillingIdentity != nil {
+ billingModel = strings.TrimSpace(result.BillingIdentity.BillingModel)
+ }
if result.BillingModel != "" {
billingModel = strings.TrimSpace(result.BillingModel)
}
@@ -5092,19 +5542,77 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
billingModel = input.ChannelMappedModel
}
if input.BillingModelSource == BillingModelSourceRequested && input.OriginalModel != "" {
- billingModel = input.OriginalModel
+ if mappedBillingModel := mappedBillingModelOverRequested(input.OriginalModel, result.BillingModel, input.ChannelMappedModel, result.UpstreamModel, billingModel); mappedBillingModel != "" {
+ billingModel = mappedBillingModel
+ } else {
+ billingModel = input.OriginalModel
+ }
}
+ billingModels := usageBillingModelCandidates(
+ billingModel,
+ result.BillingModel,
+ input.ChannelMappedModel,
+ input.OriginalModel,
+ result.UpstreamModel,
+ result.Model,
+ )
serviceTier := ""
if result.ServiceTier != nil {
serviceTier = strings.TrimSpace(*result.ServiceTier)
}
- cost, err = s.calculateOpenAIRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, tokens, serviceTier)
+ var successfulBillingModel string
+ usedPreflightQuote := false
+ var usedPricingQuote *PricingQuote
+ if result.BillingIdentity != nil && result.BillingIdentity.Pricing != nil {
+ usedPricingQuote = result.BillingIdentity.Pricing
+ if result.ImageCount == 0 && result.BillingIdentity.TextPricing != nil {
+ usedPricingQuote = result.BillingIdentity.TextPricing
+ billingModel = strings.TrimSpace(result.BillingIdentity.TextBillingModel)
+ }
+ resolved := usedPricingQuote.CloneResolved()
+ if result.ImageCount > 0 && resolved != nil && resolved.Mode != BillingModeImage && resolved.Mode != BillingModePerRequest {
+ return errors.New("openai image usage has no frozen image pricing quote")
+ }
+ resolver := s.resolver
+ if resolver == nil {
+ resolver = NewModelPricingResolver(s.channelService, s.billingService)
+ }
+ var groupID *int64
+ if apiKey != nil && apiKey.Group != nil {
+ gid := apiKey.Group.ID
+ groupID = &gid
+ }
+ requestCount := 1
+ rateMultiplier := multiplier
+ sizeTier := ""
+ if result.ImageCount > 0 {
+ requestCount = result.ImageCount
+ rateMultiplier = imageMultiplier
+ sizeTier = result.ImageSize
+ }
+ if result.ImageCount == 0 && resolved != nil && (resolved.Mode == BillingModeImage || resolved.Mode == BillingModePerRequest) {
+ cost = &CostBreakdown{BillingMode: string(resolved.Mode)}
+ } else {
+ cost, err = s.billingService.CalculateCostUnified(CostInput{
+ Ctx: ctx, Model: billingModel, GroupID: groupID, Tokens: tokens,
+ RequestCount: requestCount, SizeTier: sizeTier, RateMultiplier: rateMultiplier, ServiceTier: serviceTier,
+ Resolver: resolver, Resolved: resolved,
+ })
+ }
+ successfulBillingModel = billingModel
+ usedPreflightQuote = err == nil
+ } else {
+ cost, successfulBillingModel, err = s.calculateOpenAIRecordUsageCost(ctx, result, apiKey, billingModels, multiplier, imageMultiplier, tokens, serviceTier)
+ }
if err != nil {
- cost = &CostBreakdown{ActualCost: 0}
+ return err
}
- // Determine billing type
- isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType()
+ // Determine billing type。2026-05-17 follow-up:用户在 admin 切 key 到 plan_groups
+ // 链内 standard group 时,subscription 仍然由 middleware GetActiveSubscriptionCoveringGroup
+ // 找到,billing 应该按订阅扣 sub quota 而不是 fallback 主余额。EffectiveBillingContext 统一判断。
+ isSubscriptionBilling, effectiveBillingGroup := EffectiveBillingContext(apiKey.Group, subscription)
+ effectiveBillingGroupID := resolveEffectiveBillingGroupID(apiKey.GroupID, effectiveBillingGroup, subscription)
billingType := BillingTypeBalance
if isSubscriptionBilling {
billingType = BillingTypeSubscription
@@ -5120,15 +5628,27 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
if input.OriginalModel != "" {
requestedModel = input.OriginalModel
}
+ // Product-visible OpenAI usage identity is always the model that actually
+ // executed upstream. Requested GPT aliases and Claude compatibility aliases
+ // remain available only in requested_model for audit and client debugging.
+ logModel := firstNonEmptyModel(
+ result.UpstreamModel,
+ successfulBillingModel,
+ result.BillingModel,
+ input.ChannelMappedModel,
+ result.Model,
+ requestedModel,
+ )
usageLog := &UsageLog{
UserID: user.ID,
APIKeyID: apiKey.ID,
AccountID: account.ID,
RequestID: requestID,
- Model: result.Model,
+ Model: logModel,
RequestedModel: requestedModel,
- UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, result.Model),
+ UpstreamModel: optionalNonEqualStringPtr(result.UpstreamModel, requestedModel),
+ BillingModel: optionalTrimmedStringPtr(successfulBillingModel),
ServiceTier: result.ServiceTier,
ReasoningEffort: result.ReasoningEffort,
InboundEndpoint: optionalTrimmedStringPtr(input.InboundEndpoint),
@@ -5141,6 +5661,15 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
ImageCount: result.ImageCount,
ImageSize: optionalTrimmedStringPtr(result.ImageSize),
}
+ if result.BillingIdentity != nil {
+ usageLog.UsageBillingAttemptID = strings.TrimSpace(result.BillingIdentity.AdmissionAttemptID)
+ }
+ if usedPreflightQuote {
+ evidence := usedPricingQuote.Evidence
+ usageLog.PricingSource = optionalTrimmedStringPtr(evidence.Source)
+ usageLog.PricingRevision = optionalTrimmedStringPtr(evidence.Revision)
+ usageLog.PricingHash = optionalTrimmedStringPtr(evidence.Hash)
+ }
if cost != nil {
usageLog.InputCost = cost.InputCost
usageLog.OutputCost = cost.OutputCost
@@ -5150,7 +5679,11 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
usageLog.TotalCost = cost.TotalCost
usageLog.ActualCost = cost.ActualCost
}
- usageLog.RateMultiplier = multiplier
+ if result.ImageCount > 0 {
+ usageLog.RateMultiplier = imageMultiplier
+ } else {
+ usageLog.RateMultiplier = multiplier
+ }
usageLog.AccountRateMultiplier = &accountRateMultiplier
usageLog.BillingType = billingType
usageLog.Stream = result.Stream
@@ -5204,20 +5737,43 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
return nil
}
- billingErr := func() error {
- _, err := applyUsageBilling(ctx, requestID, usageLog, &postUsageBillingParams{
- Cost: cost,
- User: user,
- APIKey: apiKey,
- Account: account,
- Subscription: subscription,
- RequestPayloadHash: resolveUsageBillingPayloadFingerprint(ctx, input.RequestPayloadHash),
- IsSubscriptionBill: isSubscriptionBilling,
- AccountRateMultiplier: accountRateMultiplier,
- APIKeyService: input.APIKeyService,
- }, s.billingDeps(), s.usageBillingRepo)
- return err
- }()
+ billingParams := &postUsageBillingParams{
+ Cost: cost,
+ User: user,
+ APIKey: apiKey,
+ Account: account,
+ Subscription: subscription,
+ EffectiveBillingGroupID: effectiveBillingGroupID,
+ RequestPayloadHash: resolveUsageBillingPayloadFingerprint(ctx, input.RequestPayloadHash),
+ IsSubscriptionBill: isSubscriptionBilling,
+ AccountRateMultiplier: accountRateMultiplier,
+ APIKeyService: input.APIKeyService,
+ }
+ if s.requireUsageBillingOutbox {
+ if !usedPreflightQuote {
+ return fmt.Errorf("%w: immutable pricing quote is missing", ErrUsageBillingOutboxUnavailable)
+ }
+ if s.usageBillingOutboxRepo == nil {
+ return ErrUsageBillingOutboxUnavailable
+ }
+ cmd := buildUsageBillingCommand(requestID, usageLog, billingParams)
+ if cmd == nil {
+ return fmt.Errorf("%w: billing command is invalid", ErrUsageBillingEnvelopeInvalid)
+ }
+ envelope, err := NewUsageBillingEnvelopeFromUsageLog(usageLog, cmd)
+ if err != nil {
+ return err
+ }
+ if err := enqueueUsageBillingOutboxDurably(ctx, s.usageBillingOutboxRepo, envelope, "service.openai_gateway.usage_billing_outbox"); err != nil {
+ return err
+ }
+ if s.usageBillingOutboxWake != nil {
+ s.usageBillingOutboxWake.Wake()
+ }
+ return nil
+ }
+
+ _, billingErr := applyUsageBilling(ctx, requestID, usageLog, billingParams, s.billingDeps(), s.usageBillingRepo)
if billingErr != nil {
return billingErr
@@ -5231,14 +5787,61 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost(
ctx context.Context,
result *OpenAIForwardResult,
apiKey *APIKey,
- billingModel string,
+ billingModels []string,
multiplier float64,
+ imageMultiplier float64,
tokens UsageTokens,
serviceTier string,
-) (*CostBreakdown, error) {
+) (*CostBreakdown, string, error) {
+ candidates := make([]string, 0, len(billingModels))
+ for _, candidate := range billingModels {
+ if candidate = strings.TrimSpace(candidate); candidate != "" {
+ candidates = append(candidates, candidate)
+ }
+ }
+ if len(candidates) == 0 {
+ return nil, "", errors.New("openai usage billing model is empty")
+ }
+ billingModel := candidates[0]
if result != nil && result.ImageCount > 0 {
- return s.calculateOpenAIImageCost(ctx, billingModel, apiKey, result, multiplier), nil
+ var lastErr error
+ for _, candidate := range candidates {
+ cost, matched, err := s.calculateOpenAIExplicitImageCost(ctx, candidate, apiKey, result, imageMultiplier)
+ if err != nil {
+ lastErr = err
+ continue
+ }
+ if matched {
+ return cost, candidate, nil
+ }
+ }
+ if lastErr != nil {
+ return nil, "", fmt.Errorf("calculate OpenAI image usage cost failed for billing models %s: %w", strings.Join(candidates, ","), lastErr)
+ }
+ return s.calculateOpenAIImageCost(billingModel, apiKey, result, imageMultiplier), billingModel, nil
}
+ var lastErr error
+ for _, candidate := range candidates {
+ cost, err := s.calculateOpenAIRecordUsageTokenCost(ctx, apiKey, candidate, multiplier, tokens, serviceTier)
+ if err == nil {
+ return cost, candidate, nil
+ }
+ lastErr = err
+ }
+ if lastErr == nil {
+ lastErr = errors.New("no non-empty billing model candidates")
+ }
+ return nil, "", fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(candidates, ","), lastErr)
+}
+
+func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost(
+ ctx context.Context,
+ apiKey *APIKey,
+ billingModel string,
+ multiplier float64,
+ tokens UsageTokens,
+ serviceTier string,
+) (*CostBreakdown, error) {
if s.resolver != nil && apiKey.Group != nil {
gid := apiKey.Group.ID
return s.billingService.CalculateCostUnified(CostInput{
@@ -5255,13 +5858,13 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost(
return s.billingService.CalculateCostWithServiceTier(billingModel, tokens, multiplier, serviceTier)
}
-func (s *OpenAIGatewayService) calculateOpenAIImageCost(
+func (s *OpenAIGatewayService) calculateOpenAIExplicitImageCost(
ctx context.Context,
billingModel string,
apiKey *APIKey,
result *OpenAIForwardResult,
multiplier float64,
-) *CostBreakdown {
+) (*CostBreakdown, bool, error) {
if resolved := s.resolveOpenAIChannelPricing(ctx, billingModel, apiKey); resolved != nil &&
(resolved.Mode == BillingModePerRequest || resolved.Mode == BillingModeImage) {
gid := apiKey.Group.ID
@@ -5269,18 +5872,29 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost(
Ctx: ctx,
Model: billingModel,
GroupID: &gid,
- RequestCount: 1,
+ RequestCount: result.ImageCount,
SizeTier: result.ImageSize,
RateMultiplier: multiplier,
Resolver: s.resolver,
Resolved: resolved,
})
- if err == nil {
- return cost
+ if err != nil {
+ return nil, true, err
}
- logger.LegacyPrintf("service.openai_gateway", "Calculate image channel cost failed: %v", err)
+ return cost, true, nil
+ }
+ if hasOpenAIGroupImagePrice(apiKey, result.ImageSize) || s.hasOpenAIModelImagePrice(billingModel) {
+ return s.calculateOpenAIImageCost(billingModel, apiKey, result, multiplier), true, nil
}
+ return nil, false, nil
+}
+func (s *OpenAIGatewayService) calculateOpenAIImageCost(
+ billingModel string,
+ apiKey *APIKey,
+ result *OpenAIForwardResult,
+ multiplier float64,
+) *CostBreakdown {
var groupConfig *ImagePriceConfig
if apiKey != nil && apiKey.Group != nil {
groupConfig = &ImagePriceConfig{
@@ -5292,6 +5906,30 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost(
return s.billingService.CalculateImageCost(billingModel, result.ImageSize, result.ImageCount, groupConfig, multiplier)
}
+func hasOpenAIGroupImagePrice(apiKey *APIKey, imageSize string) bool {
+ if apiKey == nil || apiKey.Group == nil {
+ return false
+ }
+ switch imageSize {
+ case "1K":
+ return apiKey.Group.ImagePrice1K != nil
+ case "2K":
+ return apiKey.Group.ImagePrice2K != nil
+ case "4K":
+ return apiKey.Group.ImagePrice4K != nil
+ default:
+ return false
+ }
+}
+
+func (s *OpenAIGatewayService) hasOpenAIModelImagePrice(billingModel string) bool {
+ if s.billingService == nil || s.billingService.pricingService == nil {
+ return false
+ }
+ pricing := s.billingService.pricingService.GetModelPricing(billingModel)
+ return pricing != nil && pricing.OutputCostPerImage > 0
+}
+
func (s *OpenAIGatewayService) resolveOpenAIChannelPricing(ctx context.Context, billingModel string, apiKey *APIKey) *ResolvedPricing {
if s.resolver == nil || apiKey == nil || apiKey.Group == nil {
return nil
@@ -5482,7 +6120,11 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc
go func() {
updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
- _ = s.accountRepo.UpdateExtra(updateCtx, accountID, updates)
+ if err := s.accountRepo.UpdateExtra(updateCtx, accountID, updates); err != nil {
+ slog.Warn("openai_codex_snapshot_update_extra_failed", "account_id", accountID, "error", err)
+ return
+ }
+ applyOpenAIQuotaGuardFromUpdates(updateCtx, s.accountRepo, accountID, updates, now)
}()
}
diff --git a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go
index fe58e92f69f..0ea7c5feff6 100644
--- a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go
+++ b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go
@@ -23,7 +23,6 @@ func (s *stubCodexRestrictionDetector) Detect(_ *gin.Context, _ *Account) CodexC
}
func TestOpenAIGatewayService_GetCodexClientRestrictionDetector(t *testing.T) {
- gin.SetMode(gin.TestMode)
t.Run("使用注入的 detector", func(t *testing.T) {
expected := &stubCodexRestrictionDetector{
@@ -60,7 +59,6 @@ func TestOpenAIGatewayService_GetCodexClientRestrictionDetector(t *testing.T) {
}
func TestGetAPIKeyIDFromContext(t *testing.T) {
- gin.SetMode(gin.TestMode)
t.Run("context 为 nil", func(t *testing.T) {
require.Equal(t, int64(0), getAPIKeyIDFromContext(nil))
@@ -124,7 +122,6 @@ func TestLogCodexCLIOnlyDetection_OnlyLogsRejected(t *testing.T) {
}
func TestLogCodexCLIOnlyDetection_RejectedIncludesRequestDetails(t *testing.T) {
- gin.SetMode(gin.TestMode)
logSink, restore := captureStructuredLog(t)
defer restore()
@@ -153,7 +150,6 @@ func TestLogCodexCLIOnlyDetection_RejectedIncludesRequestDetails(t *testing.T) {
}
func TestLogOpenAIInstructionsRequiredDebug_LogsRequestDetails(t *testing.T) {
- gin.SetMode(gin.TestMode)
logSink, restore := captureStructuredLog(t)
defer restore()
@@ -188,7 +184,6 @@ func TestLogOpenAIInstructionsRequiredDebug_LogsRequestDetails(t *testing.T) {
}
func TestLogOpenAIInstructionsRequiredDebug_NonTargetErrorSkipped(t *testing.T) {
- gin.SetMode(gin.TestMode)
logSink, restore := captureStructuredLog(t)
defer restore()
@@ -232,7 +227,6 @@ func TestIsOpenAITransientProcessingError(t *testing.T) {
}
func TestOpenAIGatewayService_Forward_LogsInstructionsRequiredDetails(t *testing.T) {
- gin.SetMode(gin.TestMode)
logSink, restore := captureStructuredLog(t)
defer restore()
@@ -286,7 +280,6 @@ func TestOpenAIGatewayService_Forward_LogsInstructionsRequiredDetails(t *testing
}
func TestOpenAIGatewayService_Forward_TransientProcessingErrorTriggersFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
diff --git a/backend/internal/service/openai_gateway_service_hotpath_test.go b/backend/internal/service/openai_gateway_service_hotpath_test.go
index 234dee00cf8..63d1bd7f394 100644
--- a/backend/internal/service/openai_gateway_service_hotpath_test.go
+++ b/backend/internal/service/openai_gateway_service_hotpath_test.go
@@ -107,7 +107,6 @@ func TestExtractOpenAIReasoningEffortFromBody(t *testing.T) {
}
func TestGetOpenAIRequestBodyMap_UsesContextCache(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -126,7 +125,6 @@ func TestGetOpenAIRequestBodyMap_ParseErrorWithoutCache(t *testing.T) {
}
func TestGetOpenAIRequestBodyMap_WriteBackContextCache(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go
index b55f0d2ce8e..3a79b940387 100644
--- a/backend/internal/service/openai_gateway_service_test.go
+++ b/backend/internal/service/openai_gateway_service_test.go
@@ -46,6 +46,10 @@ func (r *snapshotUpdateAccountRepo) UpdateExtra(ctx context.Context, id int64, u
return nil
}
+func (r stubOpenAIAccountRepo) SetTempUnschedulable(ctx context.Context, id int64, until time.Time, reason string) error {
+ return nil
+}
+
func (r stubOpenAIAccountRepo) GetByID(ctx context.Context, id int64) (*Account, error) {
for i := range r.accounts {
if r.accounts[i].ID == id {
@@ -153,7 +157,6 @@ func (c stubConcurrencyCache) GetAccountsLoadBatch(ctx context.Context, accounts
}
func TestOpenAIGatewayService_GenerateSessionHash_Priority(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
@@ -198,7 +201,6 @@ func TestOpenAIGatewayService_GenerateSessionHash_Priority(t *testing.T) {
}
func TestOpenAIGatewayService_GenerateSessionHash_UsesXXHash64(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
@@ -212,7 +214,6 @@ func TestOpenAIGatewayService_GenerateSessionHash_UsesXXHash64(t *testing.T) {
}
func TestOpenAIGatewayService_GenerateSessionHash_AttachesLegacyHashToContext(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
@@ -228,7 +229,6 @@ func TestOpenAIGatewayService_GenerateSessionHash_AttachesLegacyHashToContext(t
}
func TestOpenAIGatewayService_GenerateExplicitSessionHash_SkipsContentFallback(t *testing.T) {
- gin.SetMode(gin.TestMode)
svc := &OpenAIGatewayService{}
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat"}`)
@@ -263,7 +263,6 @@ func TestOpenAIGatewayService_GenerateExplicitSessionHash_SkipsContentFallback(t
}
func TestOpenAIGatewayService_GenerateSessionHashWithFallback(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
@@ -281,7 +280,6 @@ func TestOpenAIGatewayService_GenerateSessionHashWithFallback(t *testing.T) {
}
func TestOpenAIGatewayService_GenerateSessionHash_ContentFallback(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/chat/completions", nil)
@@ -306,7 +304,6 @@ func TestOpenAIGatewayService_GenerateSessionHash_ContentFallback(t *testing.T)
}
func TestOpenAIGatewayService_GenerateSessionHash_ExplicitSignalWinsOverContent(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/chat/completions", nil)
@@ -324,7 +321,6 @@ func TestOpenAIGatewayService_GenerateSessionHash_ExplicitSignalWinsOverContent(
}
func TestOpenAIGatewayService_GenerateSessionHash_EmptyBodyStillEmpty(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/chat/completions", nil)
@@ -346,6 +342,8 @@ func (c stubConcurrencyCache) GetAccountWaitingCount(ctx context.Context, accoun
type stubGatewayCache struct {
sessionBindings map[string]int64
deletedSessions map[string]int
+ lastSetTTL time.Duration
+ lastRefreshTTL time.Duration
}
func (c *stubGatewayCache) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) {
@@ -360,10 +358,12 @@ func (c *stubGatewayCache) SetSessionAccountID(ctx context.Context, groupID int6
c.sessionBindings = make(map[string]int64)
}
c.sessionBindings[sessionHash] = accountID
+ c.lastSetTTL = ttl
return nil
}
func (c *stubGatewayCache) RefreshSessionTTL(ctx context.Context, groupID int64, sessionHash string, ttl time.Duration) error {
+ c.lastRefreshTTL = ttl
return nil
}
@@ -979,7 +979,6 @@ func TestOpenAISelectAccountWithLoadAwareness_PreferNeverUsed(t *testing.T) {
}
func TestOpenAIStreamingTimeout(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
StreamDataIntervalTimeout: 1,
@@ -1014,7 +1013,6 @@ func TestOpenAIStreamingTimeout(t *testing.T) {
}
func TestOpenAIStreamingContextCanceledReturnsIncompleteErrorWithoutInjectingErrorEvent(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
StreamDataIntervalTimeout: 0,
@@ -1046,7 +1044,6 @@ func TestOpenAIStreamingContextCanceledReturnsIncompleteErrorWithoutInjectingErr
}
func TestOpenAIStreamingReadErrorBeforeOutputReturnsFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
StreamDataIntervalTimeout: 0,
@@ -1076,7 +1073,6 @@ func TestOpenAIStreamingReadErrorBeforeOutputReturnsFailover(t *testing.T) {
}
func TestOpenAIStreamingResponseFailedBeforeOutputReturnsFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
StreamDataIntervalTimeout: 0,
@@ -1117,7 +1113,6 @@ func TestOpenAIStreamingResponseFailedBeforeOutputReturnsFailover(t *testing.T)
}
func TestOpenAIStreamingPreambleOnlyMissingTerminalReturnsFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
StreamDataIntervalTimeout: 0,
@@ -1153,7 +1148,6 @@ func TestOpenAIStreamingPreambleOnlyMissingTerminalReturnsFailover(t *testing.T)
}
func TestOpenAIStreamingPreambleKeepaliveUsesDownstreamIdle(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
StreamDataIntervalTimeout: 0,
@@ -1193,7 +1187,6 @@ func TestOpenAIStreamingPreambleKeepaliveUsesDownstreamIdle(t *testing.T) {
}
func TestOpenAIStreamingPolicyResponseFailedBeforeOutputPassesThrough(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
StreamDataIntervalTimeout: 0,
@@ -1230,7 +1223,6 @@ func TestOpenAIStreamingPolicyResponseFailedBeforeOutputPassesThrough(t *testing
}
func TestOpenAIStreamingClientDisconnectDrainsUpstreamUsage(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
StreamDataIntervalTimeout: 0,
@@ -1275,7 +1267,6 @@ func TestOpenAIStreamingClientDisconnectDrainsUpstreamUsage(t *testing.T) {
}
func TestOpenAIStreamingMissingTerminalEventReturnsIncompleteError(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
StreamDataIntervalTimeout: 0,
@@ -1309,7 +1300,6 @@ func TestOpenAIStreamingMissingTerminalEventReturnsIncompleteError(t *testing.T)
}
func TestOpenAIStreamingPassthroughMissingTerminalEventReturnsIncompleteError(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
MaxLineSize: defaultMaxLineSize,
@@ -1341,7 +1331,6 @@ func TestOpenAIStreamingPassthroughMissingTerminalEventReturnsIncompleteError(t
}
func TestOpenAIStreamingPassthroughResponseFailedBeforeOutputReturnsFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
MaxLineSize: defaultMaxLineSize,
@@ -1377,7 +1366,6 @@ func TestOpenAIStreamingPassthroughResponseFailedBeforeOutputReturnsFailover(t *
}
func TestOpenAIStreamingPassthroughResponseDoneWithoutDoneMarkerStillSucceeds(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
MaxLineSize: defaultMaxLineSize,
@@ -1412,7 +1400,6 @@ func TestOpenAIStreamingPassthroughResponseDoneWithoutDoneMarkerStillSucceeds(t
}
func TestOpenAIStreamingPassthroughResponseIncompleteWithoutDoneMarkerStillSucceeds(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
MaxLineSize: defaultMaxLineSize,
@@ -1447,7 +1434,6 @@ func TestOpenAIStreamingPassthroughResponseIncompleteWithoutDoneMarkerStillSucce
}
func TestOpenAIStreamingTooLong(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
StreamDataIntervalTimeout: 0,
@@ -1487,7 +1473,6 @@ func TestOpenAIStreamingTooLong(t *testing.T) {
}
func TestOpenAINonStreamingContentTypePassThrough(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Security: config.SecurityConfig{
ResponseHeaders: config.ResponseHeaderConfig{Enabled: false},
@@ -1517,7 +1502,6 @@ func TestOpenAINonStreamingContentTypePassThrough(t *testing.T) {
}
func TestOpenAINonStreamingContentTypeDefault(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Security: config.SecurityConfig{
ResponseHeaders: config.ResponseHeaderConfig{Enabled: false},
@@ -1547,7 +1531,6 @@ func TestOpenAINonStreamingContentTypeDefault(t *testing.T) {
}
func TestOpenAIStreamingHeadersOverride(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Security: config.SecurityConfig{
ResponseHeaders: config.ResponseHeaderConfig{Enabled: false},
@@ -1598,7 +1581,6 @@ func TestOpenAIStreamingHeadersOverride(t *testing.T) {
}
func TestOpenAIStreamingReuseScannerBufferAndStillWorks(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Gateway: config.GatewayConfig{
StreamDataIntervalTimeout: 0,
@@ -1635,7 +1617,6 @@ func TestOpenAIStreamingReuseScannerBufferAndStillWorks(t *testing.T) {
}
func TestOpenAIInvalidBaseURLWhenAllowlistDisabled(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{
Security: config.SecurityConfig{
URLAllowlist: config.URLAllowlistConfig{Enabled: false},
@@ -1699,7 +1680,7 @@ func TestOpenAIValidateUpstreamBaseURLDisabledAllowsHTTP(t *testing.T) {
}
}
-func TestOpenAIValidateUpstreamBaseURLEnabledEnforcesAllowlist(t *testing.T) {
+func TestOpenAIValidateUpstreamBaseURLEnabledDoesNotEnforceHostAllowlist(t *testing.T) {
cfg := &config.Config{
Security: config.SecurityConfig{
URLAllowlist: config.URLAllowlistConfig{
@@ -1713,8 +1694,8 @@ func TestOpenAIValidateUpstreamBaseURLEnabledEnforcesAllowlist(t *testing.T) {
if _, err := svc.validateUpstreamBaseURL("https://example.com"); err != nil {
t.Fatalf("expected allowlisted host to pass, got %v", err)
}
- if _, err := svc.validateUpstreamBaseURL("https://evil.com"); err == nil {
- t.Fatalf("expected non-allowlisted host to fail")
+ if _, err := svc.validateUpstreamBaseURL("https://evil.com"); err != nil {
+ t.Fatalf("expected custom upstream host to pass without host allowlist, got %v", err)
}
}
@@ -1743,7 +1724,6 @@ func TestOpenAIUpdateCodexUsageSnapshotFromHeaders(t *testing.T) {
}
func TestOpenAIResponsesRequestPathSuffix(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -1786,7 +1766,6 @@ func TestNormalizeOpenAICompactRequestBodyPreservesCurrentCodexPayloadFields(t *
}
func TestOpenAIBuildUpstreamRequestOpenAIPassthroughPreservesCompactPath(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader([]byte(`{"model":"gpt-5"}`)))
@@ -1803,7 +1782,6 @@ func TestOpenAIBuildUpstreamRequestOpenAIPassthroughPreservesCompactPath(t *test
}
func TestOpenAIBuildUpstreamRequestCompactForcesJSONAcceptForOAuth(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses/compact", bytes.NewReader([]byte(`{"model":"gpt-5"}`)))
@@ -1822,8 +1800,29 @@ func TestOpenAIBuildUpstreamRequestCompactForcesJSONAcceptForOAuth(t *testing.T)
require.NotEmpty(t, req.Header.Get("Session_Id"))
}
+func TestOpenAIBuildUpstreamRequestOAuthMessagesBridgeUsesSessionOnly(t *testing.T) {
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ body := []byte(`{"model":"gpt-5.5","prompt_cache_key":"anthropic-metadata-session-1","input":[{"type":"message","role":"developer","content":[{"type":"input_text","text":""}]},{"type":"message","role":"user","content":"hello"}]}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
+ c.Request.Header.Set("OpenAI-Beta", "responses=experimental")
+ c.Request.Header.Set("originator", "codex_cli_rs")
+
+ svc := &OpenAIGatewayService{}
+ account := &Account{
+ Type: AccountTypeOAuth,
+ Credentials: map[string]any{"chatgpt_account_id": "chatgpt-acc"},
+ }
+
+ req, err := svc.buildUpstreamRequest(c.Request.Context(), c, account, body, "token", true, "anthropic-metadata-session-1", false)
+ require.NoError(t, err)
+ require.NotEmpty(t, req.Header.Get("Session_Id"))
+ require.Empty(t, req.Header.Get("Conversation_Id"))
+ require.Empty(t, req.Header.Get("OpenAI-Beta"))
+ require.Empty(t, req.Header.Get("originator"))
+}
+
func TestOpenAIBuildUpstreamRequestPreservesCompactPathForAPIKeyBaseURL(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/responses/compact", bytes.NewReader([]byte(`{"model":"gpt-5"}`)))
@@ -1845,7 +1844,6 @@ func TestOpenAIBuildUpstreamRequestPreservesCompactPathForAPIKeyBaseURL(t *testi
}
func TestOpenAIBuildUpstreamRequestOAuthOfficialClientOriginatorCompatibility(t *testing.T) {
- gin.SetMode(gin.TestMode)
tests := []struct {
name string
@@ -2168,7 +2166,6 @@ func TestExtractCodexFinalResponse_SampleReplay(t *testing.T) {
}
func TestHandleSSEToJSON_CompletedEventReturnsJSON(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
@@ -2197,7 +2194,6 @@ func TestHandleSSEToJSON_CompletedEventReturnsJSON(t *testing.T) {
}
func TestHandleSSEToJSON_ReconstructsImageGenerationOutputItemDone(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
@@ -2224,7 +2220,6 @@ func TestHandleSSEToJSON_ReconstructsImageGenerationOutputItemDone(t *testing.T)
}
func TestHandleSSEToJSON_NoFinalResponseKeepsSSEBody(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
@@ -2248,7 +2243,6 @@ func TestHandleSSEToJSON_NoFinalResponseKeepsSSEBody(t *testing.T) {
}
func TestHandleSSEToJSON_ResponseFailedReturnsProtocolError(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
@@ -2270,3 +2264,157 @@ func TestHandleSSEToJSON_ResponseFailedReturnsProtocolError(t *testing.T) {
require.Contains(t, rec.Body.String(), "upstream rejected request")
require.Contains(t, rec.Header().Get("Content-Type"), "application/json")
}
+
+// TestWeightedShuffleByLoadFactor_RatioMatchesLoadFactor 验证当所有候选账号
+// (Priority, LoadRate) 相同时,weightedShuffleByLoadFactorWithinSortGroups 把
+// 按 EffectiveLoadFactor 加权的概率分配到位置 0:1 个 Pro 20x(LF=15)+ 9 个
+// Plus(LF=1),位置 0 落在 Pro 上的概率应 ≈ 15/24 ≈ 62.5%,每个 Plus ≈ 4.17%。
+//
+// 这是 selectAccountWithLoadAwareness Layer 2 的实际选号入口,覆盖生产
+// `openai_advanced_scheduler_enabled=false` 时唯一的 OpenAI 选号路径。
+func TestWeightedShuffleByLoadFactor_RatioMatchesLoadFactor(t *testing.T) {
+ const (
+ proID = int64(12)
+ proLoadFactor = 15
+ plusCount = 9
+ iterations = 5000
+ )
+
+ build := func() []accountWithLoad {
+ out := []accountWithLoad{
+ {
+ account: &Account{
+ ID: proID,
+ Priority: 1,
+ LoadFactor: intPtrForTest(proLoadFactor),
+ },
+ loadInfo: &AccountLoadInfo{LoadRate: 0},
+ },
+ }
+ for i := 0; i < plusCount; i++ {
+ out = append(out, accountWithLoad{
+ account: &Account{
+ ID: int64(100 + i),
+ Priority: 1,
+ LoadFactor: intPtrForTest(1),
+ },
+ loadInfo: &AccountLoadInfo{LoadRate: 0},
+ })
+ }
+ return out
+ }
+
+ hits := make(map[int64]int)
+ for i := 0; i < iterations; i++ {
+ pool := build()
+ weightedShuffleByLoadFactorWithinSortGroups(pool)
+ hits[pool[0].account.ID]++
+ }
+
+ totalWeight := float64(proLoadFactor + plusCount)
+ expectedProRatio := float64(proLoadFactor) / totalWeight
+ expectedPlusRatio := 1.0 / totalWeight
+
+ proRatio := float64(hits[proID]) / float64(iterations)
+ require.InDelta(t, expectedProRatio, proRatio, 0.05,
+ "Pro 20x LoadFactor=%d 实际占比 %.2f%% 与期望 %.2f%% 偏差超过 5%%",
+ proLoadFactor, proRatio*100, expectedProRatio*100)
+
+ for i := 0; i < plusCount; i++ {
+ plusID := int64(100 + i)
+ plusRatio := float64(hits[plusID]) / float64(iterations)
+ require.InDelta(t, expectedPlusRatio, plusRatio, 0.03,
+ "Plus 号 id=%d 实际占比 %.2f%% 与期望 %.2f%% 偏差超过 3%%",
+ plusID, plusRatio*100, expectedPlusRatio*100)
+ }
+}
+
+// TestWeightedShuffleByLoadFactor_CrossLoadRateSamePriority 验证同 Priority 且
+// 未满载的账号即使 LoadRate 不同,也必须进入同一个 LoadFactor 加权池。
+// 回归场景:Plus 账号 LoadRate=0 排在前段,高容量账号 LoadRate=50 被旧分组
+// 切到后段,导致高容量账号首位命中率从理论值掉到 0。
+func TestWeightedShuffleByLoadFactor_CrossLoadRateSamePriority(t *testing.T) {
+ const (
+ proID = int64(19)
+ proLoadFactor = 15
+ plusCount = 5
+ iterations = 3000
+ )
+
+ build := func() []accountWithLoad {
+ out := make([]accountWithLoad, 0, plusCount+1)
+ for i := 0; i < plusCount; i++ {
+ out = append(out, accountWithLoad{
+ account: &Account{
+ ID: int64(300 + i),
+ Priority: 1,
+ LoadFactor: intPtrForTest(1),
+ },
+ loadInfo: &AccountLoadInfo{LoadRate: 0},
+ })
+ }
+ out = append(out, accountWithLoad{
+ account: &Account{
+ ID: proID,
+ Priority: 1,
+ LoadFactor: intPtrForTest(proLoadFactor),
+ },
+ loadInfo: &AccountLoadInfo{LoadRate: 50},
+ })
+ return out
+ }
+
+ proHits := 0
+ for i := 0; i < iterations; i++ {
+ pool := build()
+ weightedShuffleByLoadFactorWithinSortGroups(pool)
+ if pool[0].account.ID == proID {
+ proHits++
+ }
+ }
+
+ expectedProRatio := float64(proLoadFactor) / float64(proLoadFactor+plusCount)
+ proRatio := float64(proHits) / float64(iterations)
+ require.InDelta(t, expectedProRatio, proRatio, 0.06,
+ "同 Priority 健康账号不应被 LoadRate 切 segment;Pro 实际占比 %.2f%% 与期望 %.2f%% 偏差过大",
+ proRatio*100, expectedProRatio*100)
+}
+
+// TestWeightedShuffleByLoadFactor_PreservesPriorityGrouping 验证不同 Priority
+// 的账号即使在同一切片里也会被严格分组:高 Priority(数值小)的账号永远
+// 排在低 Priority 前面,加权抽样只在同 Priority 子组内生效。
+func TestWeightedShuffleByLoadFactor_PreservesPriorityGrouping(t *testing.T) {
+ const iterations = 1000
+ for i := 0; i < iterations; i++ {
+ pool := []accountWithLoad{
+ {account: &Account{ID: 1, Priority: 1, LoadFactor: intPtrForTest(15)}, loadInfo: &AccountLoadInfo{LoadRate: 0}},
+ {account: &Account{ID: 2, Priority: 1, LoadFactor: intPtrForTest(1)}, loadInfo: &AccountLoadInfo{LoadRate: 0}},
+ {account: &Account{ID: 3, Priority: 2, LoadFactor: intPtrForTest(100)}, loadInfo: &AccountLoadInfo{LoadRate: 0}},
+ }
+ weightedShuffleByLoadFactorWithinSortGroups(pool)
+ require.Contains(t, []int64{1, 2}, pool[0].account.ID,
+ "Priority=1 的账号必须排在 Priority=2 之前")
+ require.Contains(t, []int64{1, 2}, pool[1].account.ID,
+ "Priority=1 的账号必须排在 Priority=2 之前")
+ require.Equal(t, int64(3), pool[2].account.ID,
+ "Priority=2 的账号必须排在最后")
+ }
+}
+
+// TestWeightedShuffleByLoadFactor_LoadFactorNilFallsBack 验证 LoadFactor=nil
+// 的旧账号会 fallback 到 Concurrency(再不够 fallback 到 1),不会被当作
+// 权重 0 而抽不到。
+func TestWeightedShuffleByLoadFactor_LoadFactorNilFallsBack(t *testing.T) {
+ const iterations = 2000
+ hits := make(map[int64]int)
+ for i := 0; i < iterations; i++ {
+ pool := []accountWithLoad{
+ {account: &Account{ID: 201, Priority: 1, LoadFactor: nil, Concurrency: 5}, loadInfo: &AccountLoadInfo{LoadRate: 0}},
+ {account: &Account{ID: 202, Priority: 1, LoadFactor: nil, Concurrency: 5}, loadInfo: &AccountLoadInfo{LoadRate: 0}},
+ }
+ weightedShuffleByLoadFactorWithinSortGroups(pool)
+ hits[pool[0].account.ID]++
+ }
+ require.InDelta(t, 0.5, float64(hits[201])/float64(iterations), 0.05)
+ require.InDelta(t, 0.5, float64(hits[202])/float64(iterations), 0.05)
+}
diff --git a/backend/internal/service/openai_image_generation_controls_test.go b/backend/internal/service/openai_image_generation_controls_test.go
new file mode 100644
index 00000000000..b59d974e4f6
--- /dev/null
+++ b/backend/internal/service/openai_image_generation_controls_test.go
@@ -0,0 +1,390 @@
+package service
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+func TestOpenAIGatewayServiceForward_RejectsDisabledImageGenerationIntents(t *testing.T) {
+
+ tests := []struct {
+ name string
+ body []byte
+ }{
+ {
+ name: "image model",
+ body: []byte(`{"model":"gpt-image-2","input":"draw"}`),
+ },
+ {
+ name: "image tool",
+ body: []byte(`{"model":"gpt-5.4","input":"draw","tools":[{"type":"image_generation"}]}`),
+ },
+ {
+ name: "image tool choice",
+ body: []byte(`{"model":"gpt-5.4","input":"draw","tool_choice":{"type":"image_generation"}}`),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ upstream := &httpUpstreamRecorder{}
+ svc := newOpenAIImageGenerationControlTestService(upstream)
+ c, recorder := newOpenAIImageGenerationControlTestContext(false, "unit-test-agent/1.0")
+ account := newOpenAIImageGenerationControlTestAccount()
+
+ result, err := svc.Forward(context.Background(), c, account, tt.body)
+
+ require.Error(t, err)
+ require.Nil(t, result)
+ require.Equal(t, http.StatusForbidden, recorder.Code)
+ require.Equal(t, "permission_error", gjson.GetBytes(recorder.Body.Bytes(), "error.type").String())
+ require.Nil(t, upstream.lastReq, "disabled image request must not reach upstream")
+ })
+ }
+}
+
+func TestOpenAIGatewayServiceForward_DisabledGroupAllowsTextOnlyResponses(t *testing.T) {
+
+ upstream := &httpUpstreamRecorder{
+ resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(`{"id":"resp_text","model":"gpt-5.4","usage":{"input_tokens":3,"output_tokens":2}}`)),
+ },
+ }
+ svc := newOpenAIImageGenerationControlTestService(upstream)
+ c, recorder := newOpenAIImageGenerationControlTestContext(false, "unit-test-agent/1.0")
+ account := newOpenAIImageGenerationControlTestAccount()
+
+ result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.4","input":"write code","stream":false}`))
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, http.StatusOK, recorder.Code)
+ require.Equal(t, 3, result.Usage.InputTokens)
+ require.Equal(t, 2, result.Usage.OutputTokens)
+ require.Equal(t, 0, result.ImageCount)
+ require.NotNil(t, upstream.lastReq)
+}
+
+func TestOpenAIGatewayServiceForward_CodexImageInjectionRespectsGroupCapability(t *testing.T) {
+
+ tests := []struct {
+ name string
+ allowImages bool
+ bridgeEnabled bool
+ wantInjected bool
+ }{
+ {name: "disabled group skips injection", allowImages: false, bridgeEnabled: true, wantInjected: false},
+ {name: "enabled group skips injection by default", allowImages: true, bridgeEnabled: false, wantInjected: false},
+ {name: "enabled group injects image tool when bridge enabled", allowImages: true, bridgeEnabled: true, wantInjected: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ upstream := &httpUpstreamRecorder{
+ resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(`{"id":"resp_codex","model":"gpt-5.4","usage":{"input_tokens":1,"output_tokens":1}}`)),
+ },
+ }
+ svc := newOpenAIImageGenerationControlTestService(upstream)
+ svc.cfg.Gateway.CodexImageGenerationBridgeEnabled = tt.bridgeEnabled
+ c, _ := newOpenAIImageGenerationControlTestContext(tt.allowImages, "codex_cli_rs/0.98.0")
+ account := newOpenAIImageGenerationControlTestAccount()
+
+ result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.4","input":"write code","stream":false}`))
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.NotNil(t, upstream.lastReq)
+ hasImageTool := gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation")`).Exists()
+ require.Equal(t, tt.wantInjected, hasImageTool)
+ instructions := gjson.GetBytes(upstream.lastBody, "instructions").String()
+ require.Equal(t, tt.wantInjected, strings.Contains(instructions, "image_generation"))
+ })
+ }
+}
+
+func TestOpenAIGatewayServiceForward_ExplicitImageToolWorksWithBridgeDisabled(t *testing.T) {
+
+ upstream := &httpUpstreamRecorder{
+ resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(`{"id":"resp_explicit_image","model":"gpt-5.4","usage":{"input_tokens":2,"output_tokens":1}}`)),
+ },
+ }
+ svc := newOpenAIImageGenerationControlTestService(upstream)
+ c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.98.0")
+ account := newOpenAIImageGenerationControlTestAccount()
+ body := []byte(`{"model":"gpt-5.4","input":"draw","stream":false,"tools":[{"type":"image_generation","format":"jpeg"}]}`)
+
+ result, err := svc.Forward(context.Background(), c, account, body)
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.NotNil(t, upstream.lastReq)
+ require.True(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation")`).Exists())
+ require.Equal(t, "jpeg", gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation").output_format`).String())
+ require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation").format`).Exists())
+ instructions := gjson.GetBytes(upstream.lastBody, "instructions").String()
+ require.NotContains(t, instructions, "image_generation")
+}
+
+func TestOpenAIGatewayServiceForward_ImagePricingPreflightRejectsBeforeUpstream(t *testing.T) {
+
+ upstream := &httpUpstreamRecorder{}
+ svc := newOpenAIImageGenerationControlTestService(upstream)
+ c, _ := newOpenAIImageGenerationControlTestContext(true, "unit-test-agent/1.0")
+ account := newOpenAIImageGenerationControlTestAccount()
+ body := []byte(`{"model":"gpt-5.4","input":"draw","stream":false,"tools":[{"type":"image_generation","model":"custom-unpriceable-image","size":"1024x1024"}],"tool_choice":{"type":"image_generation"}}`)
+ groupID := int64(4242)
+
+ result, err := svc.ForwardWithOptions(context.Background(), c, account, body, OpenAIForwardOptions{
+ RequestedModel: "gpt-5.4", GroupID: &groupID, RequirePricingPreflight: true,
+ })
+
+ require.Error(t, err)
+ require.ErrorIs(t, err, ErrOpenAIBillingPreflight)
+ require.Nil(t, result)
+ require.Nil(t, upstream.lastReq, "image pricing failure must happen before upstream transport")
+}
+
+func TestOpenAIGatewayServiceForward_ChannelBridgeOverrideEnablesCodexInjection(t *testing.T) {
+
+ upstream := &httpUpstreamRecorder{
+ resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(`{"id":"resp_channel_bridge","model":"gpt-5.4","usage":{"input_tokens":1,"output_tokens":1}}`)),
+ },
+ }
+ svc := newOpenAIImageGenerationControlTestService(upstream)
+ groupID := int64(4242)
+ svc.channelService = newOpenAIImageGenerationControlChannelService(groupID, &Channel{
+ ID: 9001,
+ Status: StatusActive,
+ FeaturesConfig: map[string]any{
+ featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: true},
+ },
+ })
+ c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.98.0")
+ account := newOpenAIImageGenerationControlTestAccount()
+
+ result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.4","input":"write code","stream":false}`))
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.NotNil(t, upstream.lastReq)
+ require.True(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation")`).Exists())
+ instructions := gjson.GetBytes(upstream.lastBody, "instructions").String()
+ require.Contains(t, instructions, "image_generation")
+}
+
+func TestOpenAIGatewayService_CodexImageGenerationBridgeOverridePrecedence(t *testing.T) {
+ groupID := int64(4242)
+
+ tests := []struct {
+ name string
+ global bool
+ channel *Channel
+ account *Account
+ want bool
+ }{
+ {
+ name: "global default enables bridge",
+ global: true,
+ account: &Account{
+ Platform: PlatformOpenAI,
+ },
+ want: true,
+ },
+ {
+ name: "channel true overrides disabled global",
+ global: false,
+ channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{
+ featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: true},
+ }},
+ account: &Account{Platform: PlatformOpenAI},
+ want: true,
+ },
+ {
+ name: "channel false overrides enabled global",
+ global: true,
+ channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{
+ featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: false},
+ }},
+ account: &Account{Platform: PlatformOpenAI},
+ want: false,
+ },
+ {
+ name: "account false overrides channel and global true",
+ global: true,
+ channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{
+ featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: true},
+ }},
+ account: &Account{
+ Platform: PlatformOpenAI,
+ Extra: map[string]any{featureKeyCodexImageGenerationBridge: false},
+ },
+ want: false,
+ },
+ {
+ name: "nested account true overrides channel false",
+ global: false,
+ channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{
+ featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: false},
+ }},
+ account: &Account{
+ Platform: PlatformOpenAI,
+ Extra: map[string]any{
+ PlatformOpenAI: map[string]any{"codex_image_generation_bridge_enabled": true},
+ },
+ },
+ want: true,
+ },
+ {
+ name: "non openai account extra is ignored",
+ global: false,
+ account: &Account{
+ Platform: PlatformAnthropic,
+ Extra: map[string]any{featureKeyCodexImageGenerationBridge: true},
+ },
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ svc := newOpenAIImageGenerationControlTestService(&httpUpstreamRecorder{})
+ svc.cfg.Gateway.CodexImageGenerationBridgeEnabled = tt.global
+ if tt.channel != nil {
+ svc.channelService = newOpenAIImageGenerationControlChannelService(groupID, tt.channel)
+ }
+ apiKey := &APIKey{GroupID: &groupID}
+
+ got := svc.isCodexImageGenerationBridgeEnabled(context.Background(), tt.account, apiKey)
+
+ require.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestOpenAIGatewayServiceHandleResponsesImageOutputs_NonStreaming(t *testing.T) {
+
+ svc := newOpenAIImageGenerationControlTestService(&httpUpstreamRecorder{})
+ c, _ := newOpenAIImageGenerationControlTestContext(true, "unit-test-agent/1.0")
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: io.NopCloser(strings.NewReader(`{
+ "id":"resp_image_json",
+ "model":"gpt-5.4",
+ "output":[{"id":"ig_json_1","type":"image_generation_call","result":"final-image"}],
+ "usage":{"input_tokens":7,"output_tokens":3,"output_tokens_details":{"image_tokens":2}}
+ }`)),
+ }
+
+ result, err := svc.handleNonStreamingResponse(context.Background(), resp, c, &Account{ID: 1, Type: AccountTypeAPIKey}, "gpt-5.4", "gpt-5.4")
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, 1, result.imageCount)
+ require.NotNil(t, result.usage)
+ require.Equal(t, 7, result.usage.InputTokens)
+ require.Equal(t, 3, result.usage.OutputTokens)
+ require.Equal(t, 2, result.usage.ImageOutputTokens)
+}
+
+func TestOpenAIGatewayServiceHandleResponsesImageOutputs_Streaming(t *testing.T) {
+
+ svc := newOpenAIImageGenerationControlTestService(&httpUpstreamRecorder{})
+ c, _ := newOpenAIImageGenerationControlTestContext(true, "unit-test-agent/1.0")
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(
+ "data: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"ig_stream_1\",\"type\":\"image_generation_call\",\"result\":\"final-image\"}}\n\n" +
+ "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_image_stream\",\"model\":\"gpt-5.5\",\"output\":[{\"id\":\"ig_stream_1\",\"type\":\"image_generation_call\",\"result\":\"final-image\"}],\"usage\":{\"input_tokens\":11,\"output_tokens\":5,\"output_tokens_details\":{\"image_tokens\":4}}}}\n\n",
+ )),
+ }
+
+ result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "gpt-5.5", "gpt-5.5")
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, 1, result.imageCount)
+ require.NotNil(t, result.usage)
+ require.Equal(t, 11, result.usage.InputTokens)
+ require.Equal(t, 5, result.usage.OutputTokens)
+ require.Equal(t, 4, result.usage.ImageOutputTokens)
+}
+
+func newOpenAIImageGenerationControlTestService(upstream *httpUpstreamRecorder) *OpenAIGatewayService {
+ cfg := &config.Config{}
+ return &OpenAIGatewayService{
+ cfg: cfg,
+ httpUpstream: upstream,
+ cache: &stubGatewayCache{},
+ openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
+ toolCorrector: NewCodexToolCorrector(),
+ }
+}
+
+func newOpenAIImageGenerationControlChannelService(groupID int64, ch *Channel) *ChannelService {
+ svc := &ChannelService{}
+ cache := newEmptyChannelCache()
+ if ch != nil {
+ cache.channelByGroupID[groupID] = ch
+ cache.byID[ch.ID] = ch
+ }
+ cache.loadedAt = time.Now()
+ svc.cache.Store(cache)
+ return svc
+}
+
+func newOpenAIImageGenerationControlTestContext(allowImages bool, userAgent string) (*gin.Context, *httptest.ResponseRecorder) {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
+ c.Request.Header.Set("User-Agent", userAgent)
+ groupID := int64(4242)
+ c.Set("api_key", &APIKey{
+ ID: 2424,
+ GroupID: &groupID,
+ Group: &Group{
+ ID: groupID,
+ AllowImageGeneration: allowImages,
+ RateMultiplier: 1,
+ ImageRateMultiplier: 1,
+ },
+ })
+ return c, recorder
+}
+
+func newOpenAIImageGenerationControlTestAccount() *Account {
+ return &Account{
+ ID: 5151,
+ Name: "openai-image-controls",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Status: StatusActive,
+ Schedulable: true,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "api_key": "sk-test",
+ },
+ }
+}
diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go
index 4badcb1c2cb..319d70ea8c3 100644
--- a/backend/internal/service/openai_images.go
+++ b/backend/internal/service/openai_images.go
@@ -14,8 +14,10 @@ import (
"mime/multipart"
"net/http"
"net/textproto"
+ "net/url"
"strconv"
"strings"
+ "sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
@@ -48,6 +50,77 @@ const (
OpenAIImagesCapabilityNative OpenAIImagesCapability = "images-native"
)
+type OpenAIImageOutputAuditRequest struct {
+ ModerationBody []byte
+ OutputHashes []string
+ UpstreamRequestID string
+ PolicyRule string
+ UnavailableReason string
+}
+
+type OpenAIImageOutputAuditor interface {
+ AuditOpenAIImageOutput(ctx context.Context, req OpenAIImageOutputAuditRequest) (*ContentModerationDecision, error)
+}
+
+type OpenAIImageOutputAuditorFunc func(ctx context.Context, req OpenAIImageOutputAuditRequest) (*ContentModerationDecision, error)
+
+func (f OpenAIImageOutputAuditorFunc) AuditOpenAIImageOutput(ctx context.Context, req OpenAIImageOutputAuditRequest) (*ContentModerationDecision, error) {
+ return f(ctx, req)
+}
+
+type OpenAIImagesForwardOptions struct {
+ SafetyIdentifier string
+ OutputAuditor OpenAIImageOutputAuditor
+ Billing OpenAIForwardOptions
+}
+
+type OpenAIImagesForwardOption func(*OpenAIImagesForwardOptions)
+
+func WithOpenAIImagesSafetyIdentifier(value string) OpenAIImagesForwardOption {
+ return func(opts *OpenAIImagesForwardOptions) {
+ if opts != nil {
+ opts.SafetyIdentifier = strings.TrimSpace(value)
+ }
+ }
+}
+
+func WithOpenAIImagesOutputAuditor(auditor OpenAIImageOutputAuditor) OpenAIImagesForwardOption {
+ return func(opts *OpenAIImagesForwardOptions) {
+ if opts != nil {
+ opts.OutputAuditor = auditor
+ }
+ }
+}
+
+func WithOpenAIImagesBillingPreflight(options OpenAIForwardOptions) OpenAIImagesForwardOption {
+ return func(opts *OpenAIImagesForwardOptions) {
+ if opts != nil {
+ opts.Billing = options
+ }
+ }
+}
+
+type OpenAIImageOutputAuditError struct {
+ Decision *ContentModerationDecision
+}
+
+func (e *OpenAIImageOutputAuditError) Error() string {
+ if e == nil || e.Decision == nil || strings.TrimSpace(e.Decision.Message) == "" {
+ return "openai image output audit blocked response"
+ }
+ return e.Decision.Message
+}
+
+func resolveOpenAIImagesForwardOptions(options []OpenAIImagesForwardOption) OpenAIImagesForwardOptions {
+ var out OpenAIImagesForwardOptions
+ for _, option := range options {
+ if option != nil {
+ option(&out)
+ }
+ }
+ return out
+}
+
type OpenAIImagesUpload struct {
FieldName string
FileName string
@@ -89,6 +162,140 @@ type OpenAIImagesRequest struct {
bodyHash string
}
+type OpenAIImagesSafetyError struct {
+ StatusCode int
+ Type string
+ Message string
+ PolicyRule string
+}
+
+func (e *OpenAIImagesSafetyError) Error() string {
+ if e == nil {
+ return ""
+ }
+ return e.Message
+}
+
+func ValidateOpenAIImagesSafetyRequest(req *OpenAIImagesRequest) *OpenAIImagesSafetyError {
+ if req == nil {
+ return &OpenAIImagesSafetyError{
+ StatusCode: http.StatusBadRequest,
+ Type: "invalid_request_error",
+ Message: "parsed images request is required",
+ PolicyRule: "blocked_image_invalid_request",
+ }
+ }
+ if req.Stream {
+ return &OpenAIImagesSafetyError{
+ StatusCode: http.StatusBadRequest,
+ Type: "invalid_request_error",
+ Message: "stream is disabled for Image 2 safety audit",
+ PolicyRule: "blocked_image_stream",
+ }
+ }
+ if req.PartialImages != nil && *req.PartialImages > 0 {
+ return &OpenAIImagesSafetyError{
+ StatusCode: http.StatusBadRequest,
+ Type: "invalid_request_error",
+ Message: "partial_images is disabled for Image 2 safety audit",
+ PolicyRule: "blocked_image_partial_images",
+ }
+ }
+ if req.N != 1 {
+ return &OpenAIImagesSafetyError{
+ StatusCode: http.StatusBadRequest,
+ Type: "invalid_request_error",
+ Message: "n must be 1 for Image 2 safety audit",
+ PolicyRule: "blocked_image_multi_n",
+ }
+ }
+ moderation := strings.ToLower(strings.TrimSpace(req.Moderation))
+ if moderation != "" && moderation != "auto" {
+ return &OpenAIImagesSafetyError{
+ StatusCode: http.StatusBadRequest,
+ Type: "invalid_request_error",
+ Message: "moderation must be auto for Image 2 safety audit",
+ PolicyRule: "blocked_image_moderation_override",
+ }
+ }
+ return nil
+}
+
+func BuildOpenAIImageSafetyIdentifier(userID int64, apiKeyID int64, groupID *int64) string {
+ parts := []string{
+ fmt.Sprintf("user:%d", userID),
+ fmt.Sprintf("api_key:%d", apiKeyID),
+ }
+ if groupID != nil {
+ parts = append(parts, fmt.Sprintf("group:%d", *groupID))
+ }
+ sum := sha256.Sum256([]byte(strings.Join(parts, "|")))
+ return "hfc_" + hex.EncodeToString(sum[:])[:24]
+}
+
+func (r *OpenAIImagesRequest) ModerationBody() []byte {
+ if r == nil {
+ return nil
+ }
+ payload := map[string]any{}
+ if prompt := strings.TrimSpace(r.Prompt); prompt != "" {
+ payload["prompt"] = prompt
+ }
+ images := r.moderationImages()
+ if len(images) > 0 {
+ payload["images"] = images
+ }
+ if len(payload) == 0 {
+ return nil
+ }
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return nil
+ }
+ return body
+}
+
+func (r *OpenAIImagesRequest) moderationImages() []map[string]string {
+ if r == nil {
+ return nil
+ }
+ images := make([]map[string]string, 0, len(r.InputImageURLs)+len(r.Uploads)+1)
+ for _, imageURL := range r.InputImageURLs {
+ imageURL = strings.TrimSpace(imageURL)
+ if imageURL != "" {
+ images = append(images, map[string]string{"image_url": imageURL})
+ }
+ }
+ for _, upload := range r.Uploads {
+ if dataURL := upload.ModerationDataURL(); dataURL != "" {
+ images = append(images, map[string]string{"image_url": dataURL})
+ }
+ }
+ if maskURL := strings.TrimSpace(r.MaskImageURL); maskURL != "" {
+ images = append(images, map[string]string{"image_url": maskURL})
+ }
+ if r.MaskUpload != nil {
+ if dataURL := r.MaskUpload.ModerationDataURL(); dataURL != "" {
+ images = append(images, map[string]string{"image_url": dataURL})
+ }
+ }
+ return images
+}
+
+func (u OpenAIImagesUpload) ModerationDataURL() string {
+ if len(u.Data) == 0 {
+ return ""
+ }
+ contentType := strings.TrimSpace(u.ContentType)
+ if contentType == "" {
+ contentType = http.DetectContentType(u.Data)
+ }
+ if !strings.HasPrefix(strings.ToLower(contentType), "image/") {
+ return ""
+ }
+ return fmt.Sprintf("data:%s;base64,%s", contentType, base64.StdEncoding.EncodeToString(u.Data))
+}
+
func (r *OpenAIImagesRequest) IsEdits() bool {
return r != nil && r.Endpoint == openAIImagesEditsEndpoint
}
@@ -154,7 +361,7 @@ func (s *OpenAIGatewayService) ParseOpenAIImagesRequest(c *gin.Context, body []b
if err := validateOpenAIImagesModel(req.Model); err != nil {
return nil, err
}
- req.SizeTier = normalizeOpenAIImageSizeTier(req.Size)
+ req.SizeTier = normalizeOpenAIImageSizeTier(req.Size, req.Quality)
req.RequiredCapability = classifyOpenAIImagesCapability(req)
return req, nil
}
@@ -467,15 +674,72 @@ func isOpenAINativeImageOption(name string) bool {
}
}
-func normalizeOpenAIImageSizeTier(size string) string {
- switch strings.ToLower(strings.TrimSpace(size)) {
+// normalizeOpenAIImageSizeTier maps an image request to a billing tier
+// ("1K"/"2K"/"4K") consumed by BillingService.CalculateImageCost and the
+// Group.ImagePrice1K/2K/4K subscription pricing fields.
+//
+// gpt-image-2's image_tokens scale primarily with `quality`, not pixel size
+// (low ≈ 1k tokens, medium ≈ 2k, high ≈ 4k), so an explicit quality value
+// takes precedence. When quality is empty / "auto" / unrecognized we fall
+// back to size-dimension classification for legacy callers (older
+// gpt-image-1, dall-e flows).
+func normalizeOpenAIImageSizeTier(size, quality string) string {
+ switch strings.ToLower(strings.TrimSpace(quality)) {
+ case "low":
+ return "1K"
+ case "medium":
+ return "2K"
+ case "high":
+ return "4K"
+ }
+ trimmed := strings.TrimSpace(size)
+ normalized := strings.ToLower(trimmed)
+ switch normalized {
+ case "", "auto":
+ return "2K"
case "1024x1024":
return "1K"
- case "1536x1024", "1024x1536", "1792x1024", "1024x1792", "", "auto":
+ case "1536x1024", "1024x1536", "1792x1024", "1024x1792", "2048x2048", "2048x1152", "1152x2048":
return "2K"
- default:
+ case "3840x2160", "2160x3840":
+ return "4K"
+ }
+ width, height, ok := parseOpenAIImageSizeDimensions(trimmed)
+ if !ok {
return "2K"
}
+ return classifyUnknownOpenAIImageSizeTier(width, height)
+}
+
+const (
+ openAIImage2KMaxPixels = 2560 * 1440
+)
+
+func parseOpenAIImageSizeDimensions(size string) (int, int, bool) {
+ trimmed := strings.TrimSpace(size)
+ parts := strings.Split(strings.ToLower(trimmed), "x")
+ if len(parts) != 2 {
+ return 0, 0, false
+ }
+ width, err := strconv.Atoi(strings.TrimSpace(parts[0]))
+ if err != nil {
+ return 0, 0, false
+ }
+ height, err := strconv.Atoi(strings.TrimSpace(parts[1]))
+ if err != nil {
+ return 0, 0, false
+ }
+ if width <= 0 || height <= 0 {
+ return 0, 0, false
+ }
+ return width, height, true
+}
+
+func classifyUnknownOpenAIImageSizeTier(width int, height int) string {
+ if height > 0 && width > openAIImage2KMaxPixels/height {
+ return "4K"
+ }
+ return "2K"
}
func (s *OpenAIGatewayService) ForwardImages(
@@ -485,18 +749,54 @@ func (s *OpenAIGatewayService) ForwardImages(
body []byte,
parsed *OpenAIImagesRequest,
channelMappedModel string,
+ options ...OpenAIImagesForwardOption,
) (*OpenAIForwardResult, error) {
if parsed == nil {
return nil, fmt.Errorf("parsed images request is required")
}
+ forwardOptions := resolveOpenAIImagesForwardOptions(options)
+ var billingIdentity *ResolvedOpenAIBillingIdentity
+ if forwardOptions.Billing.RequirePricingPreflight {
+ requestModel := firstNonEmptyModel(forwardOptions.Billing.RequestedModel, parsed.Model)
+ channelMapped := firstNonEmptyModel(channelMappedModel, requestModel)
+ upstreamModel := channelMapped
+ if account.Type == AccountTypeAPIKey {
+ upstreamModel = account.GetMappedModel(channelMapped)
+ }
+ identityInput := OpenAIBillingIdentityInput{
+ RequestedModel: requestModel, DispatchModel: channelMapped, ChannelMappedModel: channelMapped,
+ ChannelMappingApplied: forwardOptions.Billing.ChannelMapping.Mapped,
+ ChannelMappingExact: forwardOptions.Billing.ChannelMapping.MappingExact,
+ AccountMappedModel: upstreamModel, UpstreamModel: upstreamModel,
+ BillingModelSource: BillingModelSourceUpstream,
+ ChannelID: forwardOptions.Billing.ChannelMapping.ChannelID,
+ ModelMappingChain: forwardOptions.Billing.ChannelMapping.BuildModelMappingChain(requestModel, upstreamModel),
+ GroupID: forwardOptions.Billing.GroupID,
+ }
+ var err error
+ billingIdentity, err = s.ResolveOpenAIImageBillingIdentity(ctx, identityInput, upstreamModel, parsed.SizeTier, forwardOptions.Billing.ImagePriceConfig)
+ if err != nil {
+ return nil, err
+ }
+ }
+ setOpenAIUsageBillingReservationBody(forwardOptions.Billing.UsageBilling, body)
+ if err := s.prepareOpenAIForwardUsageBilling(ctx, account, billingIdentity, forwardOptions.Billing); err != nil {
+ return nil, err
+ }
+ var result *OpenAIForwardResult
+ var err error
switch account.Type {
case AccountTypeAPIKey:
- return s.forwardOpenAIImagesAPIKey(ctx, c, account, body, parsed, channelMappedModel)
+ result, err = s.forwardOpenAIImagesAPIKey(ctx, c, account, body, parsed, channelMappedModel, forwardOptions)
case AccountTypeOAuth:
- return s.forwardOpenAIImagesOAuth(ctx, c, account, parsed, channelMappedModel)
+ result, err = s.forwardOpenAIImagesOAuth(ctx, c, account, parsed, channelMappedModel, forwardOptions)
default:
return nil, fmt.Errorf("unsupported account type: %s", account.Type)
}
+ if result != nil && billingIdentity != nil {
+ attachOpenAIBillingIdentity(result, billingIdentity)
+ }
+ return result, err
}
func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
@@ -506,6 +806,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
body []byte,
parsed *OpenAIImagesRequest,
channelMappedModel string,
+ options OpenAIImagesForwardOptions,
) (*OpenAIForwardResult, error) {
startTime := time.Now()
requestModel := strings.TrimSpace(parsed.Model)
@@ -527,7 +828,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
parsed.Endpoint,
account.Type,
)
- forwardBody, forwardContentType, err := rewriteOpenAIImagesModel(body, parsed.ContentType, upstreamModel)
+ forwardBody, forwardContentType, err := rewriteOpenAIImagesForwardBody(body, parsed.ContentType, upstreamModel, options.SafetyIdentifier)
if err != nil {
return nil, err
}
@@ -535,11 +836,14 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
setOpsUpstreamRequestBody(c, forwardBody)
}
- token, _, err := s.GetAccessToken(ctx, account)
+ upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, parsed.Stream)
+ defer releaseUpstreamCtx()
+
+ token, _, err := s.GetAccessToken(upstreamCtx, account)
if err != nil {
return nil, err
}
- upstreamReq, err := s.buildOpenAIImagesRequest(ctx, c, account, forwardBody, forwardContentType, token, parsed.Endpoint)
+ upstreamReq, err := s.buildOpenAIImagesRequest(upstreamCtx, c, account, forwardBody, forwardContentType, token, parsed.Endpoint)
if err != nil {
return nil, err
}
@@ -582,37 +886,65 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey(
Kind: "failover",
Message: upstreamMsg,
})
- s.handleFailoverSideEffects(ctx, resp, account)
+ s.handleFailoverSideEffects(upstreamCtx, resp, account, upstreamModel)
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode),
}
}
- return s.handleErrorResponse(ctx, resp, c, account, forwardBody)
+ return s.handleErrorResponse(upstreamCtx, resp, c, account, forwardBody)
}
defer func() { _ = resp.Body.Close() }()
var usage OpenAIUsage
- imageCount := parsed.N
+ // Successful upstream parsing is authoritative, including an explicit zero.
+ // parsed.N is a request intent, not proof that images were produced.
+ imageCount := 0
var firstTokenMs *int
- if parsed.Stream {
+ if parsed.Stream && isEventStreamResponse(resp.Header) {
streamUsage, streamCount, ttft, err := s.handleOpenAIImagesStreamingResponse(resp, c, startTime)
if err != nil {
+ if streamCount > 0 {
+ return &OpenAIForwardResult{
+ RequestID: resp.Header.Get("x-request-id"),
+ Usage: streamUsage,
+ Model: requestModel,
+ UpstreamModel: upstreamModel,
+ Stream: parsed.Stream,
+ ResponseHeaders: resp.Header.Clone(),
+ Duration: time.Since(startTime),
+ FirstTokenMs: ttft,
+ ImageCount: streamCount,
+ ImageSize: parsed.SizeTier,
+ }, err
+ }
return nil, err
}
usage = streamUsage
imageCount = streamCount
firstTokenMs = ttft
} else {
- nonStreamUsage, nonStreamCount, err := s.handleOpenAIImagesNonStreamingResponse(resp, c)
+ nonStreamUsage, nonStreamCount, err := s.handleOpenAIImagesNonStreamingResponse(upstreamCtx, resp, c, options)
if err != nil {
+ if nonStreamCount > 0 {
+ return &OpenAIForwardResult{
+ RequestID: resp.Header.Get("x-request-id"),
+ Usage: nonStreamUsage,
+ Model: requestModel,
+ UpstreamModel: upstreamModel,
+ Stream: parsed.Stream,
+ ResponseHeaders: resp.Header.Clone(),
+ Duration: time.Since(startTime),
+ FirstTokenMs: firstTokenMs,
+ ImageCount: nonStreamCount,
+ ImageSize: parsed.SizeTier,
+ }, err
+ }
return nil, err
}
usage = nonStreamUsage
- if nonStreamCount > 0 {
- imageCount = nonStreamCount
- }
+ imageCount = nonStreamCount
}
return &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"),
@@ -685,24 +1017,35 @@ func buildOpenAIImagesURL(base string, endpoint string) string {
return normalized + endpoint
}
-func rewriteOpenAIImagesModel(body []byte, contentType string, model string) ([]byte, string, error) {
+func rewriteOpenAIImagesForwardBody(body []byte, contentType string, model string, safetyIdentifier string) ([]byte, string, error) {
model = strings.TrimSpace(model)
- if model == "" {
- return body, contentType, nil
- }
+ safetyIdentifier = strings.TrimSpace(safetyIdentifier)
mediaType, _, err := mime.ParseMediaType(contentType)
if err == nil && strings.EqualFold(mediaType, "multipart/form-data") {
- rewrittenBody, rewrittenType, rewriteErr := rewriteOpenAIImagesMultipartModel(body, contentType, model)
+ rewrittenBody, rewrittenType, rewriteErr := rewriteOpenAIImagesMultipartSafetyFields(body, contentType, model, safetyIdentifier)
return rewrittenBody, rewrittenType, rewriteErr
}
- rewritten, err := sjson.SetBytes(body, "model", model)
+ rewritten := body
+ if model != "" {
+ rewritten, err = sjson.SetBytes(rewritten, "model", model)
+ if err != nil {
+ return nil, "", fmt.Errorf("rewrite image request model: %w", err)
+ }
+ }
+ rewritten, err = sjson.SetBytes(rewritten, "moderation", "auto")
if err != nil {
- return nil, "", fmt.Errorf("rewrite image request model: %w", err)
+ return nil, "", fmt.Errorf("rewrite image request moderation: %w", err)
+ }
+ if safetyIdentifier != "" {
+ rewritten, err = sjson.SetBytes(rewritten, "safety_identifier", safetyIdentifier)
+ if err != nil {
+ return nil, "", fmt.Errorf("rewrite image request safety identifier: %w", err)
+ }
}
return rewritten, contentType, nil
}
-func rewriteOpenAIImagesMultipartModel(body []byte, contentType string, model string) ([]byte, string, error) {
+func rewriteOpenAIImagesMultipartSafetyFields(body []byte, contentType string, model string, safetyIdentifier string) ([]byte, string, error) {
_, params, err := mime.ParseMediaType(contentType)
if err != nil {
return nil, "", fmt.Errorf("parse multipart content-type: %w", err)
@@ -715,7 +1058,16 @@ func rewriteOpenAIImagesMultipartModel(body []byte, contentType string, model st
reader := multipart.NewReader(bytes.NewReader(body), boundary)
var buffer bytes.Buffer
writer := multipart.NewWriter(&buffer)
- modelWritten := false
+ fields := map[string]string{
+ "moderation": "auto",
+ }
+ if strings.TrimSpace(model) != "" {
+ fields["model"] = strings.TrimSpace(model)
+ }
+ if strings.TrimSpace(safetyIdentifier) != "" {
+ fields["safety_identifier"] = strings.TrimSpace(safetyIdentifier)
+ }
+ written := make(map[string]bool, len(fields))
for {
part, err := reader.NextPart()
@@ -734,12 +1086,12 @@ func rewriteOpenAIImagesMultipartModel(body []byte, contentType string, model st
return nil, "", fmt.Errorf("create multipart part: %w", err)
}
- if formName == "model" && part.FileName() == "" {
- if _, err := target.Write([]byte(model)); err != nil {
+ if value, ok := fields[formName]; ok && part.FileName() == "" {
+ if _, err := target.Write([]byte(value)); err != nil {
_ = part.Close()
- return nil, "", fmt.Errorf("rewrite multipart model: %w", err)
+ return nil, "", fmt.Errorf("rewrite multipart %s: %w", formName, err)
}
- modelWritten = true
+ written[formName] = true
_ = part.Close()
continue
}
@@ -750,9 +1102,12 @@ func rewriteOpenAIImagesMultipartModel(body []byte, contentType string, model st
_ = part.Close()
}
- if !modelWritten {
- if err := writer.WriteField("model", model); err != nil {
- return nil, "", fmt.Errorf("append multipart model field: %w", err)
+ for name, value := range fields {
+ if written[name] {
+ continue
+ }
+ if err := writer.WriteField(name, value); err != nil {
+ return nil, "", fmt.Errorf("append multipart %s field: %w", name, err)
}
}
if err := writer.Close(); err != nil {
@@ -771,11 +1126,16 @@ func cloneMultipartHeader(src textproto.MIMEHeader) textproto.MIMEHeader {
return dst
}
-func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse(resp *http.Response, c *gin.Context) (OpenAIUsage, int, error) {
+func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, options OpenAIImagesForwardOptions) (OpenAIUsage, int, error) {
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
if err != nil {
return OpenAIUsage{}, 0, err
}
+ usage, _ := extractOpenAIUsageFromJSONBytes(body)
+ imageCount := extractOpenAIImageCountFromJSONBytes(body)
+ if err := s.auditOpenAIImagesOutput(ctx, body, resp.Header, resp.Header.Get("x-request-id"), options); err != nil {
+ return usage, imageCount, err
+ }
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
contentType := "application/json"
if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled {
@@ -784,9 +1144,83 @@ func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse(resp *http
}
}
c.Data(resp.StatusCode, contentType, body)
+ return usage, imageCount, nil
+}
- usage, _ := extractOpenAIUsageFromJSONBytes(body)
- return usage, extractOpenAIImageCountFromJSONBytes(body), nil
+func (s *OpenAIGatewayService) auditOpenAIImagesOutput(ctx context.Context, body []byte, headers http.Header, upstreamRequestID string, options OpenAIImagesForwardOptions) error {
+ if options.OutputAuditor == nil {
+ return nil
+ }
+ auditReq, err := s.buildOpenAIImageOutputAuditRequest(ctx, body, headers, upstreamRequestID)
+ if err != nil {
+ auditReq = OpenAIImageOutputAuditRequest{
+ UpstreamRequestID: strings.TrimSpace(upstreamRequestID),
+ PolicyRule: "output_audit_unavailable",
+ UnavailableReason: err.Error(),
+ }
+ }
+ decision, auditErr := options.OutputAuditor.AuditOpenAIImageOutput(ctx, auditReq)
+ if auditErr != nil {
+ return auditErr
+ }
+ if decision != nil && decision.Blocked {
+ return &OpenAIImageOutputAuditError{Decision: decision}
+ }
+ return nil
+}
+
+func (s *OpenAIGatewayService) buildOpenAIImageOutputAuditRequest(ctx context.Context, body []byte, headers http.Header, upstreamRequestID string) (OpenAIImageOutputAuditRequest, error) {
+ pointers := collectOpenAIImagePointers(body)
+ if len(pointers) == 0 {
+ return OpenAIImageOutputAuditRequest{}, fmt.Errorf("upstream image response did not include auditable image output")
+ }
+ client := req.C()
+ images := make([]map[string]string, 0, len(pointers))
+ outputHashes := make([]string, 0, len(pointers))
+ prompt := ""
+ for _, pointer := range pointers {
+ if prompt == "" {
+ prompt = strings.TrimSpace(pointer.Prompt)
+ }
+ data, err := resolveOpenAIImageBytes(ctx, client, headers, "", pointer)
+ if err != nil {
+ return OpenAIImageOutputAuditRequest{}, fmt.Errorf("resolve output image for moderation: %w", err)
+ }
+ if len(data) == 0 {
+ return OpenAIImageOutputAuditRequest{}, fmt.Errorf("output image is empty")
+ }
+ sum := sha256.Sum256(data)
+ outputHashes = append(outputHashes, hex.EncodeToString(sum[:]))
+ mimeType := strings.TrimSpace(pointer.MimeType)
+ if mimeType == "" {
+ mimeType = "image/png"
+ }
+ if !strings.HasPrefix(strings.ToLower(mimeType), "image/") {
+ mimeType = openAIImageOutputMIMEType(mimeType)
+ }
+ images = append(images, map[string]string{
+ "image_url": "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data),
+ })
+ }
+ if len(images) == 0 {
+ return OpenAIImageOutputAuditRequest{}, fmt.Errorf("upstream image response did not include auditable image bytes")
+ }
+ payload := map[string]any{
+ "images": images,
+ }
+ if prompt != "" {
+ payload["prompt"] = prompt
+ }
+ moderationBody, err := json.Marshal(payload)
+ if err != nil {
+ return OpenAIImageOutputAuditRequest{}, fmt.Errorf("marshal output moderation body: %w", err)
+ }
+ return OpenAIImageOutputAuditRequest{
+ ModerationBody: moderationBody,
+ OutputHashes: outputHashes,
+ UpstreamRequestID: strings.TrimSpace(upstreamRequestID),
+ PolicyRule: "moderation_flagged_output",
+ }, nil
}
func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
@@ -807,39 +1241,265 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse(
return OpenAIUsage{}, 0, nil, fmt.Errorf("streaming is not supported by response writer")
}
- reader := bufio.NewReader(resp.Body)
usage := OpenAIUsage{}
- imageCount := 0
+ imageCounter := newOpenAIImageOutputCounter()
var firstTokenMs *int
-
- for {
- line, err := reader.ReadBytes('\n')
- if len(line) > 0 {
- if firstTokenMs == nil {
- ms := int(time.Since(startTime).Milliseconds())
- firstTokenMs = &ms
+ clientDisconnected := false
+ lastDownstreamWriteAt := time.Now()
+ var fallbackBody bytes.Buffer
+ fallbackBytes := int64(0)
+ fallbackLimit := resolveUpstreamResponseReadLimit(s.cfg)
+ seenSSEData := false
+ seenTerminal := false
+ fallbackTooLarge := false
+ sseData := newOpenAISSEDataAccumulator(fallbackLimit)
+
+ processSSEData := func(dataBytes []byte) {
+ seenSSEData = true
+ trimmedData := bytes.TrimSpace(dataBytes)
+ if bytes.Equal(trimmedData, []byte("[DONE]")) {
+ seenTerminal = true
+ } else {
+ switch strings.TrimSpace(gjson.GetBytes(trimmedData, "type").String()) {
+ case "image_generation.completed", "image_edit.completed", "response.completed":
+ seenTerminal = true
}
+ }
+ fallbackBody.Reset()
+ fallbackBytes = 0
+ mergeOpenAIUsage(&usage, dataBytes)
+ imageCounter.AddSSEData(dataBytes)
+ }
+
+ flushSSEEvent := func() error {
+ return sseData.Flush(processSSEData)
+ }
+
+ processLine := func(line []byte) error {
+ if len(line) == 0 {
+ return nil
+ }
+ if firstTokenMs == nil {
+ ms := int(time.Since(startTime).Milliseconds())
+ firstTokenMs = &ms
+ }
+ if !clientDisconnected {
if _, writeErr := c.Writer.Write(line); writeErr != nil {
- return OpenAIUsage{}, 0, firstTokenMs, writeErr
+ clientDisconnected = true
+ logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images stream client disconnected, continue draining upstream for billing")
+ } else {
+ flusher.Flush()
+ lastDownstreamWriteAt = time.Now()
}
- flusher.Flush()
+ }
- if data, ok := extractOpenAISSEDataLine(strings.TrimRight(string(line), "\r\n")); ok && data != "" && data != "[DONE]" {
- dataBytes := []byte(data)
- mergeOpenAIUsage(&usage, dataBytes)
- if count := extractOpenAIImageCountFromJSONBytes(dataBytes); count > imageCount {
- imageCount = count
- }
+ trimmedLine := strings.TrimRight(string(line), "\r\n")
+ if _, ok := extractOpenAISSEDataLine(trimmedLine); ok || strings.TrimSpace(trimmedLine) == "" {
+ return sseData.AddLine(trimmedLine, processSSEData)
+ }
+ if !seenSSEData && !fallbackTooLarge {
+ fallbackBytes += int64(len(line))
+ if fallbackBytes <= fallbackLimit {
+ _, _ = fallbackBody.Write(line)
+ } else {
+ fallbackTooLarge = true
+ fallbackBody.Reset()
}
}
- if err == io.EOF {
- break
+ return nil
+ }
+
+ finalizeFallbackBody := func() {
+ if seenSSEData || fallbackBody.Len() == 0 {
+ return
}
- if err != nil {
- return OpenAIUsage{}, 0, firstTokenMs, err
+ body := bytes.TrimSpace(fallbackBody.Bytes())
+ if len(body) == 0 {
+ return
+ }
+ mergeOpenAIUsage(&usage, body)
+ imageCounter.AddJSONResponse(body)
+ }
+
+ validateStreamCompletion := func() error {
+ if seenSSEData {
+ if seenTerminal {
+ return nil
+ }
+ return fmt.Errorf("image stream incomplete: upstream ended without terminal event")
+ }
+ if json.Valid(bytes.TrimSpace(fallbackBody.Bytes())) {
+ return nil
+ }
+ return fmt.Errorf("image stream incomplete: upstream ended without a complete response")
+ }
+
+ streamInterval := s.openAIImageStreamDataInterval()
+ keepaliveInterval := s.openAIImageStreamKeepaliveInterval()
+ if streamInterval <= 0 && keepaliveInterval <= 0 {
+ reader := bufio.NewReader(resp.Body)
+ for {
+ line, err := readOpenAISSELineBounded(reader, fallbackLimit)
+ if processErr := processLine(line); processErr != nil {
+ return usage, imageCounter.Count(), firstTokenMs, processErr
+ }
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ _ = flushSSEEvent()
+ return usage, imageCounter.Count(), firstTokenMs, err
+ }
+ }
+ if err := flushSSEEvent(); err != nil {
+ return usage, imageCounter.Count(), firstTokenMs, err
+ }
+ finalizeFallbackBody()
+ if err := validateStreamCompletion(); err != nil {
+ return usage, imageCounter.Count(), firstTokenMs, err
+ }
+ return usage, imageCounter.Count(), firstTokenMs, nil
+ }
+
+ type readEvent struct {
+ line []byte
+ err error
+ }
+ events := make(chan readEvent, 1)
+ done := make(chan struct{})
+ sendEvent := func(ev readEvent) bool {
+ select {
+ case events <- ev:
+ return true
+ case <-done:
+ return false
+ }
+ }
+ var lastReadAt int64
+ atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
+ go func() {
+ defer close(events)
+ reader := bufio.NewReader(resp.Body)
+ for {
+ line, err := readOpenAISSELineBounded(reader, fallbackLimit)
+ if len(line) > 0 {
+ atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
+ }
+ if len(line) > 0 && !sendEvent(readEvent{line: line}) {
+ return
+ }
+ if err == io.EOF {
+ return
+ }
+ if err != nil {
+ _ = sendEvent(readEvent{err: err})
+ return
+ }
+ }
+ }()
+ defer close(done)
+
+ var intervalTicker *time.Ticker
+ if streamInterval > 0 {
+ intervalTicker = time.NewTicker(streamInterval)
+ defer intervalTicker.Stop()
+ }
+ var intervalCh <-chan time.Time
+ if intervalTicker != nil {
+ intervalCh = intervalTicker.C
+ }
+
+ var keepaliveTicker *time.Ticker
+ if keepaliveInterval > 0 {
+ keepaliveTicker = time.NewTicker(keepaliveInterval)
+ defer keepaliveTicker.Stop()
+ }
+ var keepaliveCh <-chan time.Time
+ if keepaliveTicker != nil {
+ keepaliveCh = keepaliveTicker.C
+ }
+
+ for {
+ select {
+ case ev, ok := <-events:
+ if !ok {
+ if err := flushSSEEvent(); err != nil {
+ return usage, imageCounter.Count(), firstTokenMs, err
+ }
+ finalizeFallbackBody()
+ if err := validateStreamCompletion(); err != nil {
+ return usage, imageCounter.Count(), firstTokenMs, err
+ }
+ return usage, imageCounter.Count(), firstTokenMs, nil
+ }
+ if ev.err != nil {
+ _ = flushSSEEvent()
+ return usage, imageCounter.Count(), firstTokenMs, ev.err
+ }
+ if err := processLine(ev.line); err != nil {
+ return usage, imageCounter.Count(), firstTokenMs, err
+ }
+ case <-intervalCh:
+ lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
+ if time.Since(lastRead) < streamInterval {
+ continue
+ }
+ if clientDisconnected {
+ return usage, imageCounter.Count(), firstTokenMs, fmt.Errorf("image stream incomplete after timeout")
+ }
+ logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images stream data interval timeout: interval=%s", streamInterval)
+ _ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(fmt.Sprintf("upstream image stream idle for %s", streamInterval)))
+ return usage, imageCounter.Count(), firstTokenMs, fmt.Errorf("image stream data interval timeout")
+ case <-keepaliveCh:
+ if clientDisconnected || time.Since(lastDownstreamWriteAt) < keepaliveInterval {
+ continue
+ }
+ if _, writeErr := io.WriteString(c.Writer, ":\n\n"); writeErr != nil {
+ clientDisconnected = true
+ logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images stream client disconnected during keepalive, continue draining upstream for billing")
+ continue
+ }
+ flusher.Flush()
+ lastDownstreamWriteAt = time.Now()
}
}
- return usage, imageCount, firstTokenMs, nil
+}
+
+func (s *OpenAIGatewayService) openAIImageStreamDataInterval() time.Duration {
+ if s == nil || s.cfg == nil || s.cfg.Gateway.ImageStreamDataIntervalTimeout <= 0 {
+ return 0
+ }
+ return time.Duration(s.cfg.Gateway.ImageStreamDataIntervalTimeout) * time.Second
+}
+
+func (s *OpenAIGatewayService) openAIImageStreamKeepaliveInterval() time.Duration {
+ if s == nil || s.cfg == nil || s.cfg.Gateway.ImageStreamKeepaliveInterval <= 0 {
+ return 0
+ }
+ return time.Duration(s.cfg.Gateway.ImageStreamKeepaliveInterval) * time.Second
+}
+
+func extractOpenAIImagesBillableCountFromJSONBytes(body []byte) int {
+ if count := extractOpenAIImageCountFromJSONBytes(body); count > 0 {
+ return count
+ }
+ if len(body) == 0 || !gjson.ValidBytes(body) {
+ return 0
+ }
+ if count := int(gjson.GetBytes(body, "usage.images").Int()); count > 0 {
+ return count
+ }
+ if count := int(gjson.GetBytes(body, "tool_usage.image_gen.images").Int()); count > 0 {
+ return count
+ }
+ eventType := strings.TrimSpace(gjson.GetBytes(body, "type").String())
+ if eventType == "" || !strings.HasSuffix(eventType, ".completed") {
+ return 0
+ }
+ if gjson.GetBytes(body, "b64_json").Exists() || gjson.GetBytes(body, "url").Exists() {
+ return 1
+ }
+ return 0
}
func mergeOpenAIUsage(dst *OpenAIUsage, body []byte) {
@@ -863,14 +1523,7 @@ func mergeOpenAIUsage(dst *OpenAIUsage, body []byte) {
}
func extractOpenAIImageCountFromJSONBytes(body []byte) int {
- if len(body) == 0 || !gjson.ValidBytes(body) {
- return 0
- }
- data := gjson.GetBytes(body, "data")
- if data.Exists() && data.IsArray() {
- return len(data.Array())
- }
- return 0
+ return countOpenAIResponseImageOutputsFromJSONBytes(body)
}
type openAIImagePointerInfo struct {
@@ -1017,6 +1670,13 @@ func resolveOpenAIImageBytes(
return base64.StdEncoding.DecodeString(normalized)
}
if downloadURL := strings.TrimSpace(pointer.DownloadURL); downloadURL != "" {
+ if strings.HasPrefix(strings.ToLower(downloadURL), "data:image/") {
+ normalized := normalizeOpenAIImageBase64(downloadURL)
+ if normalized == "" {
+ return nil, fmt.Errorf("image data url is invalid")
+ }
+ return base64.StdEncoding.DecodeString(normalized)
+ }
return downloadOpenAIImageBytes(ctx, client, headers, downloadURL)
}
if strings.TrimSpace(pointer.Pointer) == "" {
@@ -1040,7 +1700,8 @@ func normalizeOpenAIImageBase64(raw string) string {
}
}
raw = strings.TrimSpace(raw)
- raw = strings.TrimRight(raw, "=") + strings.Repeat("=", (4-len(raw)%4)%4)
+ raw = strings.TrimRight(raw, "=")
+ raw += strings.Repeat("=", (4-len(raw)%4)%4)
if raw == "" {
return ""
}
@@ -1111,6 +1772,9 @@ func isLikelyOpenAIImageDownloadURL(raw string) bool {
if raw == "" {
return false
}
+ if !isTrustedOpenAIImageDownloadURL(raw) {
+ return false
+ }
if strings.HasPrefix(strings.ToLower(raw), "data:image/") {
return true
}
@@ -1125,6 +1789,35 @@ func isLikelyOpenAIImageDownloadURL(raw string) bool {
strings.Contains(lower, ".webp")
}
+func isTrustedOpenAIImageDownloadURL(raw string) bool {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return false
+ }
+ if strings.HasPrefix(strings.ToLower(raw), "data:image/") {
+ return true
+ }
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ return false
+ }
+ if !strings.EqualFold(parsed.Scheme, "https") {
+ return false
+ }
+ host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
+ switch {
+ case host == "chatgpt.com",
+ host == "api.openai.com",
+ strings.HasSuffix(host, ".openai.com"),
+ host == "files.oaiusercontent.com",
+ strings.HasSuffix(host, ".oaiusercontent.com"),
+ host == "oaidalleapiprodscus.blob.core.windows.net":
+ return true
+ default:
+ return false
+ }
+}
+
func fetchOpenAIImageDownloadURL(
ctx context.Context,
client *req.Client,
@@ -1187,6 +1880,9 @@ func fetchOpenAIImageDownloadURL(
}
func downloadOpenAIImageBytes(ctx context.Context, client *req.Client, headers http.Header, downloadURL string) ([]byte, error) {
+ if !isTrustedOpenAIImageDownloadURL(downloadURL) {
+ return nil, fmt.Errorf("untrusted image download url")
+ }
request := client.R().
SetContext(ctx).
DisableAutoReadResponse()
diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go
index 64d995e138f..9ead27c2ae7 100644
--- a/backend/internal/service/openai_images_responses.go
+++ b/backend/internal/service/openai_images_responses.go
@@ -9,6 +9,7 @@ import (
"io"
"net/http"
"strings"
+ "sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
@@ -245,6 +246,7 @@ func buildOpenAIImagesResponsesRequest(parsed *OpenAIImagesRequest, toolModel st
tool := []byte(`{"type":"image_generation","action":"","model":""}`)
tool, _ = sjson.SetBytes(tool, "action", action)
tool, _ = sjson.SetBytes(tool, "model", strings.TrimSpace(toolModel))
+ tool, _ = sjson.SetBytes(tool, "moderation", "auto")
for _, field := range []struct {
path string
@@ -254,7 +256,6 @@ func buildOpenAIImagesResponsesRequest(parsed *OpenAIImagesRequest, toolModel st
{path: "quality", value: parsed.Quality},
{path: "background", value: parsed.Background},
{path: "output_format", value: parsed.OutputFormat},
- {path: "moderation", value: parsed.Moderation},
{path: "style", value: parsed.Style},
} {
if trimmed := strings.TrimSpace(field.value); trimmed != "" {
@@ -361,21 +362,21 @@ func collectOpenAIImagesFromResponsesBody(body []byte) ([]openAIResponsesImageRe
var (
fallbackResults []openAIResponsesImageResult
fallbackSeen = make(map[string]struct{})
+ finalResults []openAIResponsesImageResult
+ finalMeta openAIResponsesImageResult
+ collectErr error
createdAt int64
usageRaw []byte
foundFinal bool
responseMeta openAIResponsesImageResult
)
- for _, line := range bytes.Split(body, []byte("\n")) {
- line = bytes.TrimRight(line, "\r")
- data, ok := extractOpenAISSEDataLine(string(line))
- if !ok || data == "" || data == "[DONE]" {
- continue
+ forEachOpenAISSEDataPayload(string(body), func(payload []byte) {
+ if collectErr != nil || len(finalResults) > 0 {
+ return
}
- payload := []byte(data)
if !gjson.ValidBytes(payload) {
- continue
+ return
}
if meta, eventCreatedAt, ok := extractOpenAIResponsesImageMetaFromLifecycleEvent(payload); ok {
mergeOpenAIResponsesImageMeta(&responseMeta, meta)
@@ -388,7 +389,8 @@ func collectOpenAIImagesFromResponsesBody(body []byte) ([]openAIResponsesImageRe
case "response.output_item.done":
result, itemID, ok, err := extractOpenAIImageFromResponsesOutputItemDone(payload)
if err != nil {
- return nil, 0, nil, openAIResponsesImageResult{}, false, err
+ collectErr = err
+ return
}
if ok {
mergeOpenAIResponsesImageMeta(&result, responseMeta)
@@ -397,7 +399,8 @@ func collectOpenAIImagesFromResponsesBody(body []byte) ([]openAIResponsesImageRe
case "response.completed":
results, completedAt, completedUsageRaw, firstMeta, err := extractOpenAIImagesFromResponsesCompleted(payload)
if err != nil {
- return nil, 0, nil, openAIResponsesImageResult{}, false, err
+ collectErr = err
+ return
}
foundFinal = true
if completedAt > 0 {
@@ -408,14 +411,24 @@ func collectOpenAIImagesFromResponsesBody(body []byte) ([]openAIResponsesImageRe
}
if len(results) > 0 {
mergeOpenAIResponsesImageMeta(&firstMeta, responseMeta)
- return results, createdAt, usageRaw, firstMeta, true, nil
+ finalResults = results
+ finalMeta = firstMeta
+ return
}
if len(fallbackResults) > 0 {
firstMeta = fallbackResults[0]
mergeOpenAIResponsesImageMeta(&firstMeta, responseMeta)
- return fallbackResults, createdAt, usageRaw, firstMeta, true, nil
+ finalResults = fallbackResults
+ finalMeta = firstMeta
+ return
}
}
+ })
+ if collectErr != nil {
+ return nil, 0, nil, openAIResponsesImageResult{}, false, collectErr
+ }
+ if len(finalResults) > 0 {
+ return finalResults, createdAt, usageRaw, finalMeta, true, nil
}
if len(fallbackResults) > 0 {
@@ -505,11 +518,37 @@ func (s *OpenAIGatewayService) writeOpenAIImagesStreamEvent(c *gin.Context, flus
return nil
}
+func (s *OpenAIGatewayService) tryWriteOpenAIImagesStreamEvent(
+ c *gin.Context,
+ flusher http.Flusher,
+ clientDisconnected *bool,
+ lastWriteAt *time.Time,
+ eventName string,
+ payload []byte,
+) bool {
+ if clientDisconnected != nil && *clientDisconnected {
+ return false
+ }
+ if err := s.writeOpenAIImagesStreamEvent(c, flusher, eventName, payload); err != nil {
+ if clientDisconnected != nil {
+ *clientDisconnected = true
+ }
+ logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images stream client disconnected, continue draining upstream for billing")
+ return false
+ }
+ if lastWriteAt != nil {
+ *lastWriteAt = time.Now()
+ }
+ return true
+}
+
func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse(
+ ctx context.Context,
resp *http.Response,
c *gin.Context,
responseFormat string,
fallbackModel string,
+ options OpenAIImagesForwardOptions,
) (OpenAIUsage, int, error) {
body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
if err != nil {
@@ -517,22 +556,13 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse(
}
var usage OpenAIUsage
- for _, line := range bytes.Split(body, []byte("\n")) {
- line = bytes.TrimRight(line, "\r")
- data, ok := extractOpenAISSEDataLine(string(line))
- if !ok || data == "" || data == "[DONE]" {
- continue
- }
- dataBytes := []byte(data)
- s.parseSSEUsageBytes(dataBytes, &usage)
- }
+ forEachOpenAISSEDataPayload(string(body), func(data []byte) {
+ s.parseSSEUsageBytes(data, &usage)
+ })
results, createdAt, usageRaw, firstMeta, _, err := collectOpenAIImagesFromResponsesBody(body)
if err != nil {
return OpenAIUsage{}, 0, err
}
- if len(results) == 0 {
- return OpenAIUsage{}, 0, fmt.Errorf("upstream did not return image output")
- }
if strings.TrimSpace(firstMeta.Model) == "" {
firstMeta.Model = strings.TrimSpace(fallbackModel)
}
@@ -541,6 +571,11 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse(
if err != nil {
return OpenAIUsage{}, 0, err
}
+ if len(results) > 0 {
+ if err := s.auditOpenAIImagesOutput(ctx, responseBody, resp.Header, resp.Header.Get("x-request-id"), options); err != nil {
+ return usage, len(results), err
+ }
+ }
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
c.Data(resp.StatusCode, "application/json; charset=utf-8", responseBody)
return usage, len(results), nil
@@ -570,7 +605,6 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
format = "b64_json"
}
- reader := bufio.NewReader(resp.Body)
usage := OpenAIUsage{}
imageCount := 0
var firstTokenMs *int
@@ -579,141 +613,313 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse(
pendingSeen := make(map[string]struct{})
streamMeta := openAIResponsesImageResult{Model: strings.TrimSpace(fallbackModel)}
var createdAt int64
+ clientDisconnected := false
+ lastDownstreamWriteAt := time.Now()
+ sseLimit := resolveUpstreamResponseReadLimit(s.cfg)
+ sseData := newOpenAISSEDataAccumulator(sseLimit)
+ var processDataErr error
+ processDataDone := false
- for {
- line, err := reader.ReadBytes('\n')
- if len(line) > 0 {
- trimmedLine := strings.TrimRight(string(line), "\r\n")
- data, ok := extractOpenAISSEDataLine(trimmedLine)
- if ok && data != "" && data != "[DONE]" {
- if firstTokenMs == nil {
- ms := int(time.Since(startTime).Milliseconds())
- firstTokenMs = &ms
- }
- dataBytes := []byte(data)
- s.parseSSEUsageBytes(dataBytes, &usage)
- if gjson.ValidBytes(dataBytes) {
- if meta, eventCreatedAt, ok := extractOpenAIResponsesImageMetaFromLifecycleEvent(dataBytes); ok {
- mergeOpenAIResponsesImageMeta(&streamMeta, meta)
- if eventCreatedAt > 0 {
- createdAt = eventCreatedAt
- }
- }
- switch gjson.GetBytes(dataBytes, "type").String() {
- case "response.image_generation_call.partial_image":
- b64 := strings.TrimSpace(gjson.GetBytes(dataBytes, "partial_image_b64").String())
- if b64 != "" {
- eventName := streamPrefix + ".partial_image"
- partialMeta := streamMeta
- mergeOpenAIResponsesImageMeta(&partialMeta, openAIResponsesImageResult{
- OutputFormat: strings.TrimSpace(gjson.GetBytes(dataBytes, "output_format").String()),
- Background: strings.TrimSpace(gjson.GetBytes(dataBytes, "background").String()),
- })
- payload := buildOpenAIImagesStreamPartialPayload(
- eventName,
- b64,
- gjson.GetBytes(dataBytes, "partial_image_index").Int(),
- format,
- createdAt,
- partialMeta,
- )
- if writeErr := s.writeOpenAIImagesStreamEvent(c, flusher, eventName, payload); writeErr != nil {
- return OpenAIUsage{}, imageCount, firstTokenMs, writeErr
- }
- }
- case "response.output_item.done":
- img, itemID, ok, extractErr := extractOpenAIImageFromResponsesOutputItemDone(dataBytes)
- if extractErr != nil {
- _ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(extractErr.Error()))
- return OpenAIUsage{}, imageCount, firstTokenMs, extractErr
- }
- if !ok {
- break
- }
- mergeOpenAIResponsesImageMeta(&streamMeta, img)
- mergeOpenAIResponsesImageMeta(&img, streamMeta)
- key := openAIResponsesImageResultKey(itemID, img)
- if _, exists := emitted[key]; exists {
- break
- }
- if _, exists := pendingSeen[key]; exists {
- break
- }
- pendingSeen[key] = struct{}{}
- pendingResults = append(pendingResults, img)
- case "response.completed":
- results, _, usageRaw, firstMeta, extractErr := extractOpenAIImagesFromResponsesCompleted(dataBytes)
- if extractErr != nil {
- _ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(extractErr.Error()))
- return OpenAIUsage{}, imageCount, firstTokenMs, extractErr
- }
- mergeOpenAIResponsesImageMeta(&streamMeta, firstMeta)
- finalResults := make([]openAIResponsesImageResult, 0, len(results)+len(pendingResults))
- finalSeen := make(map[string]struct{})
- for _, img := range results {
- mergeOpenAIResponsesImageMeta(&img, streamMeta)
- appendOpenAIResponsesImageResultDedup(&finalResults, finalSeen, "", img)
- }
- for _, img := range pendingResults {
- mergeOpenAIResponsesImageMeta(&img, streamMeta)
- appendOpenAIResponsesImageResultDedup(&finalResults, finalSeen, "", img)
- }
- if len(finalResults) == 0 {
- err = fmt.Errorf("upstream did not return image output")
- _ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(err.Error()))
- return OpenAIUsage{}, imageCount, firstTokenMs, err
- }
- eventName := streamPrefix + ".completed"
- for _, img := range finalResults {
- key := openAIResponsesImageResultKey("", img)
- if _, exists := emitted[key]; exists {
- continue
- }
- payload := buildOpenAIImagesStreamCompletedPayload(eventName, img, format, createdAt, usageRaw)
- if writeErr := s.writeOpenAIImagesStreamEvent(c, flusher, eventName, payload); writeErr != nil {
- return OpenAIUsage{}, imageCount, firstTokenMs, writeErr
- }
- emitted[key] = struct{}{}
- }
- imageCount = len(emitted)
- return usage, imageCount, firstTokenMs, nil
- }
+ processData := func(dataBytes []byte) {
+ if processDataDone || processDataErr != nil {
+ return
+ }
+ if firstTokenMs == nil {
+ ms := int(time.Since(startTime).Milliseconds())
+ firstTokenMs = &ms
+ }
+ s.parseSSEUsageBytes(dataBytes, &usage)
+ if !gjson.ValidBytes(dataBytes) {
+ return
+ }
+ if meta, eventCreatedAt, ok := extractOpenAIResponsesImageMetaFromLifecycleEvent(dataBytes); ok {
+ mergeOpenAIResponsesImageMeta(&streamMeta, meta)
+ if eventCreatedAt > 0 {
+ createdAt = eventCreatedAt
+ }
+ }
+ switch gjson.GetBytes(dataBytes, "type").String() {
+ case "response.image_generation_call.partial_image":
+ b64 := strings.TrimSpace(gjson.GetBytes(dataBytes, "partial_image_b64").String())
+ if b64 == "" {
+ return
+ }
+ eventName := streamPrefix + ".partial_image"
+ partialMeta := streamMeta
+ mergeOpenAIResponsesImageMeta(&partialMeta, openAIResponsesImageResult{
+ OutputFormat: strings.TrimSpace(gjson.GetBytes(dataBytes, "output_format").String()),
+ Background: strings.TrimSpace(gjson.GetBytes(dataBytes, "background").String()),
+ })
+ payload := buildOpenAIImagesStreamPartialPayload(
+ eventName,
+ b64,
+ gjson.GetBytes(dataBytes, "partial_image_index").Int(),
+ format,
+ createdAt,
+ partialMeta,
+ )
+ s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, eventName, payload)
+ case "response.output_item.done":
+ img, itemID, ok, extractErr := extractOpenAIImageFromResponsesOutputItemDone(dataBytes)
+ if extractErr != nil {
+ s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(extractErr.Error()))
+ processDataErr = extractErr
+ processDataDone = true
+ return
+ }
+ if !ok {
+ return
+ }
+ mergeOpenAIResponsesImageMeta(&streamMeta, img)
+ mergeOpenAIResponsesImageMeta(&img, streamMeta)
+ key := openAIResponsesImageResultKey(itemID, img)
+ if _, exists := emitted[key]; exists {
+ return
+ }
+ if _, exists := pendingSeen[key]; exists {
+ return
+ }
+ pendingSeen[key] = struct{}{}
+ pendingResults = append(pendingResults, img)
+ case "response.completed":
+ results, _, usageRaw, firstMeta, extractErr := extractOpenAIImagesFromResponsesCompleted(dataBytes)
+ if extractErr != nil {
+ s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(extractErr.Error()))
+ processDataErr = extractErr
+ processDataDone = true
+ return
+ }
+ mergeOpenAIResponsesImageMeta(&streamMeta, firstMeta)
+ finalResults := make([]openAIResponsesImageResult, 0, len(results)+len(pendingResults))
+ finalSeen := make(map[string]struct{})
+ for _, img := range results {
+ mergeOpenAIResponsesImageMeta(&img, streamMeta)
+ appendOpenAIResponsesImageResultDedup(&finalResults, finalSeen, "", img)
+ }
+ for _, img := range pendingResults {
+ mergeOpenAIResponsesImageMeta(&img, streamMeta)
+ appendOpenAIResponsesImageResultDedup(&finalResults, finalSeen, "", img)
+ }
+ if len(finalResults) == 0 {
+ // response.completed with an empty output is an authoritative
+ // zero-image success. A disconnected stream without a terminal
+ // event remains the separate, unknowable legacy error below.
+ imageCount = 0
+ processDataDone = true
+ return
+ }
+ eventName := streamPrefix + ".completed"
+ for _, img := range finalResults {
+ key := openAIResponsesImageResultKey("", img)
+ if _, exists := emitted[key]; exists {
+ continue
}
+ payload := buildOpenAIImagesStreamCompletedPayload(eventName, img, format, createdAt, usageRaw)
+ emitted[key] = struct{}{}
+ s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, eventName, payload)
}
+ imageCount = len(emitted)
+ processDataDone = true
}
- if err == io.EOF {
- break
+ }
+
+ processLine := func(line []byte) (bool, error) {
+ if len(line) == 0 {
+ return false, nil
}
- if err != nil {
- _ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(err.Error()))
- return OpenAIUsage{}, imageCount, firstTokenMs, err
+ if err := sseData.AddLine(string(line), processData); err != nil {
+ return true, err
+ }
+ if processDataErr != nil {
+ return true, processDataErr
}
+ return processDataDone, nil
}
- if imageCount > 0 {
- return usage, imageCount, firstTokenMs, nil
+ flushData := func() (bool, error) {
+ if err := sseData.Flush(processData); err != nil {
+ return true, err
+ }
+ if processDataErr != nil {
+ return true, processDataErr
+ }
+ return processDataDone, nil
}
- if len(pendingResults) > 0 {
- eventName := streamPrefix + ".completed"
- for _, img := range pendingResults {
- mergeOpenAIResponsesImageMeta(&img, streamMeta)
- key := openAIResponsesImageResultKey("", img)
- if _, exists := emitted[key]; exists {
- continue
+
+ finalizePending := func() error {
+ if imageCount > 0 {
+ return nil
+ }
+ if len(pendingResults) > 0 {
+ eventName := streamPrefix + ".completed"
+ for _, img := range pendingResults {
+ mergeOpenAIResponsesImageMeta(&img, streamMeta)
+ key := openAIResponsesImageResultKey("", img)
+ if _, exists := emitted[key]; exists {
+ continue
+ }
+ payload := buildOpenAIImagesStreamCompletedPayload(eventName, img, format, createdAt, nil)
+ emitted[key] = struct{}{}
+ s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, eventName, payload)
}
- payload := buildOpenAIImagesStreamCompletedPayload(eventName, img, format, createdAt, nil)
- if writeErr := s.writeOpenAIImagesStreamEvent(c, flusher, eventName, payload); writeErr != nil {
- return OpenAIUsage{}, imageCount, firstTokenMs, writeErr
+ imageCount = len(emitted)
+ return nil
+ }
+
+ streamErr := fmt.Errorf("stream disconnected before image generation completed")
+ s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(streamErr.Error()))
+ return streamErr
+ }
+
+ streamInterval := s.openAIImageStreamDataInterval()
+ keepaliveInterval := s.openAIImageStreamKeepaliveInterval()
+ if streamInterval <= 0 && keepaliveInterval <= 0 {
+ reader := bufio.NewReader(resp.Body)
+ for {
+ line, err := readOpenAISSELineBounded(reader, sseLimit)
+ done, processErr := processLine(line)
+ if processErr != nil {
+ return usage, imageCount, firstTokenMs, processErr
}
- emitted[key] = struct{}{}
+ if done {
+ return usage, imageCount, firstTokenMs, nil
+ }
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ if done, processErr := flushData(); processErr != nil {
+ return usage, imageCount, firstTokenMs, processErr
+ } else if done {
+ return usage, imageCount, firstTokenMs, nil
+ }
+ s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(err.Error()))
+ return usage, imageCount, firstTokenMs, err
+ }
+ }
+ if done, processErr := flushData(); processErr != nil {
+ return usage, imageCount, firstTokenMs, processErr
+ } else if done {
+ return usage, imageCount, firstTokenMs, nil
+ }
+ if err := finalizePending(); err != nil {
+ return usage, imageCount, firstTokenMs, err
}
- imageCount = len(emitted)
return usage, imageCount, firstTokenMs, nil
}
- streamErr := fmt.Errorf("stream disconnected before image generation completed")
- _ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(streamErr.Error()))
- return OpenAIUsage{}, imageCount, firstTokenMs, streamErr
+ type readEvent struct {
+ line []byte
+ err error
+ }
+ events := make(chan readEvent, 1)
+ done := make(chan struct{})
+ sendEvent := func(ev readEvent) bool {
+ select {
+ case events <- ev:
+ return true
+ case <-done:
+ return false
+ }
+ }
+ var lastReadAt int64
+ atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
+ go func() {
+ defer close(events)
+ reader := bufio.NewReader(resp.Body)
+ for {
+ line, err := readOpenAISSELineBounded(reader, sseLimit)
+ if len(line) > 0 {
+ atomic.StoreInt64(&lastReadAt, time.Now().UnixNano())
+ }
+ if len(line) > 0 && !sendEvent(readEvent{line: line}) {
+ return
+ }
+ if err == io.EOF {
+ return
+ }
+ if err != nil {
+ _ = sendEvent(readEvent{err: err})
+ return
+ }
+ }
+ }()
+ defer close(done)
+
+ var intervalTicker *time.Ticker
+ if streamInterval > 0 {
+ intervalTicker = time.NewTicker(streamInterval)
+ defer intervalTicker.Stop()
+ }
+ var intervalCh <-chan time.Time
+ if intervalTicker != nil {
+ intervalCh = intervalTicker.C
+ }
+
+ var keepaliveTicker *time.Ticker
+ if keepaliveInterval > 0 {
+ keepaliveTicker = time.NewTicker(keepaliveInterval)
+ defer keepaliveTicker.Stop()
+ }
+ var keepaliveCh <-chan time.Time
+ if keepaliveTicker != nil {
+ keepaliveCh = keepaliveTicker.C
+ }
+
+ for {
+ select {
+ case ev, ok := <-events:
+ if !ok {
+ if done, processErr := flushData(); processErr != nil {
+ return usage, imageCount, firstTokenMs, processErr
+ } else if done {
+ return usage, imageCount, firstTokenMs, nil
+ }
+ if err := finalizePending(); err != nil {
+ return usage, imageCount, firstTokenMs, err
+ }
+ return usage, imageCount, firstTokenMs, nil
+ }
+ if ev.err != nil {
+ if done, processErr := flushData(); processErr != nil {
+ return usage, imageCount, firstTokenMs, processErr
+ } else if done {
+ return usage, imageCount, firstTokenMs, nil
+ }
+ s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(ev.err.Error()))
+ return usage, imageCount, firstTokenMs, ev.err
+ }
+ done, processErr := processLine(ev.line)
+ if processErr != nil {
+ return usage, imageCount, firstTokenMs, processErr
+ }
+ if done {
+ return usage, imageCount, firstTokenMs, nil
+ }
+ case <-intervalCh:
+ lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt))
+ if time.Since(lastRead) < streamInterval {
+ continue
+ }
+ if clientDisconnected {
+ return usage, imageCount, firstTokenMs, fmt.Errorf("image stream incomplete after timeout")
+ }
+ logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images responses stream data interval timeout: interval=%s", streamInterval)
+ s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(fmt.Sprintf("upstream image stream idle for %s", streamInterval)))
+ return usage, imageCount, firstTokenMs, fmt.Errorf("image stream data interval timeout")
+ case <-keepaliveCh:
+ if clientDisconnected || time.Since(lastDownstreamWriteAt) < keepaliveInterval {
+ continue
+ }
+ if _, writeErr := io.WriteString(c.Writer, ":\n\n"); writeErr != nil {
+ clientDisconnected = true
+ logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images responses stream client disconnected during keepalive, continue draining upstream for billing")
+ continue
+ }
+ flusher.Flush()
+ lastDownstreamWriteAt = time.Now()
+ }
+ }
}
func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
@@ -722,6 +928,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
account *Account,
parsed *OpenAIImagesRequest,
channelMappedModel string,
+ options OpenAIImagesForwardOptions,
) (*OpenAIForwardResult, error) {
startTime := time.Now()
requestModel := strings.TrimSpace(parsed.Model)
@@ -752,7 +959,10 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
)
}
- token, _, err := s.GetAccessToken(ctx, account)
+ upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, parsed.Stream)
+ defer releaseUpstreamCtx()
+
+ token, _, err := s.GetAccessToken(upstreamCtx, account)
if err != nil {
return nil, err
}
@@ -763,7 +973,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
}
setOpsUpstreamRequestBody(c, responsesBody)
- upstreamReq, err := s.buildUpstreamRequest(ctx, c, account, responsesBody, token, true, parsed.StickySessionSeed(), false)
+ upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, responsesBody, token, true, parsed.StickySessionSeed(), false)
if err != nil {
return nil, err
}
@@ -808,14 +1018,14 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
Kind: "failover",
Message: upstreamMsg,
})
- s.handleFailoverSideEffects(ctx, resp, account)
+ s.handleFailoverSideEffects(upstreamCtx, resp, account, requestModel)
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode),
}
}
- return s.handleErrorResponse(ctx, resp, c, account, responsesBody)
+ return s.handleErrorResponse(upstreamCtx, resp, c, account, responsesBody)
}
defer func() { _ = resp.Body.Close() }()
@@ -827,17 +1037,42 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth(
if parsed.Stream {
usage, imageCount, firstTokenMs, err = s.handleOpenAIImagesOAuthStreamingResponse(resp, c, startTime, parsed.ResponseFormat, openAIImagesStreamPrefix(parsed), requestModel)
if err != nil {
+ if imageCount > 0 {
+ return &OpenAIForwardResult{
+ RequestID: resp.Header.Get("x-request-id"),
+ Usage: usage,
+ Model: requestModel,
+ UpstreamModel: requestModel,
+ Stream: parsed.Stream,
+ ResponseHeaders: resp.Header.Clone(),
+ Duration: time.Since(startTime),
+ FirstTokenMs: firstTokenMs,
+ ImageCount: imageCount,
+ ImageSize: parsed.SizeTier,
+ }, err
+ }
return nil, err
}
} else {
- usage, imageCount, err = s.handleOpenAIImagesOAuthNonStreamingResponse(resp, c, parsed.ResponseFormat, requestModel)
+ usage, imageCount, err = s.handleOpenAIImagesOAuthNonStreamingResponse(upstreamCtx, resp, c, parsed.ResponseFormat, requestModel, options)
if err != nil {
+ if imageCount > 0 {
+ return &OpenAIForwardResult{
+ RequestID: resp.Header.Get("x-request-id"),
+ Usage: usage,
+ Model: requestModel,
+ UpstreamModel: requestModel,
+ Stream: parsed.Stream,
+ ResponseHeaders: resp.Header.Clone(),
+ Duration: time.Since(startTime),
+ FirstTokenMs: firstTokenMs,
+ ImageCount: imageCount,
+ ImageSize: parsed.SizeTier,
+ }, err
+ }
return nil, err
}
}
- if imageCount <= 0 {
- imageCount = parsed.N
- }
return &OpenAIForwardResult{
RequestID: resp.Header.Get("x-request-id"),
Usage: usage,
diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go
index 47113d4df18..214cfd9bc70 100644
--- a/backend/internal/service/openai_images_test.go
+++ b/backend/internal/service/openai_images_test.go
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"context"
+ "errors"
"io"
"mime/multipart"
"net/http"
@@ -10,6 +11,7 @@ import (
"net/textproto"
"strings"
"testing"
+ "time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
@@ -17,8 +19,21 @@ import (
"github.com/tidwall/gjson"
)
+type failingOpenAIImageWriter struct {
+ gin.ResponseWriter
+ failAfter int
+ writes int
+}
+
+func (w *failingOpenAIImageWriter) Write(p []byte) (int, error) {
+ if w.writes >= w.failAfter {
+ return 0, errors.New("write failed: client disconnected")
+ }
+ w.writes++
+ return w.ResponseWriter.Write(p)
+}
+
func TestOpenAIGatewayServiceParseOpenAIImagesRequest_JSON(t *testing.T) {
- gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024","quality":"high","stream":true}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
@@ -36,13 +51,12 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_JSON(t *testing.T) {
require.Equal(t, "draw a cat", parsed.Prompt)
require.True(t, parsed.Stream)
require.Equal(t, "1024x1024", parsed.Size)
- require.Equal(t, "1K", parsed.SizeTier)
+ require.Equal(t, "4K", parsed.SizeTier)
require.Equal(t, OpenAIImagesCapabilityNative, parsed.RequiredCapability)
require.False(t, parsed.Multipart)
}
func TestOpenAIGatewayServiceParseOpenAIImagesRequest_MultipartEdit(t *testing.T) {
- gin.SetMode(gin.TestMode)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
@@ -75,8 +89,143 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_MultipartEdit(t *testing.T
require.Equal(t, OpenAIImagesCapabilityNative, parsed.RequiredCapability)
}
+func TestOpenAIImagesRequestModerationBody_JSONEditIncludesInputImageURLs(t *testing.T) {
+ parsed := &OpenAIImagesRequest{
+ Endpoint: openAIImagesEditsEndpoint,
+ Prompt: "replace background",
+ InputImageURLs: []string{"https://example.com/source.png"},
+ MaskImageURL: "https://example.com/mask.png",
+ }
+
+ input := ExtractContentModerationInput(ContentModerationProtocolOpenAIImages, parsed.ModerationBody())
+
+ require.Equal(t, "replace background", input.Text)
+ require.Equal(t, []string{"https://example.com/source.png", "https://example.com/mask.png"}, input.Images)
+}
+
+func TestOpenAIImagesRequestModerationBody_MultipartEditIncludesUploadsInMemory(t *testing.T) {
+ parsed := &OpenAIImagesRequest{
+ Endpoint: openAIImagesEditsEndpoint,
+ Prompt: "replace background",
+ Uploads: []OpenAIImagesUpload{{
+ FieldName: "image",
+ FileName: "source.png",
+ ContentType: "image/png",
+ Data: []byte("fake-image-bytes"),
+ }},
+ MaskUpload: &OpenAIImagesUpload{
+ FieldName: "mask",
+ FileName: "mask.png",
+ ContentType: "image/png",
+ Data: []byte("fake-mask-bytes"),
+ },
+ }
+
+ input := ExtractContentModerationInput(ContentModerationProtocolOpenAIImages, parsed.ModerationBody())
+
+ require.Equal(t, "replace background", input.Text)
+ require.Equal(t, []string{
+ "data:image/png;base64,ZmFrZS1pbWFnZS1ieXRlcw==",
+ "data:image/png;base64,ZmFrZS1tYXNrLWJ5dGVz",
+ }, input.Images)
+
+ log := (&ContentModerationService{}).buildLog(ContentModerationCheckInput{}, defaultContentModerationConfig(), ContentModerationActionAllow, false, "", 0, nil, input.ExcerptText(), nil, nil, "")
+ require.Equal(t, "replace background", log.InputExcerpt)
+ require.NotContains(t, log.InputExcerpt, "ZmFrZS")
+}
+
+func TestOpenAIGatewayServiceParseOpenAIImagesRequest_NormalizesOfficialAndCustomSizes(t *testing.T) {
+
+ tests := []struct {
+ size string
+ wantTier string
+ }{
+ {size: "1024x1024", wantTier: "1K"},
+ {size: "1536x1024", wantTier: "2K"},
+ {size: "1024x1536", wantTier: "2K"},
+ {size: "2048x2048", wantTier: "2K"},
+ {size: "2048x1152", wantTier: "2K"},
+ {size: "3840x2160", wantTier: "4K"},
+ {size: "2160x3840", wantTier: "4K"},
+ {size: "1024X768", wantTier: "2K"},
+ {size: "1280x768", wantTier: "2K"},
+ {size: "2560x1440", wantTier: "2K"},
+ {size: "2560x1600", wantTier: "4K"},
+ {size: "auto", wantTier: "2K"},
+ }
+
+ svc := &OpenAIGatewayService{}
+ for _, tt := range tests {
+ t.Run(tt.size, func(t *testing.T) {
+ body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"` + tt.size + `"}`)
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+
+ parsed, err := svc.ParseOpenAIImagesRequest(c, body)
+ require.NoError(t, err)
+ require.NotNil(t, parsed)
+ require.Equal(t, tt.size, parsed.Size)
+ require.Equal(t, tt.wantTier, parsed.SizeTier)
+ })
+ }
+}
+
+func TestOpenAIGatewayServiceParseOpenAIImagesRequest_UnknownSizesDoNotBlockPassthrough(t *testing.T) {
+
+ tests := []struct {
+ size string
+ wantTier string
+ }{
+ {size: "2048x1153", wantTier: "2K"},
+ {size: "4096x1024", wantTier: "4K"},
+ {size: "3840x1024", wantTier: "4K"},
+ {size: "512x512", wantTier: "2K"},
+ {size: "invalid", wantTier: "2K"},
+ {size: "999999999999999999999999999x2", wantTier: "2K"},
+ }
+
+ svc := &OpenAIGatewayService{}
+ for _, tt := range tests {
+ t.Run(tt.size, func(t *testing.T) {
+ body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"` + tt.size + `"}`)
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+
+ parsed, err := svc.ParseOpenAIImagesRequest(c, body)
+ require.NoError(t, err)
+ require.NotNil(t, parsed)
+ require.Equal(t, tt.size, parsed.Size)
+ require.Equal(t, tt.wantTier, parsed.SizeTier)
+ })
+ }
+}
+
+func TestOpenAIGatewayServiceParseOpenAIImagesRequest_LegacyImageModelUnknownSizePassthrough(t *testing.T) {
+ body := []byte(`{"model":"gpt-image-1.5","prompt":"draw a cat","size":"2048x1152"}`)
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+
+ svc := &OpenAIGatewayService{}
+ parsed, err := svc.ParseOpenAIImagesRequest(c, body)
+ require.NoError(t, err)
+ require.NotNil(t, parsed)
+ require.Equal(t, "2048x1152", parsed.Size)
+ require.Equal(t, "2K", parsed.SizeTier)
+}
+
func TestOpenAIGatewayServiceParseOpenAIImagesRequest_MultipartEditWithMaskAndNativeOptions(t *testing.T) {
- gin.SetMode(gin.TestMode)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
@@ -128,7 +277,6 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_MultipartEditWithMaskAndNa
}
func TestOpenAIGatewayServiceParseOpenAIImagesRequest_PromptOnlyDefaultsRemainBasic(t *testing.T) {
- gin.SetMode(gin.TestMode)
body := []byte(`{"prompt":"draw a cat"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
@@ -146,7 +294,6 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_PromptOnlyDefaultsRemainBa
}
func TestOpenAIGatewayServiceParseOpenAIImagesRequest_ExplicitSizeRequiresNativeCapability(t *testing.T) {
- gin.SetMode(gin.TestMode)
body := []byte(`{"prompt":"draw a cat","size":"1024x1024"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
@@ -163,7 +310,6 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_ExplicitSizeRequiresNative
}
func TestOpenAIGatewayServiceParseOpenAIImagesRequest_RejectsNonImageModel(t *testing.T) {
- gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","prompt":"draw a cat"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
@@ -178,8 +324,55 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_RejectsNonImageModel(t *te
require.ErrorContains(t, err, `images endpoint requires an image model, got "gpt-5.4"`)
}
+func TestValidateOpenAIImagesSafetyRequest_RejectsP0UnsafeOptions(t *testing.T) {
+ partialImages := 1
+ tests := []struct {
+ name string
+ req *OpenAIImagesRequest
+ wantRule string
+ wantStatus int
+ }{
+ {
+ name: "stream",
+ req: &OpenAIImagesRequest{Stream: true, N: 1},
+ wantRule: "blocked_image_stream",
+ wantStatus: http.StatusBadRequest,
+ },
+ {
+ name: "partial images",
+ req: &OpenAIImagesRequest{PartialImages: &partialImages, N: 1},
+ wantRule: "blocked_image_partial_images",
+ wantStatus: http.StatusBadRequest,
+ },
+ {
+ name: "multi n",
+ req: &OpenAIImagesRequest{N: 2},
+ wantRule: "blocked_image_multi_n",
+ wantStatus: http.StatusBadRequest,
+ },
+ {
+ name: "moderation low",
+ req: &OpenAIImagesRequest{N: 1, Moderation: "low"},
+ wantRule: "blocked_image_moderation_override",
+ wantStatus: http.StatusBadRequest,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := ValidateOpenAIImagesSafetyRequest(tt.req)
+ require.NotNil(t, err)
+ require.Equal(t, tt.wantRule, err.PolicyRule)
+ require.Equal(t, tt.wantStatus, err.StatusCode)
+ require.Equal(t, "invalid_request_error", err.Type)
+ })
+ }
+
+ require.Nil(t, ValidateOpenAIImagesSafetyRequest(&OpenAIImagesRequest{N: 1, Moderation: "auto"}))
+ require.Nil(t, ValidateOpenAIImagesSafetyRequest(&OpenAIImagesRequest{N: 1}))
+}
+
func TestOpenAIGatewayServiceParseOpenAIImagesRequest_JSONEditURLs(t *testing.T) {
- gin.SetMode(gin.TestMode)
body := []byte(`{
"model":"gpt-image-2",
"prompt":"replace the background",
@@ -217,7 +410,8 @@ func TestCollectOpenAIImagePointers_RecognizesDirectAssets(t *testing.T) {
"revised_prompt": "cat astronaut",
"parts": [
{"b64_json":"QUJD"},
- {"download_url":"https://files.example.com/image.png?sig=1"},
+ {"download_url":"https://files.oaiusercontent.com/image.png?sig=1"},
+ {"download_url":"https://example.com/image.png?sig=1"},
{"asset_pointer":"file-service://file_123"}
]
}`))
@@ -229,9 +423,10 @@ func TestCollectOpenAIImagePointers_RecognizesDirectAssets(t *testing.T) {
sawBase64 = true
require.Equal(t, "cat astronaut", item.Prompt)
}
- if item.DownloadURL == "https://files.example.com/image.png?sig=1" {
+ if item.DownloadURL == "https://files.oaiusercontent.com/image.png?sig=1" {
sawURL = true
}
+ require.NotEqual(t, "https://example.com/image.png?sig=1", item.DownloadURL)
if item.Pointer == "file-service://file_123" {
sawPointer = true
}
@@ -241,6 +436,14 @@ func TestCollectOpenAIImagePointers_RecognizesDirectAssets(t *testing.T) {
require.True(t, sawPointer)
}
+func TestOpenAIImageDownloadURLTrustPolicy(t *testing.T) {
+ require.True(t, isLikelyOpenAIImageDownloadURL("https://files.oaiusercontent.com/image.png?sig=1"))
+ require.True(t, isLikelyOpenAIImageDownloadURL("https://oaidalleapiprodscus.blob.core.windows.net/private/image.webp?sig=1"))
+ require.True(t, isTrustedOpenAIImageDownloadURL("data:image/png;base64,QUJD"))
+ require.False(t, isLikelyOpenAIImageDownloadURL("https://example.com/image.png?sig=1"))
+ require.False(t, isTrustedOpenAIImageDownloadURL("http://files.oaiusercontent.com/image.png?sig=1"))
+}
+
func TestResolveOpenAIImageBytes_PrefersInlineBase64(t *testing.T) {
data, err := resolveOpenAIImageBytes(context.Background(), nil, nil, "", openAIImagePointerInfo{
B64JSON: "data:image/png;base64,QUJD",
@@ -249,6 +452,22 @@ func TestResolveOpenAIImageBytes_PrefersInlineBase64(t *testing.T) {
require.Equal(t, []byte("ABC"), data)
}
+func TestBuildOpenAIImageOutputAuditRequest_JSONB64(t *testing.T) {
+ req, err := (&OpenAIGatewayService{}).buildOpenAIImageOutputAuditRequest(
+ context.Background(),
+ []byte(`{"created":1710000007,"data":[{"b64_json":"aGVsbG8=","revised_prompt":"draw"}]}`),
+ http.Header{"X-Request-Id": []string{"req_img_audit"}},
+ "req_img_audit",
+ )
+
+ require.NoError(t, err)
+ require.Equal(t, "req_img_audit", req.UpstreamRequestID)
+ require.Equal(t, "moderation_flagged_output", req.PolicyRule)
+ require.Len(t, req.OutputHashes, 1)
+ require.Equal(t, "draw", gjson.GetBytes(req.ModerationBody, "prompt").String())
+ require.True(t, strings.HasPrefix(gjson.GetBytes(req.ModerationBody, "images.0.image_url").String(), "data:image/png;base64,"))
+}
+
func TestAccountSupportsOpenAIImageCapability_OAuthSupportsNative(t *testing.T) {
account := &Account{
Platform: PlatformOpenAI,
@@ -317,7 +536,6 @@ func findOpenAIImageTestSSEEvent(events []openAIImageTestSSEEvent, name string)
}
func TestOpenAIGatewayServiceForwardImages_OAuthUsesResponsesAPI(t *testing.T) {
- gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024","quality":"high","n":2}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
@@ -357,11 +575,25 @@ func TestOpenAIGatewayServiceForwardImages_OAuthUsesResponsesAPI(t *testing.T) {
},
}
- result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
+ var auditCalled bool
+ result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "",
+ WithOpenAIImagesSafetyIdentifier("hfc_test_safety_id"),
+ WithOpenAIImagesBillingPreflight(OpenAIForwardOptions{RequestedModel: "gpt-image-2", RequirePricingPreflight: true}),
+ WithOpenAIImagesOutputAuditor(OpenAIImageOutputAuditorFunc(func(ctx context.Context, req OpenAIImageOutputAuditRequest) (*ContentModerationDecision, error) {
+ auditCalled = true
+ require.NotEmpty(t, req.ModerationBody)
+ require.Len(t, req.OutputHashes, 1)
+ return &ContentModerationDecision{Allowed: true, Action: ContentModerationActionAllow}, nil
+ })),
+ )
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "gpt-image-2", result.Model)
require.Equal(t, "gpt-image-2", result.UpstreamModel)
+ require.NotNil(t, result.BillingIdentity)
+ require.Equal(t, "gpt-image-2", result.BillingIdentity.BillingModel)
+ require.Equal(t, BillingModeImage, result.BillingIdentity.Pricing.Resolved.Mode)
+ require.NotEmpty(t, result.BillingIdentity.Pricing.Evidence.Hash)
require.Equal(t, 1, result.ImageCount)
require.Equal(t, 11, result.Usage.InputTokens)
require.Equal(t, 22, result.Usage.OutputTokens)
@@ -380,6 +612,8 @@ func TestOpenAIGatewayServiceForwardImages_OAuthUsesResponsesAPI(t *testing.T) {
require.Equal(t, "image_generation", gjson.GetBytes(upstream.lastBody, "tools.0.type").String())
require.Equal(t, "generate", gjson.GetBytes(upstream.lastBody, "tools.0.action").String())
require.Equal(t, "gpt-image-2", gjson.GetBytes(upstream.lastBody, "tools.0.model").String())
+ require.Equal(t, "auto", gjson.GetBytes(upstream.lastBody, "tools.0.moderation").String())
+ require.False(t, gjson.GetBytes(upstream.lastBody, "safety_identifier").Exists())
require.Equal(t, "1024x1024", gjson.GetBytes(upstream.lastBody, "tools.0.size").String())
require.Equal(t, "high", gjson.GetBytes(upstream.lastBody, "tools.0.quality").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "tools.0.n").Exists())
@@ -389,10 +623,98 @@ func TestOpenAIGatewayServiceForwardImages_OAuthUsesResponsesAPI(t *testing.T) {
require.Equal(t, "gpt-image-2", gjson.Get(rec.Body.String(), "model").String())
require.Equal(t, "aGVsbG8=", gjson.Get(rec.Body.String(), "data.0.b64_json").String())
require.Equal(t, "draw a cat", gjson.Get(rec.Body.String(), "data.0.revised_prompt").String())
+ require.True(t, auditCalled)
+}
+
+func TestOpenAIGatewayServiceForwardImages_InvalidImagePriceFailsBeforeTokenAndUpstream(t *testing.T) {
+ body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024"}`)
+ req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+ upstream := &httpUpstreamRecorder{}
+ svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
+ parsed, err := svc.ParseOpenAIImagesRequest(c, body)
+ require.NoError(t, err)
+ account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "must-not-be-read"}}
+ invalid := -1.0
+
+ result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "",
+ WithOpenAIImagesBillingPreflight(OpenAIForwardOptions{
+ RequestedModel: "gpt-image-2", ImagePriceConfig: &ImagePriceConfig{Price1K: &invalid}, RequirePricingPreflight: true,
+ }),
+ )
+
+ require.Error(t, err)
+ require.ErrorIs(t, err, ErrOpenAIBillingPreflight)
+ require.Nil(t, result)
+ require.Nil(t, upstream.lastReq)
+}
+
+func TestOpenAIGatewayServiceForwardImages_OAuthOutputAuditBlocksBeforeWrite(t *testing.T) {
+ body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024"}`)
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+
+ svc := &OpenAIGatewayService{}
+ parsed, err := svc.ParseOpenAIImagesRequest(c, body)
+ require.NoError(t, err)
+
+ svc.httpUpstream = &httpUpstreamRecorder{
+ resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{
+ "Content-Type": []string{"text/event-stream"},
+ "X-Request-Id": []string{"req_img_oauth_audit_block"},
+ },
+ Body: io.NopCloser(strings.NewReader(
+ "data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000000,\"usage\":{\"input_tokens\":3,\"output_tokens\":5,\"output_tokens_details\":{\"image_tokens\":2}},\"tool_usage\":{\"image_gen\":{\"images\":1}},\"output\":[{\"type\":\"image_generation_call\",\"result\":\"YmxvY2tlZA==\",\"revised_prompt\":\"draw a cat\",\"output_format\":\"png\"}]}}\n\n" +
+ "data: [DONE]\n\n",
+ )),
+ },
+ }
+
+ account := &Account{
+ ID: 1,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Credentials: map[string]any{
+ "access_token": "token-123",
+ },
+ }
+
+ result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "",
+ WithOpenAIImagesOutputAuditor(OpenAIImageOutputAuditorFunc(func(ctx context.Context, req OpenAIImageOutputAuditRequest) (*ContentModerationDecision, error) {
+ require.Equal(t, "req_img_oauth_audit_block", req.UpstreamRequestID)
+ require.Equal(t, "moderation_flagged_output", req.PolicyRule)
+ require.Len(t, req.OutputHashes, 1)
+ return &ContentModerationDecision{
+ Allowed: false,
+ Blocked: true,
+ Message: defaultContentModerationBlockMessage,
+ StatusCode: http.StatusForbidden,
+ Action: ContentModerationActionBlock,
+ PolicyRule: req.PolicyRule,
+ }, nil
+ })),
+ )
+
+ require.Error(t, err)
+ var auditErr *OpenAIImageOutputAuditError
+ require.ErrorAs(t, err, &auditErr)
+ require.NotNil(t, result)
+ require.Equal(t, 1, result.ImageCount)
+ require.Equal(t, 3, result.Usage.InputTokens)
+ require.Empty(t, rec.Body.String())
}
func TestOpenAIGatewayServiceForwardImages_APIKeyGenerationUsesConfiguredV1BaseURL(t *testing.T) {
- gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
@@ -428,7 +750,16 @@ func TestOpenAIGatewayServiceForwardImages_APIKeyGenerationUsesConfiguredV1BaseU
},
}
- result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
+ var auditCalled bool
+ result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "",
+ WithOpenAIImagesSafetyIdentifier("hfc_test_safety_id"),
+ WithOpenAIImagesOutputAuditor(OpenAIImageOutputAuditorFunc(func(ctx context.Context, req OpenAIImageOutputAuditRequest) (*ContentModerationDecision, error) {
+ auditCalled = true
+ require.NotEmpty(t, req.ModerationBody)
+ require.Len(t, req.OutputHashes, 1)
+ return &ContentModerationDecision{Allowed: true, Action: ContentModerationActionAllow}, nil
+ })),
+ )
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 1, result.ImageCount)
@@ -442,12 +773,359 @@ func TestOpenAIGatewayServiceForwardImages_APIKeyGenerationUsesConfiguredV1BaseU
require.Equal(t, "Bearer test-api-key", upstream.lastReq.Header.Get("Authorization"))
require.Equal(t, "application/json", upstream.lastReq.Header.Get("Content-Type"))
require.Equal(t, "gpt-image-2", gjson.GetBytes(upstream.lastBody, "model").String())
+ require.Equal(t, "auto", gjson.GetBytes(upstream.lastBody, "moderation").String())
+ require.Equal(t, "hfc_test_safety_id", gjson.GetBytes(upstream.lastBody, "safety_identifier").String())
+ require.True(t, auditCalled)
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.Equal(t, "aGVsbG8=", gjson.Get(rec.Body.String(), "data.0.b64_json").String())
+}
+
+func TestOpenAIGatewayServiceForwardImages_UpstreamZeroImagesDoesNotUseRequestedCount(t *testing.T) {
+ for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} {
+ t.Run(accountType, func(t *testing.T) {
+ body := []byte(`{"model":"gpt-image-2","prompt":"draw","size":"1024x1024","n":3}`)
+ req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+ responseBody := `{"created":1710000007,"data":[]}`
+ responseType := "application/json"
+ if accountType == AccountTypeOAuth {
+ responseType = "text/event-stream"
+ responseBody = "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_zero_images\",\"created_at\":1710000007,\"usage\":{\"input_tokens\":2,\"output_tokens\":1},\"output\":[]}}\n\n" +
+ "data: [DONE]\n\n"
+ }
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{responseType}, "X-Request-Id": []string{"req_zero_images"}},
+ Body: io.NopCloser(strings.NewReader(responseBody)),
+ }}
+ svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
+ parsed, err := svc.ParseOpenAIImagesRequest(c, body)
+ require.NoError(t, err)
+ credentials := map[string]any{"api_key": "test-key"}
+ if accountType == AccountTypeOAuth {
+ credentials = map[string]any{"access_token": "test-token", "chatgpt_account_id": "acct-test"}
+ }
+ account := &Account{ID: 1, Platform: PlatformOpenAI, Type: accountType, Credentials: credentials}
+
+ result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "",
+ WithOpenAIImagesBillingPreflight(OpenAIForwardOptions{RequestedModel: "gpt-image-2", RequirePricingPreflight: true}),
+ )
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Zero(t, result.ImageCount)
+ require.NotEqual(t, parsed.N, result.ImageCount)
+ })
+ }
+}
+
+func TestOpenAIGatewayServiceHandleImagesStream_EOFWithoutTerminalIsUnknown(t *testing.T) {
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(
+ "data: {\"type\":\"image_generation.partial_image\",\"partial_image_index\":0,\"b64_json\":\"cGFydGlhbA==\"}\n\n",
+ )),
+ }
+
+ _, imageCount, _, err := (&OpenAIGatewayService{cfg: &config.Config{}}).
+ handleOpenAIImagesStreamingResponse(resp, c, time.Now())
+
+ require.ErrorContains(t, err, "incomplete")
+ require.Zero(t, imageCount)
+}
+
+func TestOpenAIGatewayServiceHandleImagesStream_TerminalZeroImagesIsAuthoritative(t *testing.T) {
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(
+ "data: {\"type\":\"response.completed\",\"response\":{\"output\":[]}}\n\n" +
+ "data: [DONE]\n\n",
+ )),
+ }
+
+ _, imageCount, _, err := (&OpenAIGatewayService{cfg: &config.Config{}}).
+ handleOpenAIImagesStreamingResponse(resp, c, time.Now())
+
+ require.NoError(t, err)
+ require.Zero(t, imageCount)
+}
+
+func TestOpenAIGatewayServiceHandleImagesStream_RejectsOversizedSSELine(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ interval int
+ }{{name: "synchronous"}, {name: "timed reader", interval: 1}} {
+ t.Run(tc.name, func(t *testing.T) {
+ cfg := &config.Config{}
+ cfg.Gateway.UpstreamResponseReadMaxBytes = 64
+ cfg.Gateway.ImageStreamDataIntervalTimeout = tc.interval
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(strings.Repeat("x", 65) + "\n")),
+ }
+
+ _, _, _, err := (&OpenAIGatewayService{cfg: cfg}).
+ handleOpenAIImagesStreamingResponse(resp, c, time.Now())
+
+ require.Error(t, err)
+ require.True(t, errors.Is(err, ErrUpstreamResponseBodyTooLarge))
+ require.Empty(t, rec.Body.Bytes())
+ })
+ }
+}
+
+func TestOpenAIGatewayServiceHandleImagesOAuthStream_RejectsOversizedSSELine(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ interval int
+ }{{name: "synchronous"}, {name: "timed reader", interval: 1}} {
+ t.Run(tc.name, func(t *testing.T) {
+ cfg := &config.Config{}
+ cfg.Gateway.UpstreamResponseReadMaxBytes = 64
+ cfg.Gateway.ImageStreamDataIntervalTimeout = tc.interval
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ resp := &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(strings.Repeat("x", 65) + "\n")),
+ }
+
+ _, _, _, err := (&OpenAIGatewayService{cfg: cfg}).
+ handleOpenAIImagesOAuthStreamingResponse(resp, c, time.Now(), "b64_json", "image_generation", "gpt-image-2")
+
+ require.Error(t, err)
+ require.True(t, errors.Is(err, ErrUpstreamResponseBodyTooLarge))
+ require.Contains(t, rec.Body.String(), "event: error")
+ require.NotContains(t, rec.Body.String(), strings.Repeat("x", 65))
+ })
+ }
+}
+
+func TestOpenAIGatewayServiceForwardImages_APIKeyOutputAuditBlocksBeforeWrite(t *testing.T) {
+ body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`)
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{},
+ httpUpstream: &httpUpstreamRecorder{
+ resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{
+ "Content-Type": []string{"application/json"},
+ "X-Request-Id": []string{"req_img_audit_block"},
+ },
+ Body: io.NopCloser(strings.NewReader(`{"created":1710000007,"usage":{"input_tokens":1,"output_tokens":2},"data":[{"b64_json":"YmxvY2tlZA==","revised_prompt":"draw a cat"}]}`)),
+ },
+ },
+ }
+ parsed, err := svc.ParseOpenAIImagesRequest(c, body)
+ require.NoError(t, err)
+
+ account := &Account{
+ ID: 6,
+ Name: "openai-apikey",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "test-api-key",
+ },
+ }
+
+ result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "",
+ WithOpenAIImagesOutputAuditor(OpenAIImageOutputAuditorFunc(func(ctx context.Context, req OpenAIImageOutputAuditRequest) (*ContentModerationDecision, error) {
+ require.Equal(t, "req_img_audit_block", req.UpstreamRequestID)
+ require.Equal(t, "moderation_flagged_output", req.PolicyRule)
+ require.Len(t, req.OutputHashes, 1)
+ return &ContentModerationDecision{
+ Allowed: false,
+ Blocked: true,
+ Message: defaultContentModerationBlockMessage,
+ StatusCode: http.StatusForbidden,
+ Action: ContentModerationActionBlock,
+ PolicyRule: req.PolicyRule,
+ }, nil
+ })),
+ )
+
+ require.Error(t, err)
+ var auditErr *OpenAIImageOutputAuditError
+ require.ErrorAs(t, err, &auditErr)
+ require.NotNil(t, result)
+ require.Equal(t, 1, result.ImageCount)
+ require.Equal(t, 1, result.Usage.InputTokens)
+ require.Empty(t, rec.Body.String())
+}
+
+func TestOpenAIGatewayServiceForwardImages_APIKeyStreamJSONResponseBillsImage(t *testing.T) {
+ body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`)
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{},
+ httpUpstream: &httpUpstreamRecorder{
+ resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{
+ "Content-Type": []string{"application/json"},
+ "X-Request-Id": []string{"req_img_stream_json"},
+ },
+ Body: io.NopCloser(strings.NewReader(`{"created":1710000008,"usage":{"input_tokens":12,"output_tokens":21,"output_tokens_details":{"image_tokens":9}},"data":[{"b64_json":"aGVsbG8=","revised_prompt":"draw a cat"}]}`)),
+ },
+ },
+ }
+ parsed, err := svc.ParseOpenAIImagesRequest(c, body)
+ require.NoError(t, err)
+
+ account := &Account{
+ ID: 7,
+ Name: "openai-apikey",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "test-api-key",
+ "base_url": "https://image-upstream.example/v1",
+ },
+ }
+
+ result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.True(t, result.Stream)
+ require.Equal(t, 1, result.ImageCount)
+ require.Equal(t, 12, result.Usage.InputTokens)
+ require.Equal(t, 21, result.Usage.OutputTokens)
+ require.Equal(t, 9, result.Usage.ImageOutputTokens)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "aGVsbG8=", gjson.Get(rec.Body.String(), "data.0.b64_json").String())
}
+func TestOpenAIGatewayServiceForwardImages_APIKeyStreamRawJSONEventStreamFallbackBillsImage(t *testing.T) {
+ body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`)
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{},
+ httpUpstream: &httpUpstreamRecorder{
+ resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{
+ "Content-Type": []string{"text/event-stream"},
+ "X-Request-Id": []string{"req_img_stream_json_mislabeled"},
+ },
+ Body: io.NopCloser(strings.NewReader(`{"created":1710000009,"usage":{"input_tokens":10,"output_tokens":18,"output_tokens_details":{"image_tokens":8}},"data":[{"b64_json":"ZmluYWw="}]}`)),
+ },
+ },
+ }
+ parsed, err := svc.ParseOpenAIImagesRequest(c, body)
+ require.NoError(t, err)
+
+ account := &Account{
+ ID: 8,
+ Name: "openai-apikey",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "test-api-key",
+ "base_url": "https://image-upstream.example/v1",
+ },
+ }
+
+ result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.True(t, result.Stream)
+ require.Equal(t, 1, result.ImageCount)
+ require.Equal(t, 10, result.Usage.InputTokens)
+ require.Equal(t, 18, result.Usage.OutputTokens)
+ require.Equal(t, 8, result.Usage.ImageOutputTokens)
+ require.Equal(t, "ZmluYWw=", gjson.Get(rec.Body.String(), "data.0.b64_json").String())
+}
+
+func TestOpenAIGatewayServiceForwardImages_APIKeyStreamMultilineSSEDataBillsImage(t *testing.T) {
+ body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`)
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{},
+ httpUpstream: &httpUpstreamRecorder{
+ resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{
+ "Content-Type": []string{"text/event-stream"},
+ "X-Request-Id": []string{"req_img_stream_multiline"},
+ },
+ Body: io.NopCloser(strings.NewReader(
+ "data: {\"type\":\"image_generation.completed\",\n" +
+ "data: \"usage\":{\"input_tokens\":10,\"output_tokens\":18,\"output_tokens_details\":{\"image_tokens\":8}},\n" +
+ "data: \"b64_json\":\"ZmluYWw=\",\"output_format\":\"png\"}\n\n" +
+ "data: [DONE]\n\n",
+ )),
+ },
+ },
+ }
+ parsed, err := svc.ParseOpenAIImagesRequest(c, body)
+ require.NoError(t, err)
+
+ account := &Account{
+ ID: 8,
+ Name: "openai-apikey",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "test-api-key",
+ },
+ }
+
+ result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.True(t, result.Stream)
+ require.Equal(t, 1, result.ImageCount)
+ require.Equal(t, 10, result.Usage.InputTokens)
+ require.Equal(t, 18, result.Usage.OutputTokens)
+ require.Equal(t, 8, result.Usage.ImageOutputTokens)
+}
+
+func TestExtractOpenAIImagesBillableCountFromJSONBytes_CompletedEvent(t *testing.T) {
+ body := []byte(`{"type":"image_generation.completed","b64_json":"ZmluYWw=","usage":{"input_tokens":10,"output_tokens":18}}`)
+
+ require.Equal(t, 1, extractOpenAIImagesBillableCountFromJSONBytes(body))
+}
+
func TestOpenAIGatewayServiceForwardImages_APIKeyEditUsesConfiguredV1BaseURL(t *testing.T) {
- gin.SetMode(gin.TestMode)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
@@ -492,7 +1170,9 @@ func TestOpenAIGatewayServiceForwardImages_APIKeyEditUsesConfiguredV1BaseURL(t *
},
}
- result, err := svc.ForwardImages(context.Background(), c, account, body.Bytes(), parsed, "")
+ result, err := svc.ForwardImages(context.Background(), c, account, body.Bytes(), parsed, "",
+ WithOpenAIImagesSafetyIdentifier("hfc_edit_safety_id"),
+ )
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 1, result.ImageCount)
@@ -505,12 +1185,15 @@ func TestOpenAIGatewayServiceForwardImages_APIKeyEditUsesConfiguredV1BaseURL(t *
require.Contains(t, upstream.lastReq.Header.Get("Content-Type"), "multipart/form-data")
require.Contains(t, string(upstream.lastBody), `name="model"`)
require.Contains(t, string(upstream.lastBody), "gpt-image-2")
+ require.Contains(t, string(upstream.lastBody), `name="moderation"`)
+ require.Contains(t, string(upstream.lastBody), "auto")
+ require.Contains(t, string(upstream.lastBody), `name="safety_identifier"`)
+ require.Contains(t, string(upstream.lastBody), "hfc_edit_safety_id")
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "ZWRpdGVk", gjson.Get(rec.Body.String(), "data.0.b64_json").String())
}
func TestOpenAIGatewayServiceForwardImages_OAuthStreamingTransformsEvents(t *testing.T) {
- gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"url"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
@@ -583,8 +1266,61 @@ func TestOpenAIGatewayServiceForwardImages_OAuthStreamingTransformsEvents(t *tes
require.False(t, gjson.Get(completed.Data, "revised_prompt").Exists())
}
+func TestOpenAIGatewayServiceForwardImages_APIKeyStreamingDrainsAfterClientDisconnect(t *testing.T) {
+ body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true}`)
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+ c.Writer = &failingOpenAIImageWriter{ResponseWriter: c.Writer, failAfter: 1}
+
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{
+ Gateway: config.GatewayConfig{
+ ImageStreamDataIntervalTimeout: 1,
+ ImageStreamKeepaliveInterval: 0,
+ },
+ },
+ httpUpstream: &httpUpstreamRecorder{
+ resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{
+ "Content-Type": []string{"text/event-stream"},
+ "X-Request-Id": []string{"req_img_stream_disconnect_apikey"},
+ },
+ Body: io.NopCloser(strings.NewReader(
+ "data: {\"type\":\"image_generation.partial_image\",\"b64_json\":\"cGFydGlhbA==\"}\n\n" +
+ "data: {\"type\":\"image_generation.completed\",\"usage\":{\"input_tokens\":3,\"output_tokens\":4,\"output_tokens_details\":{\"image_tokens\":2}},\"b64_json\":\"ZmluYWw=\",\"output_format\":\"png\"}\n\n" +
+ "data: [DONE]\n\n",
+ )),
+ },
+ },
+ }
+ parsed, err := svc.ParseOpenAIImagesRequest(c, body)
+ require.NoError(t, err)
+
+ account := &Account{
+ ID: 8,
+ Name: "openai-apikey",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{
+ "api_key": "test-api-key",
+ },
+ }
+
+ result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, 1, result.ImageCount)
+ require.Equal(t, 3, result.Usage.InputTokens)
+ require.Equal(t, 4, result.Usage.OutputTokens)
+ require.Equal(t, 2, result.Usage.ImageOutputTokens)
+}
+
func TestOpenAIGatewayServiceForwardImages_OAuthEditsMultipartUsesResponsesAPI(t *testing.T) {
- gin.SetMode(gin.TestMode)
var body bytes.Buffer
writer := multipart.NewWriter(&body)
@@ -664,7 +1400,6 @@ func TestOpenAIGatewayServiceForwardImages_OAuthEditsMultipartUsesResponsesAPI(t
}
func TestOpenAIGatewayServiceForwardImages_OAuthEditsStreamingTransformsEvents(t *testing.T) {
- gin.SetMode(gin.TestMode)
body := []byte(`{
"model":"gpt-image-2",
"prompt":"replace background with aurora",
@@ -798,8 +1533,24 @@ func TestCollectOpenAIImagesFromResponsesBody_FallsBackToOutputItemDone(t *testi
require.JSONEq(t, `{"images":1}`, string(usageRaw))
}
+func TestCollectOpenAIImagesFromResponsesBody_MultilineSSE(t *testing.T) {
+ body := []byte(
+ "data: {\"type\":\"response.completed\",\n" +
+ "data: \"response\":{\"created_at\":1710000010,\"usage\":{\"input_tokens\":5,\"output_tokens\":9,\"output_tokens_details\":{\"image_tokens\":4}},\"tool_usage\":{\"image_gen\":{\"images\":1}},\"output\":[{\"type\":\"image_generation_call\",\"result\":\"ZmluYWw=\",\"output_format\":\"png\"}]}}\n\n" +
+ "data: [DONE]\n\n",
+ )
+
+ results, createdAt, usageRaw, firstMeta, foundFinal, err := collectOpenAIImagesFromResponsesBody(body)
+ require.NoError(t, err)
+ require.True(t, foundFinal)
+ require.Equal(t, int64(1710000010), createdAt)
+ require.Len(t, results, 1)
+ require.Equal(t, "ZmluYWw=", results[0].Result)
+ require.Equal(t, "png", firstMeta.OutputFormat)
+ require.JSONEq(t, `{"images":1}`, string(usageRaw))
+}
+
func TestOpenAIGatewayServiceForwardImages_OAuthStreamingHandlesOutputItemDoneFallback(t *testing.T) {
- gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"url"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
@@ -854,3 +1605,114 @@ func TestOpenAIGatewayServiceForwardImages_OAuthStreamingHandlesOutputItemDoneFa
require.JSONEq(t, `{"images":1}`, gjson.Get(completed.Data, "usage").Raw)
require.NotContains(t, rec.Body.String(), "event: error")
}
+
+func TestOpenAIGatewayServiceForwardImages_OAuthStreamingHandlesMultilineSSE(t *testing.T) {
+ body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`)
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+
+ svc := &OpenAIGatewayService{}
+ parsed, err := svc.ParseOpenAIImagesRequest(c, body)
+ require.NoError(t, err)
+
+ svc.httpUpstream = &httpUpstreamRecorder{
+ resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{
+ "Content-Type": []string{"text/event-stream"},
+ "X-Request-Id": []string{"req_img_stream_multiline_oauth"},
+ },
+ Body: io.NopCloser(strings.NewReader(
+ "data: {\"type\":\"response.completed\",\n" +
+ "data: \"response\":{\"created_at\":1710000011,\"usage\":{\"input_tokens\":6,\"output_tokens\":10,\"output_tokens_details\":{\"image_tokens\":5}},\"tool_usage\":{\"image_gen\":{\"images\":1}},\"output\":[{\"type\":\"image_generation_call\",\"result\":\"TXVsdGlsaW5l\",\"output_format\":\"png\"}]}}\n\n" +
+ "data: [DONE]\n\n",
+ )),
+ },
+ }
+
+ account := &Account{
+ ID: 11,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Credentials: map[string]any{
+ "access_token": "token-123",
+ },
+ }
+
+ result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.True(t, result.Stream)
+ require.Equal(t, 1, result.ImageCount)
+ require.Equal(t, 6, result.Usage.InputTokens)
+ require.Equal(t, 10, result.Usage.OutputTokens)
+ require.Equal(t, 5, result.Usage.ImageOutputTokens)
+ events := parseOpenAIImageTestSSEEvents(rec.Body.String())
+ completed, ok := findOpenAIImageTestSSEEvent(events, "image_generation.completed")
+ require.True(t, ok)
+ require.Equal(t, "TXVsdGlsaW5l", gjson.Get(completed.Data, "b64_json").String())
+ require.JSONEq(t, `{"images":1}`, gjson.Get(completed.Data, "usage").Raw)
+ require.NotContains(t, rec.Body.String(), "event: error")
+}
+
+func TestOpenAIGatewayServiceForwardImages_OAuthStreamingDrainsAfterClientDisconnect(t *testing.T) {
+ body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"url"}`)
+
+ req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+ c.Writer = &failingOpenAIImageWriter{ResponseWriter: c.Writer, failAfter: 1}
+
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{
+ Gateway: config.GatewayConfig{
+ ImageStreamDataIntervalTimeout: 1,
+ ImageStreamKeepaliveInterval: 0,
+ },
+ },
+ }
+ parsed, err := svc.ParseOpenAIImagesRequest(c, body)
+ require.NoError(t, err)
+
+ upstream := &httpUpstreamRecorder{
+ resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{
+ "Content-Type": []string{"text/event-stream"},
+ "X-Request-Id": []string{"req_img_stream_disconnect_oauth"},
+ },
+ Body: io.NopCloser(strings.NewReader(
+ "data: {\"type\":\"response.image_generation_call.partial_image\",\"partial_image_b64\":\"cGFydGlhbA==\",\"partial_image_index\":0,\"output_format\":\"png\"}\n\n" +
+ "data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000009,\"usage\":{\"input_tokens\":5,\"output_tokens\":9,\"output_tokens_details\":{\"image_tokens\":4}},\"tool_usage\":{\"image_gen\":{\"images\":1}},\"output\":[{\"type\":\"image_generation_call\",\"result\":\"ZmluYWw=\",\"output_format\":\"png\"}]}}\n\n" +
+ "data: [DONE]\n\n",
+ )),
+ },
+ }
+ svc.httpUpstream = upstream
+
+ account := &Account{
+ ID: 9,
+ Name: "openai-oauth",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Credentials: map[string]any{
+ "access_token": "token-123",
+ },
+ }
+
+ result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "")
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.True(t, result.Stream)
+ require.Equal(t, 1, result.ImageCount)
+ require.Equal(t, 5, result.Usage.InputTokens)
+ require.Equal(t, 9, result.Usage.OutputTokens)
+ require.Equal(t, 4, result.Usage.ImageOutputTokens)
+}
diff --git a/backend/internal/service/openai_messages_bridge.go b/backend/internal/service/openai_messages_bridge.go
new file mode 100644
index 00000000000..d67b4b1e918
--- /dev/null
+++ b/backend/internal/service/openai_messages_bridge.go
@@ -0,0 +1,57 @@
+package service
+
+import (
+ "bytes"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/tidwall/gjson"
+)
+
+const openAICompatMessagesBridgeContextKey = "openai_compat_messages_bridge"
+
+func isOpenAICompatMessagesBridgeBody(body []byte) bool {
+ if len(body) == 0 {
+ return false
+ }
+ if bytes.Contains(body, []byte(openAICompatClaudeCodeTodoGuardMarker)) {
+ return true
+ }
+ return isOpenAICompatMessagesBridgePromptCacheKey(gjson.GetBytes(body, "prompt_cache_key").String())
+}
+
+func isOpenAICompatMessagesBridgeRequestBody(reqBody map[string]any) bool {
+ if reqBody == nil {
+ return false
+ }
+ if input, ok := reqBody["input"].([]any); ok && inputContainsText(input, openAICompatClaudeCodeTodoGuardMarker) {
+ return true
+ }
+ return isOpenAICompatMessagesBridgePromptCacheKey(firstNonEmptyString(reqBody["prompt_cache_key"]))
+}
+
+func isOpenAICompatMessagesBridgePromptCacheKey(key string) bool {
+ key = strings.TrimSpace(key)
+ return strings.HasPrefix(key, "anthropic-metadata-") ||
+ strings.HasPrefix(key, "anthropic-cache-") ||
+ strings.HasPrefix(key, "anthropic-digest-")
+}
+
+func setOpenAICompatMessagesBridgeContext(c *gin.Context, enabled bool) {
+ if c == nil || !enabled {
+ return
+ }
+ c.Set(openAICompatMessagesBridgeContextKey, true)
+}
+
+func isOpenAICompatMessagesBridgeContext(c *gin.Context) bool {
+ if c == nil {
+ return false
+ }
+ value, ok := c.Get(openAICompatMessagesBridgeContextKey)
+ if !ok {
+ return false
+ }
+ enabled, ok := value.(bool)
+ return ok && enabled
+}
diff --git a/backend/internal/service/openai_messages_continuation.go b/backend/internal/service/openai_messages_continuation.go
new file mode 100644
index 00000000000..57d0478459b
--- /dev/null
+++ b/backend/internal/service/openai_messages_continuation.go
@@ -0,0 +1,277 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
+ "github.com/gin-gonic/gin"
+ "github.com/tidwall/gjson"
+)
+
+type openAICompatSessionResponseBinding struct {
+ ResponseID string
+ TurnState string
+ ContinuationDisabled bool
+ ExpiresAt time.Time
+}
+
+func openAICompatContinuationEnabled(account *Account, model string) bool {
+ if account == nil || account.Type != AccountTypeAPIKey {
+ return false
+ }
+ return shouldAutoInjectPromptCacheKeyForCompat(model)
+}
+
+func trimAnthropicCompatResponsesInputToLatestTurn(req *apicompat.ResponsesRequest) {
+ if req == nil || len(req.Input) == 0 {
+ return
+ }
+
+ var items []apicompat.ResponsesInputItem
+ if err := json.Unmarshal(req.Input, &items); err != nil || len(items) == 0 {
+ return
+ }
+
+ start := len(items) - 1
+ for start > 0 && items[start].Type == "function_call_output" {
+ start--
+ }
+ trimmed := append([]apicompat.ResponsesInputItem(nil), items[start:]...)
+ if len(trimmed) == len(items) {
+ return
+ }
+ if input, err := json.Marshal(trimmed); err == nil {
+ req.Input = input
+ }
+}
+
+func isOpenAICompatPreviousResponseNotFound(statusCode int, upstreamMsg string, upstreamBody []byte) bool {
+ if statusCode != http.StatusBadRequest && statusCode != http.StatusNotFound {
+ return false
+ }
+ check := func(s string) bool {
+ lower := strings.ToLower(strings.TrimSpace(s))
+ return strings.Contains(lower, "previous_response_not_found") ||
+ (strings.Contains(lower, "previous response") && strings.Contains(lower, "not found")) ||
+ (strings.Contains(lower, "unsupported parameter") && strings.Contains(lower, "previous_response_id"))
+ }
+ if check(upstreamMsg) || check(string(upstreamBody)) {
+ return true
+ }
+ return check(gjson.GetBytes(upstreamBody, "error.code").String()) ||
+ check(gjson.GetBytes(upstreamBody, "error.message").String())
+}
+
+func isOpenAICompatPreviousResponseUnsupported(statusCode int, upstreamMsg string, upstreamBody []byte) bool {
+ if statusCode != http.StatusBadRequest {
+ return false
+ }
+ check := func(s string) bool {
+ lower := strings.ToLower(strings.TrimSpace(s))
+ if !strings.Contains(lower, "previous_response_id") {
+ return false
+ }
+ return strings.Contains(lower, "unsupported parameter") ||
+ strings.Contains(lower, "only supported on responses websocket") ||
+ strings.Contains(lower, "not supported")
+ }
+ if check(upstreamMsg) || check(string(upstreamBody)) {
+ return true
+ }
+ return check(gjson.GetBytes(upstreamBody, "error.code").String()) ||
+ check(gjson.GetBytes(upstreamBody, "error.message").String())
+}
+
+func openAICompatSessionResponseKey(c *gin.Context, account *Account, promptCacheKey string) string {
+ key := strings.TrimSpace(promptCacheKey)
+ if account == nil || key == "" {
+ return ""
+ }
+ apiKeyID := int64(0)
+ if c != nil {
+ apiKeyID = getAPIKeyIDFromContext(c)
+ }
+ return strings.Join([]string{
+ strconv.FormatInt(account.ID, 10),
+ strconv.FormatInt(apiKeyID, 10),
+ key,
+ }, "\x00")
+}
+
+func (s *OpenAIGatewayService) getOpenAICompatSessionResponseID(_ context.Context, c *gin.Context, account *Account, promptCacheKey string) string {
+ if s == nil {
+ return ""
+ }
+ key := openAICompatSessionResponseKey(c, account, promptCacheKey)
+ if key == "" {
+ return ""
+ }
+ raw, ok := s.openaiCompatSessionResponses.Load(key)
+ if !ok {
+ return ""
+ }
+ binding, ok := raw.(openAICompatSessionResponseBinding)
+ if !ok {
+ s.openaiCompatSessionResponses.Delete(key)
+ return ""
+ }
+ if !binding.ExpiresAt.IsZero() && time.Now().After(binding.ExpiresAt) {
+ s.openaiCompatSessionResponses.Delete(key)
+ return ""
+ }
+ if binding.ContinuationDisabled {
+ return ""
+ }
+ if strings.TrimSpace(binding.ResponseID) == "" {
+ s.openaiCompatSessionResponses.Delete(key)
+ return ""
+ }
+ return strings.TrimSpace(binding.ResponseID)
+}
+
+func (s *OpenAIGatewayService) bindOpenAICompatSessionResponseID(_ context.Context, c *gin.Context, account *Account, promptCacheKey, responseID string) {
+ if s == nil {
+ return
+ }
+ key := openAICompatSessionResponseKey(c, account, promptCacheKey)
+ id := strings.TrimSpace(responseID)
+ if key == "" || id == "" {
+ return
+ }
+ binding := openAICompatSessionResponseBinding{
+ ResponseID: id,
+ ExpiresAt: time.Now().Add(s.openAIWSResponseStickyTTL()),
+ }
+ if raw, ok := s.openaiCompatSessionResponses.Load(key); ok {
+ if existing, ok := raw.(openAICompatSessionResponseBinding); ok {
+ if existing.ContinuationDisabled {
+ existing.ResponseID = ""
+ existing.ExpiresAt = time.Now().Add(s.openAIWSResponseStickyTTL())
+ s.openaiCompatSessionResponses.Store(key, existing)
+ return
+ }
+ binding.TurnState = existing.TurnState
+ }
+ }
+ s.openaiCompatSessionResponses.Store(key, binding)
+}
+
+func (s *OpenAIGatewayService) deleteOpenAICompatSessionResponseID(_ context.Context, c *gin.Context, account *Account, promptCacheKey string) {
+ if s == nil {
+ return
+ }
+ key := openAICompatSessionResponseKey(c, account, promptCacheKey)
+ if key == "" {
+ return
+ }
+ raw, ok := s.openaiCompatSessionResponses.Load(key)
+ if !ok {
+ return
+ }
+ binding, ok := raw.(openAICompatSessionResponseBinding)
+ if !ok {
+ s.openaiCompatSessionResponses.Delete(key)
+ return
+ }
+ binding.ResponseID = ""
+ if strings.TrimSpace(binding.TurnState) == "" && !binding.ContinuationDisabled {
+ s.openaiCompatSessionResponses.Delete(key)
+ return
+ }
+ binding.ExpiresAt = time.Now().Add(s.openAIWSResponseStickyTTL())
+ s.openaiCompatSessionResponses.Store(key, binding)
+}
+
+func (s *OpenAIGatewayService) disableOpenAICompatSessionContinuation(_ context.Context, c *gin.Context, account *Account, promptCacheKey string) {
+ if s == nil {
+ return
+ }
+ key := openAICompatSessionResponseKey(c, account, promptCacheKey)
+ if key == "" {
+ return
+ }
+ binding := openAICompatSessionResponseBinding{
+ ContinuationDisabled: true,
+ ExpiresAt: time.Now().Add(s.openAIWSResponseStickyTTL()),
+ }
+ if raw, ok := s.openaiCompatSessionResponses.Load(key); ok {
+ if existing, ok := raw.(openAICompatSessionResponseBinding); ok {
+ binding.TurnState = existing.TurnState
+ }
+ }
+ s.openaiCompatSessionResponses.Store(key, binding)
+}
+
+func (s *OpenAIGatewayService) isOpenAICompatSessionContinuationDisabled(_ context.Context, c *gin.Context, account *Account, promptCacheKey string) bool {
+ if s == nil {
+ return false
+ }
+ key := openAICompatSessionResponseKey(c, account, promptCacheKey)
+ if key == "" {
+ return false
+ }
+ raw, ok := s.openaiCompatSessionResponses.Load(key)
+ if !ok {
+ return false
+ }
+ binding, ok := raw.(openAICompatSessionResponseBinding)
+ if !ok {
+ s.openaiCompatSessionResponses.Delete(key)
+ return false
+ }
+ if !binding.ExpiresAt.IsZero() && time.Now().After(binding.ExpiresAt) {
+ s.openaiCompatSessionResponses.Delete(key)
+ return false
+ }
+ return binding.ContinuationDisabled
+}
+
+func (s *OpenAIGatewayService) getOpenAICompatSessionTurnState(_ context.Context, c *gin.Context, account *Account, promptCacheKey string) string {
+ if s == nil {
+ return ""
+ }
+ key := openAICompatSessionResponseKey(c, account, promptCacheKey)
+ if key == "" {
+ return ""
+ }
+ raw, ok := s.openaiCompatSessionResponses.Load(key)
+ if !ok {
+ return ""
+ }
+ binding, ok := raw.(openAICompatSessionResponseBinding)
+ if !ok || strings.TrimSpace(binding.TurnState) == "" {
+ return ""
+ }
+ if !binding.ExpiresAt.IsZero() && time.Now().After(binding.ExpiresAt) {
+ s.openaiCompatSessionResponses.Delete(key)
+ return ""
+ }
+ return strings.TrimSpace(binding.TurnState)
+}
+
+func (s *OpenAIGatewayService) bindOpenAICompatSessionTurnState(_ context.Context, c *gin.Context, account *Account, promptCacheKey, turnState string) {
+ if s == nil {
+ return
+ }
+ key := openAICompatSessionResponseKey(c, account, promptCacheKey)
+ state := strings.TrimSpace(turnState)
+ if key == "" || state == "" {
+ return
+ }
+ binding := openAICompatSessionResponseBinding{
+ TurnState: state,
+ ExpiresAt: time.Now().Add(s.openAIWSResponseStickyTTL()),
+ }
+ if raw, ok := s.openaiCompatSessionResponses.Load(key); ok {
+ if existing, ok := raw.(openAICompatSessionResponseBinding); ok {
+ binding.ResponseID = existing.ResponseID
+ binding.ContinuationDisabled = existing.ContinuationDisabled
+ }
+ }
+ s.openaiCompatSessionResponses.Store(key, binding)
+}
diff --git a/backend/internal/service/openai_messages_digest_session.go b/backend/internal/service/openai_messages_digest_session.go
new file mode 100644
index 00000000000..44a49d1e399
--- /dev/null
+++ b/backend/internal/service/openai_messages_digest_session.go
@@ -0,0 +1,135 @@
+package service
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
+)
+
+type openAICompatAnthropicDigestBinding struct {
+ PromptCacheKey string
+ ExpiresAt time.Time
+}
+
+func buildOpenAICompatAnthropicDigestChain(req *apicompat.AnthropicRequest) string {
+ if req == nil {
+ return ""
+ }
+
+ parts := make([]string, 0, len(req.Messages)+1)
+ if len(req.System) > 0 && strings.TrimSpace(string(req.System)) != "" && strings.TrimSpace(string(req.System)) != "null" {
+ parts = append(parts, "s:"+shortHash(req.System))
+ }
+ for _, msg := range req.Messages {
+ content := msg.Content
+ if len(content) == 0 || strings.TrimSpace(string(content)) == "" {
+ continue
+ }
+ prefix := "u"
+ if strings.TrimSpace(msg.Role) == "assistant" {
+ prefix = "a"
+ }
+ parts = append(parts, prefix+":"+shortHash(content))
+ }
+ return strings.Join(parts, "-")
+}
+
+func openAICompatAnthropicDigestNamespace(account *Account, cAPIKeyID int64) string {
+ if account == nil || account.ID <= 0 {
+ return ""
+ }
+ return fmt.Sprintf("%d|%d|", account.ID, cAPIKeyID)
+}
+
+func (s *OpenAIGatewayService) findOpenAICompatAnthropicDigestPromptCacheKey(account *Account, cAPIKeyID int64, digestChain string) (promptCacheKey string, matchedChain string) {
+ if s == nil || digestChain == "" {
+ return "", ""
+ }
+ ns := openAICompatAnthropicDigestNamespace(account, cAPIKeyID)
+ if ns == "" {
+ return "", ""
+ }
+ chain := digestChain
+ for {
+ if raw, ok := s.openaiCompatAnthropicDigestSessions.Load(ns + chain); ok {
+ if binding, ok := raw.(openAICompatAnthropicDigestBinding); ok {
+ if binding.ExpiresAt.IsZero() || time.Now().Before(binding.ExpiresAt) {
+ if key := strings.TrimSpace(binding.PromptCacheKey); key != "" {
+ return key, chain
+ }
+ }
+ }
+ s.openaiCompatAnthropicDigestSessions.Delete(ns + chain)
+ }
+ i := strings.LastIndex(chain, "-")
+ if i < 0 {
+ return "", ""
+ }
+ chain = chain[:i]
+ }
+}
+
+func (s *OpenAIGatewayService) bindOpenAICompatAnthropicDigestPromptCacheKey(account *Account, cAPIKeyID int64, digestChain, promptCacheKey, oldDigestChain string) {
+ if s == nil || digestChain == "" || strings.TrimSpace(promptCacheKey) == "" {
+ return
+ }
+ ns := openAICompatAnthropicDigestNamespace(account, cAPIKeyID)
+ if ns == "" {
+ return
+ }
+ binding := openAICompatAnthropicDigestBinding{
+ PromptCacheKey: strings.TrimSpace(promptCacheKey),
+ ExpiresAt: time.Now().Add(s.openAIWSResponseStickyTTL()),
+ }
+ s.openaiCompatAnthropicDigestSessions.Store(ns+digestChain, binding)
+ if oldDigestChain != "" && oldDigestChain != digestChain {
+ s.openaiCompatAnthropicDigestSessions.Delete(ns + oldDigestChain)
+ }
+}
+
+func promptCacheKeyFromAnthropicDigest(digestChain string) string {
+ if strings.TrimSpace(digestChain) == "" {
+ return ""
+ }
+ return "anthropic-digest-" + hashSensitiveValueForLog(digestChain)
+}
+
+func promptCacheKeyFromAnthropicMetadataSession(req *apicompat.AnthropicRequest) string {
+ if req == nil || len(req.Metadata) == 0 {
+ return ""
+ }
+ var metadata struct {
+ UserID string `json:"user_id"`
+ }
+ if err := json.Unmarshal(req.Metadata, &metadata); err != nil {
+ return ""
+ }
+ parsed := ParseMetadataUserID(metadata.UserID)
+ if parsed == nil || strings.TrimSpace(parsed.SessionID) == "" {
+ return ""
+ }
+ seed := strings.Join([]string{
+ "anthropic-metadata",
+ strings.TrimSpace(parsed.DeviceID),
+ strings.TrimSpace(parsed.AccountUUID),
+ strings.TrimSpace(parsed.SessionID),
+ }, "|")
+ return "anthropic-metadata-" + hashSensitiveValueForLog(seed)
+}
+
+func cloneAnthropicRequestForDigest(req *apicompat.AnthropicRequest) *apicompat.AnthropicRequest {
+ if req == nil {
+ return nil
+ }
+ cp := *req
+ if len(req.System) > 0 {
+ cp.System = append(json.RawMessage(nil), req.System...)
+ }
+ if len(req.Messages) > 0 {
+ cp.Messages = append([]apicompat.AnthropicMessage(nil), req.Messages...)
+ }
+ return &cp
+}
diff --git a/backend/internal/service/openai_messages_dispatch_test.go b/backend/internal/service/openai_messages_dispatch_test.go
index a625aaddd43..b764f04b576 100644
--- a/backend/internal/service/openai_messages_dispatch_test.go
+++ b/backend/internal/service/openai_messages_dispatch_test.go
@@ -25,3 +25,17 @@ func TestNormalizeOpenAIMessagesDispatchModelConfig(t *testing.T) {
"claude-sonnet-4-5-20250929": "gpt-5.2",
}, cfg.ExactModelMappings)
}
+
+func TestResolveMessagesDispatchModel_NativeGPT56DoesNotEnterClaudeFamilyMapping(t *testing.T) {
+ t.Parallel()
+
+ group := &Group{MessagesDispatchModelConfig: OpenAIMessagesDispatchModelConfig{
+ OpusMappedModel: "gpt-5.6-sol",
+ SonnetMappedModel: "gpt-5.6-terra",
+ HaikuMappedModel: "gpt-5.6-luna",
+ }}
+
+ for _, model := range []string{"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"} {
+ require.Empty(t, group.ResolveMessagesDispatchModel(model), "native GPT model %q must bypass Claude family dispatch", model)
+ }
+}
diff --git a/backend/internal/service/openai_messages_replay_guard.go b/backend/internal/service/openai_messages_replay_guard.go
new file mode 100644
index 00000000000..2ad9b6bc462
--- /dev/null
+++ b/backend/internal/service/openai_messages_replay_guard.go
@@ -0,0 +1,90 @@
+package service
+
+import (
+ "encoding/json"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
+)
+
+const openAICompatAnthropicReplayMaxTailMessages = 12
+
+func applyAnthropicCompatFullReplayGuard(req *apicompat.AnthropicRequest) bool {
+ if req == nil || len(req.Messages) <= openAICompatAnthropicReplayMaxTailMessages {
+ return false
+ }
+
+ start := len(req.Messages) - openAICompatAnthropicReplayMaxTailMessages
+ start = expandAnthropicCompatTrimBoundary(req.Messages, start)
+ if start <= 0 {
+ return false
+ }
+
+ req.Messages = append([]apicompat.AnthropicMessage(nil), req.Messages[start:]...)
+ return true
+}
+
+func expandAnthropicCompatTrimBoundary(messages []apicompat.AnthropicMessage, start int) int {
+ if start <= 0 || start >= len(messages) {
+ return start
+ }
+
+ toolUseIndex := make(map[string]int)
+ toolResultIndex := make(map[string]int)
+ for i, msg := range messages {
+ uses, results := anthropicCompatMessageToolIDs(msg)
+ for _, id := range uses {
+ if _, exists := toolUseIndex[id]; !exists {
+ toolUseIndex[id] = i
+ }
+ }
+ for _, id := range results {
+ if _, exists := toolResultIndex[id]; !exists {
+ toolResultIndex[id] = i
+ }
+ }
+ }
+
+ for {
+ next := start
+ for i := start; i < len(messages); i++ {
+ uses, results := anthropicCompatMessageToolIDs(messages[i])
+ for _, id := range results {
+ if useIdx, ok := toolUseIndex[id]; ok && useIdx < next {
+ next = useIdx
+ }
+ }
+ for _, id := range uses {
+ if resultIdx, ok := toolResultIndex[id]; ok && resultIdx < next {
+ next = resultIdx
+ }
+ }
+ }
+ if next == start {
+ return start
+ }
+ start = next
+ }
+}
+
+func anthropicCompatMessageToolIDs(msg apicompat.AnthropicMessage) ([]string, []string) {
+ var blocks []apicompat.AnthropicContentBlock
+ if err := json.Unmarshal(msg.Content, &blocks); err != nil {
+ return nil, nil
+ }
+
+ uses := make([]string, 0, 1)
+ results := make([]string, 0, 1)
+ for _, block := range blocks {
+ switch block.Type {
+ case "tool_use":
+ if block.ID != "" {
+ uses = append(uses, block.ID)
+ }
+ case "tool_result":
+ if block.ToolUseID != "" {
+ results = append(results, block.ToolUseID)
+ }
+ }
+ }
+ return uses, results
+}
diff --git a/backend/internal/service/openai_messages_replay_guard_test.go b/backend/internal/service/openai_messages_replay_guard_test.go
new file mode 100644
index 00000000000..6176beeca73
--- /dev/null
+++ b/backend/internal/service/openai_messages_replay_guard_test.go
@@ -0,0 +1,58 @@
+package service
+
+import (
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
+ "github.com/stretchr/testify/require"
+)
+
+func TestApplyAnthropicCompatFullReplayGuard_TrimsOldMessages(t *testing.T) {
+ t.Parallel()
+
+ req := &apicompat.AnthropicRequest{Messages: make([]apicompat.AnthropicMessage, 0, openAICompatAnthropicReplayMaxTailMessages+3)}
+ for i := 0; i < openAICompatAnthropicReplayMaxTailMessages+3; i++ {
+ req.Messages = append(req.Messages, apicompat.AnthropicMessage{
+ Role: "user",
+ Content: json.RawMessage(fmt.Sprintf(`"message-%02d"`, i)),
+ })
+ }
+
+ trimmed := applyAnthropicCompatFullReplayGuard(req)
+
+ require.True(t, trimmed)
+ require.Len(t, req.Messages, openAICompatAnthropicReplayMaxTailMessages)
+ require.JSONEq(t, `"message-03"`, string(req.Messages[0].Content))
+ require.JSONEq(t, `"message-14"`, string(req.Messages[len(req.Messages)-1].Content))
+}
+
+func TestApplyAnthropicCompatFullReplayGuard_KeepsToolBoundaryIntact(t *testing.T) {
+ t.Parallel()
+
+ req := &apicompat.AnthropicRequest{Messages: make([]apicompat.AnthropicMessage, 0, openAICompatAnthropicReplayMaxTailMessages+3)}
+ for i := 0; i < openAICompatAnthropicReplayMaxTailMessages+3; i++ {
+ role := "user"
+ content := json.RawMessage(fmt.Sprintf(`"message-%02d"`, i))
+ if i == 1 {
+ role = "assistant"
+ content = json.RawMessage(`[{"type":"tool_use","id":"toolu_keep","name":"Read","input":{"file_path":"main.go"}}]`)
+ }
+ if i == 3 {
+ content = json.RawMessage(`[{"type":"tool_result","tool_use_id":"toolu_keep","content":"ok"}]`)
+ }
+ req.Messages = append(req.Messages, apicompat.AnthropicMessage{
+ Role: role,
+ Content: content,
+ })
+ }
+
+ trimmed := applyAnthropicCompatFullReplayGuard(req)
+
+ require.True(t, trimmed)
+ require.Len(t, req.Messages, openAICompatAnthropicReplayMaxTailMessages+2)
+ require.Equal(t, "assistant", req.Messages[0].Role)
+ require.Contains(t, string(req.Messages[0].Content), `"toolu_keep"`)
+ require.Contains(t, string(req.Messages[2].Content), `"tool_result"`)
+}
diff --git a/backend/internal/service/openai_messages_todo_guard.go b/backend/internal/service/openai_messages_todo_guard.go
new file mode 100644
index 00000000000..96fc90cbe11
--- /dev/null
+++ b/backend/internal/service/openai_messages_todo_guard.go
@@ -0,0 +1,121 @@
+package service
+
+import (
+ "encoding/json"
+ "strings"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
+)
+
+const (
+ openAICompatClaudeCodeTodoGuardMarker = ""
+ openAICompatClaudeCodeTodoGuardText = openAICompatClaudeCodeTodoGuardMarker + "\nWhen using Claude Code todo or task tracking tools, keep the visible task list consistent. Do not send final or summary text while any item remains in_progress. Before finishing, asking the user to choose, or reporting a blocker, update the todo list so completed work is completed and deferred work is pending/open; leave an item in_progress only when active work will continue in the same turn.\n "
+)
+
+func appendOpenAICompatClaudeCodeTodoGuard(req *apicompat.ResponsesRequest) bool {
+ if req == nil || len(req.Input) == 0 {
+ return false
+ }
+
+ var items []apicompat.ResponsesInputItem
+ if err := json.Unmarshal(req.Input, &items); err != nil {
+ return false
+ }
+ if len(items) == 0 || responsesInputItemsContainText(items, openAICompatClaudeCodeTodoGuardMarker) {
+ return false
+ }
+
+ content, err := json.Marshal([]apicompat.ResponsesContentPart{{
+ Type: "input_text",
+ Text: openAICompatClaudeCodeTodoGuardText,
+ }})
+ if err != nil {
+ return false
+ }
+
+ guard := apicompat.ResponsesInputItem{
+ Type: "message",
+ Role: "developer",
+ Content: content,
+ }
+
+ insertAt := 0
+ for insertAt < len(items) && items[insertAt].Type == "message" && items[insertAt].Role == "developer" {
+ insertAt++
+ }
+
+ items = append(items, apicompat.ResponsesInputItem{})
+ copy(items[insertAt+1:], items[insertAt:])
+ items[insertAt] = guard
+
+ input, err := json.Marshal(items)
+ if err != nil {
+ return false
+ }
+ req.Input = input
+ return true
+}
+
+func appendOpenAICompatClaudeCodeTodoGuardToRequestBody(reqBody map[string]any) bool {
+ if reqBody == nil {
+ return false
+ }
+
+ input, ok := reqBody["input"].([]any)
+ if !ok || len(input) == 0 || inputContainsText(input, openAICompatClaudeCodeTodoGuardMarker) {
+ return false
+ }
+
+ guard := map[string]any{
+ "type": "message",
+ "role": "developer",
+ "content": []any{
+ map[string]any{
+ "type": "input_text",
+ "text": openAICompatClaudeCodeTodoGuardText,
+ },
+ },
+ }
+
+ insertAt := 0
+ for insertAt < len(input) {
+ item, ok := input[insertAt].(map[string]any)
+ if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "message" || strings.TrimSpace(firstNonEmptyString(item["role"])) != "developer" {
+ break
+ }
+ insertAt++
+ }
+
+ input = append(input, nil)
+ copy(input[insertAt+1:], input[insertAt:])
+ input[insertAt] = guard
+ reqBody["input"] = input
+ return true
+}
+
+func responsesInputItemsContainText(items []apicompat.ResponsesInputItem, needle string) bool {
+ needle = strings.TrimSpace(needle)
+ if needle == "" {
+ return false
+ }
+ for _, item := range items {
+ if strings.Contains(string(item.Content), needle) {
+ return true
+ }
+ }
+ return false
+}
+
+func inputContainsText(input []any, needle string) bool {
+ needle = strings.TrimSpace(needle)
+ if needle == "" {
+ return false
+ }
+ for _, item := range input {
+ b, err := json.Marshal(item)
+ if err == nil && strings.Contains(string(b), needle) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/backend/internal/service/openai_model_alias.go b/backend/internal/service/openai_model_alias.go
new file mode 100644
index 00000000000..1504217a1d7
--- /dev/null
+++ b/backend/internal/service/openai_model_alias.go
@@ -0,0 +1,174 @@
+package service
+
+import "strings"
+
+func lastOpenAIModelSegment(model string) string {
+ model = strings.TrimSpace(model)
+ if model == "" {
+ return ""
+ }
+ if strings.Contains(model, "/") {
+ parts := strings.Split(model, "/")
+ model = parts[len(parts)-1]
+ }
+ return strings.TrimSpace(model)
+}
+
+func canonicalizeOpenAIModelAliasSpelling(model string) string {
+ model = strings.ToLower(lastOpenAIModelSegment(model))
+ if model == "" {
+ return ""
+ }
+
+ normalized := strings.ReplaceAll(model, "_", "-")
+ normalized = strings.Join(strings.Fields(normalized), "-")
+ for strings.Contains(normalized, "--") {
+ normalized = strings.ReplaceAll(normalized, "--", "-")
+ }
+
+ if strings.HasPrefix(normalized, "gpt5") {
+ normalized = "gpt-5" + strings.TrimPrefix(normalized, "gpt5")
+ }
+ if !strings.HasPrefix(normalized, "gpt-") && !strings.Contains(normalized, "codex") {
+ return ""
+ }
+
+ replacements := []struct {
+ from string
+ to string
+ }{
+ {"gpt-5.4mini", "gpt-5.4-mini"},
+ {"gpt-5.4nano", "gpt-5.4-nano"},
+ {"gpt-5.3-codexspark", "gpt-5.3-codex-spark"},
+ {"gpt-5.3codexspark", "gpt-5.3-codex-spark"},
+ {"gpt-5.3codex", "gpt-5.3-codex"},
+ }
+ for _, replacement := range replacements {
+ normalized = strings.ReplaceAll(normalized, replacement.from, replacement.to)
+ }
+ return normalized
+}
+
+func normalizeKnownOpenAICodexModel(model string) string {
+ normalized := canonicalizeOpenAIModelAliasSpelling(model)
+ if normalized == "" {
+ return ""
+ }
+
+ if normalizedGPT56, isGPT56Family := classifyOpenAIGPT56PreviewModel(normalized); isGPT56Family {
+ return normalizedGPT56
+ }
+ if mapped := getNormalizedCodexModel(normalized); mapped != "" {
+ return mapped
+ }
+ if strings.HasSuffix(normalized, "-openai-compact") {
+ if mapped := getNormalizedCodexModel(strings.TrimSuffix(normalized, "-openai-compact")); mapped != "" {
+ return mapped
+ }
+ }
+
+ switch {
+ case strings.Contains(normalized, "gpt-5.5"):
+ return "gpt-5.5"
+ case strings.Contains(normalized, "gpt-5.4-mini"):
+ return "gpt-5.4-mini"
+ case strings.Contains(normalized, "gpt-5.4-nano"):
+ return "gpt-5.4-nano"
+ case strings.Contains(normalized, "gpt-5.4"):
+ return "gpt-5.4"
+ case strings.Contains(normalized, "gpt-5.2"):
+ return "gpt-5.2"
+ case strings.Contains(normalized, "gpt-5.3-codex-spark"):
+ return "gpt-5.3-codex-spark"
+ case strings.Contains(normalized, "gpt-5.3-codex"):
+ return "gpt-5.3-codex"
+ case strings.Contains(normalized, "gpt-5.3"):
+ return "gpt-5.3-codex"
+ case strings.Contains(normalized, "codex"):
+ return "gpt-5.3-codex"
+ case strings.Contains(normalized, "gpt-5.7"):
+ return ""
+ case strings.Contains(normalized, "gpt-5"):
+ return "gpt-5.4"
+ default:
+ return ""
+ }
+}
+
+func classifyOpenAIGPT56PreviewModel(model string) (normalizedModel string, isFamily bool) {
+ canonical := canonicalizeOpenAIModelAliasSpelling(model)
+ if canonical == "" || !strings.Contains(canonical, "gpt-5.6") {
+ return "", false
+ }
+ mapped := getNormalizedCodexModel(canonical)
+ switch mapped {
+ case "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna":
+ return mapped, true
+ default:
+ return "", true
+ }
+}
+
+// NormalizeOpenAIGPT56PreviewModel returns the exact canonical preview tier
+// and reports whether the input belongs to the GPT-5.6 family.
+func NormalizeOpenAIGPT56PreviewModel(model string) (normalizedModel string, isFamily bool) {
+ return classifyOpenAIGPT56PreviewModel(model)
+}
+
+// ValidateOpenAIGPT56ModelTransition enforces the preview-tier boundary for
+// any explicit model mapping. Exact preview requests may only map within the
+// same tier; malformed preview family names fail closed. Legacy models may map
+// to an exact preview tier, preserving the existing compatibility direction.
+func ValidateOpenAIGPT56ModelTransition(requestedModel, targetModel string) bool {
+ targetModel = strings.TrimSpace(targetModel)
+ if targetModel == "" {
+ return false
+ }
+
+ requestedTier, requestedIsFamily := classifyOpenAIGPT56PreviewModel(requestedModel)
+ targetTier, targetIsFamily := classifyOpenAIGPT56PreviewModel(targetModel)
+ if requestedIsFamily {
+ return requestedTier != "" && targetIsFamily && targetTier == requestedTier
+ }
+ if targetIsFamily {
+ return targetTier != ""
+ }
+ return true
+}
+
+func appendUsageBillingModelCandidate(candidates []string, seen map[string]struct{}, model string) []string {
+ trimmed := strings.TrimSpace(model)
+ if trimmed == "" {
+ return candidates
+ }
+ add := func(candidate string) {
+ candidate = strings.TrimSpace(candidate)
+ if candidate == "" {
+ return
+ }
+ key := strings.ToLower(candidate)
+ if _, ok := seen[key]; ok {
+ return
+ }
+ seen[key] = struct{}{}
+ candidates = append(candidates, candidate)
+ }
+
+ add(trimmed)
+ if canonical := canonicalizeOpenAIModelAliasSpelling(trimmed); canonical != "" {
+ add(canonical)
+ }
+ if normalized := normalizeKnownOpenAICodexModel(trimmed); normalized != "" {
+ add(normalized)
+ }
+ return candidates
+}
+
+func usageBillingModelCandidates(primary string, alternates ...string) []string {
+ seen := make(map[string]struct{}, 1+len(alternates))
+ candidates := appendUsageBillingModelCandidate(nil, seen, primary)
+ for _, alternate := range alternates {
+ candidates = appendUsageBillingModelCandidate(candidates, seen, alternate)
+ }
+ return candidates
+}
diff --git a/backend/internal/service/openai_model_alias_test.go b/backend/internal/service/openai_model_alias_test.go
new file mode 100644
index 00000000000..7cce423d11b
--- /dev/null
+++ b/backend/internal/service/openai_model_alias_test.go
@@ -0,0 +1,71 @@
+package service
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ openaiModels "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNormalizeKnownOpenAICodexModel_GPT56ExactTiers(t *testing.T) {
+ t.Parallel()
+
+ tiers := []string{"sol", "terra", "luna"}
+ suffixes := []string{"none", "low", "medium", "high", "xhigh"}
+ for _, tier := range tiers {
+ tier := tier
+ exact := "gpt-5.6-" + tier
+ cases := []string{
+ exact,
+ "openai/" + exact,
+ strings.ToUpper("OPENAI/" + exact),
+ }
+ for _, suffix := range suffixes {
+ cases = append(cases, exact+"-"+suffix)
+ }
+ for _, input := range cases {
+ input := input
+ t.Run(fmt.Sprintf("%s/%s", tier, input), func(t *testing.T) {
+ require.Equal(t, exact, normalizeKnownOpenAICodexModel(input))
+ })
+ }
+ }
+}
+
+func TestNormalizeKnownOpenAICodexModel_UnknownGPT56FailsClosed(t *testing.T) {
+ t.Parallel()
+
+ for _, input := range []string{
+ "gpt-5.6",
+ "gpt-5.6-unknown",
+ "gpt-5.6-sol-turbo",
+ "gpt-5.6-unknown-high",
+ "gpt-5.6-sol-minimal",
+ "gpt-5.6-sol-20260710",
+ "gpt-5.6-sol-openai-compact",
+ "gpt-5.7",
+ } {
+ input := input
+ t.Run(input, func(t *testing.T) {
+ require.Empty(t, normalizeKnownOpenAICodexModel(input))
+ })
+ }
+}
+
+func TestDefaultOpenAIModels_HideGPT56PreviewTiers(t *testing.T) {
+ t.Parallel()
+
+ require.Empty(t, filterGPT56ModelIDs(openaiModels.DefaultModelIDs()))
+}
+
+func filterGPT56ModelIDs(modelIDs []string) []string {
+ filtered := make([]string, 0, 3)
+ for _, modelID := range modelIDs {
+ if strings.HasPrefix(modelID, "gpt-5.6") {
+ filtered = append(filtered, modelID)
+ }
+ }
+ return filtered
+}
diff --git a/backend/internal/service/openai_model_mapping.go b/backend/internal/service/openai_model_mapping.go
index f332633c86e..671240193a4 100644
--- a/backend/internal/service/openai_model_mapping.go
+++ b/backend/internal/service/openai_model_mapping.go
@@ -2,59 +2,60 @@ package service
import "strings"
-// resolveOpenAIForwardModel determines the upstream model for OpenAI-compatible
-// forwarding. Group-level default mapping only applies when the account itself
-// did not match any explicit model_mapping rule.
+// resolveOpenAIForwardModel 解析 OpenAI 兼容转发使用的模型。
+// defaultMappedModel 只服务于 /v1/messages 的 Claude 系列显式调度映射,
+// 不作为普通 OpenAI 请求的未知模型兜底。
func resolveOpenAIForwardModel(account *Account, requestedModel, defaultMappedModel string) string {
+ defaultMappedModel = strings.TrimSpace(defaultMappedModel)
+ hasMessagesDispatch := defaultMappedModel != "" && claudeMessagesDispatchFamily(requestedModel) != ""
if account == nil {
- if defaultMappedModel != "" {
+ if hasMessagesDispatch {
return defaultMappedModel
}
return requestedModel
}
- mappedModel, matched := account.ResolveMappedModel(requestedModel)
- if !matched && defaultMappedModel != "" && !isExplicitCodexModel(requestedModel) {
- return defaultMappedModel
- }
- return mappedModel
-}
-
-func isExplicitCodexModel(model string) bool {
- model = strings.TrimSpace(model)
- if model == "" {
- return false
- }
- if strings.Contains(model, "/") {
- parts := strings.Split(model, "/")
- model = parts[len(parts)-1]
- }
- model = strings.ToLower(strings.TrimSpace(model))
- if getNormalizedCodexModel(model) != "" {
- return true
+ targetModel := requestedModel
+ if hasMessagesDispatch {
+ if _, aliasMatched := resolveRequestedModelInMapping(account.GetModelMapping(), requestedModel); aliasMatched && !account.IsModelSupported(requestedModel) {
+ return ""
+ }
+ targetModel = defaultMappedModel
}
- if strings.HasSuffix(model, "-openai-compact") {
- base := strings.TrimSuffix(model, "-openai-compact")
- return getNormalizedCodexModel(base) != ""
+ if !account.IsModelSupported(targetModel) {
+ return ""
}
- return false
+ mappedModel, _ := account.ResolveMappedModel(targetModel)
+ return mappedModel
}
// resolveOpenAICompactForwardModel determines the compact-only upstream model
// for /responses/compact requests. It never affects normal /responses traffic.
// When no compact-specific mapping matches, the input model is returned as-is.
func resolveOpenAICompactForwardModel(account *Account, model string) string {
+ mappedModel, valid := resolveOpenAICompactForwardModelWithValidity(account, model)
+ if !valid {
+ return ""
+ }
+ return mappedModel
+}
+
+func resolveOpenAICompactForwardModelWithValidity(account *Account, model string) (string, bool) {
trimmedModel := strings.TrimSpace(model)
- if trimmedModel == "" || account == nil {
- return trimmedModel
+ if trimmedModel == "" {
+ return "", false
+ }
+ if account == nil {
+ return trimmedModel, ValidateOpenAIGPT56ModelTransition(trimmedModel, trimmedModel)
}
mappedModel, matched := account.ResolveCompactMappedModel(trimmedModel)
if !matched {
- return trimmedModel
+ return trimmedModel, ValidateOpenAIGPT56ModelTransition(trimmedModel, trimmedModel)
}
- if trimmedMapped := strings.TrimSpace(mappedModel); trimmedMapped != "" {
- return trimmedMapped
+ trimmedMapped := strings.TrimSpace(mappedModel)
+ if !ValidateOpenAIGPT56ModelTransition(trimmedModel, trimmedMapped) {
+ return "", false
}
- return trimmedModel
+ return trimmedMapped, true
}
diff --git a/backend/internal/service/openai_model_mapping_test.go b/backend/internal/service/openai_model_mapping_test.go
index 4802c0890fb..8e32ea2e501 100644
--- a/backend/internal/service/openai_model_mapping_test.go
+++ b/backend/internal/service/openai_model_mapping_test.go
@@ -11,7 +11,7 @@ func TestResolveOpenAIForwardModel(t *testing.T) {
expectedModel string
}{
{
- name: "falls back to group default when account has no mapping",
+ name: "uses messages dispatch default for claude model",
account: &Account{
Credentials: map[string]any{},
},
@@ -19,6 +19,15 @@ func TestResolveOpenAIForwardModel(t *testing.T) {
defaultMappedModel: "gpt-4o-mini",
expectedModel: "gpt-4o-mini",
},
+ {
+ name: "does not fall back to group default for invalid gpt model",
+ account: &Account{
+ Credentials: map[string]any{},
+ },
+ requestedModel: "gpt6",
+ defaultMappedModel: "gpt-5.4",
+ expectedModel: "gpt6",
+ },
{
name: "preserves explicit gpt-5.4 instead of group default",
account: &Account{
@@ -85,6 +94,15 @@ func TestResolveOpenAIForwardModel(t *testing.T) {
defaultMappedModel: "gpt-5.4",
expectedModel: "gpt-5.5",
},
+ {
+ name: "preserves compact-spelled gpt5.5 instead of group default",
+ account: &Account{
+ Credentials: map[string]any{},
+ },
+ requestedModel: "gpt5.5",
+ defaultMappedModel: "gpt-5.4",
+ expectedModel: "gpt5.5",
+ },
{
name: "preserves openai namespaced gpt-5.5 instead of group default",
account: &Account{
@@ -103,6 +121,30 @@ func TestResolveOpenAIForwardModel(t *testing.T) {
defaultMappedModel: "gpt-5.4",
expectedModel: "gpt-5.5-openai-compact",
},
+ {
+ name: "gpt56 messages dispatch fails without exact entitlement",
+ account: &Account{
+ Platform: PlatformOpenAI,
+ Credentials: map[string]any{"model_mapping": map[string]any{
+ "claude-sonnet-4-6": "gpt-5.4",
+ }},
+ },
+ requestedModel: "claude-sonnet-4-6",
+ defaultMappedModel: "gpt-5.6-terra",
+ expectedModel: "",
+ },
+ {
+ name: "gpt56 messages dispatch fails on cross tier entitlement target",
+ account: &Account{
+ Platform: PlatformOpenAI,
+ Credentials: map[string]any{"model_mapping": map[string]any{
+ "gpt-5.6-terra": "gpt-5.6-sol",
+ }},
+ },
+ requestedModel: "claude-sonnet-4-6",
+ defaultMappedModel: "gpt-5.6-terra",
+ expectedModel: "",
+ },
}
for _, tt := range tests {
@@ -119,14 +161,14 @@ func TestResolveOpenAIForwardModel_PreventsClaudeModelFromFallingBackToGpt54(t *
Credentials: map[string]any{},
}
- withoutDefault := normalizeCodexModel(resolveOpenAIForwardModel(account, "claude-opus-4-6", ""))
- if withoutDefault != "gpt-5.4" {
- t.Fatalf("normalizeCodexModel(...) = %q, want %q", withoutDefault, "gpt-5.4")
+ withoutDefault := resolveOpenAIForwardModel(account, "claude-opus-4-6", "")
+ if withoutDefault != "claude-opus-4-6" {
+ t.Fatalf("resolveOpenAIForwardModel(...) = %q, want %q", withoutDefault, "claude-opus-4-6")
}
- withDefault := normalizeCodexModel(resolveOpenAIForwardModel(account, "claude-opus-4-6", "gpt-5.4"))
+ withDefault := resolveOpenAIForwardModel(account, "claude-opus-4-6", "gpt-5.4")
if withDefault != "gpt-5.4" {
- t.Fatalf("normalizeCodexModel(...) = %q, want %q", withDefault, "gpt-5.4")
+ t.Fatalf("resolveOpenAIForwardModel(...) = %q, want %q", withDefault, "gpt-5.4")
}
}
@@ -198,6 +240,41 @@ func TestResolveOpenAICompactForwardModel(t *testing.T) {
}
}
+func TestResolveOpenAICompactForwardModelWithValidity_GPT56SameTierGuard(t *testing.T) {
+ tests := []struct {
+ name string
+ requested string
+ target string
+ entitlement string
+ expectedModel string
+ expectedValid bool
+ }{
+ {name: "same tier provider suffix accepted", requested: "gpt-5.6-sol", target: "openai/gpt-5.6-sol-high", entitlement: "gpt-5.6-sol", expectedModel: "openai/gpt-5.6-sol-high", expectedValid: true},
+ {name: "downgrade rejected", requested: "gpt-5.6-sol", target: "gpt-5.4", expectedValid: false},
+ {name: "cross tier rejected", requested: "gpt-5.6-terra", target: "gpt-5.6-sol", expectedValid: false},
+ {name: "malformed suffix rejected", requested: "gpt-5.6-luna", target: "gpt-5.6-luna-minimal", expectedValid: false},
+ {name: "malformed requested family rejected", requested: "gpt-5.6-unknown", target: "gpt-5.6-sol", expectedValid: false},
+ {name: "empty target rejected", requested: "gpt-5.6-sol", target: "", expectedValid: false},
+ {name: "legacy to exact preview without entitlement rejected", requested: "gpt-5.4", target: "gpt-5.6-terra", expectedValid: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ credentials := map[string]any{
+ "compact_model_mapping": map[string]any{tt.requested: tt.target},
+ }
+ if tt.entitlement != "" {
+ credentials["model_mapping"] = map[string]any{tt.entitlement: tt.entitlement}
+ }
+ account := &Account{Platform: PlatformOpenAI, Credentials: credentials}
+ gotModel, gotValid := resolveOpenAICompactForwardModelWithValidity(account, tt.requested)
+ if gotModel != tt.expectedModel || gotValid != tt.expectedValid {
+ t.Fatalf("resolveOpenAICompactForwardModelWithValidity(...) = (%q, %v), want (%q, %v)", gotModel, gotValid, tt.expectedModel, tt.expectedValid)
+ }
+ })
+ }
+}
+
func TestNormalizeCodexModel(t *testing.T) {
cases := map[string]string{
"gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
@@ -205,6 +282,10 @@ func TestNormalizeCodexModel(t *testing.T) {
"gpt-5.3-codex-spark-xhigh": "gpt-5.3-codex-spark",
"gpt-5.3": "gpt-5.3-codex",
"gpt-image-2": "gpt-image-2",
+ "gpt-5.4-nano": "gpt-5.4-nano",
+ "gpt-5.4-nano-high": "gpt-5.4-nano",
+ "gpt6": "gpt6",
+ "claude-opus-4-6": "claude-opus-4-6",
}
for input, expected := range cases {
@@ -222,9 +303,21 @@ func TestNormalizeOpenAIModelForUpstream(t *testing.T) {
want string
}{
{
- name: "oauth keeps codex normalization behavior",
+ name: "oauth preserves unknown non codex model",
account: &Account{Type: AccountTypeOAuth},
model: "gemini-3-flash-preview",
+ want: "gemini-3-flash-preview",
+ },
+ {
+ name: "oauth preserves invalid gpt model",
+ account: &Account{Type: AccountTypeOAuth},
+ model: "gpt6",
+ want: "gpt6",
+ },
+ {
+ name: "oauth normalizes known codex alias",
+ account: &Account{Type: AccountTypeOAuth},
+ model: "gpt-5.4-high",
want: "gpt-5.4",
},
{
diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go
index 049ffdd8dff..eeabd28d312 100644
--- a/backend/internal/service/openai_oauth_passthrough_test.go
+++ b/backend/internal/service/openai_oauth_passthrough_test.go
@@ -25,9 +25,12 @@ func f64p(v float64) *float64 { return &v }
type httpUpstreamRecorder struct {
lastReq *http.Request
lastBody []byte
+ requests []*http.Request
+ bodies [][]byte
- resp *http.Response
- err error
+ resp *http.Response
+ responses []*http.Response
+ err error
}
func (u *httpUpstreamRecorder) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
@@ -35,12 +38,19 @@ func (u *httpUpstreamRecorder) Do(req *http.Request, proxyURL string, accountID
if req != nil && req.Body != nil {
b, _ := io.ReadAll(req.Body)
u.lastBody = b
+ u.bodies = append(u.bodies, append([]byte(nil), b...))
_ = req.Body.Close()
req.Body = io.NopCloser(bytes.NewReader(b))
}
+ u.requests = append(u.requests, req)
if u.err != nil {
return nil, u.err
}
+ if len(u.responses) > 0 {
+ resp := u.responses[0]
+ u.responses = u.responses[1:]
+ return resp, nil
+ }
return u.resp, nil
}
@@ -48,6 +58,91 @@ func (u *httpUpstreamRecorder) DoWithTLS(req *http.Request, proxyURL string, acc
return u.Do(req, proxyURL, accountID, accountConcurrency)
}
+func TestOpenAIGatewayService_ResponsesUnknownModelDoesNotFallbackToGPT54(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ originalBody := []byte(`{"model":"gpt6","stream":false,"instructions":"local-test-instructions","input":[{"type":"text","text":"hi"}]}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(originalBody))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusBadRequest,
+ Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_unknown_model"}},
+ Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"model not found"}}`)),
+ }}
+
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{},
+ httpUpstream: upstream,
+ }
+ account := &Account{
+ ID: 123,
+ Name: "acc",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ Status: StatusActive,
+ Schedulable: true,
+ }
+
+ result, err := svc.Forward(context.Background(), c, account, originalBody)
+ require.Error(t, err)
+ require.Nil(t, result)
+ require.NotNil(t, upstream.lastReq)
+ require.Equal(t, "https://chatgpt.com/backend-api/codex/responses", upstream.lastReq.URL.String())
+ require.Equal(t, "gpt6", gjson.GetBytes(upstream.lastBody, "model").String())
+ require.NotEqual(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String())
+ require.True(t, rec.Code >= http.StatusBadRequest)
+}
+
+func TestOpenAIGatewayService_OAuthMessagesBridgeDoesNotInjectDefaultInstructions(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ originalBody := []byte(`{"model":"gpt-5.5","stream":true,"prompt_cache_key":"anthropic-metadata-session-1","input":[{"type":"message","role":"developer","content":[{"type":"input_text","text":""}]},{"type":"message","role":"user","content":"hello"}]}`)
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(originalBody))
+ c.Request.Header.Set("Content-Type", "application/json")
+
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusBadRequest,
+ Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_bridge"}},
+ Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"bridge stop"}}`)),
+ }}
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{},
+ httpUpstream: upstream,
+ }
+ account := &Account{
+ ID: 123,
+ Name: "acc",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "access_token": "oauth-token",
+ "chatgpt_account_id": "chatgpt-acc",
+ },
+ Status: StatusActive,
+ Schedulable: true,
+ }
+
+ result, err := svc.Forward(context.Background(), c, account, originalBody)
+ require.Error(t, err)
+ require.Nil(t, result)
+ require.NotNil(t, upstream.lastReq)
+ require.Equal(t, "", gjson.GetBytes(upstream.lastBody, "instructions").String())
+ require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").Exists())
+ require.NotEmpty(t, upstream.lastReq.Header.Get("Session_Id"))
+ require.Empty(t, upstream.lastReq.Header.Get("Conversation_Id"))
+ require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta"))
+ require.Empty(t, upstream.lastReq.Header.Get("originator"))
+}
+
type openAIPassthroughFailoverRepo struct {
stubOpenAIAccountRepo
rateLimitCalls []time.Time
@@ -167,7 +262,6 @@ func captureStructuredLog(t *testing.T) (*inMemoryLogSink, func()) {
}
func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormalized(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -254,7 +348,6 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
}
func TestOpenAIGatewayService_OAuthPassthrough_CompactUsesJSONAndKeepsNonStreaming(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -307,8 +400,52 @@ func TestOpenAIGatewayService_OAuthPassthrough_CompactUsesJSONAndKeepsNonStreami
require.Contains(t, rec.Body.String(), `"id":"cmp_123"`)
}
+func TestOpenAIGatewayService_OAuthPassthrough_UpstreamRequestIgnoresClientCancel(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ reqCtx, cancel := context.WithCancel(context.Background())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)).WithContext(reqCtx)
+ c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0")
+ cancel()
+
+ originalBody := []byte(`{"model":"gpt-5.2","stream":true,"store":true,"instructions":"local-test-instructions","input":[{"type":"text","text":"hi"}]}`)
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_passthrough_ctx"}},
+ Body: io.NopCloser(strings.NewReader(strings.Join([]string{
+ `data: {"type":"response.completed","response":{"usage":{"input_tokens":2,"output_tokens":1}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n"))),
+ }}
+
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}},
+ httpUpstream: upstream,
+ }
+ account := &Account{
+ ID: 123,
+ Name: "acc",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
+ Extra: map[string]any{"openai_passthrough": true, "openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeOff},
+ Status: StatusActive,
+ Schedulable: true,
+ RateMultiplier: f64p(1),
+ }
+
+ result, err := svc.Forward(reqCtx, c, account, originalBody)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.NotNil(t, upstream.lastReq)
+ require.NoError(t, upstream.lastReq.Context().Err())
+}
+
func TestOpenAIGatewayService_OAuthPassthrough_CodexMissingInstructionsRejectedBeforeUpstream(t *testing.T) {
- gin.SetMode(gin.TestMode)
logSink, restore := captureStructuredLog(t)
defer restore()
@@ -361,7 +498,6 @@ func TestOpenAIGatewayService_OAuthPassthrough_CodexMissingInstructionsRejectedB
}
func TestOpenAIGatewayService_OAuthPassthrough_DisabledUsesLegacyTransform(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -405,8 +541,52 @@ func TestOpenAIGatewayService_OAuthPassthrough_DisabledUsesLegacyTransform(t *te
require.Contains(t, string(upstream.lastBody), `"stream":true`)
}
+func TestOpenAIGatewayService_OAuthLegacy_UpstreamRequestIgnoresClientCancel(t *testing.T) {
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ reqCtx, cancel := context.WithCancel(context.Background())
+ c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)).WithContext(reqCtx)
+ c.Request.Header.Set("User-Agent", "codex_cli_rs/0.1.0")
+ cancel()
+
+ originalBody := []byte(`{"model":"gpt-5.2","stream":false,"store":true,"input":[{"type":"text","text":"hi"}]}`)
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_legacy_ctx"}},
+ Body: io.NopCloser(strings.NewReader(strings.Join([]string{
+ `data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}`,
+ "",
+ "data: [DONE]",
+ "",
+ }, "\n"))),
+ }}
+
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}},
+ httpUpstream: upstream,
+ }
+ account := &Account{
+ ID: 123,
+ Name: "acc",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Concurrency: 1,
+ Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
+ Extra: map[string]any{"openai_passthrough": false, "openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeOff},
+ Status: StatusActive,
+ Schedulable: true,
+ RateMultiplier: f64p(1),
+ }
+
+ result, err := svc.Forward(reqCtx, c, account, originalBody)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.NotNil(t, upstream.lastReq)
+ require.NoError(t, upstream.lastReq.Context().Err())
+}
+
func TestOpenAIGatewayService_OAuthLegacy_CompositeCodexUAUsesCodexOriginator(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -449,7 +629,6 @@ func TestOpenAIGatewayService_OAuthLegacy_CompositeCodexUAUsesCodexOriginator(t
}
func TestOpenAIGatewayService_OAuthPassthrough_ResponseHeadersAllowXCodex(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -507,7 +686,6 @@ func TestOpenAIGatewayService_OAuthPassthrough_ResponseHeadersAllowXCodex(t *tes
}
func TestOpenAIGatewayService_OAuthPassthrough_UpstreamErrorIncludesPassthroughFlag(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -557,7 +735,6 @@ func TestOpenAIGatewayService_OAuthPassthrough_UpstreamErrorIncludesPassthroughF
}
func TestOpenAIGatewayService_OpenAIPassthrough_429And529TriggerFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
originalBody := []byte(`{"model":"gpt-5.2","stream":false,"instructions":"local-test-instructions","input":[{"type":"text","text":"hi"}]}`)
newAccount := func(accountType string) *Account {
@@ -695,7 +872,6 @@ func TestOpenAIGatewayService_OpenAIPassthrough_429And529TriggerFailover(t *test
}
func TestOpenAIGatewayService_OAuthPassthrough_NonCodexUAFallbackToCodexUA(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -738,7 +914,6 @@ func TestOpenAIGatewayService_OAuthPassthrough_NonCodexUAFallbackToCodexUA(t *te
}
func TestOpenAIGatewayService_CodexCLIOnly_RejectsNonCodexClient(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -771,7 +946,6 @@ func TestOpenAIGatewayService_CodexCLIOnly_RejectsNonCodexClient(t *testing.T) {
}
func TestOpenAIGatewayService_CodexCLIOnly_AllowOfficialClientFamilies(t *testing.T) {
- gin.SetMode(gin.TestMode)
tests := []struct {
name string
@@ -829,7 +1003,6 @@ func TestOpenAIGatewayService_CodexCLIOnly_AllowOfficialClientFamilies(t *testin
}
func TestOpenAIGatewayService_OAuthPassthrough_StreamingSetsFirstTokenMs(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -881,7 +1054,6 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamingSetsFirstTokenMs(t *test
}
func TestOpenAIGatewayService_OAuthPassthrough_StreamClientDisconnectStillCollectsUsage(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -936,7 +1108,6 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamClientDisconnectStillCollec
}
func TestOpenAIGatewayService_APIKeyPassthrough_PreservesBodyAndUsesResponsesEndpoint(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -984,7 +1155,6 @@ func TestOpenAIGatewayService_APIKeyPassthrough_PreservesBodyAndUsesResponsesEnd
}
func TestOpenAIGatewayService_OAuthPassthrough_WarnOnTimeoutHeadersForStream(t *testing.T) {
- gin.SetMode(gin.TestMode)
logSink, restore := captureStructuredLog(t)
defer restore()
@@ -1025,7 +1195,6 @@ func TestOpenAIGatewayService_OAuthPassthrough_WarnOnTimeoutHeadersForStream(t *
}
func TestOpenAIGatewayService_OAuthPassthrough_InfoWhenStreamEndsWithoutDone(t *testing.T) {
- gin.SetMode(gin.TestMode)
logSink, restore := captureStructuredLog(t)
defer restore()
@@ -1067,7 +1236,6 @@ func TestOpenAIGatewayService_OAuthPassthrough_InfoWhenStreamEndsWithoutDone(t *
}
func TestOpenAIGatewayService_OAuthPassthrough_DefaultFiltersTimeoutHeaders(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -1113,7 +1281,6 @@ func TestOpenAIGatewayService_OAuthPassthrough_DefaultFiltersTimeoutHeaders(t *t
}
func TestOpenAIGatewayService_OAuthPassthrough_AllowTimeoutHeadersWhenConfigured(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
diff --git a/backend/internal/service/openai_quota_guard.go b/backend/internal/service/openai_quota_guard.go
new file mode 100644
index 00000000000..e922257930f
--- /dev/null
+++ b/backend/internal/service/openai_quota_guard.go
@@ -0,0 +1,120 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "strings"
+ "time"
+)
+
+const (
+ OpenAIQuotaGuardThresholdPercent = 95.0
+ OpenAIQuotaGuardReasonPrefix = "hfc-quota-guard:"
+ OpenAIQuotaGuardReasonCodex7d = OpenAIQuotaGuardReasonPrefix + "openai-codex-7d>=95"
+ OpenAIQuotaGuardReasonNoReset = OpenAIQuotaGuardReasonCodex7d + ":no-reset-at"
+ OpenAIQuotaGuardNoResetCooldown = 6 * time.Hour
+)
+
+type OpenAIQuotaGuardDecision struct {
+ Until time.Time
+ Reason string
+ UsedPercent float64
+}
+
+func (a *Account) OpenAIQuotaGuardDecision(now time.Time) *OpenAIQuotaGuardDecision {
+ if a == nil || a.Platform != PlatformOpenAI || a.Type != AccountTypeOAuth {
+ return nil
+ }
+ return openAIQuotaGuardDecisionFromExtra(a.Extra, now)
+}
+
+func (a *Account) IsOpenAIQuotaGuardedAt(now time.Time) bool {
+ return a.OpenAIQuotaGuardDecision(now) != nil
+}
+
+func openAIQuotaGuardDecisionFromExtra(extra map[string]any, now time.Time) *OpenAIQuotaGuardDecision {
+ if len(extra) == 0 {
+ return nil
+ }
+ usedPercent := parseExtraFloat64(extra["codex_7d_used_percent"])
+ if usedPercent < OpenAIQuotaGuardThresholdPercent {
+ return nil
+ }
+
+ if resetAt, ok := parseOpenAIQuotaGuardTime(extra["codex_7d_reset_at"]); ok {
+ if resetAt.After(now) {
+ return &OpenAIQuotaGuardDecision{
+ Until: resetAt,
+ Reason: OpenAIQuotaGuardReasonCodex7d,
+ UsedPercent: usedPercent,
+ }
+ }
+ return nil
+ }
+
+ base := now
+ if updatedAt, ok := parseOpenAIQuotaGuardTime(extra["codex_usage_updated_at"]); ok {
+ base = updatedAt
+ }
+ until := base.Add(OpenAIQuotaGuardNoResetCooldown)
+ if !until.After(now) {
+ return nil
+ }
+ return &OpenAIQuotaGuardDecision{
+ Until: until,
+ Reason: OpenAIQuotaGuardReasonNoReset,
+ UsedPercent: usedPercent,
+ }
+}
+
+func parseOpenAIQuotaGuardTime(value any) (time.Time, bool) {
+ switch v := value.(type) {
+ case time.Time:
+ if v.IsZero() {
+ return time.Time{}, false
+ }
+ return v, true
+ case *time.Time:
+ if v == nil || v.IsZero() {
+ return time.Time{}, false
+ }
+ return *v, true
+ }
+
+ raw := strings.TrimSpace(fmt.Sprint(value))
+ if raw == "" || raw == "" {
+ return time.Time{}, false
+ }
+ parsed, err := parseTime(raw)
+ if err != nil || parsed.IsZero() {
+ return time.Time{}, false
+ }
+ return parsed, true
+}
+
+func applyOpenAIQuotaGuardFromUpdates(ctx context.Context, repo AccountRepository, accountID int64, updates map[string]any, now time.Time) {
+ if repo == nil || accountID <= 0 {
+ return
+ }
+ decision := openAIQuotaGuardDecisionFromExtra(updates, now)
+ if decision == nil {
+ return
+ }
+ if err := repo.SetTempUnschedulable(ctx, accountID, decision.Until, decision.Reason); err != nil {
+ slog.Warn("openai_quota_guard_set_temp_unschedulable_failed",
+ "account_id", accountID,
+ "used_percent", decision.UsedPercent,
+ "until", decision.Until,
+ "reason", decision.Reason,
+ "error", err,
+ )
+ return
+ }
+ slog.Info("openai_quota_guard_temp_unschedulable_set",
+ "account_id", accountID,
+ "used_percent", decision.UsedPercent,
+ "until", decision.Until,
+ "reason", decision.Reason,
+ )
+}
diff --git a/backend/internal/service/openai_quota_guard_test.go b/backend/internal/service/openai_quota_guard_test.go
new file mode 100644
index 00000000000..da6e9e54d7d
--- /dev/null
+++ b/backend/internal/service/openai_quota_guard_test.go
@@ -0,0 +1,150 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestOpenAIQuotaGuardDecisionFromExtra(t *testing.T) {
+ now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC)
+ resetAt := now.Add(3 * time.Hour)
+
+ tests := []struct {
+ name string
+ extra map[string]any
+ wantGuard bool
+ wantReason string
+ }{
+ {
+ name: "below threshold",
+ extra: map[string]any{
+ "codex_7d_used_percent": 94.9,
+ "codex_7d_reset_at": resetAt.Format(time.RFC3339),
+ },
+ wantGuard: false,
+ },
+ {
+ name: "threshold with future reset",
+ extra: map[string]any{
+ "codex_7d_used_percent": 95.0,
+ "codex_7d_reset_at": resetAt.Format(time.RFC3339),
+ },
+ wantGuard: true,
+ wantReason: OpenAIQuotaGuardReasonCodex7d,
+ },
+ {
+ name: "above threshold with expired reset",
+ extra: map[string]any{
+ "codex_7d_used_percent": 99.0,
+ "codex_7d_reset_at": now.Add(-1 * time.Minute).Format(time.RFC3339),
+ },
+ wantGuard: false,
+ },
+ {
+ name: "above threshold without reset uses short guard",
+ extra: map[string]any{
+ "codex_7d_used_percent": 95.0,
+ "codex_usage_updated_at": now.Add(-1 * time.Hour).
+ Format(time.RFC3339),
+ },
+ wantGuard: true,
+ wantReason: OpenAIQuotaGuardReasonNoReset,
+ },
+ {
+ name: "stale no-reset guard expires",
+ extra: map[string]any{
+ "codex_7d_used_percent": 95.0,
+ "codex_usage_updated_at": now.Add(-7 * time.Hour).
+ Format(time.RFC3339),
+ },
+ wantGuard: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := openAIQuotaGuardDecisionFromExtra(tt.extra, now)
+ if !tt.wantGuard {
+ require.Nil(t, got)
+ return
+ }
+ require.NotNil(t, got)
+ require.Equal(t, tt.wantReason, got.Reason)
+ require.True(t, got.Until.After(now))
+ })
+ }
+}
+
+func TestAccountIsSchedulable_OpenAIQuotaGuard(t *testing.T) {
+ resetAt := time.Now().Add(2 * time.Hour).UTC()
+
+ guarded := &Account{
+ Platform: PlatformOpenAI,
+ Type: AccountTypeOAuth,
+ Status: StatusActive,
+ Schedulable: true,
+ Extra: map[string]any{
+ "codex_7d_used_percent": 95.0,
+ "codex_7d_reset_at": resetAt.Format(time.RFC3339),
+ },
+ }
+ require.False(t, guarded.IsSchedulable())
+
+ apiKey := &Account{
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Status: StatusActive,
+ Schedulable: true,
+ Extra: map[string]any{
+ "codex_7d_used_percent": 95.0,
+ "codex_7d_reset_at": resetAt.Format(time.RFC3339),
+ },
+ }
+ require.True(t, apiKey.IsSchedulable())
+}
+
+type quotaGuardRepoStub struct {
+ AccountRepository
+ calls int
+ until time.Time
+ reason string
+}
+
+func (r *quotaGuardRepoStub) SetTempUnschedulable(_ context.Context, _ int64, until time.Time, reason string) error {
+ r.calls++
+ r.until = until
+ r.reason = reason
+ return nil
+}
+
+func TestApplyOpenAIQuotaGuardFromUpdates(t *testing.T) {
+ now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC)
+ resetAt := now.Add(4 * time.Hour)
+ repo := "aGuardRepoStub{}
+
+ applyOpenAIQuotaGuardFromUpdates(context.Background(), repo, 123, map[string]any{
+ "codex_7d_used_percent": 95.0,
+ "codex_7d_reset_at": resetAt.Format(time.RFC3339),
+ }, now)
+
+ require.Equal(t, 1, repo.calls)
+ require.Equal(t, OpenAIQuotaGuardReasonCodex7d, repo.reason)
+ require.WithinDuration(t, resetAt, repo.until, time.Second)
+}
+
+func TestApplyOpenAIQuotaGuardFromUpdatesBelowThreshold(t *testing.T) {
+ now := time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC)
+ repo := "aGuardRepoStub{}
+
+ applyOpenAIQuotaGuardFromUpdates(context.Background(), repo, 123, map[string]any{
+ "codex_7d_used_percent": 94.9,
+ "codex_7d_reset_at": now.Add(4 * time.Hour).Format(time.RFC3339),
+ }, now)
+
+ require.Zero(t, repo.calls)
+}
diff --git a/backend/internal/service/openai_sse_data.go b/backend/internal/service/openai_sse_data.go
new file mode 100644
index 00000000000..f2171624410
--- /dev/null
+++ b/backend/internal/service/openai_sse_data.go
@@ -0,0 +1,124 @@
+package service
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/tidwall/gjson"
+)
+
+const openAISSEMaxDataLinesPerEvent = 4096
+
+type openAISSEDataAccumulator struct {
+ lines []string
+ bytes int64
+ maxBytes int64
+}
+
+func newOpenAISSEDataAccumulator(maxBytes int64) openAISSEDataAccumulator {
+ if maxBytes <= 0 {
+ maxBytes = defaultUpstreamResponseReadMaxBytes
+ }
+ return openAISSEDataAccumulator{maxBytes: maxBytes}
+}
+
+func (a *openAISSEDataAccumulator) AddLine(line string, fn func([]byte)) error {
+ if fn == nil {
+ return nil
+ }
+ trimmedLine := strings.TrimRight(line, "\r\n")
+ if data, ok := extractOpenAISSEDataLine(trimmedLine); ok {
+ maxBytes := a.maxBytes
+ if maxBytes <= 0 {
+ maxBytes = defaultUpstreamResponseReadMaxBytes
+ }
+ separatorBytes := int64(0)
+ if len(a.lines) > 0 {
+ separatorBytes = 1
+ }
+ dataBytes := int64(len(data))
+ if len(a.lines) >= openAISSEMaxDataLinesPerEvent ||
+ dataBytes > maxBytes-a.bytes-separatorBytes {
+ return fmt.Errorf("%w: SSE event limit=%d", ErrUpstreamResponseBodyTooLarge, maxBytes)
+ }
+ a.lines = append(a.lines, data)
+ a.bytes += separatorBytes + dataBytes
+ return nil
+ }
+ if strings.TrimSpace(trimmedLine) == "" {
+ return a.Flush(fn)
+ }
+ return nil
+}
+
+func (a *openAISSEDataAccumulator) Flush(fn func([]byte)) error {
+ if fn == nil || len(a.lines) == 0 {
+ return nil
+ }
+ emitOpenAISSEDataPayloads(a.lines, fn)
+ a.lines = a.lines[:0]
+ a.bytes = 0
+ return nil
+}
+
+func readOpenAISSELineBounded(reader *bufio.Reader, maxBytes int64) ([]byte, error) {
+ if reader == nil {
+ return nil, errors.New("SSE reader is nil")
+ }
+ if maxBytes <= 0 {
+ maxBytes = defaultUpstreamResponseReadMaxBytes
+ }
+
+ var line []byte
+ for {
+ chunk, err := reader.ReadSlice('\n')
+ if int64(len(chunk)) > maxBytes-int64(len(line)) {
+ return nil, fmt.Errorf("%w: SSE line limit=%d", ErrUpstreamResponseBodyTooLarge, maxBytes)
+ }
+ line = append(line, chunk...)
+ if !errors.Is(err, bufio.ErrBufferFull) {
+ return line, err
+ }
+ }
+}
+
+func forEachOpenAISSEDataPayload(body string, fn func([]byte)) {
+ if fn == nil || strings.TrimSpace(body) == "" {
+ return
+ }
+ acc := newOpenAISSEDataAccumulator(defaultUpstreamResponseReadMaxBytes)
+ for _, line := range strings.Split(body, "\n") {
+ if err := acc.AddLine(line, fn); err != nil {
+ return
+ }
+ }
+ _ = acc.Flush(fn)
+}
+
+func emitOpenAISSEDataPayloads(lines []string, fn func([]byte)) {
+ if fn == nil || len(lines) == 0 {
+ return
+ }
+ if len(lines) == 1 {
+ emitOpenAISSEDataPayload(lines[0], fn)
+ return
+ }
+ joined := strings.Join(lines, "\n")
+ if gjson.Valid(joined) {
+ emitOpenAISSEDataPayload(joined, fn)
+ return
+ }
+ for _, line := range lines {
+ emitOpenAISSEDataPayload(line, fn)
+ }
+}
+
+func emitOpenAISSEDataPayload(data string, fn func([]byte)) {
+ data = strings.TrimSpace(data)
+ if data == "" || data == "[DONE]" {
+ return
+ }
+ fn([]byte(data))
+}
diff --git a/backend/internal/service/openai_sse_data_test.go b/backend/internal/service/openai_sse_data_test.go
new file mode 100644
index 00000000000..9735843efa2
--- /dev/null
+++ b/backend/internal/service/openai_sse_data_test.go
@@ -0,0 +1,68 @@
+package service
+
+import (
+ "bufio"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestReadOpenAISSELineBounded(t *testing.T) {
+ t.Run("returns a complete line within the limit", func(t *testing.T) {
+ reader := bufio.NewReader(strings.NewReader("data: ok\nnext"))
+
+ line, err := readOpenAISSELineBounded(reader, 16)
+
+ require.NoError(t, err)
+ require.Equal(t, []byte("data: ok\n"), line)
+ })
+
+ t.Run("rejects an oversized line without returning attacker bytes", func(t *testing.T) {
+ reader := bufio.NewReaderSize(strings.NewReader(strings.Repeat("x", 65)+"\n"), 8)
+
+ line, err := readOpenAISSELineBounded(reader, 64)
+
+ require.Nil(t, line)
+ require.Error(t, err)
+ require.True(t, errors.Is(err, ErrUpstreamResponseBodyTooLarge))
+ })
+}
+
+func TestOpenAISSEDataAccumulatorBounded(t *testing.T) {
+ t.Run("emits a valid multi-line event", func(t *testing.T) {
+ acc := newOpenAISSEDataAccumulator(64)
+ var payloads []string
+ emit := func(payload []byte) { payloads = append(payloads, string(payload)) }
+
+ require.NoError(t, acc.AddLine("data: {\"ok\":", emit))
+ require.NoError(t, acc.AddLine("data: true}", emit))
+ require.NoError(t, acc.AddLine("", emit))
+ require.Equal(t, []string{"{\"ok\":\ntrue}"}, payloads)
+ })
+
+ t.Run("rejects an event whose aggregate data exceeds the limit", func(t *testing.T) {
+ acc := newOpenAISSEDataAccumulator(8)
+ emit := func([]byte) { t.Fatal("oversized event must not be emitted") }
+
+ require.NoError(t, acc.AddLine("data: 1234", emit))
+ err := acc.AddLine("data: 5678", emit)
+
+ require.Error(t, err)
+ require.True(t, errors.Is(err, ErrUpstreamResponseBodyTooLarge))
+ })
+
+ t.Run("rejects excessive empty data fields", func(t *testing.T) {
+ acc := newOpenAISSEDataAccumulator(openAISSEMaxDataLinesPerEvent * 2)
+ emit := func([]byte) { t.Fatal("oversized event must not be emitted") }
+ for range openAISSEMaxDataLinesPerEvent {
+ require.NoError(t, acc.AddLine("data:", emit))
+ }
+
+ err := acc.AddLine("data:", emit)
+
+ require.Error(t, err)
+ require.True(t, errors.Is(err, ErrUpstreamResponseBodyTooLarge))
+ })
+}
diff --git a/backend/internal/service/openai_sticky_compat_test.go b/backend/internal/service/openai_sticky_compat_test.go
index 9f57c35808a..b8891a121ff 100644
--- a/backend/internal/service/openai_sticky_compat_test.go
+++ b/backend/internal/service/openai_sticky_compat_test.go
@@ -3,12 +3,39 @@ package service
import (
"context"
"testing"
+ "time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/stretchr/testify/require"
)
+// 验证 openAIWSSessionStickyTTL 在 cfg 设置后真生效(修复前 set/refresh 流程
+// hardcode openaiStickySessionTTL=1h,cfg 字段被静默忽略)。
+func TestOpenAIWSSessionStickyTTL_ConfigOverridesDefault(t *testing.T) {
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{
+ Gateway: config.GatewayConfig{
+ OpenAIWS: config.GatewayOpenAIWSConfig{
+ StickySessionTTLSeconds: 600,
+ },
+ },
+ },
+ }
+ require.Equal(t, 600*time.Second, svc.openAIWSSessionStickyTTL())
+}
+
+func TestOpenAIWSSessionStickyTTL_DefaultWhenUnset(t *testing.T) {
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{
+ Gateway: config.GatewayConfig{
+ OpenAIWS: config.GatewayOpenAIWSConfig{},
+ },
+ },
+ }
+ require.Equal(t, openaiStickySessionTTL, svc.openAIWSSessionStickyTTL())
+}
+
func TestGetStickySessionAccountID_FallbackToLegacyKey(t *testing.T) {
beforeFallbackTotal, beforeFallbackHit, _ := openAIStickyCompatStats()
diff --git a/backend/internal/service/openai_token_provider.go b/backend/internal/service/openai_token_provider.go
index e438588edb8..e6ff83e6d0e 100644
--- a/backend/internal/service/openai_token_provider.go
+++ b/backend/internal/service/openai_token_provider.go
@@ -140,8 +140,16 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou
// 1) Try cache first.
if p.tokenCache != nil {
if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && strings.TrimSpace(token) != "" {
- slog.Debug("openai_token_cache_hit", "account_id", account.ID)
- return token, nil
+ latestAccount, cacheHitIsCurrent, validationErr := validateOAuthTokenCacheHit(ctx, account, p.accountRepo)
+ if validationErr != nil {
+ return "", validationErr
+ }
+ if cacheHitIsCurrent {
+ slog.Debug("openai_token_cache_hit", "account_id", account.ID)
+ return token, nil
+ }
+ account = latestAccount
+ cacheKey = OpenAITokenCacheKey(account)
} else if err != nil {
slog.Warn("openai_token_cache_get_failed", "account_id", account.ID, "error", err)
}
@@ -175,8 +183,17 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou
return "", waitErr
}
if strings.TrimSpace(token) != "" {
- slog.Debug("openai_token_cache_hit_after_wait", "account_id", account.ID)
- return token, nil
+ latestAccount, cacheHitIsCurrent, validationErr := validateOAuthTokenCacheHit(ctx, account, p.accountRepo)
+ if validationErr != nil {
+ return "", validationErr
+ }
+ if cacheHitIsCurrent {
+ slog.Debug("openai_token_cache_hit_after_wait", "account_id", account.ID)
+ return token, nil
+ }
+ account = latestAccount
+ cacheKey = OpenAITokenCacheKey(account)
+ expiresAt = account.GetCredentialAsTime("expires_at")
}
}
} else if result.Refreshed {
@@ -191,9 +208,9 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou
// Backward-compatible test path when refreshAPI is not injected.
p.metrics.refreshRequests.Add(1)
p.metrics.touchNow()
- locked, lockErr := p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
+ locked, ownershipToken, lockErr := p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
if lockErr == nil && locked {
- defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey) }()
+ defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey, ownershipToken) }()
} else if lockErr != nil {
p.metrics.lockAcquireFailure.Add(1)
p.metrics.touchNow()
@@ -206,8 +223,17 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou
return "", waitErr
}
if strings.TrimSpace(token) != "" {
- slog.Debug("openai_token_cache_hit_after_wait", "account_id", account.ID)
- return token, nil
+ latestAccount, cacheHitIsCurrent, validationErr := validateOAuthTokenCacheHit(ctx, account, p.accountRepo)
+ if validationErr != nil {
+ return "", validationErr
+ }
+ if cacheHitIsCurrent {
+ slog.Debug("openai_token_cache_hit_after_wait", "account_id", account.ID)
+ return token, nil
+ }
+ account = latestAccount
+ cacheKey = OpenAITokenCacheKey(account)
+ expiresAt = account.GetCredentialAsTime("expires_at")
}
}
}
@@ -219,8 +245,10 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou
// 3) Populate cache with TTL.
if p.tokenCache != nil {
- latestAccount, isStale := CheckTokenVersion(ctx, account, p.accountRepo)
- if isStale && latestAccount != nil {
+ latestAccount, isStale, versionErr := CheckTokenVersion(ctx, account, p.accountRepo)
+ if versionErr != nil {
+ slog.Warn("openai_token_version_check_unavailable", "account_id", account.ID, "error", versionErr)
+ } else if isStale && latestAccount != nil {
slog.Debug("openai_token_version_stale_use_latest", "account_id", account.ID)
accessToken = latestAccount.GetOpenAIAccessToken()
if strings.TrimSpace(accessToken) == "" {
diff --git a/backend/internal/service/openai_token_provider_test.go b/backend/internal/service/openai_token_provider_test.go
index e81fb465602..3c12339b13a 100644
--- a/backend/internal/service/openai_token_provider_test.go
+++ b/backend/internal/service/openai_token_provider_test.go
@@ -68,18 +68,21 @@ func (s *openAITokenCacheStub) DeleteAccessToken(ctx context.Context, cacheKey s
return nil
}
-func (s *openAITokenCacheStub) AcquireRefreshLock(ctx context.Context, cacheKey string, ttl time.Duration) (bool, error) {
+func (s *openAITokenCacheStub) AcquireRefreshLock(ctx context.Context, cacheKey string, ttl time.Duration) (bool, string, error) {
atomic.AddInt32(&s.lockCalled, 1)
if s.lockErr != nil {
- return false, s.lockErr
+ return false, "", s.lockErr
}
if s.simulateLockRace {
- return false, nil
+ return false, "", nil
}
- return s.lockAcquired, nil
+ if !s.lockAcquired {
+ return false, "", nil
+ }
+ return true, "openai-test-owner", nil
}
-func (s *openAITokenCacheStub) ReleaseRefreshLock(ctx context.Context, cacheKey string) error {
+func (s *openAITokenCacheStub) ReleaseRefreshLock(ctx context.Context, cacheKey string, ownershipToken string) error {
atomic.AddInt32(&s.unlockCalled, 1)
return s.releaseLockErr
}
@@ -147,7 +150,7 @@ func TestOpenAITokenProvider_CacheHit(t *testing.T) {
cacheKey := OpenAITokenCacheKey(account)
cache.tokens[cacheKey] = "cached-token"
- provider := NewOpenAITokenProvider(nil, cache, nil)
+ provider := NewOpenAITokenProvider(&mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}, cache, nil)
token, err := provider.GetAccessToken(context.Background(), account)
require.NoError(t, err)
@@ -170,7 +173,9 @@ func TestOpenAITokenProvider_CacheMiss_FromCredentials(t *testing.T) {
},
}
- provider := NewOpenAITokenProvider(nil, cache, nil)
+ provider := NewOpenAITokenProvider(&mockAccountRepoForGemini{
+ accountsByID: map[int64]*Account{account.ID: account},
+ }, cache, nil)
token, err := provider.GetAccessToken(context.Background(), account)
require.NoError(t, err)
@@ -248,9 +253,9 @@ func (p *testOpenAITokenProvider) GetAccessToken(ctx context.Context, account *A
needsRefresh := expiresAt == nil || time.Until(*expiresAt) <= openAITokenRefreshSkew
refreshFailed := false
if needsRefresh && p.tokenCache != nil {
- locked, err := p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
+ locked, ownershipToken, err := p.tokenCache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
if err == nil && locked {
- defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey) }()
+ defer func() { _ = p.tokenCache.ReleaseRefreshLock(ctx, cacheKey, ownershipToken) }()
// Check cache again after acquiring lock
if token, err := p.tokenCache.GetAccessToken(ctx, cacheKey); err == nil && token != "" {
@@ -429,7 +434,7 @@ func TestOpenAITokenProvider_CacheGetError(t *testing.T) {
},
}
- provider := NewOpenAITokenProvider(nil, cache, nil)
+ provider := NewOpenAITokenProvider(&mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}, cache, nil)
// Should gracefully degrade and return from credentials
token, err := provider.GetAccessToken(context.Background(), account)
@@ -452,7 +457,7 @@ func TestOpenAITokenProvider_CacheSetError(t *testing.T) {
},
}
- provider := NewOpenAITokenProvider(nil, cache, nil)
+ provider := NewOpenAITokenProvider(&mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}, cache, nil)
// Should still work even if cache set fails
token, err := provider.GetAccessToken(context.Background(), account)
@@ -473,7 +478,7 @@ func TestOpenAITokenProvider_MissingAccessToken(t *testing.T) {
},
}
- provider := NewOpenAITokenProvider(nil, cache, nil)
+ provider := NewOpenAITokenProvider(&mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}, cache, nil)
token, err := provider.GetAccessToken(context.Background(), account)
require.Error(t, err)
@@ -576,7 +581,9 @@ func TestOpenAITokenProvider_TTLCalculation(t *testing.T) {
},
}
- provider := NewOpenAITokenProvider(nil, cache, nil)
+ provider := NewOpenAITokenProvider(&mockAccountRepoForGemini{
+ accountsByID: map[int64]*Account{account.ID: account},
+ }, cache, nil)
_, err := provider.GetAccessToken(context.Background(), account)
require.NoError(t, err)
@@ -664,7 +671,7 @@ func TestOpenAITokenProvider_Real_LockFailedWait(t *testing.T) {
cache.mu.Unlock()
}()
- provider := NewOpenAITokenProvider(nil, cache, nil)
+ provider := NewOpenAITokenProvider(&mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}, cache, nil)
token, err := provider.GetAccessToken(context.Background(), account)
require.NoError(t, err)
// Should get either the fallback token or the refreshed one
@@ -696,7 +703,7 @@ func TestOpenAITokenProvider_Real_CacheHitAfterWait(t *testing.T) {
cache.mu.Unlock()
}()
- provider := NewOpenAITokenProvider(nil, cache, nil)
+ provider := NewOpenAITokenProvider(&mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}, cache, nil)
token, err := provider.GetAccessToken(context.Background(), account)
require.NoError(t, err)
require.NotEmpty(t, token)
@@ -832,7 +839,7 @@ func TestOpenAITokenProvider_Real_LockRace_PollingHitsCache(t *testing.T) {
cache.mu.Unlock()
}()
- provider := NewOpenAITokenProvider(nil, cache, nil)
+ provider := NewOpenAITokenProvider(&mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}, cache, nil)
token, err := provider.GetAccessToken(context.Background(), account)
require.NoError(t, err)
require.Equal(t, "winner-token", token)
@@ -887,7 +894,7 @@ func TestOpenAITokenProvider_RuntimeMetrics_LockWaitHitAndSnapshot(t *testing.T)
cache.mu.Unlock()
}()
- provider := NewOpenAITokenProvider(nil, cache, nil)
+ provider := NewOpenAITokenProvider(&mockAccountRepoForGemini{accountsByID: map[int64]*Account{account.ID: account}}, cache, nil)
token, err := provider.GetAccessToken(context.Background(), account)
require.NoError(t, err)
require.Equal(t, "winner-token", token)
diff --git a/backend/internal/service/openai_usage_billing_admission.go b/backend/internal/service/openai_usage_billing_admission.go
new file mode 100644
index 00000000000..5808106acb1
--- /dev/null
+++ b/backend/internal/service/openai_usage_billing_admission.go
@@ -0,0 +1,250 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
+)
+
+// OpenAIUsageBillingAdmissionInput binds authenticated principals and the
+// original client payload to the exact pricing identity resolved by the
+// forward preflight. The raw body is used only for a conservative cost bound.
+type OpenAIUsageBillingAdmissionInput struct {
+ APIKey *APIKey
+ User *User
+ Subscription *UserSubscription
+ RequestBody []byte
+ // ReservationBody is the final transformed upstream payload. It is used
+ // only for a conservative in-memory token bound and is never persisted.
+ ReservationBody []byte
+ RequestPayloadHash string
+ MaxOutputTokens int
+ RequestCount int
+ BillingIdentity *ResolvedOpenAIBillingIdentity
+ AdmissionRef UsageBillingAdmissionAttemptRef
+ DeliveryOutcome UsageBillingDeliveryOutcome
+ ForwardAccepted bool
+}
+
+func (s *OpenAIGatewayService) PrepareOpenAIUsageBillingAdmission(
+ ctx context.Context,
+ account *Account,
+ input *OpenAIUsageBillingAdmissionInput,
+) (context.Context, *UsageBillingAdmissionSession, error) {
+ if s == nil {
+ return ctx, nil, ErrUsageBillingOutboxUnavailable
+ }
+ if (s.cfg != nil && s.cfg.RunMode == config.RunModeSimple) || !s.requireUsageBillingOutbox {
+ return ctx, nil, nil
+ }
+ if input == nil || input.APIKey == nil || input.User == nil || account == nil ||
+ input.BillingIdentity == nil || input.BillingIdentity.Pricing == nil || len(input.RequestBody) == 0 {
+ return ctx, nil, ErrUsageBillingLifecycleContractInvalid
+ }
+ if !usageBillingRequestContextPrepared(ctx) {
+ return ctx, nil, ErrUsageBillingLifecycleContractInvalid
+ }
+ wantPayloadHash := HashUsageRequestPayload(input.RequestBody)
+ if wantPayloadHash == "" || !strings.EqualFold(wantPayloadHash, strings.TrimSpace(input.RequestPayloadHash)) {
+ return ctx, nil, ErrUsageBillingLifecycleContractInvalid
+ }
+ reservationBody := input.RequestBody
+ if len(input.ReservationBody) > len(reservationBody) {
+ reservationBody = input.ReservationBody
+ }
+ textMultiplier := s.resolveOpenAIUsageBillingAdmissionRate(ctx, input.APIKey, input.User, input.Subscription)
+ multiplier := textMultiplier
+ if mode := pricingQuoteMode(input.BillingIdentity); mode == BillingModeImage || mode == BillingModePerRequest {
+ multiplier = resolveImageRateMultiplier(input.APIKey, multiplier)
+ }
+ quote, err := BuildUsageBillingReservationQuote(UsageBillingReservationQuoteBuildInput{
+ RequestPayloadHash: input.RequestPayloadHash,
+ BillingModel: input.BillingIdentity.BillingModel,
+ Pricing: input.BillingIdentity.Pricing,
+ RateMultiplier: multiplier,
+ RequestBody: reservationBody,
+ MaxOutputTokens: input.MaxOutputTokens,
+ RequestCount: input.RequestCount,
+ })
+ if err != nil {
+ return ctx, nil, errors.Join(ErrUsageBillingLifecycleContractInvalid, err)
+ }
+ if input.BillingIdentity.TextPricing != nil {
+ alternate, alternateErr := BuildUsageBillingReservationQuote(UsageBillingReservationQuoteBuildInput{
+ RequestPayloadHash: input.RequestPayloadHash,
+ BillingModel: input.BillingIdentity.TextBillingModel,
+ Pricing: input.BillingIdentity.TextPricing,
+ RateMultiplier: textMultiplier,
+ RequestBody: reservationBody,
+ MaxOutputTokens: input.MaxOutputTokens,
+ RequestCount: input.RequestCount,
+ })
+ if alternateErr != nil {
+ return ctx, nil, errors.Join(ErrUsageBillingLifecycleContractInvalid, alternateErr)
+ }
+ quote.AlternateBillingModel = alternate.BillingModel
+ quote.AlternatePricingSource = alternate.PricingSource
+ quote.AlternatePricingRevision = alternate.PricingRevision
+ quote.AlternatePricingHash = alternate.PricingHash
+ quote.AlternateRateMultiplier = alternate.RateMultiplier
+ if alternate.WorstCaseCostUSD > quote.WorstCaseCostUSD {
+ quote.WorstCaseCostUSD = alternate.WorstCaseCostUSD
+ }
+ quote.WorstCaseCostUSD = canonicalUsageBillingReservation(quote.WorstCaseCostUSD)
+ if validateErr := quote.Validate(); validateErr != nil {
+ return ctx, nil, errors.Join(ErrUsageBillingLifecycleContractInvalid, validateErr)
+ }
+ }
+ return admitUsageBillingRequest(
+ ctx, s.cfg, s.requireUsageBillingOutbox, s.usageBillingAdmissionRepo,
+ input.APIKey, input.User, account, input.Subscription, quote,
+ )
+}
+
+func setOpenAIUsageBillingReservationBody(input *OpenAIUsageBillingAdmissionInput, body []byte) {
+ if input == nil || len(body) == 0 || len(body) <= len(input.ReservationBody) {
+ return
+ }
+ input.ReservationBody = append([]byte(nil), body...)
+}
+
+func (s *OpenAIGatewayService) prepareOpenAIForwardUsageBilling(
+ ctx context.Context,
+ account *Account,
+ identity *ResolvedOpenAIBillingIdentity,
+ opts OpenAIForwardOptions,
+) error {
+ if !opts.RequireBillingAdmission {
+ return nil
+ }
+ if opts.UsageBilling == nil || identity == nil {
+ return ErrUsageBillingLifecycleContractInvalid
+ }
+ input := *opts.UsageBilling
+ input.BillingIdentity = identity
+ _, session, err := s.PrepareOpenAIUsageBillingAdmission(ctx, account, &input)
+ if err != nil {
+ return err
+ }
+ if err := s.DispatchUsageBillingRequest(ctx, session); err != nil {
+ return err
+ }
+ if session != nil {
+ identity.AdmissionAttemptID = session.AttemptID
+ identity.AdmissionRef = session.Ref()
+ opts.UsageBilling.AdmissionRef = session.Ref()
+ opts.UsageBilling.DeliveryOutcome = UsageBillingDeliveryUnknown
+ opts.UsageBilling.ForwardAccepted = false
+ }
+ return nil
+}
+
+// DispatchUsageBillingRequest advances the fenced attempt before any billable
+// upstream bytes may be sent. A dispatch fencing failure happens before
+// transport, so the prepared wallet hold is abandoned and released.
+func (s *OpenAIGatewayService) DispatchUsageBillingRequest(ctx context.Context, session *UsageBillingAdmissionSession) error {
+ if session == nil {
+ return nil
+ }
+ if s == nil || s.usageBillingAdmissionRepo == nil {
+ return ErrUsageBillingOutboxUnavailable
+ }
+ return dispatchUsageBillingRequest(ctx, s.usageBillingAdmissionRepo, session)
+}
+
+func (s *OpenAIGatewayService) MarkOpenAIUsageBillingAttemptFailed(
+ ctx context.Context,
+ input *OpenAIUsageBillingAdmissionInput,
+) error {
+ if input == nil || input.AdmissionRef.Validate() != nil {
+ return nil
+ }
+ if s == nil || s.usageBillingAdmissionRepo == nil {
+ return ErrUsageBillingOutboxUnavailable
+ }
+ mutationCtx, cancel := newUsageBillingLifecycleMutationContext(ctx)
+ defer cancel()
+ if err := s.usageBillingAdmissionRepo.MarkAttemptFailed(mutationCtx, input.AdmissionRef); err != nil {
+ return err
+ }
+ input.DeliveryOutcome = UsageBillingDeliveryDefinitelyRejected
+ return nil
+}
+
+func (s *OpenAIGatewayService) MarkOpenAIUsageBillingAccepted(input *OpenAIUsageBillingAdmissionInput) {
+ if input == nil || input.AdmissionRef.Validate() != nil {
+ return
+ }
+ input.ForwardAccepted = true
+ input.DeliveryOutcome = UsageBillingDeliveryAccepted
+}
+
+func (s *OpenAIGatewayService) FinalizeOpenAIUsageBillingRequest(
+ ctx context.Context,
+ input *OpenAIUsageBillingAdmissionInput,
+) error {
+ if input == nil || input.ForwardAccepted || input.AdmissionRef.Validate() != nil {
+ return nil
+ }
+ if s == nil || s.usageBillingAdmissionRepo == nil {
+ return ErrUsageBillingOutboxUnavailable
+ }
+ mutationCtx, cancel := newUsageBillingLifecycleMutationContext(ctx)
+ defer cancel()
+ if input.DeliveryOutcome == UsageBillingDeliveryDefinitelyRejected {
+ session := &UsageBillingAdmissionSession{
+ RequestID: input.AdmissionRef.RequestID, APIKeyID: input.AdmissionRef.APIKeyID,
+ OwnerToken: input.AdmissionRef.OwnerToken, AttemptID: input.AdmissionRef.AttemptID,
+ }
+ return abandonUsageBillingRequest(mutationCtx, s.usageBillingAdmissionRepo, session)
+ }
+ if err := s.usageBillingAdmissionRepo.MarkOrphaned(mutationCtx, input.AdmissionRef); err != nil {
+ return err
+ }
+ input.DeliveryOutcome = UsageBillingDeliveryUnknown
+ return nil
+}
+
+func (s *OpenAIGatewayService) MarkOpenAIUsageBillingResultOrphaned(
+ ctx context.Context,
+ result *OpenAIForwardResult,
+) error {
+ if result == nil || result.BillingIdentity == nil || result.BillingIdentity.AdmissionRef.Validate() != nil {
+ return nil
+ }
+ if s == nil || s.usageBillingAdmissionRepo == nil {
+ return ErrUsageBillingOutboxUnavailable
+ }
+ mutationCtx, cancel := newUsageBillingLifecycleMutationContext(ctx)
+ defer cancel()
+ return s.usageBillingAdmissionRepo.MarkOrphaned(mutationCtx, result.BillingIdentity.AdmissionRef)
+}
+
+func usageBillingRequestContextPrepared(ctx context.Context) bool {
+ if ctx == nil {
+ return false
+ }
+ requestID, _ := ctx.Value(ctxkey.UsageBillingRequestID).(string)
+ ownerToken, _ := ctx.Value(ctxkey.UsageBillingOwnerToken).(string)
+ return strings.TrimSpace(requestID) != "" && validFenceToken(strings.TrimSpace(ownerToken))
+}
+
+func (s *OpenAIGatewayService) resolveOpenAIUsageBillingAdmissionRate(
+ ctx context.Context,
+ apiKey *APIKey,
+ user *User,
+ subscription *UserSubscription,
+) float64 {
+ systemDefault := 1.0
+ if s != nil && s.cfg != nil {
+ systemDefault = s.cfg.Default.RateMultiplier
+ }
+ resolver := s.userGroupRateResolver
+ if resolver == nil {
+ resolver = newUserGroupRateResolver(nil, nil, resolveUserGroupRateCacheTTL(s.cfg), nil, "service.openai_gateway.admission")
+ }
+ return resolveEffectiveRateMultiplier(ctx, resolver, user.ID, apiKey.GroupID, apiKey.Group, subscription, systemDefault).Multiplier
+}
diff --git a/backend/internal/service/openai_usage_billing_admission_test.go b/backend/internal/service/openai_usage_billing_admission_test.go
new file mode 100644
index 00000000000..aaf1972f7d9
--- /dev/null
+++ b/backend/internal/service/openai_usage_billing_admission_test.go
@@ -0,0 +1,343 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+type usageBillingAdmissionRepoCapture struct {
+ admissions []UsageBillingAdmission
+ dispatched []UsageBillingAdmissionAttemptRef
+ failed []UsageBillingAdmissionAttemptRef
+ orphaned []UsageBillingAdmissionAttemptRef
+ abandoned []UsageBillingAdmissionAttemptRef
+ err error
+ dispatchErr error
+}
+
+func (s *usageBillingAdmissionRepoCapture) Admit(_ context.Context, admission UsageBillingAdmission) error {
+ s.admissions = append(s.admissions, admission)
+ return s.err
+}
+
+func TestOpenAIChatForwardStopsBeforeTokenAndTransportWhenWalletAdmissionFails(t *testing.T) {
+ repo := &usageBillingAdmissionRepoCapture{err: ErrWalletInsufficient}
+ cfg := &config.Config{}
+ cfg.Gateway.ForcedCodexInstructionsTemplate = strings.Repeat("x", 32*1024)
+ billing := NewBillingService(cfg, nil)
+ svc := &OpenAIGatewayService{
+ cfg: cfg, requireUsageBillingOutbox: true,
+ usageBillingAdmissionRepo: repo, billingService: billing,
+ resolver: NewModelPricingResolver(nil, billing),
+ }
+ body := []byte(`{"model":"gpt-5.6-terra","messages":[{"role":"user","content":"hello"}],"max_tokens":64,"stream":false}`)
+ request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = request
+ ctx, err := PrepareUsageBillingRequestContext(request.Context())
+ require.NoError(t, err)
+ groupID := int64(31)
+ apiKey := &APIKey{ID: 11, Key: "sk-wallet", GroupID: &groupID, Group: &Group{ID: groupID, RateMultiplier: 1}}
+
+ result, err := svc.ForwardAsChatCompletionsWithOptions(ctx, c, &Account{
+ ID: 33, Type: AccountTypeAPIKey, Platform: PlatformOpenAI,
+ Credentials: map[string]any{
+ "model_mapping": map[string]any{"gpt-5.6-terra": "gpt-5.6-terra"},
+ },
+ }, body, "", "", OpenAIForwardOptions{
+ RequestedModel: "gpt-5.6-terra", GroupID: &groupID,
+ RequirePricingPreflight: true, RequireBillingAdmission: true,
+ UsageBilling: &OpenAIUsageBillingAdmissionInput{
+ APIKey: apiKey, User: &User{ID: 22}, RequestBody: body,
+ RequestPayloadHash: HashUsageRequestPayload(body),
+ },
+ })
+ require.Nil(t, result)
+ require.ErrorIs(t, err, ErrWalletInsufficient)
+ require.Len(t, repo.admissions, 1)
+}
+
+func TestOpenAIMessagesAdmissionBoundsTransformedUpstreamRequest(t *testing.T) {
+ repo := &usageBillingAdmissionRepoCapture{err: ErrWalletInsufficient}
+ billing := NewBillingService(&config.Config{}, nil)
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{}, requireUsageBillingOutbox: true,
+ usageBillingAdmissionRepo: repo, billingService: billing,
+ resolver: NewModelPricingResolver(nil, billing),
+ }
+ body := []byte(`{"model":"gpt-5.6-luna","max_tokens":1,"messages":[{"role":"user","content":"hi"}],"stream":false}`)
+ request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = request
+ ctx, err := PrepareUsageBillingRequestContext(request.Context())
+ require.NoError(t, err)
+ groupID := int64(3)
+ walletBalance := 30.0
+ apiKey := &APIKey{ID: 424, Key: "sk-wallet", GroupID: &groupID, Group: &Group{ID: groupID, RateMultiplier: 1}}
+ account := &Account{
+ ID: 1693, Type: AccountTypeOAuth, Platform: PlatformOpenAI,
+ Credentials: map[string]any{
+ "model_mapping": map[string]any{"gpt-5.6-luna": "gpt-5.6-luna"},
+ },
+ }
+ usageInput := &OpenAIUsageBillingAdmissionInput{
+ APIKey: apiKey, User: &User{ID: 178}, Subscription: &UserSubscription{
+ ID: 169, UserID: 178, GroupID: &groupID, WalletBalanceUSD: &walletBalance,
+ },
+ RequestBody: body, RequestPayloadHash: HashUsageRequestPayload(body),
+ }
+
+ result, err := svc.ForwardAsAnthropicWithOptions(ctx, c, account, body, "", "gpt-5.6-luna", OpenAIForwardOptions{
+ RequestedModel: "gpt-5.6-luna", GroupID: &groupID,
+ RequirePricingPreflight: true, RequireBillingAdmission: true,
+ UsageBilling: usageInput,
+ })
+ require.Nil(t, result)
+ require.ErrorIs(t, err, ErrWalletInsufficient)
+ require.Len(t, repo.admissions, 1)
+
+ pricing, err := svc.resolver.ResolveQuote(ctx, PricingInput{Model: "gpt-5.6-luna", GroupID: &groupID})
+ require.NoError(t, err)
+ originalQuote, err := BuildUsageBillingReservationQuote(UsageBillingReservationQuoteBuildInput{
+ RequestPayloadHash: HashUsageRequestPayload(body), BillingModel: "gpt-5.6-luna",
+ Pricing: pricing, RateMultiplier: repo.admissions[0].RateMultiplier(), RequestBody: body,
+ })
+ require.NoError(t, err)
+ require.Equal(t, HashUsageRequestPayload(body), repo.admissions[0].RequestPayloadHash())
+ require.Greater(t, repo.admissions[0].WorstCaseCostUSD(), originalQuote.WorstCaseCostUSD+0.00001,
+ "reservation must include compatibility fields added to the actual upstream request",
+ )
+}
+func (s *usageBillingAdmissionRepoCapture) MarkDispatched(_ context.Context, ref UsageBillingAdmissionAttemptRef) error {
+ s.dispatched = append(s.dispatched, ref)
+ return s.dispatchErr
+}
+func (s *usageBillingAdmissionRepoCapture) MarkAttemptFailed(_ context.Context, ref UsageBillingAdmissionAttemptRef) error {
+ s.failed = append(s.failed, ref)
+ return nil
+}
+func (s *usageBillingAdmissionRepoCapture) MarkOrphaned(_ context.Context, ref UsageBillingAdmissionAttemptRef) error {
+ s.orphaned = append(s.orphaned, ref)
+ return nil
+}
+func (*usageBillingAdmissionRepoCapture) Heartbeat(context.Context, UsageBillingAdmissionAttemptRef, time.Duration) error {
+ return nil
+}
+func (s *usageBillingAdmissionRepoCapture) Abandon(_ context.Context, ref UsageBillingAdmissionAttemptRef) error {
+ s.abandoned = append(s.abandoned, ref)
+ return nil
+}
+
+func TestOpenAIForwardUsageBillingDispatchesAdmissionBeforeTransport(t *testing.T) {
+ repo := &usageBillingAdmissionRepoCapture{}
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{}, requireUsageBillingOutbox: true,
+ usageBillingAdmissionRepo: repo,
+ }
+ pricing, err := freezeResolvedPricingQuote(&ResolvedPricing{
+ Mode: BillingModeToken, Source: PricingSourceBuiltinGPT56, Revision: GPT56PricingRevision,
+ BasePricing: &ModelPricing{InputPricePerToken: 0.000001, OutputPricePerToken: 0.000002},
+ })
+ require.NoError(t, err)
+ body := []byte(`{"model":"gpt-5.6-terra","max_output_tokens":100,"input":"hello"}`)
+ groupID := int64(31)
+ ctx, err := PrepareUsageBillingRequestContext(context.Background())
+ require.NoError(t, err)
+
+ identity := &ResolvedOpenAIBillingIdentity{
+ BillingModel: "gpt-5.6-terra", Pricing: pricing,
+ }
+ usageInput := &OpenAIUsageBillingAdmissionInput{
+ APIKey: &APIKey{ID: 11, Key: "sk-preflight", GroupID: &groupID, Group: &Group{ID: groupID, RateMultiplier: 1}},
+ User: &User{ID: 22}, RequestBody: body, RequestPayloadHash: HashUsageRequestPayload(body),
+ }
+ err = svc.prepareOpenAIForwardUsageBilling(ctx, &Account{ID: 33, Type: AccountTypeOAuth}, identity, OpenAIForwardOptions{
+ RequireBillingAdmission: true,
+ UsageBilling: usageInput,
+ })
+ require.NoError(t, err)
+ require.Len(t, repo.admissions, 1)
+ require.Len(t, repo.dispatched, 1)
+ require.Equal(t, repo.admissions[0].AttemptID(), repo.dispatched[0].AttemptID)
+ require.Equal(t, repo.dispatched[0].AttemptID, identity.AdmissionAttemptID)
+ require.Equal(t, repo.dispatched[0], usageInput.AdmissionRef)
+ require.Equal(t, UsageBillingDeliveryUnknown, usageInput.DeliveryOutcome)
+ require.Empty(t, repo.abandoned)
+
+ require.NoError(t, svc.MarkOpenAIUsageBillingAttemptFailed(ctx, usageInput))
+ require.Equal(t, UsageBillingDeliveryDefinitelyRejected, usageInput.DeliveryOutcome)
+ require.NoError(t, svc.FinalizeOpenAIUsageBillingRequest(ctx, usageInput))
+ require.Len(t, repo.failed, 1)
+ require.Len(t, repo.abandoned, 1)
+}
+
+func TestOpenAIForwardUsageBillingAbandonsPreparedHoldWhenDispatchFenceFails(t *testing.T) {
+ repo := &usageBillingAdmissionRepoCapture{dispatchErr: ErrUsageBillingAdmissionLeaseLost}
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{}, requireUsageBillingOutbox: true,
+ usageBillingAdmissionRepo: repo,
+ }
+ pricing, err := freezeResolvedPricingQuote(&ResolvedPricing{
+ Mode: BillingModeToken, Source: PricingSourceBuiltinGPT56, Revision: GPT56PricingRevision,
+ BasePricing: &ModelPricing{InputPricePerToken: 0.000001, OutputPricePerToken: 0.000002},
+ })
+ require.NoError(t, err)
+ body := []byte(`{"model":"gpt-5.6-terra","input":"hello"}`)
+ groupID := int64(31)
+ ctx, err := PrepareUsageBillingRequestContext(context.Background())
+ require.NoError(t, err)
+
+ err = svc.prepareOpenAIForwardUsageBilling(ctx, &Account{ID: 33, Type: AccountTypeOAuth}, &ResolvedOpenAIBillingIdentity{
+ BillingModel: "gpt-5.6-terra", Pricing: pricing,
+ }, OpenAIForwardOptions{
+ RequireBillingAdmission: true,
+ UsageBilling: &OpenAIUsageBillingAdmissionInput{
+ APIKey: &APIKey{ID: 11, Key: "sk-preflight", GroupID: &groupID, Group: &Group{ID: groupID, RateMultiplier: 1}},
+ User: &User{ID: 22}, RequestBody: body, RequestPayloadHash: HashUsageRequestPayload(body),
+ },
+ })
+ require.ErrorIs(t, err, ErrUsageBillingAdmissionLeaseLost)
+ require.Len(t, repo.abandoned, 1)
+}
+
+func TestOpenAIUsageBillingUnknownDeliveryBecomesOrphaned(t *testing.T) {
+ repo := &usageBillingAdmissionRepoCapture{}
+ svc := &OpenAIGatewayService{usageBillingAdmissionRepo: repo}
+ input := &OpenAIUsageBillingAdmissionInput{
+ AdmissionRef: UsageBillingAdmissionAttemptRef{
+ RequestID: "unknown-delivery", APIKeyID: 11,
+ OwnerToken: strings.Repeat("a", 32), AttemptID: strings.Repeat("b", 32),
+ },
+ DeliveryOutcome: UsageBillingDeliveryUnknown,
+ }
+
+ require.NoError(t, svc.FinalizeOpenAIUsageBillingRequest(context.Background(), input))
+ require.Len(t, repo.orphaned, 1)
+ require.Empty(t, repo.abandoned)
+}
+
+func (*usageBillingAdmissionRepoCapture) WaitSettled(context.Context, UsageBillingAdmissionAttemptRef) error {
+ return nil
+}
+
+func TestOpenAIUsageBillingAdmissionUsesFrozenPreflightPricing(t *testing.T) {
+ repo := &usageBillingAdmissionRepoCapture{}
+ svc := &OpenAIGatewayService{
+ cfg: &config.Config{}, requireUsageBillingOutbox: true,
+ usageBillingAdmissionRepo: repo,
+ }
+ pricing, err := freezeResolvedPricingQuote(&ResolvedPricing{
+ Mode: BillingModeToken, Source: PricingSourceBuiltinGPT56, Revision: GPT56PricingRevision,
+ BasePricing: &ModelPricing{InputPricePerToken: 0.000001, OutputPricePerToken: 0.000002},
+ })
+ require.NoError(t, err)
+ body := []byte(`{"model":"gpt-5.6-terra","max_output_tokens":100,"input":"hello"}`)
+ groupID := int64(31)
+ ctx := context.WithValue(context.Background(), ctxkey.RequestID, "preflight-123")
+ ctx, err = PrepareUsageBillingRequestContext(ctx)
+ require.NoError(t, err)
+
+ preparedCtx, session, err := svc.PrepareOpenAIUsageBillingAdmission(ctx, &Account{ID: 33, Type: AccountTypeOAuth}, &OpenAIUsageBillingAdmissionInput{
+ APIKey: &APIKey{ID: 11, Key: "sk-preflight", GroupID: &groupID, Group: &Group{ID: groupID, RateMultiplier: 1}},
+ User: &User{ID: 22}, RequestBody: body, RequestPayloadHash: HashUsageRequestPayload(body),
+ BillingIdentity: &ResolvedOpenAIBillingIdentity{BillingModel: "gpt-5.6-terra", Pricing: pricing},
+ })
+ require.NoError(t, err)
+ require.NotNil(t, session)
+ require.Equal(t, "local:preflight-123", preparedCtx.Value(ctxkey.UsageBillingRequestID))
+ require.Len(t, repo.admissions, 1)
+ require.Equal(t, pricing.Evidence.Hash, repo.admissions[0].PricingHash())
+ require.Equal(t, HashUsageRequestPayload(body), repo.admissions[0].RequestPayloadHash())
+ require.Greater(t, repo.admissions[0].WorstCaseCostUSD(), 0.0)
+}
+
+func TestOpenAIUsageBillingAdmissionFreezesImageAndTextSettlementOptions(t *testing.T) {
+ repo := &usageBillingAdmissionRepoCapture{}
+ svc := &OpenAIGatewayService{cfg: &config.Config{}, requireUsageBillingOutbox: true, usageBillingAdmissionRepo: repo}
+ imagePricing, err := freezeResolvedPricingQuote(&ResolvedPricing{
+ Mode: BillingModeImage, Source: PricingSourceBuiltinFallback, Revision: builtinFallbackRevision,
+ DefaultPerRequestPrice: 0.25,
+ })
+ require.NoError(t, err)
+ textPricing, err := freezeResolvedPricingQuote(&ResolvedPricing{
+ Mode: BillingModeToken, Source: PricingSourceBuiltinGPT56, Revision: GPT56PricingRevision,
+ BasePricing: &ModelPricing{InputPricePerToken: 0.000001, OutputPricePerToken: 0.000002},
+ })
+ require.NoError(t, err)
+ body := []byte(`{"model":"gpt-image-2","n":1}`)
+ groupID := int64(31)
+ ctx, err := PrepareUsageBillingRequestContext(context.Background())
+ require.NoError(t, err)
+
+ _, session, err := svc.PrepareOpenAIUsageBillingAdmission(ctx, &Account{ID: 33, Type: AccountTypeOAuth}, &OpenAIUsageBillingAdmissionInput{
+ APIKey: &APIKey{ID: 11, Key: "sk-preflight", GroupID: &groupID, Group: &Group{ID: groupID, RateMultiplier: 1}},
+ User: &User{ID: 22}, RequestBody: body, RequestPayloadHash: HashUsageRequestPayload(body),
+ BillingIdentity: &ResolvedOpenAIBillingIdentity{
+ BillingModel: "gpt-image-2", Pricing: imagePricing,
+ TextBillingModel: "gpt-5.6-terra", TextPricing: textPricing,
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, session)
+ require.Len(t, repo.admissions, 1)
+ admission := repo.admissions[0]
+ require.Equal(t, "gpt-image-2", admission.BillingModel())
+ require.Equal(t, imagePricing.Evidence.Hash, admission.PricingHash())
+ require.Equal(t, "gpt-5.6-terra", admission.AlternateBillingModel())
+ require.Equal(t, textPricing.Evidence.Hash, admission.AlternatePricingHash())
+ require.GreaterOrEqual(t, admission.WorstCaseCostUSD(), 0.25)
+}
+
+func TestOpenAIUsageBillingAdmissionRejectsMismatchedPayloadHash(t *testing.T) {
+ repo := &usageBillingAdmissionRepoCapture{}
+ svc := &OpenAIGatewayService{cfg: &config.Config{}, requireUsageBillingOutbox: true, usageBillingAdmissionRepo: repo}
+ body := []byte(`{"model":"gpt-5.6-terra"}`)
+ groupID := int64(31)
+ pricing, err := freezeResolvedPricingQuote(&ResolvedPricing{
+ Mode: BillingModeToken, Source: PricingSourceBuiltinGPT56, Revision: GPT56PricingRevision,
+ BasePricing: &ModelPricing{InputPricePerToken: 0.000001, OutputPricePerToken: 0.000002},
+ })
+ require.NoError(t, err)
+ ctx, err := PrepareUsageBillingRequestContext(context.Background())
+ require.NoError(t, err)
+
+ _, _, err = svc.PrepareOpenAIUsageBillingAdmission(ctx, &Account{ID: 33, Type: AccountTypeOAuth}, &OpenAIUsageBillingAdmissionInput{
+ APIKey: &APIKey{ID: 11, Key: "sk-preflight", GroupID: &groupID, Group: &Group{ID: groupID, RateMultiplier: 1}},
+ User: &User{ID: 22}, RequestBody: body, RequestPayloadHash: strings.Repeat("a", 64),
+ BillingIdentity: &ResolvedOpenAIBillingIdentity{BillingModel: "gpt-5.6-terra", Pricing: pricing},
+ })
+ require.ErrorIs(t, err, ErrUsageBillingLifecycleContractInvalid)
+ require.Empty(t, repo.admissions)
+}
+
+func TestOpenAIUsageBillingAdmissionRequiresFrozenRequestContext(t *testing.T) {
+ repo := &usageBillingAdmissionRepoCapture{}
+ svc := &OpenAIGatewayService{cfg: &config.Config{}, requireUsageBillingOutbox: true, usageBillingAdmissionRepo: repo}
+ body := []byte(`{"model":"gpt-5.6-terra"}`)
+ groupID := int64(31)
+ pricing, err := freezeResolvedPricingQuote(&ResolvedPricing{
+ Mode: BillingModeToken, Source: PricingSourceBuiltinGPT56, Revision: GPT56PricingRevision,
+ BasePricing: &ModelPricing{InputPricePerToken: 0.000001, OutputPricePerToken: 0.000002},
+ })
+ require.NoError(t, err)
+
+ _, _, err = svc.PrepareOpenAIUsageBillingAdmission(context.Background(), &Account{ID: 33, Type: AccountTypeOAuth}, &OpenAIUsageBillingAdmissionInput{
+ APIKey: &APIKey{ID: 11, Key: "sk-preflight", GroupID: &groupID, Group: &Group{ID: groupID, RateMultiplier: 1}},
+ User: &User{ID: 22}, RequestBody: body, RequestPayloadHash: HashUsageRequestPayload(body),
+ BillingIdentity: &ResolvedOpenAIBillingIdentity{BillingModel: "gpt-5.6-terra", Pricing: pricing},
+ })
+ require.ErrorIs(t, err, ErrUsageBillingLifecycleContractInvalid)
+ require.Empty(t, repo.admissions)
+}
diff --git a/backend/internal/service/openai_usage_billing_outbox_producer_test.go b/backend/internal/service/openai_usage_billing_outbox_producer_test.go
new file mode 100644
index 00000000000..14b8327d109
--- /dev/null
+++ b/backend/internal/service/openai_usage_billing_outbox_producer_test.go
@@ -0,0 +1,312 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+type openAIUsageOutboxRepoStub struct {
+ envelopes []UsageBillingEnvelope
+ err error
+ retryErrs []error
+ calls int
+ lastCtxErr error
+ hadDeadline bool
+}
+
+func (s *openAIUsageOutboxRepoStub) Enqueue(ctx context.Context, envelope UsageBillingEnvelope) (*UsageBillingOutboxEvent, bool, error) {
+ s.calls++
+ s.lastCtxErr = ctx.Err()
+ _, s.hadDeadline = ctx.Deadline()
+ if len(s.retryErrs) > 0 {
+ err := s.retryErrs[0]
+ s.retryErrs = s.retryErrs[1:]
+ return nil, false, err
+ }
+ if s.err != nil {
+ return nil, false, s.err
+ }
+ s.envelopes = append(s.envelopes, envelope)
+ return &UsageBillingOutboxEvent{ID: int64(len(s.envelopes)), Envelope: envelope}, true, nil
+}
+
+func (*openAIUsageOutboxRepoStub) Claim(context.Context, string, int, time.Duration) ([]UsageBillingOutboxEvent, error) {
+ panic("not used")
+}
+func (*openAIUsageOutboxRepoStub) Complete(context.Context, int64, string, string, string) error {
+ panic("not used")
+}
+func (*openAIUsageOutboxRepoStub) Retry(context.Context, int64, string, string, time.Time, string, string) error {
+ panic("not used")
+}
+func (*openAIUsageOutboxRepoStub) DeadLetter(context.Context, int64, string, string, string, string) error {
+ panic("not used")
+}
+
+type openAIUsageOutboxWakeStub struct{ calls int }
+
+func (s *openAIUsageOutboxWakeStub) Wake() { s.calls++ }
+
+func openAIUsageProducerQuote(mode BillingMode) *PricingQuote {
+ resolved := &ResolvedPricing{
+ Mode: mode, Source: PricingSourceBuiltinGPT56,
+ BasePricing: &ModelPricing{InputPricePerToken: 0.01, OutputPricePerToken: 0.02},
+ }
+ if mode == BillingModeImage {
+ resolved.BasePricing = nil
+ resolved.DefaultPerRequestPrice = 0.25
+ }
+ return &PricingQuote{
+ Resolved: resolved,
+ Evidence: PricingEvidence{Source: PricingSourceBuiltinGPT56, Revision: GPT56PricingRevision, Hash: strings.Repeat("a", 64)},
+ }
+}
+
+func TestOpenAIUsageBillingProducer_PersistsAllEntrypointSnapshotsBeforeWake(t *testing.T) {
+ tests := []struct {
+ name string
+ inboundEndpoint string
+ requestedModel string
+ billingModel string
+ ws bool
+ imageCount int
+ imageSize string
+ }{
+ {name: "responses", inboundEndpoint: "/v1/responses", requestedModel: "gpt-5.6-sol", billingModel: "gpt-5.6-sol"},
+ {name: "chat", inboundEndpoint: "/v1/chat/completions", requestedModel: "gpt-5.6-sol", billingModel: "gpt-5.6-sol"},
+ {name: "messages compat", inboundEndpoint: "/v1/messages", requestedModel: "claude-sonnet-4-6", billingModel: "gpt-5.6-sol"},
+ {name: "native mapped GPT", inboundEndpoint: "/v1/responses", requestedModel: "gpt-5.4", billingModel: "gpt-5.6-sol"},
+ {name: "images", inboundEndpoint: "/v1/images/generations", requestedModel: "gpt-image-2", billingModel: "gpt-image-2", imageCount: 1, imageSize: "1024x1024"},
+ {name: "websocket", inboundEndpoint: "/v1/responses", requestedModel: "gpt-5.6-sol", billingModel: "gpt-5.6-sol", ws: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ usageRepo := &openAIRecordUsageLogRepoStub{}
+ billingRepo := &openAIRecordUsageBillingRepoStub{}
+ svc := newOpenAIRecordUsageServiceWithBillingRepoForTest(usageRepo, billingRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
+ outbox := &openAIUsageOutboxRepoStub{}
+ wake := &openAIUsageOutboxWakeStub{}
+ svc.requireUsageBillingOutbox = true
+ svc.usageBillingOutboxRepo = outbox
+ svc.usageBillingOutboxWake = wake
+ svc.resolver = NewModelPricingResolver(nil, svc.billingService)
+ groupID := int64(44)
+ mode := BillingModeToken
+ if tt.imageCount > 0 {
+ mode = BillingModeImage
+ }
+ identity := &ResolvedOpenAIBillingIdentity{
+ RequestedModel: tt.requestedModel, CompatModel: tt.requestedModel,
+ UpstreamModel: tt.billingModel, BillingModel: tt.billingModel,
+ BillingModelSource: BillingModelSourceUpstream,
+ ModelMappingChain: tt.requestedModel + " -> " + tt.billingModel,
+ Pricing: openAIUsageProducerQuote(mode),
+ }
+ result := &OpenAIForwardResult{
+ RequestID: "producer-" + tt.name, Model: tt.requestedModel, UpstreamModel: tt.billingModel,
+ BillingModel: tt.billingModel, BillingIdentity: identity,
+ Usage: OpenAIUsage{InputTokens: 10, OutputTokens: 2}, Duration: time.Second,
+ OpenAIWSMode: tt.ws, Stream: tt.ws, ImageCount: tt.imageCount, ImageSize: tt.imageSize,
+ }
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: result,
+ APIKey: &APIKey{ID: 11, Key: "sk-producer-test", GroupID: &groupID, Group: &Group{ID: groupID, RateMultiplier: 1}},
+ User: &User{ID: 22}, Account: &Account{ID: 33, Type: AccountTypeAPIKey},
+ RequestPayloadHash: strings.Repeat("b", 64),
+ InboundEndpoint: tt.inboundEndpoint, UpstreamEndpoint: "/v1/responses",
+ ChannelUsageFields: ChannelUsageFields{OriginalModel: tt.requestedModel, ChannelMappedModel: tt.billingModel},
+ })
+ require.NoError(t, err)
+ require.Len(t, outbox.envelopes, 1)
+ require.Equal(t, 1, wake.calls)
+ require.Zero(t, billingRepo.calls, "producer must not apply before durable replay")
+ require.Zero(t, usageRepo.calls, "producer must not write usage before billing replay")
+ log := outbox.envelopes[0].UsageLog()
+ require.Equal(t, tt.requestedModel, log.RequestedModel)
+ require.Equal(t, tt.billingModel, log.Model)
+ require.Equal(t, tt.billingModel, *log.BillingModel)
+ require.Equal(t, APIKeyAuthCacheLocator("sk-producer-test"), outbox.envelopes[0].AuthCacheLocator())
+ require.Equal(t, tt.inboundEndpoint, *log.InboundEndpoint)
+ if tt.ws {
+ require.Equal(t, RequestTypeWSV2, log.RequestType)
+ }
+ })
+ }
+}
+
+func TestOpenAIUsageBillingProducer_CanceledContextStillEnqueuesDetached(t *testing.T) {
+ svc := newOpenAIRecordUsageServiceWithBillingRepoForTest(&openAIRecordUsageLogRepoStub{}, &openAIRecordUsageBillingRepoStub{}, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
+ outbox := &openAIUsageOutboxRepoStub{}
+ svc.requireUsageBillingOutbox = true
+ svc.usageBillingOutboxRepo = outbox
+ svc.resolver = NewModelPricingResolver(nil, svc.billingService)
+ groupID := int64(44)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ err := svc.RecordUsage(ctx, &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "producer-canceled", Model: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol",
+ BillingModel: "gpt-5.6-sol", BillingIdentity: &ResolvedOpenAIBillingIdentity{
+ BillingModel: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol", Pricing: openAIUsageProducerQuote(BillingModeToken),
+ },
+ Usage: OpenAIUsage{InputTokens: 1, OutputTokens: 1}, Duration: time.Second,
+ },
+ APIKey: &APIKey{ID: 11, Key: "sk-canceled-test", GroupID: &groupID, Group: &Group{ID: groupID, RateMultiplier: 1}},
+ User: &User{ID: 22}, Account: &Account{ID: 33, Type: AccountTypeAPIKey},
+ RequestPayloadHash: strings.Repeat("b", 64),
+ })
+ require.NoError(t, err)
+ require.NoError(t, outbox.lastCtxErr)
+ require.True(t, outbox.hadDeadline, "durable admission must be bounded even after detaching from the client request")
+}
+
+func TestOpenAIUsageBillingProducer_RetriesTransientAdmissionUntilDurable(t *testing.T) {
+ svc := newOpenAIRecordUsageServiceWithBillingRepoForTest(&openAIRecordUsageLogRepoStub{}, &openAIRecordUsageBillingRepoStub{}, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
+ sentinel := errors.New("temporary outbox connection failure")
+ outbox := &openAIUsageOutboxRepoStub{retryErrs: []error{
+ MarkUsageBillingOutboxAdmissionRetryable(sentinel),
+ MarkUsageBillingOutboxAdmissionRetryable(sentinel),
+ }}
+ svc.requireUsageBillingOutbox = true
+ svc.usageBillingOutboxRepo = outbox
+ svc.resolver = NewModelPricingResolver(nil, svc.billingService)
+ groupID := int64(44)
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "producer-retry", Model: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol",
+ BillingModel: "gpt-5.6-sol", BillingIdentity: &ResolvedOpenAIBillingIdentity{
+ BillingModel: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol", Pricing: openAIUsageProducerQuote(BillingModeToken),
+ },
+ Usage: OpenAIUsage{InputTokens: 1, OutputTokens: 1}, Duration: time.Second,
+ },
+ APIKey: &APIKey{ID: 11, Key: "sk-retry-test", GroupID: &groupID, Group: &Group{ID: groupID, RateMultiplier: 1}},
+ User: &User{ID: 22}, Account: &Account{ID: 33, Type: AccountTypeAPIKey},
+ RequestPayloadHash: strings.Repeat("b", 64),
+ })
+
+ require.NoError(t, err)
+ require.Equal(t, 3, outbox.calls)
+ require.Len(t, outbox.envelopes, 1, "successful response must have one durable billing fact before RecordUsage returns")
+}
+
+func TestOpenAIUsageBillingProducer_StopsBoundedRetryWhenAdmissionNeverRecovers(t *testing.T) {
+ svc := newOpenAIRecordUsageServiceWithBillingRepoForTest(&openAIRecordUsageLogRepoStub{}, &openAIRecordUsageBillingRepoStub{}, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
+ sentinel := errors.New("temporary outbox connection failure")
+ outbox := &openAIUsageOutboxRepoStub{err: MarkUsageBillingOutboxAdmissionRetryable(sentinel)}
+ svc.requireUsageBillingOutbox = true
+ svc.usageBillingOutboxRepo = outbox
+ svc.resolver = NewModelPricingResolver(nil, svc.billingService)
+ groupID := int64(44)
+
+ started := time.Now()
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "producer-retry-bounded", Model: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol",
+ BillingModel: "gpt-5.6-sol", BillingIdentity: &ResolvedOpenAIBillingIdentity{
+ BillingModel: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol", Pricing: openAIUsageProducerQuote(BillingModeToken),
+ },
+ Usage: OpenAIUsage{InputTokens: 1, OutputTokens: 1}, Duration: time.Second,
+ },
+ APIKey: &APIKey{ID: 11, Key: "sk-retry-bounded-test", GroupID: &groupID, Group: &Group{ID: groupID, RateMultiplier: 1}},
+ User: &User{ID: 22}, Account: &Account{ID: 33, Type: AccountTypeAPIKey},
+ RequestPayloadHash: strings.Repeat("b", 64),
+ })
+
+ require.ErrorIs(t, err, ErrUsageBillingOutboxAdmissionRetryable)
+ require.ErrorIs(t, err, sentinel)
+ require.Less(t, time.Since(started), time.Second, "admission retries must not pin one HTTP goroutine indefinitely")
+ require.Equal(t, usageBillingOutboxAdmissionMaxAttempts, outbox.calls)
+}
+
+func TestOpenAIUsageBillingProducer_FreezesMonthlyAnchorGroupSeparatelyFromRoutingGroup(t *testing.T) {
+ svc := newOpenAIRecordUsageServiceWithBillingRepoForTest(&openAIRecordUsageLogRepoStub{}, &openAIRecordUsageBillingRepoStub{}, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
+ outbox := &openAIUsageOutboxRepoStub{}
+ svc.requireUsageBillingOutbox = true
+ svc.usageBillingOutboxRepo = outbox
+ svc.resolver = NewModelPricingResolver(nil, svc.billingService)
+ routingGroupID := int64(44)
+ anchorGroupID := int64(77)
+ subscriptionID := int64(88)
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "producer-monthly-anchor", Model: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol",
+ BillingModel: "gpt-5.6-sol", BillingIdentity: &ResolvedOpenAIBillingIdentity{
+ BillingModel: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol", Pricing: openAIUsageProducerQuote(BillingModeToken),
+ },
+ Usage: OpenAIUsage{InputTokens: 1, OutputTokens: 1}, Duration: time.Second,
+ },
+ APIKey: &APIKey{ID: 11, Key: "sk-monthly-test", GroupID: &routingGroupID, Group: &Group{ID: routingGroupID, RateMultiplier: 1}},
+ User: &User{ID: 22}, Account: &Account{ID: 33, Type: AccountTypeAPIKey},
+ RequestPayloadHash: strings.Repeat("b", 64),
+ Subscription: &UserSubscription{
+ ID: subscriptionID, UserID: 22, GroupID: &anchorGroupID,
+ Group: &Group{ID: anchorGroupID, SubscriptionType: SubscriptionTypeSubscription, RateMultiplier: 1},
+ },
+ })
+ require.NoError(t, err)
+ require.Len(t, outbox.envelopes, 1)
+ require.Equal(t, routingGroupID, *outbox.envelopes[0].GroupID())
+ require.Equal(t, anchorGroupID, *outbox.envelopes[0].EffectiveBillingGroupID())
+}
+
+func TestOpenAIUsageBillingProducer_FailsClosedWithoutFallback(t *testing.T) {
+ sentinel := errors.New("outbox database unavailable")
+ for _, tt := range []struct {
+ name string
+ repo UsageBillingOutboxRepository
+ want error
+ }{
+ {name: "missing repository", want: ErrUsageBillingOutboxUnavailable},
+ {name: "enqueue failure", repo: &openAIUsageOutboxRepoStub{err: sentinel}, want: sentinel},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ usageRepo := &openAIRecordUsageLogRepoStub{}
+ billingRepo := &openAIRecordUsageBillingRepoStub{}
+ svc := newOpenAIRecordUsageServiceWithBillingRepoForTest(usageRepo, billingRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
+ svc.requireUsageBillingOutbox = true
+ svc.usageBillingOutboxRepo = tt.repo
+ svc.resolver = NewModelPricingResolver(nil, svc.billingService)
+ groupID := int64(44)
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{
+ RequestID: "producer-fail-closed", Model: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol",
+ BillingModel: "gpt-5.6-sol", BillingIdentity: &ResolvedOpenAIBillingIdentity{
+ BillingModel: "gpt-5.6-sol", UpstreamModel: "gpt-5.6-sol", Pricing: openAIUsageProducerQuote(BillingModeToken),
+ },
+ Usage: OpenAIUsage{InputTokens: 1, OutputTokens: 1}, Duration: time.Second,
+ },
+ APIKey: &APIKey{ID: 11, Key: "sk-fail-closed-test", GroupID: &groupID, Group: &Group{ID: groupID, RateMultiplier: 1}},
+ User: &User{ID: 22}, Account: &Account{ID: 33, Type: AccountTypeAPIKey},
+ RequestPayloadHash: strings.Repeat("b", 64),
+ })
+ require.ErrorIs(t, err, tt.want)
+ require.Zero(t, billingRepo.calls)
+ require.Zero(t, usageRepo.calls)
+ })
+ }
+}
+
+func TestOpenAIWalletRecordUsageRequiresAdmissionIdentityWhenOutboxIsRequired(t *testing.T) {
+ walletBalance := 10.0
+ svc := &OpenAIGatewayService{requireUsageBillingOutbox: true}
+
+ err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
+ Result: &OpenAIForwardResult{RequestID: "openai-wallet-missing-admission", Model: "gpt-5.6-sol"},
+ APIKey: &APIKey{ID: 11},
+ User: &User{ID: 22},
+ Account: &Account{ID: 33},
+ Subscription: &UserSubscription{ID: 71, WalletBalanceUSD: &walletBalance},
+ })
+
+ require.ErrorIs(t, err, ErrUsageBillingLifecycleContractInvalid)
+}
diff --git a/backend/internal/service/openai_ws_account_sticky_test.go b/backend/internal/service/openai_ws_account_sticky_test.go
index 4005a921bef..11a9dd24cb6 100644
--- a/backend/internal/service/openai_ws_account_sticky_test.go
+++ b/backend/internal/service/openai_ws_account_sticky_test.go
@@ -9,6 +9,21 @@ import (
"github.com/stretchr/testify/require"
)
+type scopedPreviousResponseAccountRepo struct {
+ stubOpenAIAccountRepo
+ accountsByGroup map[int64][]Account
+}
+
+func (r scopedPreviousResponseAccountRepo) ListSchedulableByGroupIDAndPlatform(_ context.Context, groupID int64, platform string) ([]Account, error) {
+ var result []Account
+ for _, account := range r.accountsByGroup[groupID] {
+ if account.Platform == platform && account.IsSchedulable() {
+ result = append(result, account)
+ }
+ }
+ return result, nil
+}
+
func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_Hit(t *testing.T) {
ctx := context.Background()
groupID := int64(23)
@@ -48,6 +63,49 @@ func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_Hit(t *testing.T
}
}
+func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_RejectsAccountOutsideGroup(t *testing.T) {
+ ctx := context.Background()
+ victimGroupID := int64(23)
+ attackerGroupID := int64(24)
+ account := Account{
+ ID: 29,
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Status: StatusActive,
+ Schedulable: true,
+ Concurrency: 1,
+ Extra: map[string]any{
+ "openai_apikey_responses_websockets_v2_enabled": true,
+ },
+ }
+ cache := &stubGatewayCache{}
+ store := NewOpenAIWSStateStore(cache)
+ repo := scopedPreviousResponseAccountRepo{
+ stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
+ accountsByGroup: map[int64][]Account{
+ victimGroupID: {account},
+ attackerGroupID: {},
+ },
+ }
+ svc := &OpenAIGatewayService{
+ accountRepo: repo,
+ cache: cache,
+ cfg: newOpenAIWSV2TestConfig(),
+ concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
+ openaiWSStateStore: store,
+ }
+
+ // Simulate a corrupted or stale scoped binding pointing at another group's account.
+ require.NoError(t, store.BindResponseAccount(ctx, attackerGroupID, "resp_cross_group", account.ID, time.Hour))
+ selection, err := svc.SelectAccountByPreviousResponseID(ctx, &attackerGroupID, "resp_cross_group", "gpt-5.1", nil, false)
+ require.NoError(t, err)
+ require.Nil(t, selection)
+
+ boundAccountID, err := store.GetResponseAccount(ctx, attackerGroupID, "resp_cross_group")
+ require.NoError(t, err)
+ require.Zero(t, boundAccountID, "rejected cross-group binding must be evicted")
+}
+
func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_RateLimitedMiss(t *testing.T) {
ctx := context.Background()
groupID := int64(23)
diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go
index d1386b1b216..ecf71e26029 100644
--- a/backend/internal/service/openai_ws_forwarder.go
+++ b/backend/internal/service/openai_ws_forwarder.go
@@ -219,8 +219,107 @@ func (e *OpenAIWSClientCloseError) Reason() string {
// OpenAIWSIngressHooks 定义入站 WS 每个 turn 的生命周期回调。
type OpenAIWSIngressHooks struct {
- BeforeTurn func(turn int) error
- AfterTurn func(turn int, result *OpenAIForwardResult, turnErr error)
+ // InitialRequestModel 是首帧渠道映射前的请求模型,只用于 usage metadata
+ // 的 reasoning effort 后缀推导,禁止用于上游请求或计费模型。
+ InitialRequestModel string
+ BeforeTurn func(turn int) error
+ BeforeRequest func(turn int, payload []byte, originalModel string) error
+ AfterTurn func(turn int, result *OpenAIForwardResult, turnErr error)
+}
+
+func canonicalOpenAIWSSessionModelSlug(model string) string {
+ model = strings.TrimSpace(model)
+ if model == "" {
+ return ""
+ }
+ segments := strings.Split(strings.ToLower(model), "/")
+ for i, segment := range segments {
+ segment = strings.ReplaceAll(strings.TrimSpace(segment), "_", "-")
+ segment = strings.Join(strings.Fields(segment), "-")
+ for strings.Contains(segment, "--") {
+ segment = strings.ReplaceAll(segment, "--", "-")
+ }
+ segments[i] = segment
+ }
+ if len(segments) > 1 {
+ last := len(segments) - 1
+ if canonical := canonicalizeOpenAIModelAliasSpelling(segments[last]); canonical != "" {
+ segments[last] = canonical
+ }
+ return strings.Join(segments, "/")
+ }
+
+ canonical := segments[0]
+ if normalized := canonicalizeOpenAIModelAliasSpelling(canonical); normalized != "" {
+ canonical = normalized
+ }
+ if previewTier, isPreviewFamily := classifyOpenAIGPT56PreviewModel(canonical); isPreviewFamily {
+ if previewTier != "" {
+ return previewTier
+ }
+ return canonical
+ }
+ if normalized := getNormalizedCodexModel(canonical); normalized != "" {
+ switch normalized {
+ case "gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.5":
+ return normalized
+ }
+ }
+ return canonical
+}
+
+// OpenAIWSSameCanonicalModel reports whether two client-visible WS models
+// resolve to the same stable model base. Unknown provider namespaces remain
+// part of the identity and therefore cannot collide by basename alone.
+func OpenAIWSSameCanonicalModel(left, right string) bool {
+ leftCanonical := canonicalOpenAIWSSessionModelSlug(left)
+ rightCanonical := canonicalOpenAIWSSessionModelSlug(right)
+ return leftCanonical != "" && leftCanonical == rightCanonical
+}
+
+func resolveOpenAIWSSessionCanonicalModel(account *Account, model string) (string, bool) {
+ model = strings.TrimSpace(model)
+ if account == nil || model == "" {
+ return "", false
+ }
+ routingModel := NormalizeOpenAICompatRequestedModel(model)
+ if !account.IsModelSupported(model) && (routingModel == model || !account.IsModelSupported(routingModel)) {
+ return "", false
+ }
+ mappedModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(routingModel))
+ if mappedModel == "" {
+ return "", false
+ }
+ canonical := canonicalOpenAIWSSessionModelSlug(mappedModel)
+ return canonical, canonical != ""
+}
+
+func validateOpenAIWSSessionModel(account *Account, initialCanonicalModel, candidateModel string) error {
+ if strings.TrimSpace(candidateModel) == "" {
+ return nil
+ }
+ candidateCanonical, ok := resolveOpenAIWSSessionCanonicalModel(account, candidateModel)
+ if !ok {
+ return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "websocket model is not supported by selected account", nil)
+ }
+ if initialCanonicalModel == "" || candidateCanonical != initialCanonicalModel {
+ return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "websocket model cannot change within a connection", nil)
+ }
+ return nil
+}
+
+func validateOpenAIWSSessionFrameModel(account *Account, initialCanonicalModel string, msgType coderws.MessageType, payload []byte) error {
+ if msgType != coderws.MessageText && msgType != coderws.MessageBinary {
+ return nil
+ }
+ if msgType == coderws.MessageBinary && !gjson.ValidBytes(payload) {
+ return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "binary websocket client frames must contain valid JSON", nil)
+ }
+ candidateModel := openAIWSPassthroughRequestModelForFrame(payload)
+ if candidateModel == "" {
+ candidateModel = openAIWSPassthroughRequestModelFromSessionFrame(payload)
+ }
+ return validateOpenAIWSSessionModel(account, initialCanonicalModel, candidateModel)
}
func normalizeOpenAIWSLogValue(value string) string {
@@ -1790,7 +1889,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
}
preferredConnID := ""
if stateStore != nil && previousResponseID != "" {
- if connID, ok := stateStore.GetResponseConn(previousResponseID); ok {
+ if connID, ok := stateStore.GetResponseConn(groupID, previousResponseID); ok {
preferredConnID = connID
}
}
@@ -1987,6 +2086,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
}
usage := &OpenAIUsage{}
+ imageCounter := newOpenAIImageOutputCounter()
var firstTokenMs *int
responseID := ""
var finalResponse []byte
@@ -2168,6 +2268,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
if openAIWSEventShouldParseUsage(eventType) {
parseOpenAIWSResponseUsageFromCompletedEvent(message, usage)
}
+ imageCounter.AddSSEData(message)
if eventType == "error" {
errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(message)
@@ -2307,7 +2408,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
if responseID != "" && stateStore != nil {
ttl := s.openAIWSResponseStickyTTL()
logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, stateStore.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl))
- stateStore.BindResponseConn(responseID, lease.ConnID(), ttl)
+ stateStore.BindResponseConn(groupID, responseID, lease.ConnID(), ttl)
}
if stateStore != nil && storeDisabled && sessionHash != "" {
stateStore.BindSessionConn(groupID, sessionHash, lease.ConnID(), s.openAIWSSessionStickyTTL())
@@ -2340,6 +2441,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2(
Usage: *usage,
Model: originalModel,
UpstreamModel: mappedModel,
+ ImageCount: imageCounter.Count(),
ServiceTier: extractOpenAIServiceTier(reqBody),
ReasoningEffort: extractOpenAIReasoningEffort(reqBody, originalModel),
Stream: reqStream,
@@ -2446,6 +2548,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
promptCacheKey string
previousResponseID string
originalModel string
+ imageBillingModel string
+ imageSizeTier string
payloadBytes int
}
@@ -2475,6 +2579,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
return rebuilt, nil
}
+ initialCanonicalRoutingModel := ""
parseClientPayload := func(raw []byte) (openAIWSClientPayload, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 {
@@ -2518,6 +2623,28 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
nil,
)
}
+ candidateCanonical, modelSupported := resolveOpenAIWSSessionCanonicalModel(account, originalModel)
+ if !modelSupported {
+ return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(
+ coderws.StatusPolicyViolation,
+ "websocket model is not supported by selected account",
+ nil,
+ )
+ }
+ if initialCanonicalRoutingModel == "" {
+ initialCanonicalRoutingModel = candidateCanonical
+ } else if candidateCanonical != initialCanonicalRoutingModel {
+ return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(
+ coderws.StatusPolicyViolation,
+ "websocket model cannot change within a connection",
+ nil,
+ )
+ }
+ var effortErr error
+ normalized, _, effortErr = injectOpenAIGPT56ReasoningEffort(normalized, originalModel, "reasoning.effort")
+ if effortErr != nil {
+ return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket reasoning effort", effortErr)
+ }
promptCacheKey := strings.TrimSpace(values[2].String())
previousResponseID := strings.TrimSpace(values[3].String())
previousResponseIDKind := ClassifyOpenAIPreviousResponseIDKind(previousResponseID)
@@ -2543,6 +2670,19 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
}
normalized = next
}
+ imageIntent := IsImageGenerationIntent(openAIResponsesEndpoint, originalModel, normalized)
+ if imageIntent && !GroupAllowsImageGeneration(apiKeyGroup(getAPIKeyFromContext(c))) {
+ return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, ImageGenerationPermissionMessage(), nil)
+ }
+ imageBillingModel := ""
+ imageSizeTier := ""
+ if imageIntent {
+ var imageCfgErr error
+ imageBillingModel, imageSizeTier, imageCfgErr = resolveOpenAIResponsesImageBillingConfigFromBody(normalized, originalModel)
+ if imageCfgErr != nil {
+ return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, imageCfgErr.Error(), imageCfgErr)
+ }
+ }
// Apply OpenAI Fast Policy on the response.create frame using the same
// evaluator/normalize/scope rules as the HTTP entrypoints. This is the
@@ -2588,6 +2728,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
promptCacheKey: promptCacheKey,
previousResponseID: previousResponseID,
originalModel: originalModel,
+ imageBillingModel: imageBillingModel,
+ imageSizeTier: imageSizeTier,
payloadBytes: len(normalized),
}, nil
}
@@ -2609,7 +2751,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
preferredConnID := ""
if stateStore != nil && firstPayload.previousResponseID != "" {
- if connID, ok := stateStore.GetResponseConn(firstPayload.previousResponseID); ok {
+ if connID, ok := stateStore.GetResponseConn(groupID, firstPayload.previousResponseID); ok {
preferredConnID = connID
}
}
@@ -2789,7 +2931,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
return payload, nil
}
- sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string) (*OpenAIForwardResult, error) {
+ sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string) (*OpenAIForwardResult, error) {
if lease == nil {
return nil, errors.New("upstream websocket lease is nil")
}
@@ -2814,6 +2956,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
responseID := ""
usage := OpenAIUsage{}
+ imageCounter := newOpenAIImageOutputCounter()
var firstTokenMs *int
reqStream := openAIWSPayloadBoolFromRaw(payload, "stream", true)
turnPreviousResponseID := openAIWSPayloadStringFromRaw(payload, "previous_response_id")
@@ -2935,6 +3078,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
if openAIWSEventShouldParseUsage(eventType) {
parseOpenAIWSResponseUsageFromCompletedEvent(upstreamMessage, &usage)
}
+ imageCounter.AddSSEData(upstreamMessage)
if !clientDisconnected {
if needModelReplace && len(mappedModelBytes) > 0 && openAIWSEventMayContainModel(eventType) && bytes.Contains(upstreamMessage, mappedModelBytes) {
@@ -2994,7 +3138,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
clientDisconnected,
)
}
- return &OpenAIForwardResult{
+ imageCount := imageCounter.Count()
+ result := &OpenAIForwardResult{
RequestID: responseID,
Usage: usage,
Model: originalModel,
@@ -3006,13 +3151,21 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
ResponseHeaders: lease.HandshakeHeaders(),
Duration: time.Since(turnStart),
FirstTokenMs: firstTokenMs,
- }, nil
+ }
+ if imageCount > 0 {
+ result.ImageCount = imageCount
+ result.ImageSize = imageSizeTier
+ result.BillingModel = imageBillingModel
+ }
+ return result, nil
}
}
}
currentPayload := firstPayload.payloadRaw
currentOriginalModel := firstPayload.originalModel
+ currentImageBillingModel := firstPayload.imageBillingModel
+ currentImageSizeTier := firstPayload.imageSizeTier
currentPayloadBytes := firstPayload.payloadBytes
isStrictAffinityTurn := func(payload []byte) bool {
if !storeDisabled {
@@ -3101,6 +3254,12 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
if turnPrevRecoveryTried || !s.openAIWSIngressPreviousResponseRecoveryEnabled() {
return false
}
+ // 携带 function_call_output 的请求不能丢弃 previous_response_id:
+ // 上游 API 需要 response chain 来匹配 tool_result 与之前的 tool_use,
+ // 丢弃后会导致 "No tool call found for function call output" 400 错误。
+ if gjson.GetBytes(currentPayload, `input.#(type=="function_call_output")`).Exists() {
+ return false
+ }
if isStrictAffinityTurn(currentPayload) {
// Layer 2:严格亲和链路命中 previous_response_not_found 时,降级为“去掉 previous_response_id 后重放一次”。
// 该错误说明续链锚点已失效,继续 strict fail-close 只会直接中断本轮请求。
@@ -3182,6 +3341,11 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
return true
}
for {
+ if turn > 1 && !skipBeforeTurn && hooks != nil && hooks.BeforeRequest != nil {
+ if err := hooks.BeforeRequest(turn, currentPayload, currentOriginalModel); err != nil {
+ return err
+ }
+ }
if !skipBeforeTurn && hooks != nil && hooks.BeforeTurn != nil {
if err := hooks.BeforeTurn(turn); err != nil {
return err
@@ -3367,7 +3531,11 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
truncateOpenAIWSLogValue(pingErr.Error(), openAIWSLogValueMaxLen),
)
if forcePreferredConn {
- if !turnPrevRecoveryTried && currentPreviousResponseID != "" {
+ // 携带 function_call_output 的请求不能丢弃 previous_response_id:
+ // 上游 API 需要 response chain 来匹配 tool_result 与之前的 tool_use,
+ // 丢弃后会导致 "No tool call found for function call output" 400 错误。
+ hasFCOutput := gjson.GetBytes(currentPayload, `input.#(type=="function_call_output")`).Exists()
+ if !turnPrevRecoveryTried && currentPreviousResponseID != "" && !hasFCOutput {
updatedPayload, removed, dropErr := dropPreviousResponseIDFromRawPayload(currentPayload)
if dropErr != nil || !removed {
reason := "not_removed"
@@ -3457,7 +3625,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
)
}
- result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel)
+ result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel, currentImageBillingModel, currentImageSizeTier)
if relayErr != nil {
lastTurnClean = false
if recoverIngressPrevResponseNotFound(relayErr, turn, connID) {
@@ -3508,7 +3676,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
if responseID != "" && stateStore != nil {
ttl := s.openAIWSResponseStickyTTL()
logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, stateStore.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl))
- stateStore.BindResponseConn(responseID, connID, ttl)
+ stateStore.BindResponseConn(groupID, responseID, connID, ttl)
}
if stateStore != nil && storeDisabled && sessionHash != "" {
stateStore.BindSessionConn(groupID, sessionHash, connID, s.openAIWSSessionStickyTTL())
@@ -3562,7 +3730,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
)
}
if stateStore != nil && nextPayload.previousResponseID != "" {
- if stickyConnID, ok := stateStore.GetResponseConn(nextPayload.previousResponseID); ok {
+ if stickyConnID, ok := stateStore.GetResponseConn(groupID, nextPayload.previousResponseID); ok {
if sessionConnID != "" && stickyConnID != "" && stickyConnID != sessionConnID {
logOpenAIWSModeInfo(
"ingress_ws_keep_session_conn account_id=%d turn=%d conn_id=%s sticky_conn_id=%s previous_response_id=%s",
@@ -3579,6 +3747,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
}
currentPayload = nextPayload.payloadRaw
currentOriginalModel = nextPayload.originalModel
+ currentImageBillingModel = nextPayload.imageBillingModel
+ currentImageSizeTier = nextPayload.imageSizeTier
currentPayloadBytes = nextPayload.payloadBytes
storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(currentPayload, account)
if !storeDisabled {
@@ -3740,7 +3910,7 @@ func (s *OpenAIGatewayService) performOpenAIWSGeneratePrewarm(
if prewarmResponseID != "" && stateStore != nil {
ttl := s.openAIWSResponseStickyTTL()
logOpenAIWSBindResponseAccountWarn(groupID, account.ID, prewarmResponseID, stateStore.BindResponseAccount(ctx, groupID, prewarmResponseID, account.ID, ttl))
- stateStore.BindResponseConn(prewarmResponseID, lease.ConnID(), ttl)
+ stateStore.BindResponseConn(groupID, prewarmResponseID, lease.ConnID(), ttl)
}
logOpenAIWSModeInfo(
"prewarm_done account_id=%d conn_id=%s response_id=%s events=%d terminal_events=%d duration_ms=%d",
@@ -3891,7 +4061,7 @@ func (s *OpenAIGatewayService) SelectAccountByPreviousResponseID(
}
}
- account, err := s.getSchedulableAccount(ctx, accountID)
+ account, err := s.getSchedulableOpenAIAccountInGroup(ctx, groupID, accountID)
if err != nil || account == nil {
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
return nil, nil
@@ -3949,6 +4119,31 @@ func (s *OpenAIGatewayService) SelectAccountByPreviousResponseID(
return nil, nil
}
+func (s *OpenAIGatewayService) getSchedulableOpenAIAccountInGroup(ctx context.Context, groupID *int64, accountID int64) (*Account, error) {
+ if s == nil || s.accountRepo == nil || accountID <= 0 {
+ return nil, nil
+ }
+
+ var (
+ accounts []Account
+ err error
+ )
+ if groupID == nil {
+ accounts, err = s.accountRepo.ListSchedulableUngroupedByPlatform(ctx, PlatformOpenAI)
+ } else {
+ accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformOpenAI)
+ }
+ if err != nil {
+ return nil, err
+ }
+ for i := range accounts {
+ if accounts[i].ID == accountID {
+ return &accounts[i], nil
+ }
+ }
+ return nil, nil
+}
+
func classifyOpenAIWSAcquireError(err error) string {
if err == nil {
return "acquire_conn"
diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go
index 30fd4142041..15d14df875e 100644
--- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go
+++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go
@@ -19,7 +19,6 @@ import (
)
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_KeepLeaseAcrossTurns(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -165,7 +164,6 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_KeepLeaseAcrossT
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_DedicatedModeDoesNotReuseConnAcrossSessions(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -219,6 +217,9 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_DedicatedModeDoe
Concurrency: 1,
Credentials: map[string]any{
"api_key": "sk-test",
+ "model_mapping": map[string]any{
+ "gpt-5.1": "gpt-5.4",
+ },
},
Extra: map[string]any{
"openai_apikey_responses_websockets_v2_mode": OpenAIWSIngressModeDedicated,
@@ -299,7 +300,6 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_DedicatedModeDoe
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughModeRelaysByCaddyAdapter(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -339,6 +339,9 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughModeR
Concurrency: 1,
Credentials: map[string]any{
"api_key": "sk-test",
+ "model_mapping": map[string]any{
+ "gpt-5.1": "gpt-5.4",
+ },
},
Extra: map[string]any{
"openai_apikey_responses_websockets_v2_mode": OpenAIWSIngressModePassthrough,
@@ -399,7 +402,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughModeR
}()
writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
- err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false,"service_tier":"fast"}`))
+ err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false,"service_tier":"fast","reasoning":{"effort":"HIGH"}}`))
cancelWrite()
require.NoError(t, err)
@@ -426,21 +429,25 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughModeR
select {
case result := <-resultCh:
require.Equal(t, "resp_passthrough_turn_1", result.RequestID)
+ require.Equal(t, "gpt-5.4", result.BillingModel)
+ require.Equal(t, "gpt-5.4", result.UpstreamModel)
require.True(t, result.OpenAIWSMode)
require.Equal(t, 2, result.Usage.InputTokens)
require.Equal(t, 3, result.Usage.OutputTokens)
require.NotNil(t, result.ServiceTier)
require.Equal(t, "priority", *result.ServiceTier)
+ require.NotNil(t, result.ReasoningEffort)
+ require.Equal(t, "high", *result.ReasoningEffort)
case <-time.After(2 * time.Second):
t.Fatal("未收到 passthrough turn 结果回调")
}
require.Equal(t, 1, captureDialer.DialCount(), "passthrough 模式应直接建立上游 websocket")
require.Len(t, upstreamConn.writes, 1, "passthrough 模式应透传首条 response.create")
+ require.Equal(t, "gpt-5.4", upstreamConn.writes[0]["model"])
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_ModeOffReturnsPolicyViolation(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -538,7 +545,6 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_ModeOffReturnsPo
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledPrevResponseStrictDropToFullCreate(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -674,7 +680,6 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledPre
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledPrevResponseStrictDropBeforePreflightPingFailReconnects(t *testing.T) {
- gin.SetMode(gin.TestMode)
prevPreflightPingIdle := openAIWSIngressPreflightPingIdle
openAIWSIngressPreflightPingIdle = 0
defer func() {
@@ -826,7 +831,6 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledPre
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreEnabledSkipsStrictPrevResponseEval(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -958,7 +962,6 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreEnabledSkip
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledPrevResponsePreflightSkipForFunctionCallOutput(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -1090,7 +1093,6 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledPre
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledFunctionCallOutputAutoAttachPreviousResponseID(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -1222,7 +1224,6 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledFun
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledFunctionCallOutputSkipsAutoAttachWhenLastResponseIDMissing(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -1355,7 +1356,6 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledFun
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledFunctionCallOutputSkipsAutoAttachWhenToolCallContextPresent(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -1489,7 +1489,6 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledFun
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledFunctionCallOutputAutoAttachWhenOnlyItemReferencesPresent(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -1623,7 +1622,6 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledFun
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PreflightPingFailReconnectsBeforeTurn(t *testing.T) {
- gin.SetMode(gin.TestMode)
prevPreflightPingIdle := openAIWSIngressPreflightPingIdle
openAIWSIngressPreflightPingIdle = 0
defer func() {
@@ -1765,7 +1763,6 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PreflightPingFai
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledStrictAffinityPreflightPingFailAutoRecoveryReconnects(t *testing.T) {
- gin.SetMode(gin.TestMode)
prevPreflightPingIdle := openAIWSIngressPreflightPingIdle
openAIWSIngressPreflightPingIdle = 0
defer func() {
@@ -1917,7 +1914,6 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledStr
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_WriteFailBeforeDownstreamRetriesOnce(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -2078,7 +2074,6 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_WriteFailBeforeD
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PreviousResponseNotFoundRecoversByDroppingPrevID(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -2229,7 +2224,6 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PreviousResponse
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledStrictAffinityPreviousResponseNotFoundLayer2Recovery(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -2385,7 +2379,6 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_StoreDisabledStr
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PreviousResponseNotFoundRecoveryRemovesDuplicatePrevID(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -2536,7 +2529,6 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PreviousResponse
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_RejectsMessageIDAsPreviousResponseID(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
@@ -2759,7 +2751,6 @@ func (c *openAIWSWriteFailAfterFirstTurnConn) Close() error {
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_ClientDisconnectStillDrainsUpstream(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go
index 7a76c38573d..9a6e444131c 100644
--- a/backend/internal/service/openai_ws_forwarder_success_test.go
+++ b/backend/internal/service/openai_ws_forwarder_success_test.go
@@ -23,7 +23,6 @@ import (
)
func TestOpenAIGatewayService_Forward_WSv2_SuccessAndBindSticky(t *testing.T) {
- gin.SetMode(gin.TestMode)
type receivedPayload struct {
Type string
@@ -97,6 +96,7 @@ func TestOpenAIGatewayService_Forward_WSv2_SuccessAndBindSticky(t *testing.T) {
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -163,7 +163,7 @@ func TestOpenAIGatewayService_Forward_WSv2_SuccessAndBindSticky(t *testing.T) {
mappedAccountID, getErr := store.GetResponseAccount(context.Background(), groupID, "resp_new_1")
require.NoError(t, getErr)
require.Equal(t, account.ID, mappedAccountID)
- connID, ok := store.GetResponseConn("resp_new_1")
+ connID, ok := store.GetResponseConn(groupID, "resp_new_1")
require.True(t, ok)
require.NotEmpty(t, connID)
@@ -171,6 +171,127 @@ func TestOpenAIGatewayService_Forward_WSv2_SuccessAndBindSticky(t *testing.T) {
require.Equal(t, "resp_new_1", gjson.GetBytes(responseBody, "id").String())
}
+func TestOpenAIGatewayService_Forward_WSv2_ImageGenerationCountsOutputs(t *testing.T) {
+
+ upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
+ wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ t.Errorf("upgrade websocket failed: %v", err)
+ return
+ }
+ defer func() {
+ _ = conn.Close()
+ }()
+
+ var request map[string]any
+ if err := conn.ReadJSON(&request); err != nil {
+ t.Errorf("read ws request failed: %v", err)
+ return
+ }
+
+ if err := conn.WriteJSON(map[string]any{
+ "type": "response.output_item.done",
+ "item": map[string]any{
+ "id": "ig_ws_1",
+ "type": "image_generation_call",
+ "result": "final-image",
+ },
+ }); err != nil {
+ t.Errorf("write response.output_item.done failed: %v", err)
+ return
+ }
+ if err := conn.WriteJSON(map[string]any{
+ "type": "response.completed",
+ "response": map[string]any{
+ "id": "resp_ws_image_1",
+ "model": "gpt-5.4",
+ "output": []any{
+ map[string]any{
+ "id": "ig_ws_1",
+ "type": "image_generation_call",
+ "result": "final-image",
+ },
+ },
+ "usage": map[string]any{
+ "input_tokens": 9,
+ "output_tokens": 4,
+ },
+ },
+ }); err != nil {
+ t.Errorf("write response.completed failed: %v", err)
+ return
+ }
+ }))
+ defer wsServer.Close()
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
+ groupID := int64(1010)
+ c.Set("api_key", &APIKey{
+ GroupID: &groupID,
+ Group: &Group{
+ ID: groupID,
+ AllowImageGeneration: true,
+ },
+ })
+
+ cfg := &config.Config{}
+ cfg.Security.URLAllowlist.Enabled = false
+ cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
+ cfg.Gateway.OpenAIWS.Enabled = true
+ cfg.Gateway.OpenAIWS.OAuthEnabled = true
+ cfg.Gateway.OpenAIWS.APIKeyEnabled = true
+ cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
+ cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
+ cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
+ cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
+ cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8
+ cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
+ cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5
+ cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
+
+ svc := &OpenAIGatewayService{
+ cfg: cfg,
+ httpUpstream: &httpUpstreamRecorder{},
+ cache: &stubGatewayCache{},
+ openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
+ toolCorrector: NewCodexToolCorrector(),
+ }
+
+ account := &Account{
+ ID: 10,
+ Name: "openai-ws-image",
+ Platform: PlatformOpenAI,
+ Type: AccountTypeAPIKey,
+ Status: StatusActive,
+ Schedulable: true,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "api_key": "sk-test",
+ "base_url": wsServer.URL,
+ },
+ Extra: map[string]any{
+ "responses_websockets_v2_enabled": true,
+ },
+ }
+
+ body := []byte(`{"model":"gpt-5.4","stream":false,"input":"draw","tools":[{"type":"image_generation","model":"gpt-image-2","size":"1024x1024"}],"tool_choice":{"type":"image_generation"}}`)
+ result, err := svc.Forward(context.Background(), c, account, body)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.Equal(t, "resp_ws_image_1", result.RequestID)
+ require.Equal(t, 1, result.ImageCount)
+ require.Equal(t, "1K", result.ImageSize)
+ require.Equal(t, "gpt-image-2", result.BillingModel)
+ require.Equal(t, 9, result.Usage.InputTokens)
+ require.Equal(t, 4, result.Usage.OutputTokens)
+ require.True(t, result.OpenAIWSMode)
+ require.Equal(t, "resp_ws_image_1", gjson.GetBytes(rec.Body.Bytes(), "id").String())
+}
+
func requestToJSONString(payload map[string]any) string {
if len(payload) == 0 {
return "{}"
@@ -192,7 +313,6 @@ func TestLogOpenAIWSBindResponseAccountWarn(t *testing.T) {
}
func TestOpenAIGatewayService_Forward_WSv2_RewriteModelAndToolCallsOnCompletedEvent(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -204,6 +324,7 @@ func TestOpenAIGatewayService_Forward_WSv2_RewriteModelAndToolCallsOnCompletedEv
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -277,7 +398,6 @@ func TestOpenAIWSPayloadString_OnlyAcceptsStringValues(t *testing.T) {
}
func TestOpenAIGatewayService_Forward_WSv2_PoolReuseNotOneToOne(t *testing.T) {
- gin.SetMode(gin.TestMode)
var upgradeCount atomic.Int64
var sequence atomic.Int64
@@ -329,6 +449,7 @@ func TestOpenAIGatewayService_Forward_WSv2_PoolReuseNotOneToOne(t *testing.T) {
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -388,7 +509,6 @@ func TestOpenAIGatewayService_Forward_WSv2_PoolReuseNotOneToOne(t *testing.T) {
}
func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -400,6 +520,7 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -462,7 +583,6 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T
}
func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testing.T) {
- gin.SetMode(gin.TestMode)
tests := []struct {
name string
@@ -490,6 +610,7 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testi
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -542,7 +663,6 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthOriginatorCompatibility(t *testi
}
func TestOpenAIGatewayService_Forward_WSv2_HeaderSessionFallbackFromPromptCacheKey(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -552,6 +672,7 @@ func TestOpenAIGatewayService_Forward_WSv2_HeaderSessionFallbackFromPromptCacheK
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -607,7 +728,6 @@ func TestOpenAIGatewayService_Forward_WSv2_HeaderSessionFallbackFromPromptCacheK
}
func TestOpenAIGatewayService_Forward_WSv1_Unsupported(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -617,6 +737,7 @@ func TestOpenAIGatewayService_Forward_WSv1_Unsupported(t *testing.T) {
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -667,7 +788,6 @@ func TestOpenAIGatewayService_Forward_WSv1_Unsupported(t *testing.T) {
}
func TestOpenAIGatewayService_Forward_WSv2_TurnStateAndMetadataReplayOnReconnect(t *testing.T) {
- gin.SetMode(gin.TestMode)
var connIndex atomic.Int64
headersCh := make(chan http.Header, 4)
@@ -715,6 +835,7 @@ func TestOpenAIGatewayService_Forward_WSv2_TurnStateAndMetadataReplayOnReconnect
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -765,7 +886,7 @@ func TestOpenAIGatewayService_Forward_WSv2_TurnStateAndMetadataReplayOnReconnect
require.Equal(t, "turn_state_first", turnState)
// 主动淘汰连接,模拟下一次请求发生重连。
- connID, hasConn := store.GetResponseConn(result1.RequestID)
+ connID, hasConn := store.GetResponseConn(0, result1.RequestID)
require.True(t, hasConn)
svc.getOpenAIWSConnPool().evictConn(account.ID, connID)
@@ -786,7 +907,6 @@ func TestOpenAIGatewayService_Forward_WSv2_TurnStateAndMetadataReplayOnReconnect
}
func TestOpenAIGatewayService_Forward_WSv2_GeneratePrewarm(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -796,6 +916,7 @@ func TestOpenAIGatewayService_Forward_WSv2_GeneratePrewarm(t *testing.T) {
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -905,11 +1026,11 @@ func TestOpenAIGatewayService_PrewarmReadHonorsParentContext(t *testing.T) {
}
func TestOpenAIGatewayService_Forward_WSv2_TurnMetadataInPayloadOnConnReuse(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -989,7 +1110,6 @@ func TestOpenAIGatewayService_Forward_WSv2_TurnMetadataInPayloadOnConnReuse(t *t
}
func TestOpenAIGatewayService_Forward_WSv2StoreFalseSessionConnIsolation(t *testing.T) {
- gin.SetMode(gin.TestMode)
var upgradeCount atomic.Int64
var sequence atomic.Int64
@@ -1031,6 +1151,7 @@ func TestOpenAIGatewayService_Forward_WSv2StoreFalseSessionConnIsolation(t *test
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -1096,7 +1217,6 @@ func TestOpenAIGatewayService_Forward_WSv2StoreFalseSessionConnIsolation(t *test
}
func TestOpenAIGatewayService_Forward_WSv2StoreFalseDisableForceNewConnAllowsReuse(t *testing.T) {
- gin.SetMode(gin.TestMode)
var upgradeCount atomic.Int64
var sequence atomic.Int64
@@ -1138,6 +1258,7 @@ func TestOpenAIGatewayService_Forward_WSv2StoreFalseDisableForceNewConnAllowsReu
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -1194,7 +1315,6 @@ func TestOpenAIGatewayService_Forward_WSv2StoreFalseDisableForceNewConnAllowsReu
}
func TestOpenAIGatewayService_Forward_WSv2ReadTimeoutAppliesPerRead(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -1204,6 +1324,7 @@ func TestOpenAIGatewayService_Forward_WSv2ReadTimeoutAppliesPerRead(t *testing.T
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
diff --git a/backend/internal/service/openai_ws_protocol_forward_test.go b/backend/internal/service/openai_ws_protocol_forward_test.go
index f3936de1333..7a3a50e8e9e 100644
--- a/backend/internal/service/openai_ws_protocol_forward_test.go
+++ b/backend/internal/service/openai_ws_protocol_forward_test.go
@@ -63,7 +63,6 @@ func (u *httpUpstreamSequenceRecorder) DoWithTLS(req *http.Request, proxyURL str
}
func TestOpenAIGatewayService_Forward_PreservePreviousResponseIDWhenWSEnabled(t *testing.T) {
- gin.SetMode(gin.TestMode)
wsFallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
@@ -87,6 +86,7 @@ func TestOpenAIGatewayService_Forward_PreservePreviousResponseIDWhenWSEnabled(t
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -121,7 +121,6 @@ func TestOpenAIGatewayService_Forward_PreservePreviousResponseIDWhenWSEnabled(t
}
func TestOpenAIGatewayService_Forward_HTTPIngressStaysHTTPWhenWSEnabled(t *testing.T) {
- gin.SetMode(gin.TestMode)
wsFallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
@@ -146,6 +145,7 @@ func TestOpenAIGatewayService_Forward_HTTPIngressStaysHTTPWhenWSEnabled(t *testi
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -187,7 +187,6 @@ func TestOpenAIGatewayService_Forward_HTTPIngressStaysHTTPWhenWSEnabled(t *testi
}
func TestOpenAIGatewayService_Forward_HTTPIngressRetriesInvalidEncryptedContentOnce(t *testing.T) {
- gin.SetMode(gin.TestMode)
wsFallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
@@ -221,6 +220,7 @@ func TestOpenAIGatewayService_Forward_HTTPIngressRetriesInvalidEncryptedContentO
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -273,7 +273,6 @@ func TestOpenAIGatewayService_Forward_HTTPIngressRetriesInvalidEncryptedContentO
}
func TestOpenAIGatewayService_Forward_HTTPIngressRetriesWrappedInvalidEncryptedContentOnce(t *testing.T) {
- gin.SetMode(gin.TestMode)
wsFallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
@@ -310,6 +309,7 @@ func TestOpenAIGatewayService_Forward_HTTPIngressRetriesWrappedInvalidEncryptedC
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -357,7 +357,6 @@ func TestOpenAIGatewayService_Forward_HTTPIngressRetriesWrappedInvalidEncryptedC
}
func TestOpenAIGatewayService_Forward_RemovePreviousResponseIDWhenWSDisabled(t *testing.T) {
- gin.SetMode(gin.TestMode)
wsFallbackServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
@@ -381,6 +380,7 @@ func TestOpenAIGatewayService_Forward_RemovePreviousResponseIDWhenWSDisabled(t *
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = false
cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
@@ -413,7 +413,6 @@ func TestOpenAIGatewayService_Forward_RemovePreviousResponseIDWhenWSDisabled(t *
}
func TestOpenAIGatewayService_Forward_WSv2Dial426FallbackHTTP(t *testing.T) {
- gin.SetMode(gin.TestMode)
ws426Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUpgradeRequired)
_, _ = w.Write([]byte(`upgrade required`))
@@ -438,6 +437,7 @@ func TestOpenAIGatewayService_Forward_WSv2Dial426FallbackHTTP(t *testing.T) {
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -476,7 +476,6 @@ func TestOpenAIGatewayService_Forward_WSv2Dial426FallbackHTTP(t *testing.T) {
}
func TestOpenAIGatewayService_Forward_WSv2FallbackCoolingSkipWS(t *testing.T) {
- gin.SetMode(gin.TestMode)
wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
@@ -500,6 +499,7 @@ func TestOpenAIGatewayService_Forward_WSv2FallbackCoolingSkipWS(t *testing.T) {
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -539,7 +539,6 @@ func TestOpenAIGatewayService_Forward_WSv2FallbackCoolingSkipWS(t *testing.T) {
}
func TestOpenAIGatewayService_Forward_ReturnErrorWhenOnlyWSv1Enabled(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -559,6 +558,7 @@ func TestOpenAIGatewayService_Forward_ReturnErrorWhenOnlyWSv1Enabled(t *testing.
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -604,6 +604,7 @@ func TestNewOpenAIGatewayService_InitializesOpenAIWSResolver(t *testing.T) {
nil,
nil,
nil,
+ nil, // walletRepo
nil,
nil,
cfg,
@@ -619,6 +620,9 @@ func TestNewOpenAIGatewayService_InitializesOpenAIWSResolver(t *testing.T) {
nil,
nil,
nil,
+ nil,
+ nil,
+ nil,
)
decision := svc.getOpenAIWSProtocolResolver().Resolve(nil)
@@ -627,7 +631,6 @@ func TestNewOpenAIGatewayService_InitializesOpenAIWSResolver(t *testing.T) {
}
func TestOpenAIGatewayService_Forward_WSv2FallbackWhenResponseAlreadyWrittenReturnsWSError(t *testing.T) {
- gin.SetMode(gin.TestMode)
ws426Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUpgradeRequired)
_, _ = w.Write([]byte(`upgrade required`))
@@ -651,6 +654,7 @@ func TestOpenAIGatewayService_Forward_WSv2FallbackWhenResponseAlreadyWrittenRetu
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -687,7 +691,6 @@ func TestOpenAIGatewayService_Forward_WSv2FallbackWhenResponseAlreadyWrittenRetu
}
func TestOpenAIGatewayService_Forward_WSv2StreamEarlyCloseFallbackHTTP(t *testing.T) {
- gin.SetMode(gin.TestMode)
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -743,6 +746,7 @@ func TestOpenAIGatewayService_Forward_WSv2StreamEarlyCloseFallbackHTTP(t *testin
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -780,7 +784,6 @@ func TestOpenAIGatewayService_Forward_WSv2StreamEarlyCloseFallbackHTTP(t *testin
}
func TestOpenAIGatewayService_Forward_WSv2RetryFiveTimesThenFallbackHTTP(t *testing.T) {
- gin.SetMode(gin.TestMode)
var wsAttempts atomic.Int32
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
@@ -825,6 +828,7 @@ func TestOpenAIGatewayService_Forward_WSv2RetryFiveTimesThenFallbackHTTP(t *test
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -862,7 +866,6 @@ func TestOpenAIGatewayService_Forward_WSv2RetryFiveTimesThenFallbackHTTP(t *test
}
func TestOpenAIGatewayService_Forward_WSv2PolicyViolationFastFallbackHTTP(t *testing.T) {
- gin.SetMode(gin.TestMode)
var wsAttempts atomic.Int32
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
@@ -903,6 +906,7 @@ func TestOpenAIGatewayService_Forward_WSv2PolicyViolationFastFallbackHTTP(t *tes
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -943,7 +947,6 @@ func TestOpenAIGatewayService_Forward_WSv2PolicyViolationFastFallbackHTTP(t *tes
}
func TestOpenAIGatewayService_Forward_WSv2ConnectionLimitReachedRetryThenFallbackHTTP(t *testing.T) {
- gin.SetMode(gin.TestMode)
var wsAttempts atomic.Int32
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
@@ -990,6 +993,7 @@ func TestOpenAIGatewayService_Forward_WSv2ConnectionLimitReachedRetryThenFallbac
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -1027,7 +1031,6 @@ func TestOpenAIGatewayService_Forward_WSv2ConnectionLimitReachedRetryThenFallbac
}
func TestOpenAIGatewayService_Forward_WSv2PreviousResponseNotFoundRecoversByDroppingPreviousResponseID(t *testing.T) {
- gin.SetMode(gin.TestMode)
var wsAttempts atomic.Int32
var wsRequestPayloads [][]byte
@@ -1097,6 +1100,7 @@ func TestOpenAIGatewayService_Forward_WSv2PreviousResponseNotFoundRecoversByDrop
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -1144,7 +1148,6 @@ func TestOpenAIGatewayService_Forward_WSv2PreviousResponseNotFoundRecoversByDrop
}
func TestOpenAIGatewayService_Forward_WSv2PreviousResponseNotFoundSkipsRecoveryForFunctionCallOutput(t *testing.T) {
- gin.SetMode(gin.TestMode)
var wsAttempts atomic.Int32
var wsRequestPayloads [][]byte
@@ -1197,6 +1200,7 @@ func TestOpenAIGatewayService_Forward_WSv2PreviousResponseNotFoundSkipsRecoveryF
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -1242,7 +1246,6 @@ func TestOpenAIGatewayService_Forward_WSv2PreviousResponseNotFoundSkipsRecoveryF
}
func TestOpenAIGatewayService_Forward_WSv2PreviousResponseNotFoundSkipsRecoveryWithoutPreviousResponseID(t *testing.T) {
- gin.SetMode(gin.TestMode)
var wsAttempts atomic.Int32
var wsRequestPayloads [][]byte
@@ -1295,6 +1298,7 @@ func TestOpenAIGatewayService_Forward_WSv2PreviousResponseNotFoundSkipsRecoveryW
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -1339,7 +1343,6 @@ func TestOpenAIGatewayService_Forward_WSv2PreviousResponseNotFoundSkipsRecoveryW
}
func TestOpenAIGatewayService_Forward_WSv2PreviousResponseNotFoundOnlyRecoversOnce(t *testing.T) {
- gin.SetMode(gin.TestMode)
var wsAttempts atomic.Int32
var wsRequestPayloads [][]byte
@@ -1392,6 +1395,7 @@ func TestOpenAIGatewayService_Forward_WSv2PreviousResponseNotFoundOnlyRecoversOn
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -1437,7 +1441,6 @@ func TestOpenAIGatewayService_Forward_WSv2PreviousResponseNotFoundOnlyRecoversOn
}
func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentRecoversOnce(t *testing.T) {
- gin.SetMode(gin.TestMode)
var wsAttempts atomic.Int32
var wsRequestPayloads [][]byte
@@ -1507,6 +1510,7 @@ func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentRecoversOnce(t
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -1557,7 +1561,6 @@ func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentRecoversOnce(t
}
func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentSkipsRecoveryWithoutReasoningItem(t *testing.T) {
- gin.SetMode(gin.TestMode)
var wsAttempts atomic.Int32
var wsRequestPayloads [][]byte
@@ -1610,6 +1613,7 @@ func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentSkipsRecoveryWi
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -1656,7 +1660,6 @@ func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentSkipsRecoveryWi
}
func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentRecoversSingleObjectInputAndKeepsSummary(t *testing.T) {
- gin.SetMode(gin.TestMode)
var wsAttempts atomic.Int32
var wsRequestPayloads [][]byte
@@ -1726,6 +1729,7 @@ func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentRecoversSingleO
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
@@ -1774,7 +1778,6 @@ func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentRecoversSingleO
}
func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentKeepsPreviousResponseIDForFunctionCallOutput(t *testing.T) {
- gin.SetMode(gin.TestMode)
var wsAttempts atomic.Int32
var wsRequestPayloads [][]byte
@@ -1844,6 +1847,7 @@ func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentKeepsPreviousRe
cfg := &config.Config{}
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.Enabled = true
cfg.Gateway.OpenAIWS.OAuthEnabled = true
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
diff --git a/backend/internal/service/openai_ws_ratelimit_signal_test.go b/backend/internal/service/openai_ws_ratelimit_signal_test.go
index 4ee85a3a09d..513639c7d93 100644
--- a/backend/internal/service/openai_ws_ratelimit_signal_test.go
+++ b/backend/internal/service/openai_ws_ratelimit_signal_test.go
@@ -84,7 +84,6 @@ func (r *openAICodexExtraListRepo) ListWithFilters(_ context.Context, params pag
}
func TestOpenAIGatewayService_Forward_WSv2ErrorEventUsageLimitPersistsRateLimit(t *testing.T) {
- gin.SetMode(gin.TestMode)
resetAt := time.Now().Add(2 * time.Hour).Unix()
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
@@ -129,6 +128,7 @@ func TestOpenAIGatewayService_Forward_WSv2ErrorEventUsageLimitPersistsRateLimit(
cfg := newOpenAIWSV2TestConfig()
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
account := Account{
ID: 501,
@@ -169,7 +169,6 @@ func TestOpenAIGatewayService_Forward_WSv2ErrorEventUsageLimitPersistsRateLimit(
}
func TestOpenAIGatewayService_Forward_WSv2Handshake429PersistsRateLimit(t *testing.T) {
- gin.SetMode(gin.TestMode)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("x-codex-primary-used-percent", "100")
@@ -199,6 +198,7 @@ func TestOpenAIGatewayService_Forward_WSv2Handshake429PersistsRateLimit(t *testi
cfg := newOpenAIWSV2TestConfig()
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
account := Account{
ID: 502,
@@ -240,11 +240,11 @@ func TestOpenAIGatewayService_Forward_WSv2Handshake429PersistsRateLimit(t *testi
}
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_ErrorEventUsageLimitPersistsRateLimit(t *testing.T) {
- gin.SetMode(gin.TestMode)
cfg := newOpenAIWSV2TestConfig()
cfg.Security.URLAllowlist.Enabled = false
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
diff --git a/backend/internal/service/openai_ws_state_store.go b/backend/internal/service/openai_ws_state_store.go
index b606baa1a3c..947e2f0e821 100644
--- a/backend/internal/service/openai_ws_state_store.go
+++ b/backend/internal/service/openai_ws_state_store.go
@@ -50,9 +50,9 @@ type OpenAIWSStateStore interface {
GetResponseAccount(ctx context.Context, groupID int64, responseID string) (int64, error)
DeleteResponseAccount(ctx context.Context, groupID int64, responseID string) error
- BindResponseConn(responseID, connID string, ttl time.Duration)
- GetResponseConn(responseID string) (string, bool)
- DeleteResponseConn(responseID string)
+ BindResponseConn(groupID int64, responseID, connID string, ttl time.Duration)
+ GetResponseConn(groupID int64, responseID string) (string, bool)
+ DeleteResponseConn(groupID int64, responseID string)
BindSessionTurnState(groupID int64, sessionHash, turnState string, ttl time.Duration)
GetSessionTurnState(groupID int64, sessionHash string) (string, bool)
@@ -96,13 +96,14 @@ func (s *defaultOpenAIWSStateStore) BindResponseAccount(ctx context.Context, gro
if id == "" || accountID <= 0 {
return nil
}
+ localKey := openAIWSResponseStateKey(groupID, id)
ttl = normalizeOpenAIWSTTL(ttl)
s.maybeCleanup()
expiresAt := time.Now().Add(ttl)
s.responseToAccountMu.Lock()
- ensureBindingCapacity(s.responseToAccount, id, openAIWSStateStoreMaxEntriesPerMap)
- s.responseToAccount[id] = openAIWSAccountBinding{accountID: accountID, expiresAt: expiresAt}
+ ensureBindingCapacity(s.responseToAccount, localKey, openAIWSStateStoreMaxEntriesPerMap)
+ s.responseToAccount[localKey] = openAIWSAccountBinding{accountID: accountID, expiresAt: expiresAt}
s.responseToAccountMu.Unlock()
if s.cache == nil {
@@ -119,11 +120,12 @@ func (s *defaultOpenAIWSStateStore) GetResponseAccount(ctx context.Context, grou
if id == "" {
return 0, nil
}
+ localKey := openAIWSResponseStateKey(groupID, id)
s.maybeCleanup()
now := time.Now()
s.responseToAccountMu.RLock()
- if binding, ok := s.responseToAccount[id]; ok {
+ if binding, ok := s.responseToAccount[localKey]; ok {
if now.Before(binding.expiresAt) {
accountID := binding.accountID
s.responseToAccountMu.RUnlock()
@@ -152,8 +154,9 @@ func (s *defaultOpenAIWSStateStore) DeleteResponseAccount(ctx context.Context, g
if id == "" {
return nil
}
+ localKey := openAIWSResponseStateKey(groupID, id)
s.responseToAccountMu.Lock()
- delete(s.responseToAccount, id)
+ delete(s.responseToAccount, localKey)
s.responseToAccountMu.Unlock()
if s.cache == nil {
@@ -164,34 +167,36 @@ func (s *defaultOpenAIWSStateStore) DeleteResponseAccount(ctx context.Context, g
return s.cache.DeleteSessionAccountID(cacheCtx, groupID, openAIWSResponseAccountCacheKey(id))
}
-func (s *defaultOpenAIWSStateStore) BindResponseConn(responseID, connID string, ttl time.Duration) {
+func (s *defaultOpenAIWSStateStore) BindResponseConn(groupID int64, responseID, connID string, ttl time.Duration) {
id := normalizeOpenAIWSResponseID(responseID)
conn := strings.TrimSpace(connID)
if id == "" || conn == "" {
return
}
+ localKey := openAIWSResponseStateKey(groupID, id)
ttl = normalizeOpenAIWSTTL(ttl)
s.maybeCleanup()
s.responseToConnMu.Lock()
- ensureBindingCapacity(s.responseToConn, id, openAIWSStateStoreMaxEntriesPerMap)
- s.responseToConn[id] = openAIWSConnBinding{
+ ensureBindingCapacity(s.responseToConn, localKey, openAIWSStateStoreMaxEntriesPerMap)
+ s.responseToConn[localKey] = openAIWSConnBinding{
connID: conn,
expiresAt: time.Now().Add(ttl),
}
s.responseToConnMu.Unlock()
}
-func (s *defaultOpenAIWSStateStore) GetResponseConn(responseID string) (string, bool) {
+func (s *defaultOpenAIWSStateStore) GetResponseConn(groupID int64, responseID string) (string, bool) {
id := normalizeOpenAIWSResponseID(responseID)
if id == "" {
return "", false
}
+ localKey := openAIWSResponseStateKey(groupID, id)
s.maybeCleanup()
now := time.Now()
s.responseToConnMu.RLock()
- binding, ok := s.responseToConn[id]
+ binding, ok := s.responseToConn[localKey]
s.responseToConnMu.RUnlock()
if !ok || now.After(binding.expiresAt) || strings.TrimSpace(binding.connID) == "" {
return "", false
@@ -199,13 +204,14 @@ func (s *defaultOpenAIWSStateStore) GetResponseConn(responseID string) (string,
return binding.connID, true
}
-func (s *defaultOpenAIWSStateStore) DeleteResponseConn(responseID string) {
+func (s *defaultOpenAIWSStateStore) DeleteResponseConn(groupID int64, responseID string) {
id := normalizeOpenAIWSResponseID(responseID)
if id == "" {
return
}
+ localKey := openAIWSResponseStateKey(groupID, id)
s.responseToConnMu.Lock()
- delete(s.responseToConn, id)
+ delete(s.responseToConn, localKey)
s.responseToConnMu.Unlock()
}
@@ -412,6 +418,14 @@ func normalizeOpenAIWSResponseID(responseID string) string {
return strings.TrimSpace(responseID)
}
+func openAIWSResponseStateKey(groupID int64, responseID string) string {
+ id := normalizeOpenAIWSResponseID(responseID)
+ if id == "" {
+ return ""
+ }
+ return fmt.Sprintf("%d:%s", groupID, id)
+}
+
func openAIWSResponseAccountCacheKey(responseID string) string {
sum := sha256.Sum256([]byte(responseID))
return openAIWSResponseAccountCachePrefix + hex.EncodeToString(sum[:])
diff --git a/backend/internal/service/openai_ws_state_store_test.go b/backend/internal/service/openai_ws_state_store_test.go
index 235d42331d1..b89889ae31a 100644
--- a/backend/internal/service/openai_ws_state_store_test.go
+++ b/backend/internal/service/openai_ws_state_store_test.go
@@ -28,16 +28,41 @@ func TestOpenAIWSStateStore_BindGetDeleteResponseAccount(t *testing.T) {
require.Zero(t, accountID)
}
+func TestOpenAIWSStateStore_ResponseAccountIsGroupScoped(t *testing.T) {
+ store := NewOpenAIWSStateStore(nil)
+ ctx := context.Background()
+
+ require.NoError(t, store.BindResponseAccount(ctx, 7, "resp_shared", 101, time.Minute))
+ accountID, err := store.GetResponseAccount(ctx, 8, "resp_shared")
+ require.NoError(t, err)
+ require.Zero(t, accountID)
+
+ require.NoError(t, store.BindResponseAccount(ctx, 8, "resp_shared", 202, time.Minute))
+ accountID, err = store.GetResponseAccount(ctx, 7, "resp_shared")
+ require.NoError(t, err)
+ require.Equal(t, int64(101), accountID)
+ accountID, err = store.GetResponseAccount(ctx, 8, "resp_shared")
+ require.NoError(t, err)
+ require.Equal(t, int64(202), accountID)
+
+ require.NoError(t, store.DeleteResponseAccount(ctx, 7, "resp_shared"))
+ accountID, err = store.GetResponseAccount(ctx, 8, "resp_shared")
+ require.NoError(t, err)
+ require.Equal(t, int64(202), accountID)
+}
+
func TestOpenAIWSStateStore_ResponseConnTTL(t *testing.T) {
store := NewOpenAIWSStateStore(nil)
- store.BindResponseConn("resp_conn", "conn_1", 30*time.Millisecond)
+ store.BindResponseConn(7, "resp_conn", "conn_1", 30*time.Millisecond)
- connID, ok := store.GetResponseConn("resp_conn")
+ connID, ok := store.GetResponseConn(7, "resp_conn")
require.True(t, ok)
require.Equal(t, "conn_1", connID)
+ _, ok = store.GetResponseConn(8, "resp_conn")
+ require.False(t, ok, "response connection bindings must not cross groups")
time.Sleep(60 * time.Millisecond)
- _, ok = store.GetResponseConn("resp_conn")
+ _, ok = store.GetResponseConn(7, "resp_conn")
require.False(t, ok)
}
diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go
index 3dbb199a8b1..c06a008826b 100644
--- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go
+++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go
@@ -15,6 +15,7 @@ import (
coderws "github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
+ "github.com/tidwall/sjson"
)
type openAIWSClientFrameConn struct {
@@ -124,6 +125,128 @@ func openAIWSPassthroughPolicyModelFromSessionFrame(account *Account, payload []
return normalizeOpenAIModelForUpstream(account, account.GetMappedModel(original))
}
+func rewriteOpenAIWSPassthroughMappedModel(account *Account, payload []byte) ([]byte, error) {
+ requestModel := openAIWSPassthroughRequestModelForFrame(payload)
+ if requestModel == "" {
+ requestModel = openAIWSPassthroughRequestModelFromSessionFrame(payload)
+ }
+ return rewriteOpenAIWSPassthroughMappedModelForRequest(account, payload, requestModel)
+}
+
+func rewriteOpenAIWSPassthroughMappedModelForRequest(account *Account, payload []byte, requestModel string) ([]byte, error) {
+ if account == nil || len(payload) == 0 {
+ return payload, nil
+ }
+
+ frameType := strings.TrimSpace(gjson.GetBytes(payload, "type").String())
+ modelPath := ""
+ switch frameType {
+ case "response.create", "":
+ modelPath = "model"
+ case "session.update":
+ modelPath = "session.model"
+ default:
+ return payload, nil
+ }
+
+ originalModel := strings.TrimSpace(gjson.GetBytes(payload, modelPath).String())
+ effectiveModel := strings.TrimSpace(requestModel)
+ if effectiveModel == "" {
+ effectiveModel = originalModel
+ }
+ if originalModel == "" && effectiveModel == "" {
+ return payload, nil
+ }
+ if _, modelSupported := resolveOpenAIWSSessionCanonicalModel(account, effectiveModel); !modelSupported {
+ return nil, errors.New("websocket model is not supported by selected account")
+ }
+
+ updated := payload
+ if modelPath == "model" {
+ var err error
+ updated, _, err = injectOpenAIGPT56ReasoningEffort(updated, effectiveModel, "reasoning.effort")
+ if err != nil {
+ return nil, fmt.Errorf("inject GPT-5.6 websocket reasoning effort: %w", err)
+ }
+ }
+ if originalModel == "" {
+ return updated, nil
+ }
+
+ mappedModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(originalModel))
+ if mappedModel == "" || mappedModel == originalModel {
+ return updated, nil
+ }
+ return sjson.SetBytes(updated, modelPath, mappedModel)
+}
+
+type openAIWSPassthroughUsageMeta struct {
+ serviceTier atomic.Pointer[string]
+ reasoningEffort atomic.Pointer[string]
+
+ // 仅在 client->upstream filter goroutine 中读写;Load 侧通过上方原子指针同步。
+ sessionRequestModel string
+}
+
+func newOpenAIWSPassthroughUsageMeta(initialRequestModel string, firstFrame []byte) *openAIWSPassthroughUsageMeta {
+ meta := &openAIWSPassthroughUsageMeta{
+ sessionRequestModel: strings.TrimSpace(initialRequestModel),
+ }
+ if meta.sessionRequestModel == "" {
+ meta.sessionRequestModel = openAIWSPassthroughRequestModelForFrame(firstFrame)
+ }
+ return meta
+}
+
+func (m *openAIWSPassthroughUsageMeta) initFromFirstFrame(policyOutput []byte) {
+ if m == nil {
+ return
+ }
+ m.serviceTier.Store(extractOpenAIServiceTierFromBody(policyOutput))
+ m.reasoningEffort.Store(extractOpenAIReasoningEffortFromBody(policyOutput, m.sessionRequestModel))
+}
+
+func (m *openAIWSPassthroughUsageMeta) updateSessionRequestModel(payload []byte) {
+ if m == nil {
+ return
+ }
+ if model := openAIWSPassthroughRequestModelFromSessionFrame(payload); model != "" {
+ m.sessionRequestModel = model
+ }
+}
+
+func (m *openAIWSPassthroughUsageMeta) requestModelForFrame(payload []byte) string {
+ if m == nil {
+ return openAIWSPassthroughRequestModelForFrame(payload)
+ }
+ if model := openAIWSPassthroughRequestModelForFrame(payload); model != "" {
+ return model
+ }
+ return m.sessionRequestModel
+}
+
+func (m *openAIWSPassthroughUsageMeta) updateFromResponseCreate(policyOutput []byte, requestModelForFrame string) {
+ if m == nil {
+ return
+ }
+ m.serviceTier.Store(extractOpenAIServiceTierFromBody(policyOutput))
+ m.reasoningEffort.Store(extractOpenAIReasoningEffortFromBody(policyOutput, requestModelForFrame))
+}
+
+func openAIWSPassthroughRequestModelForFrame(payload []byte) string {
+ if len(payload) == 0 || strings.TrimSpace(gjson.GetBytes(payload, "type").String()) != "response.create" {
+ return ""
+ }
+ return strings.TrimSpace(gjson.GetBytes(payload, "model").String())
+}
+
+func openAIWSPassthroughRequestModelFromSessionFrame(payload []byte) string {
+ if len(payload) == 0 || strings.TrimSpace(gjson.GetBytes(payload, "type").String()) != "session.update" {
+ return ""
+ }
+ return strings.TrimSpace(gjson.GetBytes(payload, "session.model").String())
+}
+
const openaiWSV2PassthroughModeFields = "ws_mode=passthrough ws_router=v2"
var _ openaiwsv2.FrameConn = (*openAIWSClientFrameConn)(nil)
@@ -180,6 +303,12 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
return errors.New("token is empty")
}
requestModel := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String())
+ initialCanonicalRoutingModel, modelSupported := resolveOpenAIWSSessionCanonicalModel(account, requestModel)
+ if !modelSupported {
+ return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "websocket model is not supported by selected account", nil)
+ }
+ sessionBillingModel := account.GetMappedModel(NormalizeOpenAICompatRequestedModel(requestModel))
+ sessionUpstreamModel := normalizeOpenAIModelForUpstream(account, sessionBillingModel)
requestPreviousResponseID := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "previous_response_id").String())
logOpenAIWSV2Passthrough(
"relay_start account_id=%d model=%s previous_response_id=%s first_message_type=%s first_message_bytes=%d",
@@ -204,6 +333,11 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
// silently passed through, defeating the policy on every frame after
// the first.
capturedSessionModel := openAIWSPassthroughPolicyModelForFrame(account, firstClientMessage)
+ initialRequestModel := ""
+ if hooks != nil {
+ initialRequestModel = hooks.InitialRequestModel
+ }
+ usageMeta := newOpenAIWSPassthroughUsageMeta(initialRequestModel, firstClientMessage)
updatedFirst, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, capturedSessionModel, firstClientMessage)
if policyErr != nil {
return fmt.Errorf("apply openai fast policy on first ws frame: %w", policyErr)
@@ -224,9 +358,14 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
}
return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, blocked.Message, blocked)
}
+ updatedFirst, policyErr = rewriteOpenAIWSPassthroughMappedModel(account, updatedFirst)
+ if policyErr != nil {
+ return policyErr
+ }
firstClientMessage = updatedFirst
- // 在 policy filter 之后再提取 service_tier 用于 billing 上报:filter
+ // 在 policy filter 之后再提取 service_tier / reasoning_effort 用于
+ // usage 上报:filter
// 命中时 service_tier 已经从 firstClientMessage 中删除,billing 应当
// 反映上游实际处理的 tier(nil = default),而不是用户最初请求的
// "priority"。HTTP 入口(line ~2728 extractOpenAIServiceTier(reqBody))
@@ -237,11 +376,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
// codex-rs/core/src/client.rs build_responses_request 每次重新填值)。
// 因此使用 atomic.Pointer[string] 在 filter(runClientToUpstream
// goroutine)和 OnTurnComplete / final result(runUpstreamToClient
- // goroutine)之间同步当前 turn 的 service_tier。
- // extractOpenAIServiceTierFromBody 返回 *string,本身是指针类型,
- // 可直接 Store/Load 而无需额外封装。
- var requestServiceTierPtr atomic.Pointer[string]
- requestServiceTierPtr.Store(extractOpenAIServiceTierFromBody(firstClientMessage))
+ // goroutine)之间同步当前 turn 的 usage metadata。
+ usageMeta.initFromFirstFrame(firstClientMessage)
wsURL, err := s.buildOpenAIResponsesWSURL(account)
if err != nil {
@@ -314,9 +450,25 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
// capturedSessionModel 的读写都发生在该 goroutine 内,因此无需
// 加锁/原子化。
filter: func(msgType coderws.MessageType, payload []byte) ([]byte, *OpenAIFastBlockedError, error) {
- if msgType != coderws.MessageText {
+ if msgType != coderws.MessageText && msgType != coderws.MessageBinary {
return payload, nil, nil
}
+ if err := validateOpenAIWSSessionFrameModel(account, initialCanonicalRoutingModel, msgType, payload); err != nil {
+ return payload, nil, err
+ }
+ if strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" && hooks != nil && hooks.BeforeRequest != nil {
+ turnNo := int(completedTurns.Load()) + 1
+ if turnNo < 2 {
+ turnNo = 2
+ }
+ requestModel := usageMeta.requestModelForFrame(payload)
+ if requestModel == "" {
+ requestModel = capturedSessionModel
+ }
+ if err := hooks.BeforeRequest(turnNo, payload, requestModel); err != nil {
+ return payload, nil, err
+ }
+ }
// 在评估策略前先刷新 capturedSessionModel:客户端可能通过
// session.update 修改 session-level model(Realtime /
// Responses WS 协议允许),如果不刷新就会出现
@@ -327,6 +479,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
if updated := openAIWSPassthroughPolicyModelFromSessionFrame(account, payload); updated != "" {
capturedSessionModel = updated
}
+ usageMeta.updateSessionRequestModel(payload)
+ requestModelForThisFrame := usageMeta.requestModelForFrame(payload)
// Per-frame model first; if the client omits "model" on a
// follow-up frame (legal in Realtime), fall back to the
// session-level model captured from the first frame so the
@@ -337,23 +491,26 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
model = capturedSessionModel
}
out, blocked, policyErr := s.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, model, payload)
- // 多轮 passthrough billing:仅在成功(non-block / non-err)
- // 的 response.create 帧上更新 requestServiceTierPtr,使用
+ // 多轮 passthrough usage:仅在成功(non-block / non-err)
+ // 的 response.create 帧上更新 usageMeta,使用
// filter 处理后的 payload,与首帧 policy-after-extract 语义
// 保持一致(参见上方 extractOpenAIServiceTierFromBody 注释)。
// - 非 response.create 帧(response.cancel /
// conversation.item.create / session.update 等)不携带
- // per-response service_tier,不应覆盖前一轮值。
- // - blocked != nil:该帧不会发送上游,billing tier 应保持
+ // per-response metadata,不应覆盖前一轮值。
+ // - blocked != nil:该帧不会发送上游,usage metadata 应保持
// 上一轮值。
// - policyErr != nil:异常路径,保持上一轮值。
// - 不带 service_tier 的 response.create 会让
// extractOpenAIServiceTierFromBody 返回 nil;这里有意
// 覆盖(Store(nil)),因为 OpenAI 上游对该帧实际不传
// service_tier 时按 default 处理,billing 应如实反映。
+ if policyErr == nil && blocked == nil {
+ out, policyErr = rewriteOpenAIWSPassthroughMappedModelForRequest(account, out, requestModelForThisFrame)
+ }
if policyErr == nil && blocked == nil &&
strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" {
- requestServiceTierPtr.Store(extractOpenAIServiceTierFromBody(out))
+ usageMeta.updateFromResponseCreate(out, requestModelForThisFrame)
}
return out, blocked, policyErr
},
@@ -397,7 +554,10 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
CacheReadInputTokens: turn.Usage.CacheReadInputTokens,
},
Model: turn.RequestModel,
- ServiceTier: requestServiceTierPtr.Load(),
+ BillingModel: sessionBillingModel,
+ UpstreamModel: sessionUpstreamModel,
+ ServiceTier: usageMeta.serviceTier.Load(),
+ ReasoningEffort: usageMeta.reasoningEffort.Load(),
Stream: true,
OpenAIWSMode: true,
ResponseHeaders: cloneHeader(handshakeHeaders),
@@ -445,7 +605,10 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
CacheReadInputTokens: relayResult.Usage.CacheReadInputTokens,
},
Model: relayResult.RequestModel,
- ServiceTier: requestServiceTierPtr.Load(),
+ BillingModel: sessionBillingModel,
+ UpstreamModel: sessionUpstreamModel,
+ ServiceTier: usageMeta.serviceTier.Load(),
+ ReasoningEffort: usageMeta.reasoningEffort.Load(),
Stream: true,
OpenAIWSMode: true,
ResponseHeaders: cloneHeader(handshakeHeaders),
diff --git a/backend/internal/service/ops_cleanup_executor.go b/backend/internal/service/ops_cleanup_executor.go
new file mode 100644
index 00000000000..63a7367f4ce
--- /dev/null
+++ b/backend/internal/service/ops_cleanup_executor.go
@@ -0,0 +1,164 @@
+package service
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "strings"
+ "time"
+)
+
+const (
+ opsCleanupDefaultSchedule = "0 2 * * *"
+ opsCleanupBatchSize = 5000
+ opsCleanupCronStopTimeout = 3 * time.Second
+ opsCleanupRunTimeout = 30 * time.Minute
+ opsCleanupHeartbeatTimeout = 2 * time.Second
+)
+
+type opsCleanupTarget struct {
+ retentionDays int
+ table string
+ timeCol string
+ castDate bool
+ counter *int64
+}
+
+type opsCleanupDeletedCounts struct {
+ errorLogs int64
+ retryAttempts int64
+ alertEvents int64
+ systemLogs int64
+ logAudits int64
+ systemMetrics int64
+ hourlyPreagg int64
+ dailyPreagg int64
+}
+
+func (c opsCleanupDeletedCounts) String() string {
+ return fmt.Sprintf(
+ "error_logs=%d retry_attempts=%d alert_events=%d system_logs=%d log_audits=%d system_metrics=%d hourly_preagg=%d daily_preagg=%d",
+ c.errorLogs,
+ c.retryAttempts,
+ c.alertEvents,
+ c.systemLogs,
+ c.logAudits,
+ c.systemMetrics,
+ c.hourlyPreagg,
+ c.dailyPreagg,
+ )
+}
+
+// opsCleanupPlan 把"保留天数"翻译成具体的清理动作。
+// - days < 0 → 跳过该项清理(ok=false),保留兼容老数据
+// - days == 0 → TRUNCATE TABLE(O(1) 全清),truncate=true
+// - days > 0 → 批量 DELETE 早于 now-N天 的行,cutoff = now - N 天
+func opsCleanupPlan(now time.Time, days int) (cutoff time.Time, truncate, ok bool) {
+ if days < 0 {
+ return time.Time{}, false, false
+ }
+ if days == 0 {
+ return time.Time{}, true, true
+ }
+ return now.AddDate(0, 0, -days), false, true
+}
+
+func opsCleanupRunOne(
+ ctx context.Context,
+ db *sql.DB,
+ truncate bool,
+ cutoff time.Time,
+ table, timeCol string,
+ castDate bool,
+ batchSize int,
+) (int64, error) {
+ if truncate {
+ return truncateOpsTable(ctx, db, table)
+ }
+ return deleteOldRowsByID(ctx, db, table, timeCol, cutoff, batchSize, castDate)
+}
+
+func deleteOldRowsByID(
+ ctx context.Context,
+ db *sql.DB,
+ table string,
+ timeColumn string,
+ cutoff time.Time,
+ batchSize int,
+ castCutoffToDate bool,
+) (int64, error) {
+ if db == nil {
+ return 0, nil
+ }
+ if batchSize <= 0 {
+ batchSize = opsCleanupBatchSize
+ }
+
+ where := fmt.Sprintf("%s < $1", timeColumn)
+ if castCutoffToDate {
+ where = fmt.Sprintf("%s < $1::date", timeColumn)
+ }
+
+ q := fmt.Sprintf(`
+WITH batch AS (
+ SELECT id FROM %s
+ WHERE %s
+ ORDER BY id
+ LIMIT $2
+)
+DELETE FROM %s
+WHERE id IN (SELECT id FROM batch)
+`, table, where, table)
+
+ var total int64
+ for {
+ res, err := db.ExecContext(ctx, q, cutoff, batchSize)
+ if err != nil {
+ if isMissingRelationError(err) {
+ return total, nil
+ }
+ return total, err
+ }
+ affected, err := res.RowsAffected()
+ if err != nil {
+ return total, err
+ }
+ total += affected
+ if affected == 0 {
+ break
+ }
+ }
+ return total, nil
+}
+
+// truncateOpsTable 用 TRUNCATE TABLE 清空指定表,先 SELECT COUNT(*) 取得清空前行数用于 heartbeat。
+func truncateOpsTable(ctx context.Context, db *sql.DB, table string) (int64, error) {
+ if db == nil {
+ return 0, nil
+ }
+ var count int64
+ if err := db.QueryRowContext(ctx, fmt.Sprintf("SELECT COUNT(*) FROM %s", table)).Scan(&count); err != nil {
+ if isMissingRelationError(err) {
+ return 0, nil
+ }
+ return 0, fmt.Errorf("count %s: %w", table, err)
+ }
+ if count == 0 {
+ return 0, nil
+ }
+ if _, err := db.ExecContext(ctx, fmt.Sprintf("TRUNCATE TABLE %s", table)); err != nil {
+ if isMissingRelationError(err) {
+ return 0, nil
+ }
+ return 0, fmt.Errorf("truncate %s: %w", table, err)
+ }
+ return count, nil
+}
+
+func isMissingRelationError(err error) bool {
+ if err == nil {
+ return false
+ }
+ s := strings.ToLower(err.Error())
+ return strings.Contains(s, "does not exist") && strings.Contains(s, "relation")
+}
diff --git a/backend/internal/service/ops_cleanup_overlay_test.go b/backend/internal/service/ops_cleanup_overlay_test.go
new file mode 100644
index 00000000000..f751a42627c
--- /dev/null
+++ b/backend/internal/service/ops_cleanup_overlay_test.go
@@ -0,0 +1,257 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+)
+
+// makeOverlayService 构造一个没有 cron / db 的 cleanup service,仅用来测试 effective overlay。
+func makeOverlayService(repo SettingRepository, base config.OpsCleanupConfig) *OpsCleanupService {
+ cfg := &config.Config{}
+ cfg.Ops.Cleanup = base
+ return &OpsCleanupService{
+ cfg: cfg,
+ settingRepo: repo,
+ }
+}
+
+func writeAdvancedSettings(t *testing.T, repo *runtimeSettingRepoStub, dr OpsDataRetentionSettings) {
+ t.Helper()
+ adv := OpsAdvancedSettings{DataRetention: dr}
+ raw, err := json.Marshal(adv)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if err := repo.Set(context.Background(), SettingKeyOpsAdvancedSettings, string(raw)); err != nil {
+ t.Fatalf("set: %v", err)
+ }
+}
+
+func TestComputeEffective_FallbackToCfgWhenSettingsAbsent(t *testing.T) {
+ repo := newRuntimeSettingRepoStub()
+ base := config.OpsCleanupConfig{
+ Enabled: false,
+ Schedule: "0 2 * * *",
+ ErrorLogRetentionDays: 30,
+ MinuteMetricsRetentionDays: 30,
+ HourlyMetricsRetentionDays: 30,
+ }
+ svc := makeOverlayService(repo, base)
+
+ svc.computeEffectiveLocked(context.Background())
+
+ if svc.effective != base {
+ t.Fatalf("expected effective == cfg base, got %#v", svc.effective)
+ }
+}
+
+func TestComputeEffective_SettingsOverridesAll(t *testing.T) {
+ repo := newRuntimeSettingRepoStub()
+ writeAdvancedSettings(t, repo, OpsDataRetentionSettings{
+ CleanupEnabled: true,
+ CleanupSchedule: "0 * * * *",
+ ErrorLogRetentionDays: 0,
+ MinuteMetricsRetentionDays: 7,
+ HourlyMetricsRetentionDays: 14,
+ })
+ base := config.OpsCleanupConfig{
+ Enabled: false,
+ Schedule: "0 2 * * *",
+ ErrorLogRetentionDays: 30,
+ MinuteMetricsRetentionDays: 30,
+ HourlyMetricsRetentionDays: 30,
+ }
+ svc := makeOverlayService(repo, base)
+
+ svc.computeEffectiveLocked(context.Background())
+
+ want := config.OpsCleanupConfig{
+ Enabled: true,
+ Schedule: "0 * * * *",
+ ErrorLogRetentionDays: 0,
+ MinuteMetricsRetentionDays: 7,
+ HourlyMetricsRetentionDays: 14,
+ }
+ if svc.effective != want {
+ t.Fatalf("effective mismatch:\nwant %#v\n got %#v", want, svc.effective)
+ }
+}
+
+func TestComputeEffective_EmptyScheduleFallbackToCfg(t *testing.T) {
+ repo := newRuntimeSettingRepoStub()
+ writeAdvancedSettings(t, repo, OpsDataRetentionSettings{
+ CleanupEnabled: true,
+ CleanupSchedule: " ", // 空白被 trim 后视为空
+ ErrorLogRetentionDays: 5,
+ MinuteMetricsRetentionDays: 5,
+ HourlyMetricsRetentionDays: 5,
+ })
+ base := config.OpsCleanupConfig{
+ Enabled: false,
+ Schedule: "0 2 * * *",
+ ErrorLogRetentionDays: 30,
+ MinuteMetricsRetentionDays: 30,
+ HourlyMetricsRetentionDays: 30,
+ }
+ svc := makeOverlayService(repo, base)
+
+ svc.computeEffectiveLocked(context.Background())
+
+ if svc.effective.Schedule != "0 2 * * *" {
+ t.Fatalf("expected schedule fallback to cfg, got %q", svc.effective.Schedule)
+ }
+ if !svc.effective.Enabled {
+ t.Fatalf("expected enabled=true from settings")
+ }
+ if svc.effective.ErrorLogRetentionDays != 5 {
+ t.Fatalf("expected retention=5 from settings, got %d", svc.effective.ErrorLogRetentionDays)
+ }
+}
+
+func TestComputeEffective_NegativeRetentionFallsBackToCfg(t *testing.T) {
+ repo := newRuntimeSettingRepoStub()
+ writeAdvancedSettings(t, repo, OpsDataRetentionSettings{
+ CleanupEnabled: true,
+ CleanupSchedule: "0 * * * *",
+ ErrorLogRetentionDays: -1,
+ MinuteMetricsRetentionDays: -1,
+ HourlyMetricsRetentionDays: -1,
+ })
+ base := config.OpsCleanupConfig{
+ Enabled: false,
+ Schedule: "0 2 * * *",
+ ErrorLogRetentionDays: 30,
+ MinuteMetricsRetentionDays: 60,
+ HourlyMetricsRetentionDays: 90,
+ }
+ svc := makeOverlayService(repo, base)
+
+ svc.computeEffectiveLocked(context.Background())
+
+ if svc.effective.ErrorLogRetentionDays != 30 ||
+ svc.effective.MinuteMetricsRetentionDays != 60 ||
+ svc.effective.HourlyMetricsRetentionDays != 90 {
+ t.Fatalf("expected retention fallback to cfg, got %#v", svc.effective)
+ }
+}
+
+func TestComputeEffective_BadJSONFallsBackToCfg(t *testing.T) {
+ repo := newRuntimeSettingRepoStub()
+ if err := repo.Set(context.Background(), SettingKeyOpsAdvancedSettings, "{not json"); err != nil {
+ t.Fatalf("set: %v", err)
+ }
+ base := config.OpsCleanupConfig{
+ Enabled: true,
+ Schedule: "0 3 * * *",
+ ErrorLogRetentionDays: 30,
+ MinuteMetricsRetentionDays: 30,
+ HourlyMetricsRetentionDays: 30,
+ }
+ svc := makeOverlayService(repo, base)
+
+ svc.computeEffectiveLocked(context.Background())
+
+ if svc.effective != base {
+ t.Fatalf("expected fallback to cfg on bad JSON, got %#v", svc.effective)
+ }
+}
+
+// 验证 OpsService.UpdateOpsAdvancedSettings 写入后会调用 cleanupReloader.Reload。
+type fakeCleanupReloader struct {
+ calls int
+ last context.Context
+ err error
+}
+
+func (f *fakeCleanupReloader) Reload(ctx context.Context) error {
+ f.calls++
+ f.last = ctx
+ return f.err
+}
+
+func TestUpdateOpsAdvancedSettings_TriggersReload(t *testing.T) {
+ repo := newRuntimeSettingRepoStub()
+ reloader := &fakeCleanupReloader{}
+ svc := &OpsService{settingRepo: repo}
+ svc.SetCleanupReloader(reloader)
+
+ cfg := defaultOpsAdvancedSettings()
+ cfg.DataRetention.CleanupEnabled = true
+ cfg.DataRetention.CleanupSchedule = "0 * * * *"
+ cfg.DataRetention.ErrorLogRetentionDays = 3
+ cfg.DataRetention.MinuteMetricsRetentionDays = 3
+ cfg.DataRetention.HourlyMetricsRetentionDays = 3
+
+ if _, err := svc.UpdateOpsAdvancedSettings(context.Background(), cfg); err != nil {
+ t.Fatalf("update: %v", err)
+ }
+ if reloader.calls != 1 {
+ t.Fatalf("expected reloader.Reload called once, got %d", reloader.calls)
+ }
+}
+
+func TestReload_BeforeStart_IsNoop(t *testing.T) {
+ svc := &OpsCleanupService{}
+ if err := svc.Reload(context.Background()); err != nil {
+ t.Fatalf("Reload before Start should return nil, got %v", err)
+ }
+}
+
+func TestReload_AfterStop_IsNoop(t *testing.T) {
+ svc := &OpsCleanupService{started: true, stopped: true}
+ if err := svc.Reload(context.Background()); err != nil {
+ t.Fatalf("Reload after Stop should return nil, got %v", err)
+ }
+}
+
+func TestUpdateOpsAdvancedSettings_NilReloader_NoPanic(t *testing.T) {
+ repo := newRuntimeSettingRepoStub()
+ svc := &OpsService{settingRepo: repo}
+ // cleanupReloader intentionally nil
+
+ cfg := defaultOpsAdvancedSettings()
+ cfg.DataRetention.ErrorLogRetentionDays = 7
+
+ // should not panic
+ if _, err := svc.UpdateOpsAdvancedSettings(context.Background(), cfg); err != nil {
+ t.Fatalf("update with nil reloader: %v", err)
+ }
+}
+
+func TestStart_IdempotentSecondCall(t *testing.T) {
+ svc := &OpsCleanupService{started: true}
+ svc.Start() // second call should be noop, not panic
+}
+
+func TestRefreshEffectiveBeforeRun_UpdatesSnapshot(t *testing.T) {
+ repo := newRuntimeSettingRepoStub()
+ base := config.OpsCleanupConfig{
+ Enabled: true,
+ Schedule: "0 2 * * *",
+ ErrorLogRetentionDays: 30,
+ }
+ svc := makeOverlayService(repo, base)
+ svc.computeEffectiveLocked(context.Background())
+
+ if svc.effective.ErrorLogRetentionDays != 30 {
+ t.Fatalf("initial retention should be 30, got %d", svc.effective.ErrorLogRetentionDays)
+ }
+
+ // simulate UI change
+ writeAdvancedSettings(t, repo, OpsDataRetentionSettings{
+ CleanupEnabled: true,
+ CleanupSchedule: "0 * * * *",
+ ErrorLogRetentionDays: 7,
+ })
+
+ svc.refreshEffectiveBeforeRun(context.Background())
+ snap := svc.snapshotEffective()
+ if snap.ErrorLogRetentionDays != 7 {
+ t.Fatalf("after refresh, retention should be 7, got %d", snap.ErrorLogRetentionDays)
+ }
+}
diff --git a/backend/internal/service/ops_cleanup_service.go b/backend/internal/service/ops_cleanup_service.go
index 44ec1ad17db..60a690f3960 100644
--- a/backend/internal/service/ops_cleanup_service.go
+++ b/backend/internal/service/ops_cleanup_service.go
@@ -3,6 +3,8 @@ package service
import (
"context"
"database/sql"
+ "encoding/json"
+ "errors"
"fmt"
"strings"
"sync"
@@ -45,13 +47,18 @@ type OpsCleanupService struct {
redisClient *redis.Client
cfg *config.Config
channelMonitorSvc *ChannelMonitorService
+ settingRepo SettingRepository
instanceID string
- cron *cron.Cron
-
- startOnce sync.Once
- stopOnce sync.Once
+ // mu 守护 cron 实例切换 + effective 配置切换。
+ // 这里不再用 startOnce/stopOnce,是因为 Reload 需要"停旧 cron 重启新 cron",
+ // 而 Once 一旦触发就无法再次执行;改为 started/stopped 布尔配合 mu。
+ mu sync.Mutex
+ cron *cron.Cron
+ started bool
+ stopped bool
+ effective config.OpsCleanupConfig
warnNoRedisOnce sync.Once
}
@@ -62,6 +69,7 @@ func NewOpsCleanupService(
redisClient *redis.Client,
cfg *config.Config,
channelMonitorSvc *ChannelMonitorService,
+ settingRepo SettingRepository,
) *OpsCleanupService {
return &OpsCleanupService{
opsRepo: opsRepo,
@@ -69,10 +77,13 @@ func NewOpsCleanupService(
redisClient: redisClient,
cfg: cfg,
channelMonitorSvc: channelMonitorSvc,
+ settingRepo: settingRepo,
instanceID: uuid.NewString(),
}
}
+// Start 首次启动 cron 调度。Enabled / Schedule 由 effective 配置决定(settings 优先 cfg)。
+// 重复调用幂等。
func (s *OpsCleanupService) Start() {
if s == nil {
return
@@ -80,54 +91,169 @@ func (s *OpsCleanupService) Start() {
if s.cfg != nil && !s.cfg.Ops.Enabled {
return
}
- if s.cfg != nil && !s.cfg.Ops.Cleanup.Enabled {
- logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] not started (disabled)")
- return
- }
if s.opsRepo == nil || s.db == nil {
logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] not started (missing deps)")
return
}
- s.startOnce.Do(func() {
- schedule := "0 2 * * *"
- if s.cfg != nil && strings.TrimSpace(s.cfg.Ops.Cleanup.Schedule) != "" {
- schedule = strings.TrimSpace(s.cfg.Ops.Cleanup.Schedule)
- }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.started || s.stopped {
+ return
+ }
+ s.started = true
+ if err := s.applyScheduleLocked(context.Background()); err != nil {
+ logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] not started: %v", err)
+ }
+}
- loc := time.Local
- if s.cfg != nil && strings.TrimSpace(s.cfg.Timezone) != "" {
- if parsed, err := time.LoadLocation(strings.TrimSpace(s.cfg.Timezone)); err == nil && parsed != nil {
- loc = parsed
- }
- }
+// Stop 关闭 cron。幂等。
+func (s *OpsCleanupService) Stop() {
+ if s == nil {
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.stopped {
+ return
+ }
+ s.stopped = true
+ s.stopCronLocked()
+}
- c := cron.New(cron.WithParser(opsCleanupCronParser), cron.WithLocation(loc))
- _, err := c.AddFunc(schedule, func() { s.runScheduled() })
- if err != nil {
- logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] not started (invalid schedule=%q): %v", schedule, err)
- return
+// stopCronLocked 停掉当前 cron 实例(带 3s 超时)。调用方持锁。
+func (s *OpsCleanupService) stopCronLocked() {
+ if s.cron == nil {
+ return
+ }
+ ctx := s.cron.Stop()
+ select {
+ case <-ctx.Done():
+ case <-time.After(opsCleanupCronStopTimeout):
+ logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] cron stop timed out")
+ }
+ s.cron = nil
+}
+
+// applyScheduleLocked 重新计算 effective 配置并按其 schedule 重建 cron。调用方持锁。
+// 若 effective.Enabled=false(用户在 UI 关闭清理),停旧 cron 后直接返回,不创建新 cron。
+func (s *OpsCleanupService) applyScheduleLocked(ctx context.Context) error {
+ s.computeEffectiveLocked(ctx)
+ s.stopCronLocked()
+
+ if !s.effective.Enabled {
+ logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] cron disabled by settings")
+ return nil
+ }
+
+ schedule := strings.TrimSpace(s.effective.Schedule)
+ if schedule == "" {
+ schedule = opsCleanupDefaultSchedule
+ }
+
+ loc := time.Local
+ if s.cfg != nil && strings.TrimSpace(s.cfg.Timezone) != "" {
+ if parsed, err := time.LoadLocation(strings.TrimSpace(s.cfg.Timezone)); err == nil && parsed != nil {
+ loc = parsed
}
- s.cron = c
- s.cron.Start()
- logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] started (schedule=%q tz=%s)", schedule, loc.String())
- })
+ }
+
+ c := cron.New(cron.WithParser(opsCleanupCronParser), cron.WithLocation(loc))
+ if _, err := c.AddFunc(schedule, func() { s.runScheduled() }); err != nil {
+ return fmt.Errorf("invalid schedule %q: %w", schedule, err)
+ }
+ c.Start()
+ s.cron = c
+ logger.LegacyPrintf("service.ops_cleanup",
+ "[OpsCleanup] scheduled (schedule=%q tz=%s retention_days=err:%d/min:%d/hour:%d)",
+ schedule, loc.String(),
+ s.effective.ErrorLogRetentionDays,
+ s.effective.MinuteMetricsRetentionDays,
+ s.effective.HourlyMetricsRetentionDays,
+ )
+ return nil
}
-func (s *OpsCleanupService) Stop() {
+// Reload 重新读取 ops_advanced_settings.data_retention 并按新配置重建 cron。
+// 适用于 admin 在 UI 修改清理设置后立即生效(schedule / enabled 改动需要 Reload;
+// retention 改动 runScheduled 顶部也会刷新,下一次触发即生效)。
+// 若 service 还未 Start 或已 Stop,Reload 不做任何事。
+func (s *OpsCleanupService) Reload(ctx context.Context) error {
if s == nil {
+ return nil
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if !s.started || s.stopped {
+ return nil
+ }
+ return s.applyScheduleLocked(ctx)
+}
+
+// computeEffectiveLocked 计算"生效配置"并写入 s.effective。调用方持锁。
+//
+// 优先级:UI 写入的 settings.ops_advanced_settings.data_retention(权威)覆盖 cfg.Ops.Cleanup 的副本。
+// - Enabled:settings 直接覆盖
+// - Schedule:settings 非空时覆盖,否则保留 cfg
+// - *RetentionDays:settings >=0 时覆盖(包括 0=TRUNCATE),<0 沿用 cfg
+//
+// 若 settings 表无该 key(ErrSettingNotFound)或解析失败,整体 fallback 到 cfg.Ops.Cleanup。
+func (s *OpsCleanupService) computeEffectiveLocked(ctx context.Context) {
+ base := config.OpsCleanupConfig{}
+ if s.cfg != nil {
+ base = s.cfg.Ops.Cleanup
+ }
+ defer func() { s.effective = base }()
+
+ if s.settingRepo == nil {
return
}
- s.stopOnce.Do(func() {
- if s.cron != nil {
- ctx := s.cron.Stop()
- select {
- case <-ctx.Done():
- case <-time.After(3 * time.Second):
- logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] cron stop timed out")
- }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpsAdvancedSettings)
+ if err != nil {
+ if !errors.Is(err, ErrSettingNotFound) {
+ logger.LegacyPrintf("service.ops_cleanup",
+ "[OpsCleanup] read advanced settings failed, using cfg: %v", err)
}
- })
+ return
+ }
+ var adv OpsAdvancedSettings
+ if err := json.Unmarshal([]byte(raw), &adv); err != nil {
+ logger.LegacyPrintf("service.ops_cleanup",
+ "[OpsCleanup] parse advanced settings failed, using cfg: %v", err)
+ return
+ }
+ dr := adv.DataRetention
+ base.Enabled = dr.CleanupEnabled
+ if sched := strings.TrimSpace(dr.CleanupSchedule); sched != "" {
+ base.Schedule = sched
+ }
+ if dr.ErrorLogRetentionDays >= 0 {
+ base.ErrorLogRetentionDays = dr.ErrorLogRetentionDays
+ }
+ if dr.MinuteMetricsRetentionDays >= 0 {
+ base.MinuteMetricsRetentionDays = dr.MinuteMetricsRetentionDays
+ }
+ if dr.HourlyMetricsRetentionDays >= 0 {
+ base.HourlyMetricsRetentionDays = dr.HourlyMetricsRetentionDays
+ }
+}
+
+// snapshotEffective 取一份 effective 副本(runCleanupOnce 等读路径使用)。
+func (s *OpsCleanupService) snapshotEffective() config.OpsCleanupConfig {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.effective
+}
+
+// refreshEffectiveBeforeRun 在 cron 触发时刷新 effective,让 retention 改动当次即生效。
+// schedule 改动不影响当次(cron 调度由库管理,需要 Reload 才换 schedule)。
+func (s *OpsCleanupService) refreshEffectiveBeforeRun(ctx context.Context) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.computeEffectiveLocked(ctx)
}
func (s *OpsCleanupService) runScheduled() {
@@ -135,9 +261,12 @@ func (s *OpsCleanupService) runScheduled() {
return
}
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
+ ctx, cancel := context.WithTimeout(context.Background(), opsCleanupRunTimeout)
defer cancel()
+ // 让 retention 改动当次生效(schedule/enabled 改动需要 Reload)。
+ s.refreshEffectiveBeforeRun(ctx)
+
release, ok := s.tryAcquireLeaderLock(ctx)
if !ok {
return
@@ -159,124 +288,36 @@ func (s *OpsCleanupService) runScheduled() {
logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] cleanup complete: %s", counts)
}
-type opsCleanupDeletedCounts struct {
- errorLogs int64
- retryAttempts int64
- alertEvents int64
- systemLogs int64
- logAudits int64
- systemMetrics int64
- hourlyPreagg int64
- dailyPreagg int64
-}
-
-func (c opsCleanupDeletedCounts) String() string {
- return fmt.Sprintf(
- "error_logs=%d retry_attempts=%d alert_events=%d system_logs=%d log_audits=%d system_metrics=%d hourly_preagg=%d daily_preagg=%d",
- c.errorLogs,
- c.retryAttempts,
- c.alertEvents,
- c.systemLogs,
- c.logAudits,
- c.systemMetrics,
- c.hourlyPreagg,
- c.dailyPreagg,
- )
-}
-
-// opsCleanupPlan 把"保留天数"翻译成具体的清理动作。
-// - days < 0 → 跳过该项清理(ok=false),保留兼容老数据
-// - days == 0 → TRUNCATE TABLE(O(1) 全清),truncate=true
-// - days > 0 → 批量 DELETE 早于 now-N天 的行,cutoff = now - N 天
-//
-// 之所以 days==0 走 TRUNCATE 而非"now+24h cutoff + DELETE":
-// - 速度从 O(N) 降到 O(1),对百万行级表毫秒完成
-// - 无 WAL 写入、无后续 VACUUM 压力
-// - 这些 ops 表只有 cleanup 任务自己写,TRUNCATE 的 ACCESS EXCLUSIVE 锁影响可忽略
-func opsCleanupPlan(now time.Time, days int) (cutoff time.Time, truncate, ok bool) {
- if days < 0 {
- return time.Time{}, false, false
- }
- if days == 0 {
- return time.Time{}, true, true
- }
- return now.AddDate(0, 0, -days), false, true
-}
-
func (s *OpsCleanupService) runCleanupOnce(ctx context.Context) (opsCleanupDeletedCounts, error) {
out := opsCleanupDeletedCounts{}
if s == nil || s.db == nil || s.cfg == nil {
return out, nil
}
- batchSize := 5000
-
+ effective := s.snapshotEffective()
now := time.Now().UTC()
- // runOne 把"truncate? cutoff? batched delete?"封装到一处,
- // 让三组清理(错误日志类 / 分钟指标 / 小时+日预聚合)调用方只关心表名和列名。
- runOne := func(truncate bool, cutoff time.Time, table, timeCol string, castDate bool) (int64, error) {
- if truncate {
- return truncateOpsTable(ctx, s.db, table)
- }
- return deleteOldRowsByID(ctx, s.db, table, timeCol, cutoff, batchSize, castDate)
- }
-
- // Error-like tables: error logs / retry attempts / alert events / system logs / cleanup audits.
- if cutoff, truncate, ok := opsCleanupPlan(now, s.cfg.Ops.Cleanup.ErrorLogRetentionDays); ok {
- n, err := runOne(truncate, cutoff, "ops_error_logs", "created_at", false)
- if err != nil {
- return out, err
- }
- out.errorLogs = n
-
- n, err = runOne(truncate, cutoff, "ops_retry_attempts", "created_at", false)
- if err != nil {
- return out, err
- }
- out.retryAttempts = n
-
- n, err = runOne(truncate, cutoff, "ops_alert_events", "created_at", false)
- if err != nil {
- return out, err
- }
- out.alertEvents = n
-
- n, err = runOne(truncate, cutoff, "ops_system_logs", "created_at", false)
- if err != nil {
- return out, err
- }
- out.systemLogs = n
-
- n, err = runOne(truncate, cutoff, "ops_system_log_cleanup_audits", "created_at", false)
- if err != nil {
- return out, err
- }
- out.logAudits = n
- }
-
- // Minute-level metrics snapshots.
- if cutoff, truncate, ok := opsCleanupPlan(now, s.cfg.Ops.Cleanup.MinuteMetricsRetentionDays); ok {
- n, err := runOne(truncate, cutoff, "ops_system_metrics", "created_at", false)
- if err != nil {
- return out, err
- }
- out.systemMetrics = n
- }
-
- // Pre-aggregation tables (hourly/daily).
- if cutoff, truncate, ok := opsCleanupPlan(now, s.cfg.Ops.Cleanup.HourlyMetricsRetentionDays); ok {
- n, err := runOne(truncate, cutoff, "ops_metrics_hourly", "bucket_start", false)
- if err != nil {
- return out, err
+ targets := []opsCleanupTarget{
+ {effective.ErrorLogRetentionDays, "ops_error_logs", "created_at", false, &out.errorLogs},
+ {effective.ErrorLogRetentionDays, "ops_retry_attempts", "created_at", false, &out.retryAttempts},
+ {effective.ErrorLogRetentionDays, "ops_alert_events", "created_at", false, &out.alertEvents},
+ {effective.ErrorLogRetentionDays, "ops_system_logs", "created_at", false, &out.systemLogs},
+ {effective.ErrorLogRetentionDays, "ops_system_log_cleanup_audits", "created_at", false, &out.logAudits},
+ {effective.MinuteMetricsRetentionDays, "ops_system_metrics", "created_at", false, &out.systemMetrics},
+ {effective.HourlyMetricsRetentionDays, "ops_metrics_hourly", "bucket_start", false, &out.hourlyPreagg},
+ {effective.HourlyMetricsRetentionDays, "ops_metrics_daily", "bucket_date", true, &out.dailyPreagg},
+ }
+
+ for _, t := range targets {
+ cutoff, truncate, ok := opsCleanupPlan(now, t.retentionDays)
+ if !ok {
+ continue
}
- out.hourlyPreagg = n
-
- n, err = runOne(truncate, cutoff, "ops_metrics_daily", "bucket_date", true)
+ n, err := opsCleanupRunOne(ctx, s.db, truncate, cutoff, t.table, t.timeCol, t.castDate, opsCleanupBatchSize)
if err != nil {
return out, err
}
- out.dailyPreagg = n
+ *t.counter = n
}
// Channel monitor 每日维护(聚合昨日明细 + 软删过期明细/聚合)。
@@ -291,100 +332,6 @@ func (s *OpsCleanupService) runCleanupOnce(ctx context.Context) (opsCleanupDelet
return out, nil
}
-func deleteOldRowsByID(
- ctx context.Context,
- db *sql.DB,
- table string,
- timeColumn string,
- cutoff time.Time,
- batchSize int,
- castCutoffToDate bool,
-) (int64, error) {
- if db == nil {
- return 0, nil
- }
- if batchSize <= 0 {
- batchSize = 5000
- }
-
- where := fmt.Sprintf("%s < $1", timeColumn)
- if castCutoffToDate {
- where = fmt.Sprintf("%s < $1::date", timeColumn)
- }
-
- q := fmt.Sprintf(`
-WITH batch AS (
- SELECT id FROM %s
- WHERE %s
- ORDER BY id
- LIMIT $2
-)
-DELETE FROM %s
-WHERE id IN (SELECT id FROM batch)
-`, table, where, table)
-
- var total int64
- for {
- res, err := db.ExecContext(ctx, q, cutoff, batchSize)
- if err != nil {
- // If ops tables aren't present yet (partial deployments), treat as no-op.
- if isMissingRelationError(err) {
- return total, nil
- }
- return total, err
- }
- affected, err := res.RowsAffected()
- if err != nil {
- return total, err
- }
- total += affected
- if affected == 0 {
- break
- }
- }
- return total, nil
-}
-
-// truncateOpsTable 用 TRUNCATE TABLE 清空指定表,先 SELECT COUNT(*) 取得清空前行数用于 heartbeat。
-//
-// 与 deleteOldRowsByID 的差异:
-// - 不可指定 WHERE 条件,仅用于 days==0 的"清空全部"语义
-// - O(1) 释放表的物理存储页,毫秒级完成,无 WAL 写入、无 VACUUM 压力
-// - 需要 ACCESS EXCLUSIVE 锁,但 ops 表只有清理任务自己写入,瞬间锁影响可忽略
-//
-// 表不存在(部分部署)静默返回 0,与 deleteOldRowsByID 保持一致。
-func truncateOpsTable(ctx context.Context, db *sql.DB, table string) (int64, error) {
- if db == nil {
- return 0, nil
- }
- var count int64
- if err := db.QueryRowContext(ctx, fmt.Sprintf("SELECT COUNT(*) FROM %s", table)).Scan(&count); err != nil {
- if isMissingRelationError(err) {
- return 0, nil
- }
- return 0, fmt.Errorf("count %s: %w", table, err)
- }
- if count == 0 {
- return 0, nil
- }
- if _, err := db.ExecContext(ctx, fmt.Sprintf("TRUNCATE TABLE %s", table)); err != nil {
- if isMissingRelationError(err) {
- return 0, nil
- }
- return 0, fmt.Errorf("truncate %s: %w", table, err)
- }
- return count, nil
-}
-
-// isMissingRelationError 判断 PG 报错是否为"表不存在",用于让清理任务在部分部署场景静默跳过。
-func isMissingRelationError(err error) bool {
- if err == nil {
- return false
- }
- s := strings.ToLower(err.Error())
- return strings.Contains(s, "does not exist") && strings.Contains(s, "relation")
-}
-
func (s *OpsCleanupService) tryAcquireLeaderLock(ctx context.Context) (func(), bool) {
if s == nil {
return nil, false
@@ -433,7 +380,7 @@ func (s *OpsCleanupService) recordHeartbeatSuccess(runAt time.Time, duration tim
now := time.Now().UTC()
durMs := duration.Milliseconds()
result := truncateString(counts.String(), 2048)
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), opsCleanupHeartbeatTimeout)
defer cancel()
_ = s.opsRepo.UpsertJobHeartbeat(ctx, &OpsUpsertJobHeartbeatInput{
JobName: opsCleanupJobName,
@@ -451,7 +398,7 @@ func (s *OpsCleanupService) recordHeartbeatError(runAt time.Time, duration time.
now := time.Now().UTC()
durMs := duration.Milliseconds()
msg := truncateString(err.Error(), 2048)
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ ctx, cancel := context.WithTimeout(context.Background(), opsCleanupHeartbeatTimeout)
defer cancel()
_ = s.opsRepo.UpsertJobHeartbeat(ctx, &OpsUpsertJobHeartbeatInput{
JobName: opsCleanupJobName,
diff --git a/backend/internal/service/ops_service.go b/backend/internal/service/ops_service.go
index cd3974a00f0..ad0b97b30b7 100644
--- a/backend/internal/service/ops_service.go
+++ b/backend/internal/service/ops_service.go
@@ -54,6 +54,24 @@ type OpsService struct {
geminiCompatService *GeminiMessagesCompatService
antigravityGatewayService *AntigravityGatewayService
systemLogSink *OpsSystemLogSink
+
+ // cleanupReloader 由 wire 在 OpsCleanupService 构造完成后通过 SetCleanupReloader 注入。
+ // 解耦避免 OpsService -> OpsCleanupService 的硬依赖(cleanup 也读 settings,会循环)。
+ cleanupReloader CleanupReloader
+}
+
+// CleanupReloader 由 OpsCleanupService 实现。
+// UpdateOpsAdvancedSettings 写入新配置后调用 Reload,让 schedule/enabled 改动立刻生效。
+type CleanupReloader interface {
+ Reload(ctx context.Context) error
+}
+
+// SetCleanupReloader 由 wire 注入 cleanup hook(构造期循环依赖的解耦点)。
+func (s *OpsService) SetCleanupReloader(r CleanupReloader) {
+ if s == nil {
+ return
+ }
+ s.cleanupReloader = r
}
func NewOpsService(
@@ -718,7 +736,10 @@ func sanitizeErrorBodyForStorage(raw string, maxBytes int) (sanitized string, tr
return out, trunc
}
- // Non-JSON: best-effort truncate.
+ // Non-JSON: redact common credential shapes before truncating. Upstream
+ // services frequently embed Authorization, token, or password values in a
+ // plain-text diagnostic rather than a JSON field.
+ raw = sanitizeUpstreamErrorMessage(raw)
if maxBytes > 0 && len(raw) > maxBytes {
return truncateString(raw, maxBytes), true
}
diff --git a/backend/internal/service/ops_settings.go b/backend/internal/service/ops_settings.go
index ecc3a94b7db..68c1d9ddea4 100644
--- a/backend/internal/service/ops_settings.go
+++ b/backend/internal/service/ops_settings.go
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"strings"
"time"
)
@@ -360,7 +361,7 @@ func defaultOpsAdvancedSettings() *OpsAdvancedSettings {
return &OpsAdvancedSettings{
DataRetention: OpsDataRetentionSettings{
CleanupEnabled: false,
- CleanupSchedule: "0 2 * * *",
+ CleanupSchedule: opsCleanupDefaultSchedule,
ErrorLogRetentionDays: 30,
MinuteMetricsRetentionDays: 30,
HourlyMetricsRetentionDays: 30,
@@ -385,7 +386,7 @@ func normalizeOpsAdvancedSettings(cfg *OpsAdvancedSettings) {
}
cfg.DataRetention.CleanupSchedule = strings.TrimSpace(cfg.DataRetention.CleanupSchedule)
if cfg.DataRetention.CleanupSchedule == "" {
- cfg.DataRetention.CleanupSchedule = "0 2 * * *"
+ cfg.DataRetention.CleanupSchedule = opsCleanupDefaultSchedule
}
// 保留天数:0 表示每次定时清理全部(清空所有),> 0 表示按天数保留;
// 仅在拿到非法的负数时回填默认值,避免覆盖用户主动设的 0。
@@ -477,6 +478,14 @@ func (s *OpsService) UpdateOpsAdvancedSettings(ctx context.Context, cfg *OpsAdva
return nil, err
}
+ // notify cleanup service to reload schedule/enabled.
+ if s.cleanupReloader != nil {
+ if rerr := s.cleanupReloader.Reload(ctx); rerr != nil {
+ logger.LegacyPrintf("service.ops_settings",
+ "[OpsSettings] cleanup reload after advanced-settings update failed: %v", rerr)
+ }
+ }
+
updated := &OpsAdvancedSettings{}
_ = json.Unmarshal(raw, updated)
return updated, nil
diff --git a/backend/internal/service/ops_upstream_context_test.go b/backend/internal/service/ops_upstream_context_test.go
index fa6d108598b..040ad8ae156 100644
--- a/backend/internal/service/ops_upstream_context_test.go
+++ b/backend/internal/service/ops_upstream_context_test.go
@@ -30,7 +30,6 @@ func TestSafeUpstreamURL(t *testing.T) {
}
func TestAppendOpsUpstreamError_UsesRequestBodyBytesFromContext(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -49,7 +48,6 @@ func TestAppendOpsUpstreamError_UsesRequestBodyBytesFromContext(t *testing.T) {
}
func TestAppendOpsUpstreamError_UsesRequestBodyStringFromContext(t *testing.T) {
- gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
diff --git a/backend/internal/service/ops_upstream_sanitization_security_test.go b/backend/internal/service/ops_upstream_sanitization_security_test.go
new file mode 100644
index 00000000000..e594a9352fc
--- /dev/null
+++ b/backend/internal/service/ops_upstream_sanitization_security_test.go
@@ -0,0 +1,38 @@
+package service
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestSanitizeUpstreamErrorMessageRedactsCredentialShapes(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ secret string
+ }{
+ {name: "query", input: "https://upstream.test/fail?access_token=query-secret", secret: "query-secret"},
+ {name: "authorization", input: "Authorization: Bearer bearer-private-value", secret: "bearer-private-value"},
+ {name: "password", input: "password=hunter2-private", secret: "hunter2-private"},
+ {name: "free text token", input: "internal endpoint rejected token cursor-private-token", secret: "cursor-private-token"},
+ {name: "openai shape", input: "upstream rejected sk-proj-privatevalue123", secret: "sk-proj-privatevalue123"},
+ {name: "github shape", input: "provider returned ghp_abcdefghijklmnopqrstuvwxyz1234567890", secret: "ghp_abcdefghijklmnopqrstuvwxyz1234567890"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := sanitizeUpstreamErrorMessage(tt.input)
+ require.NotContains(t, got, tt.secret)
+ require.Contains(t, got, "***")
+ })
+ }
+
+ require.Contains(t, sanitizeUpstreamErrorMessage("max_tokens 128 exceeded"), "128")
+}
+
+func TestSanitizeErrorBodyForStorageRedactsNonJSONCredentials(t *testing.T) {
+ got, _ := sanitizeErrorBodyForStorage("upstream password: body-private-value", 2048)
+ require.NotContains(t, got, "body-private-value")
+ require.Contains(t, got, "***")
+}
diff --git a/backend/internal/service/payment_config_limits.go b/backend/internal/service/payment_config_limits.go
index 973c601a06d..53992fdc8b2 100644
--- a/backend/internal/service/payment_config_limits.go
+++ b/backend/internal/service/payment_config_limits.go
@@ -2,7 +2,6 @@ package service
import (
"context"
- "encoding/json"
"fmt"
dbent "github.com/Wei-Shaw/sub2api/ent"
@@ -25,7 +24,10 @@ func (s *PaymentConfigService) GetAvailableMethodLimits(ctx context.Context) (*M
Methods: make(map[string]MethodLimits, len(typeInstances)),
}
for pt, insts := range typeInstances {
- ml := pcAggregateMethodLimits(pt, insts)
+ ml, err := pcAggregateMethodLimits(pt, insts)
+ if err != nil {
+ return nil, err
+ }
resp.Methods[ml.PaymentType] = ml
}
resp.GlobalMin, resp.GlobalMax = pcComputeGlobalRange(resp.Methods)
@@ -43,6 +45,11 @@ func (s *PaymentConfigService) pcApplyEnabledVisibleMethodInstances(ctx context.
}
for _, method := range []string{payment.TypeAlipay, payment.TypeWxpay} {
+ methodEnabled, err := s.visibleMethodEnabled(ctx, method)
+ if err != nil || !methodEnabled {
+ delete(filtered, method)
+ continue
+ }
matching := filterEnabledVisibleMethodInstances(instances, method)
providerKey, err := s.resolveVisibleMethodProviderKey(ctx, method, matching)
if err != nil {
@@ -82,7 +89,11 @@ func (s *PaymentConfigService) GetMethodLimits(ctx context.Context, types []stri
matching = append(matching, inst)
}
}
- result = append(result, pcAggregateMethodLimits(pt, matching))
+ ml, err := pcAggregateMethodLimits(pt, matching)
+ if err != nil {
+ return nil, err
+ }
+ result = append(result, ml)
}
return result, nil
}
@@ -119,16 +130,13 @@ func pcGroupByPaymentType(instances []*dbent.PaymentProviderInstance) map[string
// pcInstanceTypeLimits extracts per-type limits from a provider instance.
// Returns (limits, true) if configured; (zero, false) if unlimited.
// For Stripe instances, limits are stored under "stripe" key regardless of sub-types.
-func pcInstanceTypeLimits(inst *dbent.PaymentProviderInstance, pt string) (payment.ChannelLimits, bool) {
- if inst.Limits == "" {
- return payment.ChannelLimits{}, false
- }
- var limits payment.InstanceLimits
- if err := json.Unmarshal([]byte(inst.Limits), &limits); err != nil {
- return payment.ChannelLimits{}, false
+func pcInstanceTypeLimits(inst *dbent.PaymentProviderInstance, pt string) (payment.ChannelLimits, bool, error) {
+ limits, err := payment.ParseInstanceLimits(inst.Limits)
+ if err != nil {
+ return payment.ChannelLimits{}, false, fmt.Errorf("provider instance %d has invalid limits: %w", inst.ID, err)
}
cl, ok := limits[pt]
- return cl, ok
+ return cl, ok, nil
}
// unionFloat merges a single limit value into the aggregate using UNION semantics.
@@ -164,14 +172,17 @@ func unionFloat(agg float64, limited bool, val float64, wantMin bool) (float64,
// - SingleMin: lowest floor across instances; 0 if any is unlimited
// - SingleMax: highest ceiling across instances; 0 if any is unlimited
// - DailyLimit: highest cap across instances; 0 if any is unlimited
-func pcAggregateMethodLimits(pt string, instances []*dbent.PaymentProviderInstance) MethodLimits {
+func pcAggregateMethodLimits(pt string, instances []*dbent.PaymentProviderInstance) (MethodLimits, error) {
ml := MethodLimits{PaymentType: pt}
minLimited, maxLimited, dailyLimited := true, true, true
for _, inst := range instances {
- cl, hasLimits := pcInstanceTypeLimits(inst, pt)
+ cl, hasLimits, err := pcInstanceTypeLimits(inst, pt)
+ if err != nil {
+ return MethodLimits{}, err
+ }
if !hasLimits {
- return MethodLimits{PaymentType: pt} // any unlimited instance → all zeros
+ return MethodLimits{PaymentType: pt}, nil // any global-only instance → all zeros
}
ml.SingleMin, minLimited = unionFloat(ml.SingleMin, minLimited, cl.SingleMin, true)
ml.SingleMax, maxLimited = unionFloat(ml.SingleMax, maxLimited, cl.SingleMax, false)
@@ -187,7 +198,7 @@ func pcAggregateMethodLimits(pt string, instances []*dbent.PaymentProviderInstan
if !dailyLimited {
ml.DailyLimit = 0
}
- return ml
+ return ml, nil
}
// pcComputeGlobalRange computes the widest [min, max] across all methods.
diff --git a/backend/internal/service/payment_config_limits_test.go b/backend/internal/service/payment_config_limits_test.go
index 4df506d675b..1254a26c628 100644
--- a/backend/internal/service/payment_config_limits_test.go
+++ b/backend/internal/service/payment_config_limits_test.go
@@ -54,6 +54,15 @@ func makeInstance(id int64, providerKey, supportedTypes, limits string) *dbent.P
}
}
+func mustAggregateMethodLimits(t *testing.T, paymentType string, instances []*dbent.PaymentProviderInstance) MethodLimits {
+ t.Helper()
+ ml, err := pcAggregateMethodLimits(paymentType, instances)
+ if err != nil {
+ t.Fatalf("pcAggregateMethodLimits returned error: %v", err)
+ }
+ return ml
+}
+
func TestPcAggregateMethodLimits(t *testing.T) {
t.Parallel()
@@ -61,7 +70,7 @@ func TestPcAggregateMethodLimits(t *testing.T) {
t.Parallel()
inst := makeInstance(1, "easypay", "alipay,wxpay",
`{"alipay":{"singleMin":2,"singleMax":14},"wxpay":{"singleMin":1,"singleMax":12}}`)
- ml := pcAggregateMethodLimits("alipay", []*dbent.PaymentProviderInstance{inst})
+ ml := mustAggregateMethodLimits(t, "alipay", []*dbent.PaymentProviderInstance{inst})
if ml.SingleMin != 2 || ml.SingleMax != 14 {
t.Fatalf("alipay limits = min:%v max:%v, want min:2 max:14", ml.SingleMin, ml.SingleMax)
}
@@ -73,7 +82,7 @@ func TestPcAggregateMethodLimits(t *testing.T) {
`{"alipay":{"singleMin":5,"singleMax":100}}`)
inst2 := makeInstance(2, "easypay", "alipay,wxpay",
`{"alipay":{"singleMin":2,"singleMax":200}}`)
- ml := pcAggregateMethodLimits("alipay", []*dbent.PaymentProviderInstance{inst1, inst2})
+ ml := mustAggregateMethodLimits(t, "alipay", []*dbent.PaymentProviderInstance{inst1, inst2})
if ml.SingleMin != 2 {
t.Fatalf("SingleMin = %v, want 2 (lowest floor)", ml.SingleMin)
}
@@ -87,7 +96,7 @@ func TestPcAggregateMethodLimits(t *testing.T) {
inst1 := makeInstance(1, "easypay", "wxpay",
`{"wxpay":{"singleMin":3,"singleMax":10}}`)
inst2 := makeInstance(2, "easypay", "wxpay", "") // no limits = unlimited
- ml := pcAggregateMethodLimits("wxpay", []*dbent.PaymentProviderInstance{inst1, inst2})
+ ml := mustAggregateMethodLimits(t, "wxpay", []*dbent.PaymentProviderInstance{inst1, inst2})
if ml.SingleMin != 0 || ml.SingleMax != 0 {
t.Fatalf("limits = min:%v max:%v, want min:0 max:0 (unlimited)", ml.SingleMin, ml.SingleMax)
}
@@ -99,7 +108,7 @@ func TestPcAggregateMethodLimits(t *testing.T) {
`{"alipay":{"singleMin":5,"singleMax":100}}`)
inst2 := makeInstance(2, "easypay", "alipay",
`{"alipay":{"singleMin":3,"singleMax":0}}`) // singleMax=0 = unlimited
- ml := pcAggregateMethodLimits("alipay", []*dbent.PaymentProviderInstance{inst1, inst2})
+ ml := mustAggregateMethodLimits(t, "alipay", []*dbent.PaymentProviderInstance{inst1, inst2})
if ml.SingleMin != 3 {
t.Fatalf("SingleMin = %v, want 3 (lowest floor)", ml.SingleMin)
}
@@ -110,18 +119,17 @@ func TestPcAggregateMethodLimits(t *testing.T) {
t.Run("empty instances returns zeros", func(t *testing.T) {
t.Parallel()
- ml := pcAggregateMethodLimits("alipay", nil)
+ ml := mustAggregateMethodLimits(t, "alipay", nil)
if ml.SingleMin != 0 || ml.SingleMax != 0 || ml.DailyLimit != 0 {
t.Fatalf("empty instances should return all zeros, got %+v", ml)
}
})
- t.Run("invalid JSON treated as unlimited", func(t *testing.T) {
+ t.Run("invalid JSON fails closed", func(t *testing.T) {
t.Parallel()
inst := makeInstance(1, "easypay", "alipay", `{invalid json}`)
- ml := pcAggregateMethodLimits("alipay", []*dbent.PaymentProviderInstance{inst})
- if ml.SingleMin != 0 || ml.SingleMax != 0 {
- t.Fatalf("invalid JSON should be treated as unlimited, got %+v", ml)
+ if _, err := pcAggregateMethodLimits("alipay", []*dbent.PaymentProviderInstance{inst}); err == nil {
+ t.Fatal("invalid JSON should return a fail-closed error")
}
})
@@ -129,7 +137,7 @@ func TestPcAggregateMethodLimits(t *testing.T) {
t.Parallel()
inst := makeInstance(1, "easypay", "alipay,wxpay",
`{"wxpay":{"singleMin":1,"singleMax":10}}`) // only wxpay, no alipay
- ml := pcAggregateMethodLimits("alipay", []*dbent.PaymentProviderInstance{inst})
+ ml := mustAggregateMethodLimits(t, "alipay", []*dbent.PaymentProviderInstance{inst})
if ml.SingleMin != 0 || ml.SingleMax != 0 {
t.Fatalf("missing type should be treated as unlimited, got %+v", ml)
}
@@ -141,7 +149,7 @@ func TestPcAggregateMethodLimits(t *testing.T) {
`{"alipay":{"singleMin":1,"singleMax":100,"dailyLimit":500}}`)
inst2 := makeInstance(2, "easypay", "alipay",
`{"alipay":{"singleMin":2,"singleMax":200,"dailyLimit":1000}}`)
- ml := pcAggregateMethodLimits("alipay", []*dbent.PaymentProviderInstance{inst1, inst2})
+ ml := mustAggregateMethodLimits(t, "alipay", []*dbent.PaymentProviderInstance{inst1, inst2})
if ml.DailyLimit != 1000 {
t.Fatalf("DailyLimit = %v, want 1000 (highest cap)", ml.DailyLimit)
}
@@ -263,7 +271,10 @@ func TestPcInstanceTypeLimits(t *testing.T) {
t.Run("empty limits string returns false", func(t *testing.T) {
t.Parallel()
inst := makeInstance(1, "easypay", "alipay", "")
- _, ok := pcInstanceTypeLimits(inst, "alipay")
+ _, ok, err := pcInstanceTypeLimits(inst, "alipay")
+ if err != nil {
+ t.Fatalf("pcInstanceTypeLimits returned error: %v", err)
+ }
if ok {
t.Fatal("expected ok=false for empty limits")
}
@@ -273,7 +284,10 @@ func TestPcInstanceTypeLimits(t *testing.T) {
t.Parallel()
inst := makeInstance(1, "easypay", "alipay",
`{"alipay":{"singleMin":2,"singleMax":14,"dailyLimit":500}}`)
- cl, ok := pcInstanceTypeLimits(inst, "alipay")
+ cl, ok, err := pcInstanceTypeLimits(inst, "alipay")
+ if err != nil {
+ t.Fatalf("pcInstanceTypeLimits returned error: %v", err)
+ }
if !ok {
t.Fatal("expected ok=true")
}
@@ -286,18 +300,21 @@ func TestPcInstanceTypeLimits(t *testing.T) {
t.Parallel()
inst := makeInstance(1, "easypay", "alipay",
`{"wxpay":{"singleMin":1}}`)
- _, ok := pcInstanceTypeLimits(inst, "alipay")
+ _, ok, err := pcInstanceTypeLimits(inst, "alipay")
+ if err != nil {
+ t.Fatalf("pcInstanceTypeLimits returned error: %v", err)
+ }
if ok {
t.Fatal("expected ok=false for missing type")
}
})
- t.Run("invalid JSON returns false", func(t *testing.T) {
+ t.Run("invalid JSON returns error", func(t *testing.T) {
t.Parallel()
inst := makeInstance(1, "easypay", "alipay", `{bad json}`)
- _, ok := pcInstanceTypeLimits(inst, "alipay")
- if ok {
- t.Fatal("expected ok=false for invalid JSON")
+ _, _, err := pcInstanceTypeLimits(inst, "alipay")
+ if err == nil {
+ t.Fatal("expected fail-closed error for invalid JSON")
}
})
}
@@ -372,7 +389,9 @@ func TestGetAvailableMethodLimitsUsesConfiguredVisibleMethodSource(t *testing.T)
entClient: client,
settingRepo: &paymentConfigSettingRepoStub{
values: map[string]string{
- SettingPaymentVisibleMethodAlipaySource: tt.sourceSetting,
+ SettingPaymentVisibleMethodAlipaySource: tt.sourceSetting,
+ SettingPaymentVisibleMethodAlipayEnabled: "true",
+ SettingPaymentVisibleMethodWxpayEnabled: "true",
},
},
}
@@ -439,8 +458,11 @@ func TestGetAvailableMethodLimitsPreservesLegacyCrossProviderBehaviorWhenVisible
require.NoError(t, err)
svc := &PaymentConfigService{
- entClient: client,
- settingRepo: &paymentConfigSettingRepoStub{values: map[string]string{}},
+ entClient: client,
+ settingRepo: &paymentConfigSettingRepoStub{values: map[string]string{
+ SettingPaymentVisibleMethodAlipayEnabled: "true",
+ SettingPaymentVisibleMethodWxpayEnabled: "true",
+ }},
}
resp, err := svc.GetAvailableMethodLimits(ctx)
diff --git a/backend/internal/service/payment_config_plans.go b/backend/internal/service/payment_config_plans.go
index bb161a14525..84e13c40fa5 100644
--- a/backend/internal/service/payment_config_plans.go
+++ b/backend/internal/service/payment_config_plans.go
@@ -3,21 +3,24 @@ package service
import (
"context"
"fmt"
+ "math"
+ "sort"
"strings"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
// validatePlanRequired checks that all required fields for a plan are provided.
-func validatePlanRequired(name string, groupID int64, price float64, validityDays int, validityUnit string, originalPrice *float64) error {
+func validatePlanRequired(name string, groupID *int64, walletQuotaUSD *float64, planType string, price float64, validityDays int, validityUnit string, originalPrice *float64) error {
if strings.TrimSpace(name) == "" {
return infraerrors.BadRequest("PLAN_NAME_REQUIRED", "plan name is required")
}
- if groupID <= 0 {
- return infraerrors.BadRequest("PLAN_GROUP_REQUIRED", "group is required")
+ if err := validatePlanFulfillmentShape(groupID, walletQuotaUSD, planType); err != nil {
+ return err
}
if price <= 0 {
return infraerrors.BadRequest("PLAN_PRICE_INVALID", "price must be > 0")
@@ -34,6 +37,40 @@ func validatePlanRequired(name string, groupID int64, price float64, validityDay
return nil
}
+func validatePlanFulfillmentShape(groupID *int64, walletQuotaUSD *float64, planType string) error {
+ switch planType {
+ case PlanTypeSubscription:
+ if groupID == nil || *groupID <= 0 {
+ return infraerrors.BadRequest("PLAN_GROUP_REQUIRED", "monthly plans require a subscription group")
+ }
+ if walletQuotaUSD != nil {
+ return infraerrors.BadRequest("PLAN_MODE_INVALID", "monthly plans cannot use wallet quota")
+ }
+ case PlanTypeCredits:
+ if groupID != nil {
+ return infraerrors.BadRequest("PLAN_MODE_INVALID", "credits wallet plans must not set group_id")
+ }
+ if walletQuotaUSD == nil || *walletQuotaUSD <= 0 || math.IsNaN(*walletQuotaUSD) || math.IsInf(*walletQuotaUSD, 0) {
+ return infraerrors.BadRequest("PLAN_WALLET_QUOTA_INVALID", "credits plans require a finite wallet_quota_usd > 0")
+ }
+ default:
+ return infraerrors.BadRequest("PLAN_TYPE_INVALID", "plan_type must be 'subscription' or 'credits'")
+ }
+ return nil
+}
+
+// validatePlanType 兜底 plan_type 取值(空串视为 subscription,向后兼容)。
+func validatePlanType(planType string) (string, error) {
+ pt := strings.TrimSpace(planType)
+ if pt == "" {
+ return PlanTypeSubscription, nil
+ }
+ if pt != PlanTypeSubscription && pt != PlanTypeCredits {
+ return "", infraerrors.BadRequest("PLAN_TYPE_INVALID", "plan_type must be 'subscription' or 'credits'")
+ }
+ return pt, nil
+}
+
// validatePlanPatch validates only the non-nil fields in a patch update.
func validatePlanPatch(req UpdatePlanRequest) error {
if req.Name != nil && strings.TrimSpace(*req.Name) == "" {
@@ -42,6 +79,9 @@ func validatePlanPatch(req UpdatePlanRequest) error {
if req.GroupID != nil && *req.GroupID <= 0 {
return infraerrors.BadRequest("PLAN_GROUP_REQUIRED", "group is required")
}
+ if req.WalletQuotaUSD != nil && (*req.WalletQuotaUSD <= 0 || math.IsNaN(*req.WalletQuotaUSD) || math.IsInf(*req.WalletQuotaUSD, 0)) {
+ return infraerrors.BadRequest("PLAN_WALLET_QUOTA_INVALID", "wallet_quota_usd must be > 0")
+ }
if req.Price != nil && *req.Price <= 0 {
return infraerrors.BadRequest("PLAN_PRICE_INVALID", "price must be > 0")
}
@@ -54,11 +94,49 @@ func validatePlanPatch(req UpdatePlanRequest) error {
if req.OriginalPrice != nil && *req.OriginalPrice < 0 {
return infraerrors.BadRequest("PLAN_ORIGINAL_PRICE_INVALID", "original price must be >= 0")
}
+ if req.PlanType != nil {
+ if _, err := validatePlanType(*req.PlanType); err != nil {
+ return err
+ }
+ }
return nil
}
// --- Plan CRUD ---
+// SubscriptionPlanResponse is the admin-facing plan payload with flattened plan-group IDs.
+type SubscriptionPlanResponse struct {
+ *dbent.SubscriptionPlan
+ PlanGroupIDs []int64 `json:"plan_group_ids"`
+}
+
+func NewSubscriptionPlanResponse(plan *dbent.SubscriptionPlan) SubscriptionPlanResponse {
+ return SubscriptionPlanResponse{
+ SubscriptionPlan: plan,
+ PlanGroupIDs: planGroupIDsFromEdges(plan),
+ }
+}
+
+func NewSubscriptionPlanResponses(plans []*dbent.SubscriptionPlan) []SubscriptionPlanResponse {
+ out := make([]SubscriptionPlanResponse, 0, len(plans))
+ for _, plan := range plans {
+ out = append(out, NewSubscriptionPlanResponse(plan))
+ }
+ return out
+}
+
+func planGroupIDsFromEdges(plan *dbent.SubscriptionPlan) []int64 {
+ if plan == nil || len(plan.Edges.PlanGroups) == 0 {
+ return []int64{}
+ }
+ ids := make([]int64, 0, len(plan.Edges.PlanGroups))
+ for _, pg := range plan.Edges.PlanGroups {
+ ids = append(ids, pg.GroupID)
+ }
+ sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
+ return ids
+}
+
// PlanGroupInfo holds the group details needed for subscription plan display.
type PlanGroupInfo struct {
Platform string `json:"platform"`
@@ -85,9 +163,13 @@ func (s *PaymentConfigService) GetGroupInfoMap(ctx context.Context, plans []*dbe
ids := make([]int64, 0, len(plans))
seen := make(map[int64]bool)
for _, p := range plans {
- if !seen[p.GroupID] {
- seen[p.GroupID] = true
- ids = append(ids, p.GroupID)
+ // 钱包模式 plan (v4) GroupID 为 nil, 不参与单 group 信息聚合
+ if p.GroupID == nil {
+ continue
+ }
+ if !seen[*p.GroupID] {
+ seen[*p.GroupID] = true
+ ids = append(ids, *p.GroupID)
}
}
if len(ids) == 0 {
@@ -113,26 +195,87 @@ func (s *PaymentConfigService) GetGroupInfoMap(ctx context.Context, plans []*dbe
}
func (s *PaymentConfigService) ListPlans(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
- return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.BySortOrder()).All(ctx)
+ return s.entClient.SubscriptionPlan.Query().
+ WithPlanGroups().
+ Order(subscriptionplan.BySortOrder()).
+ All(ctx)
}
func (s *PaymentConfigService) ListPlansForSale(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
- return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.BySortOrder()).All(ctx)
+ plans, err := s.entClient.SubscriptionPlan.Query().Where(
+ subscriptionplan.ForSaleEQ(true),
+ subscriptionplan.PlanTypeEQ(PlanTypeCredits),
+ ).Order(subscriptionplan.BySortOrder()).All(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := s.validatePlanGroupAvailability(ctx, s.entClient, plans); err != nil {
+ return nil, err
+ }
+ return plans, nil
}
func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*dbent.SubscriptionPlan, error) {
- if err := validatePlanRequired(req.Name, req.GroupID, req.Price, req.ValidityDays, req.ValidityUnit, req.OriginalPrice); err != nil {
+ planType, err := validatePlanType(req.PlanType)
+ if err != nil {
return nil, err
}
- b := s.entClient.SubscriptionPlan.Create().
- SetGroupID(req.GroupID).SetName(req.Name).SetDescription(req.Description).
+ if planType != PlanTypeCredits {
+ return nil, infraerrors.BadRequest("MONTHLY_PLANS_RETIRED", "monthly plans have been retired; create a credits plan instead")
+ }
+ if err := validatePlanRequired(req.Name, req.GroupID, req.WalletQuotaUSD, planType, req.Price, req.ValidityDays, req.ValidityUnit, req.OriginalPrice); err != nil {
+ return nil, err
+ }
+ planGroupIDs, err := normalizePlanGroupIDs(req.PlanGroupIDs)
+ if err != nil {
+ return nil, err
+ }
+
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = tx.Rollback() }()
+ if err := s.requireAvailableMonthlyPlanGroup(ctx, tx.Client(), planType, req.GroupID); err != nil {
+ return nil, err
+ }
+ if err := validatePlanCoverageGroupIDs(ctx, tx.Client(), planGroupIDs); err != nil {
+ return nil, err
+ }
+
+ b := tx.SubscriptionPlan.Create().
+ SetName(req.Name).SetDescription(req.Description).
SetPrice(req.Price).SetValidityDays(req.ValidityDays).SetValidityUnit(req.ValidityUnit).
SetFeatures(req.Features).SetProductName(req.ProductName).
- SetForSale(req.ForSale).SetSortOrder(req.SortOrder)
+ SetForSale(req.ForSale).SetSortOrder(req.SortOrder).
+ SetPlanType(planType)
+ if req.GroupID != nil {
+ b.SetGroupID(*req.GroupID)
+ }
+ if req.WalletQuotaUSD != nil {
+ b.SetWalletQuotaUsd(*req.WalletQuotaUSD)
+ }
if req.OriginalPrice != nil {
b.SetOriginalPrice(*req.OriginalPrice)
}
- return b.Save(ctx)
+ plan, err := b.Save(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := replacePlanGroupIDs(ctx, tx, plan.ID, planGroupIDs); err != nil {
+ return nil, err
+ }
+ plan, err = tx.SubscriptionPlan.Query().
+ Where(subscriptionplan.IDEQ(plan.ID)).
+ WithPlanGroups().
+ Only(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, err
+ }
+ return plan, nil
}
// UpdatePlan updates a subscription plan by ID (patch semantics).
@@ -142,9 +285,68 @@ func (s *PaymentConfigService) UpdatePlan(ctx context.Context, id int64, req Upd
if err := validatePlanPatch(req); err != nil {
return nil, err
}
- u := s.entClient.SubscriptionPlan.UpdateOneID(id)
+ var planGroupIDs []int64
+ if req.PlanGroupIDs != nil {
+ var err error
+ planGroupIDs, err = normalizePlanGroupIDs(*req.PlanGroupIDs)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ existing, err := tx.SubscriptionPlan.Get(ctx, id)
+ if err != nil {
+ return nil, err
+ }
+ nextPlanType, err := validatePlanType(existing.PlanType)
+ if err != nil {
+ return nil, err
+ }
+ nextGroupID := existing.GroupID
+ nextWalletQuota := existing.WalletQuotaUsd
+ if req.GroupID != nil {
+ nextGroupID = req.GroupID
+ nextWalletQuota = nil
+ }
+ if req.WalletQuotaUSD != nil {
+ nextWalletQuota = req.WalletQuotaUSD
+ nextGroupID = nil
+ }
+ if req.PlanType != nil {
+ nextPlanType, err = validatePlanType(*req.PlanType)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if nextPlanType != PlanTypeCredits && req.ForSale != nil && *req.ForSale {
+ return nil, infraerrors.BadRequest("MONTHLY_PLANS_RETIRED", "monthly plans cannot be put back on sale")
+ }
+ if err := validatePlanFulfillmentShape(nextGroupID, nextWalletQuota, nextPlanType); err != nil {
+ return nil, err
+ }
+ if err := s.requireAvailableMonthlyPlanGroup(ctx, tx.Client(), nextPlanType, nextGroupID); err != nil {
+ return nil, err
+ }
+ if req.PlanGroupIDs != nil {
+ if err := validatePlanCoverageGroupIDs(ctx, tx.Client(), planGroupIDs); err != nil {
+ return nil, err
+ }
+ }
+
+ u := tx.SubscriptionPlan.UpdateOneID(id)
if req.GroupID != nil {
u.SetGroupID(*req.GroupID)
+ u.ClearWalletQuotaUsd()
+ }
+ if req.WalletQuotaUSD != nil {
+ u.SetWalletQuotaUsd(*req.WalletQuotaUSD)
+ u.ClearGroupID()
}
if req.Name != nil {
u.SetName(*req.Name)
@@ -176,7 +378,138 @@ func (s *PaymentConfigService) UpdatePlan(ctx context.Context, id int64, req Upd
if req.SortOrder != nil {
u.SetSortOrder(*req.SortOrder)
}
- return u.Save(ctx)
+ if req.PlanType != nil {
+ pt, err := validatePlanType(*req.PlanType)
+ if err != nil {
+ return nil, err
+ }
+ u.SetPlanType(pt)
+ }
+ if _, err := u.Save(ctx); err != nil {
+ return nil, err
+ }
+ if req.PlanGroupIDs != nil {
+ if err := replacePlanGroupIDs(ctx, tx, id, planGroupIDs); err != nil {
+ return nil, err
+ }
+ }
+ plan, err := tx.SubscriptionPlan.Query().
+ Where(subscriptionplan.IDEQ(id)).
+ WithPlanGroups().
+ Only(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, err
+ }
+ return plan, nil
+}
+
+func normalizePlanGroupIDs(ids []int64) ([]int64, error) {
+ if len(ids) == 0 {
+ return []int64{}, nil
+ }
+ seen := make(map[int64]struct{}, len(ids))
+ out := make([]int64, 0, len(ids))
+ for _, id := range ids {
+ if id <= 0 {
+ return nil, infraerrors.BadRequest("PLAN_GROUP_REQUIRED", "plan_group_ids must contain positive group IDs")
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ out = append(out, id)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
+ return out, nil
+}
+
+func validatePlanCoverageGroupIDs(ctx context.Context, client *dbent.Client, groupIDs []int64) error {
+ if len(groupIDs) == 0 {
+ return nil
+ }
+ if client == nil {
+ return infraerrors.BadRequest("PLAN_COVERAGE_GROUP_UNAVAILABLE", "plan coverage group is unavailable")
+ }
+ groups, err := client.Group.Query().
+ Where(group.IDIn(groupIDs...), group.DeletedAtIsNil()).
+ All(ctx)
+ if err != nil {
+ return fmt.Errorf("validate plan coverage groups: %w", err)
+ }
+ if len(groups) != len(groupIDs) {
+ return infraerrors.BadRequest("PLAN_COVERAGE_GROUP_UNAVAILABLE", "plan coverage groups must exist and be active public standard groups")
+ }
+ for _, candidate := range groups {
+ if candidate.Status != StatusActive ||
+ candidate.SubscriptionType != SubscriptionTypeStandard ||
+ candidate.IsExclusive ||
+ candidate.Name == WalletDefaultVIPGroupName ||
+ candidate.DeletedAt != nil {
+ return infraerrors.BadRequest("PLAN_COVERAGE_GROUP_UNAVAILABLE", "plan coverage groups must exist and be active public standard groups")
+ }
+ }
+ return nil
+}
+
+func (s *PaymentConfigService) validatePlanGroupAvailability(ctx context.Context, client *dbent.Client, plans []*dbent.SubscriptionPlan) error {
+ for _, plan := range plans {
+ if plan == nil {
+ continue
+ }
+ planType, err := validatePlanType(plan.PlanType)
+ if err != nil {
+ return err
+ }
+ if err := validatePlanFulfillmentShape(plan.GroupID, plan.WalletQuotaUsd, planType); err != nil {
+ return err
+ }
+ if err := s.requireAvailableMonthlyPlanGroup(ctx, client, planType, plan.GroupID); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *PaymentConfigService) requireAvailableMonthlyPlanGroup(ctx context.Context, client *dbent.Client, planType string, groupID *int64) error {
+ if planType != PlanTypeSubscription {
+ return nil
+ }
+ if client == nil || groupID == nil || *groupID <= 0 {
+ return infraerrors.BadRequest("PLAN_GROUP_UNAVAILABLE", "monthly plan group is unavailable")
+ }
+ exists, err := client.Group.Query().Where(
+ group.IDEQ(*groupID),
+ group.StatusEQ(StatusActive),
+ group.SubscriptionTypeEQ(SubscriptionTypeSubscription),
+ group.DeletedAtIsNil(),
+ ).Exist(ctx)
+ if err != nil {
+ return fmt.Errorf("validate monthly plan group: %w", err)
+ }
+ if !exists {
+ return infraerrors.BadRequest("PLAN_GROUP_UNAVAILABLE", "monthly plan group must be active and subscription type")
+ }
+ return nil
+}
+
+func replacePlanGroupIDs(ctx context.Context, tx *dbent.Tx, planID int64, groupIDs []int64) error {
+ if _, err := tx.SubscriptionPlanGroup.Delete().
+ Where(subscriptionplangroup.PlanIDEQ(planID)).
+ Exec(ctx); err != nil {
+ return fmt.Errorf("delete plan groups: %w", err)
+ }
+ for _, groupID := range groupIDs {
+ if _, err := tx.SubscriptionPlanGroup.Create().
+ SetPlanID(planID).
+ SetGroupID(groupID).
+ Save(ctx); err != nil {
+ return fmt.Errorf("create plan group %d: %w", groupID, err)
+ }
+ }
+ return nil
}
func (s *PaymentConfigService) DeletePlan(ctx context.Context, id int64) error {
diff --git a/backend/internal/service/payment_config_plans_test.go b/backend/internal/service/payment_config_plans_test.go
new file mode 100644
index 00000000000..825b9a5718c
--- /dev/null
+++ b/backend/internal/service/payment_config_plans_test.go
@@ -0,0 +1,298 @@
+package service
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/stretchr/testify/require"
+)
+
+// TestValidatePlanType 验证 B2.5 plan_type 兜底:空串 → subscription;
+// 取值非法 → BadRequest PLAN_TYPE_INVALID。
+func TestValidatePlanType(t *testing.T) {
+ t.Run("空串默认 subscription", func(t *testing.T) {
+ got, err := validatePlanType("")
+ require.NoError(t, err)
+ require.Equal(t, PlanTypeSubscription, got)
+ })
+ t.Run("空白也算空串", func(t *testing.T) {
+ got, err := validatePlanType(" ")
+ require.NoError(t, err)
+ require.Equal(t, PlanTypeSubscription, got)
+ })
+ t.Run("subscription 合法", func(t *testing.T) {
+ got, err := validatePlanType("subscription")
+ require.NoError(t, err)
+ require.Equal(t, PlanTypeSubscription, got)
+ })
+ t.Run("credits 合法", func(t *testing.T) {
+ got, err := validatePlanType("credits")
+ require.NoError(t, err)
+ require.Equal(t, PlanTypeCredits, got)
+ })
+ t.Run("其他取值被拒", func(t *testing.T) {
+ _, err := validatePlanType("trial")
+ require.Error(t, err)
+ require.Equal(t, "PLAN_TYPE_INVALID", infraerrors.Reason(err))
+ })
+}
+
+// TestValidatePlanPatchPlanType 验证 patch 路径下 plan_type 也走相同校验。
+func TestValidatePlanPatchPlanType(t *testing.T) {
+ bad := "garbage"
+ err := validatePlanPatch(UpdatePlanRequest{PlanType: &bad})
+ require.Error(t, err)
+ require.Equal(t, "PLAN_TYPE_INVALID", infraerrors.Reason(err))
+
+ ok := "credits"
+ require.NoError(t, validatePlanPatch(UpdatePlanRequest{PlanType: &ok}))
+}
+
+func TestPaymentConfigServiceCreateWalletPlanWithPlanGroupIDs(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ svc := &PaymentConfigService{entClient: client}
+
+ g1 := createPlanCoverageGroup(t, ctx, client, "cc-default")
+ g3 := createPlanCoverageGroup(t, ctx, client, "openai-default")
+ walletQuota := 400.0
+ originalPrice := 199.0
+
+ plan, err := svc.CreatePlan(ctx, CreatePlanRequest{
+ Name: "paid-lite-v3-30d",
+ Description: "30 days $400 wallet quota",
+ Price: 99,
+ OriginalPrice: &originalPrice,
+ ValidityDays: 30,
+ ValidityUnit: "days",
+ Features: "轻量 Claude Code / GPT 日常使用",
+ ProductName: "轻量正式版",
+ ForSale: true,
+ SortOrder: 15,
+ PlanType: PlanTypeCredits,
+ WalletQuotaUSD: &walletQuota,
+ PlanGroupIDs: []int64{g3.ID, g1.ID, g1.ID},
+ })
+ require.NoError(t, err)
+ require.Nil(t, plan.GroupID)
+ require.NotNil(t, plan.WalletQuotaUsd)
+ require.InDelta(t, walletQuota, *plan.WalletQuotaUsd, 0.000001)
+ require.Equal(t, []int64{g1.ID, g3.ID}, NewSubscriptionPlanResponse(plan).PlanGroupIDs)
+}
+
+func TestPaymentConfigServiceUpdatePlanGroupIDs(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ svc := &PaymentConfigService{entClient: client}
+
+ g1 := createPlanCoverageGroup(t, ctx, client, "cc-default")
+ g3 := createPlanCoverageGroup(t, ctx, client, "openai-default")
+ g24 := createPlanCoverageGroup(t, ctx, client, "Claude-Max pool public")
+ walletQuota := 400.0
+ plan, err := svc.CreatePlan(ctx, CreatePlanRequest{
+ Name: "paid-lite-v3-30d",
+ Price: 99,
+ ValidityDays: 30,
+ ValidityUnit: "days",
+ ForSale: true,
+ PlanType: PlanTypeCredits,
+ WalletQuotaUSD: &walletQuota,
+ PlanGroupIDs: []int64{g1.ID, g3.ID},
+ })
+ require.NoError(t, err)
+
+ nextIDs := []int64{g24.ID, g1.ID}
+ updated, err := svc.UpdatePlan(ctx, plan.ID, UpdatePlanRequest{PlanGroupIDs: &nextIDs})
+ require.NoError(t, err)
+ require.Equal(t, []int64{g1.ID, g24.ID}, NewSubscriptionPlanResponse(updated).PlanGroupIDs)
+}
+
+func TestPaymentConfigServiceRejectsRestrictedPlanGroupIDs(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ svc := &PaymentConfigService{entClient: client}
+
+ createGroup := func(name, status, subscriptionType string, exclusive bool) int64 {
+ group, err := client.Group.Create().
+ SetName(name).
+ SetStatus(status).
+ SetSubscriptionType(subscriptionType).
+ SetIsExclusive(exclusive).
+ Save(ctx)
+ require.NoError(t, err)
+ return group.ID
+ }
+
+ inactiveID := createGroup("inactive-plan-coverage", StatusDisabled, SubscriptionTypeStandard, false)
+ exclusiveID := createGroup("exclusive-plan-coverage", StatusActive, SubscriptionTypeStandard, true)
+ subscriptionID := createGroup("subscription-plan-anchor", StatusActive, SubscriptionTypeSubscription, false)
+ vipID := createGroup(WalletDefaultVIPGroupName, StatusActive, SubscriptionTypeStandard, true)
+ deletedID := createGroup("deleted-plan-coverage", StatusActive, SubscriptionTypeStandard, false)
+ _, err := client.Group.UpdateOneID(deletedID).SetDeletedAt(time.Now()).Save(ctx)
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ groupID int64
+ }{
+ {name: "missing", groupID: 999999},
+ {name: "inactive", groupID: inactiveID},
+ {name: "exclusive", groupID: exclusiveID},
+ {name: "subscription anchor", groupID: subscriptionID},
+ {name: "reserved vip", groupID: vipID},
+ {name: "soft deleted", groupID: deletedID},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ walletQuota := 100.0
+ _, err := svc.CreatePlan(ctx, CreatePlanRequest{
+ Name: "invalid coverage " + tt.name,
+ Price: 10,
+ ValidityDays: 30,
+ ValidityUnit: "days",
+ ForSale: true,
+ PlanType: PlanTypeCredits,
+ WalletQuotaUSD: &walletQuota,
+ PlanGroupIDs: []int64{tt.groupID},
+ })
+ require.Error(t, err)
+ require.Equal(t, "PLAN_COVERAGE_GROUP_UNAVAILABLE", infraerrors.Reason(err))
+ })
+ }
+}
+
+func TestPaymentConfigServiceUpdateRejectsRestrictedPlanGroupWithoutMutation(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ svc := &PaymentConfigService{entClient: client}
+
+ allowed := createPlanCoverageGroup(t, ctx, client, "allowed-plan-coverage")
+ restricted, err := client.Group.Create().
+ SetName("restricted-exclusive-coverage").
+ SetStatus(StatusActive).
+ SetSubscriptionType(SubscriptionTypeStandard).
+ SetIsExclusive(true).
+ Save(ctx)
+ require.NoError(t, err)
+ walletQuota := 100.0
+ plan, err := svc.CreatePlan(ctx, CreatePlanRequest{
+ Name: "immutable coverage",
+ Price: 10,
+ ValidityDays: 30,
+ ValidityUnit: "days",
+ ForSale: true,
+ PlanType: PlanTypeCredits,
+ WalletQuotaUSD: &walletQuota,
+ PlanGroupIDs: []int64{allowed.ID},
+ })
+ require.NoError(t, err)
+
+ restrictedIDs := []int64{restricted.ID}
+ _, err = svc.UpdatePlan(ctx, plan.ID, UpdatePlanRequest{PlanGroupIDs: &restrictedIDs})
+ require.Error(t, err)
+ require.Equal(t, "PLAN_COVERAGE_GROUP_UNAVAILABLE", infraerrors.Reason(err))
+
+ persisted, err := client.SubscriptionPlan.Query().
+ Where(subscriptionplan.IDEQ(plan.ID)).
+ WithPlanGroups().
+ Only(ctx)
+ require.NoError(t, err)
+ require.Equal(t, []int64{allowed.ID}, NewSubscriptionPlanResponse(persisted).PlanGroupIDs)
+}
+
+func TestValidateCapturedPlanCoverageGroupsRejectsLegacyRestrictedCoverage(t *testing.T) {
+ err := validateCapturedPlanCoverageGroups(PlanTypeCredits, []*dbent.Group{{
+ ID: 22,
+ Name: "legacy-restricted-coverage",
+ Status: StatusActive,
+ SubscriptionType: SubscriptionTypeStandard,
+ IsExclusive: true,
+ }})
+ require.Error(t, err)
+ require.Equal(t, "PLAN_CHANGED_RETRY", infraerrors.Reason(err))
+}
+
+func TestPaymentConfigServiceRejectsNewMonthlyPlans(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ svc := &PaymentConfigService{entClient: client}
+
+ validGroup := createMonthlyPlanGroup(t, ctx, client, "monthly-active", StatusActive)
+ _, err := svc.CreatePlan(ctx, CreatePlanRequest{
+ Name: "retired monthly",
+ GroupID: &validGroup.ID,
+ Price: 20,
+ ValidityDays: 30,
+ ValidityUnit: "days",
+ ForSale: true,
+ PlanType: PlanTypeSubscription,
+ })
+ require.Error(t, err)
+ require.Equal(t, "MONTHLY_PLANS_RETIRED", infraerrors.Reason(err))
+}
+
+func TestPaymentConfigServiceListPlansForSaleReturnsCreditsOnly(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ svc := &PaymentConfigService{entClient: client}
+ standardGroup := createPlanCoverageGroup(t, ctx, client, "invalid-sale-group")
+
+ legacy, err := client.SubscriptionPlan.Create().
+ SetName("invalid monthly sale plan").
+ SetGroupID(standardGroup.ID).
+ SetPlanType(PlanTypeSubscription).
+ SetPrice(20).
+ SetValidityDays(30).
+ SetForSale(true).
+ Save(ctx)
+ require.NoError(t, err)
+
+ walletQuota := 100.0
+ credits, err := client.SubscriptionPlan.Create().
+ SetName("credits sale plan").
+ SetPlanType(PlanTypeCredits).
+ SetWalletQuotaUsd(walletQuota).
+ SetPrice(100).
+ SetValidityDays(1).
+ SetForSale(true).
+ Save(ctx)
+ require.NoError(t, err)
+
+ plans, err := svc.ListPlansForSale(ctx)
+ require.NoError(t, err)
+ require.Len(t, plans, 1)
+ require.Equal(t, []int64{credits.ID}, []int64{plans[0].ID})
+ require.NotEqual(t, legacy.ID, plans[0].ID)
+
+ paymentSvc := &PaymentService{configService: svc}
+ _, err = paymentSvc.validateSubOrder(ctx, CreateOrderRequest{PlanID: legacy.ID})
+ require.Error(t, err)
+ require.Equal(t, "MONTHLY_PLANS_RETIRED", infraerrors.Reason(err))
+}
+
+func createPlanCoverageGroup(t *testing.T, ctx context.Context, client *dbent.Client, name string) *Group {
+ t.Helper()
+ g, err := client.Group.Create().
+ SetName(name).
+ SetSubscriptionType(SubscriptionTypeStandard).
+ SetStatus(StatusActive).
+ Save(ctx)
+ require.NoError(t, err)
+ return &Group{ID: g.ID, Name: g.Name}
+}
+
+func createMonthlyPlanGroup(t *testing.T, ctx context.Context, client *dbent.Client, name, status string) *Group {
+ t.Helper()
+ g, err := client.Group.Create().
+ SetName(name).
+ SetSubscriptionType(SubscriptionTypeSubscription).
+ SetStatus(status).
+ Save(ctx)
+ require.NoError(t, err)
+ return &Group{ID: g.ID, Name: g.Name}
+}
diff --git a/backend/internal/service/payment_config_plans_validation_test.go b/backend/internal/service/payment_config_plans_validation_test.go
index bcbe901f2a4..dd639e0816e 100644
--- a/backend/internal/service/payment_config_plans_validation_test.go
+++ b/backend/internal/service/payment_config_plans_validation_test.go
@@ -3,106 +3,128 @@
package service
import (
+ "math"
"testing"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/stretchr/testify/require"
)
func TestValidatePlanRequired_AllValid(t *testing.T) {
- err := validatePlanRequired("Pro", 1, 9.99, 30, "days", nil)
+ err := validatePlanRequired("Pro", ptrInt64(1), nil, PlanTypeSubscription, 9.99, 30, "days", nil)
require.NoError(t, err)
}
func TestValidatePlanRequired_EmptyName(t *testing.T) {
- err := validatePlanRequired("", 1, 9.99, 30, "days", nil)
+ err := validatePlanRequired("", ptrInt64(1), nil, PlanTypeSubscription, 9.99, 30, "days", nil)
require.Error(t, err)
require.Contains(t, err.Error(), "plan name")
}
func TestValidatePlanRequired_WhitespaceName(t *testing.T) {
- err := validatePlanRequired(" ", 1, 9.99, 30, "days", nil)
+ err := validatePlanRequired(" ", ptrInt64(1), nil, PlanTypeSubscription, 9.99, 30, "days", nil)
require.Error(t, err)
require.Contains(t, err.Error(), "plan name")
}
func TestValidatePlanRequired_ZeroGroupID(t *testing.T) {
- err := validatePlanRequired("Pro", 0, 9.99, 30, "days", nil)
+ err := validatePlanRequired("Pro", ptrInt64(0), nil, PlanTypeSubscription, 9.99, 30, "days", nil)
require.Error(t, err)
require.Contains(t, err.Error(), "group")
}
func TestValidatePlanRequired_NegativeGroupID(t *testing.T) {
- err := validatePlanRequired("Pro", -1, 9.99, 30, "days", nil)
+ err := validatePlanRequired("Pro", ptrInt64(-1), nil, PlanTypeSubscription, 9.99, 30, "days", nil)
require.Error(t, err)
require.Contains(t, err.Error(), "group")
}
func TestValidatePlanRequired_ZeroPrice(t *testing.T) {
- err := validatePlanRequired("Pro", 1, 0, 30, "days", nil)
+ err := validatePlanRequired("Pro", ptrInt64(1), nil, PlanTypeSubscription, 0, 30, "days", nil)
require.Error(t, err)
require.Contains(t, err.Error(), "price")
}
func TestValidatePlanRequired_NegativePrice(t *testing.T) {
- err := validatePlanRequired("Pro", 1, -5, 30, "days", nil)
+ err := validatePlanRequired("Pro", ptrInt64(1), nil, PlanTypeSubscription, -5, 30, "days", nil)
require.Error(t, err)
require.Contains(t, err.Error(), "price")
}
func TestValidatePlanRequired_ZeroValidityDays(t *testing.T) {
- err := validatePlanRequired("Pro", 1, 9.99, 0, "days", nil)
+ err := validatePlanRequired("Pro", ptrInt64(1), nil, PlanTypeSubscription, 9.99, 0, "days", nil)
require.Error(t, err)
require.Contains(t, err.Error(), "validity days")
}
func TestValidatePlanRequired_NegativeValidityDays(t *testing.T) {
- err := validatePlanRequired("Pro", 1, 9.99, -7, "days", nil)
+ err := validatePlanRequired("Pro", ptrInt64(1), nil, PlanTypeSubscription, 9.99, -7, "days", nil)
require.Error(t, err)
require.Contains(t, err.Error(), "validity days")
}
func TestValidatePlanRequired_EmptyValidityUnit(t *testing.T) {
- err := validatePlanRequired("Pro", 1, 9.99, 30, "", nil)
+ err := validatePlanRequired("Pro", ptrInt64(1), nil, PlanTypeSubscription, 9.99, 30, "", nil)
require.Error(t, err)
require.Contains(t, err.Error(), "validity unit")
}
func TestValidatePlanRequired_WhitespaceValidityUnit(t *testing.T) {
- err := validatePlanRequired("Pro", 1, 9.99, 30, " ", nil)
+ err := validatePlanRequired("Pro", ptrInt64(1), nil, PlanTypeSubscription, 9.99, 30, " ", nil)
require.Error(t, err)
require.Contains(t, err.Error(), "validity unit")
}
func TestValidatePlanRequired_NameValidatedFirst(t *testing.T) {
- err := validatePlanRequired("", 0, 0, 0, "", nil)
+ err := validatePlanRequired("", ptrInt64(0), nil, PlanTypeSubscription, 0, 0, "", nil)
require.Error(t, err)
require.Contains(t, err.Error(), "plan name")
}
func TestValidatePlanRequired_TrimmedValidName(t *testing.T) {
- err := validatePlanRequired(" Pro ", 1, 9.99, 30, "days", nil)
+ err := validatePlanRequired(" Pro ", ptrInt64(1), nil, PlanTypeSubscription, 9.99, 30, "days", nil)
require.NoError(t, err)
}
func TestValidatePlanRequired_NegativeOriginalPrice(t *testing.T) {
neg := -10.0
- err := validatePlanRequired("Pro", 1, 9.99, 30, "days", &neg)
+ err := validatePlanRequired("Pro", ptrInt64(1), nil, PlanTypeSubscription, 9.99, 30, "days", &neg)
require.Error(t, err)
require.Contains(t, err.Error(), "original price")
}
func TestValidatePlanRequired_ZeroOriginalPrice(t *testing.T) {
zero := 0.0
- err := validatePlanRequired("Pro", 1, 9.99, 30, "days", &zero)
+ err := validatePlanRequired("Pro", ptrInt64(1), nil, PlanTypeSubscription, 9.99, 30, "days", &zero)
require.NoError(t, err)
}
func TestValidatePlanRequired_ValidOriginalPrice(t *testing.T) {
op := 19.99
- err := validatePlanRequired("Pro", 1, 9.99, 30, "days", &op)
+ err := validatePlanRequired("Pro", ptrInt64(1), nil, PlanTypeSubscription, 9.99, 30, "days", &op)
require.NoError(t, err)
}
+func TestValidatePlanRequired_MonthlyAndCreditsShapes(t *testing.T) {
+ walletQuota := 20.0
+ require.Error(t, validatePlanRequired(
+ "monthly wallet", nil, &walletQuota, PlanTypeSubscription, 9.99, 30, "days", nil,
+ ))
+ require.Error(t, validatePlanRequired(
+ "credits group", ptrInt64(1), &walletQuota, PlanTypeCredits, 9.99, 30, "days", nil,
+ ))
+ require.NoError(t, validatePlanRequired(
+ "credits wallet", nil, &walletQuota, PlanTypeCredits, 9.99, 30, "days", nil,
+ ))
+
+ for _, invalidQuota := range []float64{0, -1, math.NaN(), math.Inf(1), math.Inf(-1)} {
+ quota := invalidQuota
+ err := validatePlanRequired("invalid credits", nil, "a, PlanTypeCredits, 9.99, 30, "days", nil)
+ require.Error(t, err)
+ require.Equal(t, "PLAN_WALLET_QUOTA_INVALID", infraerrors.Reason(err))
+ }
+}
+
// --- validatePlanPatch tests ---
func TestValidatePlanPatch_NegativeOriginalPrice(t *testing.T) {
@@ -133,7 +155,6 @@ func TestValidatePlanPatch_NilOriginalPrice(t *testing.T) {
func ptrStr(s string) *string { return &s }
func ptrInt(i int) *int { return &i }
-func ptrInt64(i int64) *int64 { return &i }
func ptrFloat(f float64) *float64 { return &f }
func TestValidatePlanPatch_EmptyName(t *testing.T) {
diff --git a/backend/internal/service/payment_config_providers.go b/backend/internal/service/payment_config_providers.go
index ff05e559a49..f06a14377a9 100644
--- a/backend/internal/service/payment_config_providers.go
+++ b/backend/internal/service/payment_config_providers.go
@@ -2,12 +2,11 @@ package service
import (
"context"
- "encoding/json"
"fmt"
- "log/slog"
"strconv"
"strings"
+ "entgo.io/ent/dialect"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
"github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
@@ -102,6 +101,19 @@ var pendingOrderStatuses = []string{
payment.OrderStatusRecharging,
}
+// providerSettlementProtectedOrderStatuses includes every state that can still
+// accept trusted provider payment evidence. Rotating or deleting the pinned
+// provider while one of these orders exists can make a real late payment
+// unverifiable or unfulfillable.
+var providerSettlementProtectedOrderStatuses = []string{
+ payment.OrderStatusPending,
+ payment.OrderStatusPaid,
+ payment.OrderStatusRecharging,
+ payment.OrderStatusFailed,
+ payment.OrderStatusCancelled,
+ payment.OrderStatusExpired,
+}
+
// providerSensitiveConfigFields is the authoritative list of config keys that
// are treated as secrets per provider. Must stay in sync with the frontend
// definition at frontend/src/components/payment/providerConfig.ts
@@ -158,14 +170,58 @@ func providerConfigFieldValue(config map[string]string, fieldName string) string
return ""
}
-func (s *PaymentConfigService) countPendingOrders(ctx context.Context, providerInstanceID int64) (int, error) {
- return s.entClient.PaymentOrder.Query().
+// validateEasyPayEndpointSecretBinding prevents an endpoint-only patch from
+// silently reusing the stored merchant key against a newly selected server.
+// The admin must explicitly submit the canonical pkey field whenever apiBase
+// changes; masked or blank values retain the old key and do not qualify.
+func validateEasyPayEndpointSecretBinding(providerKey string, currentConfig, patchConfig, nextConfig map[string]string) error {
+ if providerKey != payment.TypeEasyPay {
+ return nil
+ }
+ currentBase := strings.TrimRight(strings.TrimSpace(providerConfigFieldValue(currentConfig, "apiBase")), "/")
+ nextBase := strings.TrimRight(strings.TrimSpace(providerConfigFieldValue(nextConfig, "apiBase")), "/")
+ if currentBase == nextBase {
+ return nil
+ }
+ if strings.TrimSpace(patchConfig["pkey"]) == "" {
+ return infraerrors.BadRequest(
+ "EASYPAY_PKEY_REENTRY_REQUIRED",
+ "easypay pkey must be re-entered when apiBase changes",
+ )
+ }
+ return nil
+}
+
+func countProviderSettlementProtectedOrders(ctx context.Context, client *dbent.Client, providerInstanceID int64) (int, error) {
+ if client == nil {
+ return 0, fmt.Errorf("provider order protection requires a database client")
+ }
+ return client.PaymentOrder.Query().
Where(
paymentorder.ProviderInstanceIDEQ(strconv.FormatInt(providerInstanceID, 10)),
- paymentorder.StatusIn(pendingOrderStatuses...),
+ paymentorder.StatusIn(providerSettlementProtectedOrderStatuses...),
).Count(ctx)
}
+// lockPaymentProviderMutation serializes admin mutation with order admission.
+// CreateOrder locks the same provider row before inserting its reservation, so
+// the protected-order count and the following mutation form one atomic gate.
+func lockPaymentProviderMutation(ctx context.Context, client *dbent.Client, providerInstanceID int64) error {
+ if client == nil || providerInstanceID <= 0 {
+ return fmt.Errorf("provider mutation requires a valid instance")
+ }
+ query := client.PaymentProviderInstance.Query().Where(
+ paymentproviderinstance.IDEQ(providerInstanceID),
+ )
+ if client.Driver().Dialect() == dialect.Postgres {
+ query = query.ForUpdate()
+ }
+ if _, err := query.OnlyID(ctx); err != nil {
+ return fmt.Errorf("lock payment provider mutation: %w", err)
+ }
+ return nil
+}
+
func (s *PaymentConfigService) countPendingOrdersByPlan(ctx context.Context, planID int64) (int, error) {
return s.entClient.PaymentOrder.Query().
Where(
@@ -183,6 +239,9 @@ func (s *PaymentConfigService) CreateProviderInstance(ctx context.Context, req C
if err := validateProviderRequest(req.ProviderKey, req.Name, typesStr); err != nil {
return nil, err
}
+ if err := validateProviderLimits(req.ProviderKey, typesStr, req.Limits); err != nil {
+ return nil, err
+ }
if err := s.validateVisibleMethodEnablementConflicts(ctx, 0, req.ProviderKey, typesStr, req.Enabled); err != nil {
return nil, err
}
@@ -215,11 +274,27 @@ func validateProviderRequest(providerKey, name, supportedTypes string) error {
return nil
}
+func validateProviderLimits(providerKey, supportedTypes, limits string) error {
+ if err := payment.ValidateInstanceLimits(limits, providerKey, supportedTypes); err != nil {
+ return infraerrors.BadRequest("INVALID_PAYMENT_LIMITS", err.Error())
+ }
+ return nil
+}
+
// UpdateProviderInstance updates a provider instance by ID (patch semantics).
// NOTE: This function exceeds 30 lines due to per-field nil-check patch update
-// boilerplate and pending-order safety checks.
+// boilerplate and settlement-protected order safety checks.
func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id int64, req UpdateProviderInstanceRequest) (*dbent.PaymentProviderInstance, error) {
- current, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("begin provider update: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ client := tx.Client()
+ if err := lockPaymentProviderMutation(ctx, client, id); err != nil {
+ return nil, err
+ }
+ current, err := client.PaymentProviderInstance.Get(ctx, id)
if err != nil {
return nil, fmt.Errorf("load provider instance: %w", err)
}
@@ -228,7 +303,7 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
if pendingOrderCount != nil {
return *pendingOrderCount, nil
}
- count, err := s.countPendingOrders(ctx, id)
+ count, err := countProviderSettlementProtectedOrders(ctx, client, id)
if err != nil {
return 0, fmt.Errorf("check pending orders: %w", err)
}
@@ -243,6 +318,13 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
if req.SupportedTypes != nil {
nextSupportedTypes = joinTypes(req.SupportedTypes)
}
+ nextLimits := current.Limits
+ if req.Limits != nil {
+ nextLimits = *req.Limits
+ }
+ if err := validateProviderLimits(current.ProviderKey, nextSupportedTypes, nextLimits); err != nil {
+ return nil, err
+ }
if err := s.validateVisibleMethodEnablementConflicts(ctx, id, current.ProviderKey, nextSupportedTypes, nextEnabled); err != nil {
return nil, err
}
@@ -252,8 +334,8 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
if err != nil {
return nil, fmt.Errorf("decrypt existing config: %w", err)
}
- mergedConfig, err = s.mergeConfig(ctx, id, req.Config)
- if err != nil {
+ mergedConfig = mergeProviderConfig(current.ProviderKey, currentConfig, req.Config)
+ if err := validateEasyPayEndpointSecretBinding(current.ProviderKey, currentConfig, req.Config, mergedConfig); err != nil {
return nil, err
}
if hasPendingOrderProtectedConfigChange(current.ProviderKey, currentConfig, mergedConfig) {
@@ -262,7 +344,7 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
return nil, err
}
if count > 0 {
- return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has pending orders").
+ return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has settlement-protected orders").
WithMetadata(map[string]string{"count": strconv.Itoa(count)})
}
}
@@ -273,7 +355,7 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
return nil, err
}
if count > 0 {
- return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has pending orders").
+ return nil, infraerrors.Conflict("PENDING_ORDERS", "instance has settlement-protected orders").
WithMetadata(map[string]string{"count": strconv.Itoa(count)})
}
}
@@ -296,7 +378,7 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
return nil, err
}
}
- u := s.entClient.PaymentProviderInstance.UpdateOneID(id)
+ u := client.PaymentProviderInstance.UpdateOneID(id)
if req.Name != nil {
u.SetName(*req.Name)
}
@@ -308,7 +390,7 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
u.SetConfig(enc)
}
if req.SupportedTypes != nil {
- // Check pending orders before removing payment types
+ // Check settlement-protected orders before removing payment types.
count, err := getPendingOrderCount()
if err != nil {
return nil, err
@@ -330,7 +412,7 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
}
}
if !found {
- return nil, infraerrors.Conflict("PENDING_ORDERS", "cannot remove payment types while instance has pending orders").
+ return nil, infraerrors.Conflict("PENDING_ORDERS", "cannot remove payment types while instance has settlement-protected orders").
WithMetadata(map[string]string{"count": strconv.Itoa(count)})
}
}
@@ -372,7 +454,14 @@ func (s *PaymentConfigService) UpdateProviderInstance(ctx context.Context, id in
if req.PaymentMode != nil {
u.SetPaymentMode(*req.PaymentMode)
}
- return u.Save(ctx)
+ updated, err := u.Save(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, fmt.Errorf("commit provider update: %w", err)
+ }
+ return updated.Unwrap(), nil
}
// GetUserRefundEligibleInstanceIDs returns provider instance IDs that allow user refund.
@@ -392,79 +481,128 @@ func (s *PaymentConfigService) GetUserRefundEligibleInstanceIDs(ctx context.Cont
return ids, nil
}
-func (s *PaymentConfigService) mergeConfig(ctx context.Context, id int64, newConfig map[string]string) (map[string]string, error) {
- inst, err := s.entClient.PaymentProviderInstance.Get(ctx, id)
- if err != nil {
- return nil, fmt.Errorf("load existing provider: %w", err)
- }
- existing, err := s.decryptConfig(inst.Config)
- if err != nil {
- return nil, fmt.Errorf("decrypt existing config for instance %d: %w", id, err)
- }
- if existing == nil {
- existing = map[string]string{}
+func mergeProviderConfig(providerKey string, existing, newConfig map[string]string) map[string]string {
+ merged := make(map[string]string, len(existing)+len(newConfig))
+ for key, value := range existing {
+ merged[key] = value
}
for k, v := range newConfig {
// Preserve existing secrets when the client submits an empty value
// (admin UI omits the value to indicate "leave unchanged").
- if v == "" && isSensitiveProviderConfigField(inst.ProviderKey, k) {
+ if v == "" && isSensitiveProviderConfigField(providerKey, k) {
continue
}
- existing[k] = v
+ merged[k] = v
}
- return existing, nil
+ return merged
}
-// decryptConfig parses a stored provider config.
-// New records are plaintext JSON; legacy records are AES-256-GCM ciphertext
-// ("iv:authTag:ciphertext"). Values that cannot be parsed as either — including
-// legacy ciphertext with no/invalid TOTP_ENCRYPTION_KEY — are treated as empty,
-// letting the admin re-enter the config via the UI to complete the migration.
-//
-// TODO(deprecated-legacy-ciphertext): The AES fallback branch is a transitional
-// shim for pre-plaintext records. Remove it (and the encryptionKey field) after
-// a few releases once all live deployments have re-saved their provider configs.
func (s *PaymentConfigService) decryptConfig(stored string) (map[string]string, error) {
- if stored == "" {
- return nil, nil
- }
- var cfg map[string]string
- if err := json.Unmarshal([]byte(stored), &cfg); err == nil {
- return cfg, nil
+ cfg, _, err := payment.DecryptProviderConfig(stored, s.encryptionKey)
+ return cfg, err
+}
+
+// MigrateProviderConfigsToEncrypted rewrites legacy plaintext or shared-root
+// provider configs before the HTTP server starts. The compare-and-swap prevents
+// overwriting a concurrent admin change; any unreadable row fails startup.
+func (s *PaymentConfigService) MigrateProviderConfigsToEncrypted(ctx context.Context, legacyKeys ...[]byte) (int, error) {
+ if s == nil || s.entClient == nil {
+ return 0, fmt.Errorf("payment provider config migration requires a database client")
+ }
+ instances, err := s.entClient.PaymentProviderInstance.Query().All(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("list payment provider configs: %w", err)
}
- // Deprecated: legacy AES-256-GCM ciphertext fallback — scheduled for removal.
- if len(s.encryptionKey) == payment.AES256KeySize {
- //nolint:staticcheck // SA1019: intentional legacy fallback, scheduled for removal
- if plaintext, err := payment.Decrypt(stored, s.encryptionKey); err == nil {
- if err := json.Unmarshal([]byte(plaintext), &cfg); err == nil {
- return cfg, nil
+ migrated := 0
+ for _, instance := range instances {
+ if instance.Config == "" {
+ continue
+ }
+ cfg, needsRewrite, err := decryptProviderConfigForMigration(
+ instance.Config,
+ s.encryptionKey,
+ legacyKeys,
+ )
+ if err != nil {
+ return migrated, fmt.Errorf("provider instance %d config is unreadable: %w", instance.ID, err)
+ }
+ if !needsRewrite {
+ continue
+ }
+ encrypted, err := payment.EncryptProviderConfig(cfg, s.encryptionKey)
+ if err != nil {
+ return migrated, fmt.Errorf("encrypt provider instance %d config: %w", instance.ID, err)
+ }
+ updated, err := s.entClient.PaymentProviderInstance.Update().
+ Where(
+ paymentproviderinstance.IDEQ(instance.ID),
+ paymentproviderinstance.ConfigEQ(instance.Config),
+ ).
+ SetConfig(encrypted).
+ Save(ctx)
+ if err != nil {
+ return migrated, fmt.Errorf("persist encrypted provider instance %d config: %w", instance.ID, err)
+ }
+ if updated != 1 {
+ latest, getErr := s.entClient.PaymentProviderInstance.Get(ctx, instance.ID)
+ if getErr != nil {
+ return migrated, fmt.Errorf("reload concurrently migrated provider instance %d: %w", instance.ID, getErr)
+ }
+ _, stillPlaintext, decryptErr := payment.DecryptProviderConfig(latest.Config, s.encryptionKey)
+ if decryptErr != nil || stillPlaintext {
+ return migrated, fmt.Errorf("provider instance %d changed concurrently but is not valid encrypted config", instance.ID)
}
+ continue
+ }
+ migrated++
+ }
+ return migrated, nil
+}
+
+func decryptProviderConfigForMigration(stored string, currentKey []byte, legacyKeys [][]byte) (map[string]string, bool, error) {
+ cfg, legacyPlaintext, currentErr := payment.DecryptProviderConfig(stored, currentKey)
+ if currentErr == nil {
+ return cfg, legacyPlaintext, nil
+ }
+ for _, legacyKey := range legacyKeys {
+ if len(legacyKey) != payment.AES256KeySize {
+ continue
+ }
+ cfg, _, legacyErr := payment.DecryptProviderConfig(stored, legacyKey)
+ if legacyErr == nil {
+ return cfg, true, nil
}
}
- slog.Warn("payment provider config unreadable, treating as empty for re-entry",
- "stored_len", len(stored))
- return nil, nil
+ return nil, false, currentErr
}
func (s *PaymentConfigService) DeleteProviderInstance(ctx context.Context, id int64) error {
- count, err := s.countPendingOrders(ctx, id)
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return fmt.Errorf("begin provider delete: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ client := tx.Client()
+ if err := lockPaymentProviderMutation(ctx, client, id); err != nil {
+ return err
+ }
+ count, err := countProviderSettlementProtectedOrders(ctx, client, id)
if err != nil {
return fmt.Errorf("check pending orders: %w", err)
}
if count > 0 {
return infraerrors.Conflict("PENDING_ORDERS",
- fmt.Sprintf("this instance has %d in-progress orders and cannot be deleted — wait for orders to complete or disable the instance first", count))
+ fmt.Sprintf("this instance has %d settlement-protected orders and cannot be deleted", count))
+ }
+ if err := client.PaymentProviderInstance.DeleteOneID(id).Exec(ctx); err != nil {
+ return err
}
- return s.entClient.PaymentProviderInstance.DeleteOneID(id).Exec(ctx)
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit provider delete: %w", err)
+ }
+ return nil
}
-// encryptConfig serialises a provider config for storage.
-// New records are written as plaintext JSON; the historical AES-GCM wrapping
-// has been dropped but decryptConfig still accepts old ciphertext during migration.
func (s *PaymentConfigService) encryptConfig(cfg map[string]string) (string, error) {
- data, err := json.Marshal(cfg)
- if err != nil {
- return "", fmt.Errorf("marshal config: %w", err)
- }
- return string(data), nil
+ return payment.EncryptProviderConfig(cfg, s.encryptionKey)
}
diff --git a/backend/internal/service/payment_config_providers_test.go b/backend/internal/service/payment_config_providers_test.go
index e0d2908a710..a372c1dbe17 100644
--- a/backend/internal/service/payment_config_providers_test.go
+++ b/backend/internal/service/payment_config_providers_test.go
@@ -7,8 +7,10 @@ import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
+ "encoding/json"
"encoding/pem"
"strconv"
+ "strings"
"testing"
"time"
@@ -19,6 +21,111 @@ import (
"github.com/stretchr/testify/require"
)
+func TestPaymentProviderConfigsPersistEncryptedAndMigrateLegacyPlaintext(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ key := []byte("0123456789abcdef0123456789abcdef")
+ svc := &PaymentConfigService{entClient: client, encryptionKey: key}
+
+ created, err := svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{
+ ProviderKey: payment.TypeStripe,
+ Name: "encrypted-stripe",
+ Config: map[string]string{"secretKey": "sk-live-do-not-store-plain", "publishableKey": "pk-live"},
+ SupportedTypes: []string{payment.TypeStripe},
+ Enabled: false,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, created)
+ saved, err := client.PaymentProviderInstance.Get(ctx, created.ID)
+ require.NoError(t, err)
+ require.False(t, json.Valid([]byte(saved.Config)))
+ require.NotContains(t, saved.Config, "sk-live-do-not-store-plain")
+ decoded, err := svc.decryptConfig(saved.Config)
+ require.NoError(t, err)
+ require.Equal(t, "sk-live-do-not-store-plain", decoded["secretKey"])
+
+ legacySecret := "legacy-provider-secret"
+ legacy, err := client.PaymentProviderInstance.Create().
+ SetProviderKey(payment.TypeEasyPay).
+ SetName("legacy-plaintext").
+ SetConfig(`{"pid":"legacy","pkey":"` + legacySecret + `"}`).
+ SetSupportedTypes(payment.TypeAlipay).
+ Save(ctx)
+ require.NoError(t, err)
+
+ migrated, err := svc.MigrateProviderConfigsToEncrypted(ctx)
+ require.NoError(t, err)
+ require.Equal(t, 1, migrated)
+ migratedSaved, err := client.PaymentProviderInstance.Get(ctx, legacy.ID)
+ require.NoError(t, err)
+ require.False(t, json.Valid([]byte(migratedSaved.Config)))
+ require.False(t, strings.Contains(migratedSaved.Config, legacySecret))
+ decoded, err = svc.decryptConfig(migratedSaved.Config)
+ require.NoError(t, err)
+ require.Equal(t, legacySecret, decoded["pkey"])
+
+ migrated, err = svc.MigrateProviderConfigsToEncrypted(ctx)
+ require.NoError(t, err)
+ require.Zero(t, migrated)
+}
+
+func TestPaymentProviderConfigMigrationFailsClosed(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ _, err := client.PaymentProviderInstance.Create().
+ SetProviderKey(payment.TypeEasyPay).
+ SetName("corrupted-config").
+ SetConfig("not-json-or-valid-ciphertext").
+ Save(ctx)
+ require.NoError(t, err)
+
+ svc := &PaymentConfigService{
+ entClient: client,
+ encryptionKey: []byte("0123456789abcdef0123456789abcdef"),
+ }
+ _, err = svc.MigrateProviderConfigsToEncrypted(ctx)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "provider instance")
+}
+
+func TestPaymentProviderConfigMigrationRotatesSharedRootCiphertext(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ legacyKey := []byte("legacy-shared-root-32-byte-key!!")
+ currentKey := []byte("current-payment-root-32-byte-key")
+ legacyCiphertext, err := payment.EncryptProviderConfig(
+ map[string]string{"secretKey": "legacy-provider-secret"},
+ legacyKey,
+ )
+ require.NoError(t, err)
+ instance, err := client.PaymentProviderInstance.Create().
+ SetProviderKey(payment.TypeStripe).
+ SetName("legacy-shared-root").
+ SetConfig(legacyCiphertext).
+ Save(ctx)
+ require.NoError(t, err)
+
+ svc := &PaymentConfigService{entClient: client, encryptionKey: currentKey}
+ _, err = svc.MigrateProviderConfigsToEncrypted(ctx)
+ require.Error(t, err)
+ migrated, err := svc.MigrateProviderConfigsToEncrypted(ctx, legacyKey)
+ require.NoError(t, err)
+ require.Equal(t, 1, migrated)
+ refreshed, err := client.PaymentProviderInstance.Get(ctx, instance.ID)
+ require.NoError(t, err)
+ decoded, _, err := payment.DecryptProviderConfig(refreshed.Config, currentKey)
+ require.NoError(t, err)
+ require.Equal(t, "legacy-provider-secret", decoded["secretKey"])
+ _, _, err = payment.DecryptProviderConfig(refreshed.Config, legacyKey)
+ require.Error(t, err)
+}
+
func TestValidateProviderRequest(t *testing.T) {
t.Parallel()
@@ -242,6 +349,43 @@ func TestCreateProviderInstanceAllowsVisibleMethodProvidersFromDifferentSources(
require.NoError(t, err)
}
+func TestCreateProviderInstanceRejectsMalformedOrUnsafeLimits(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ limits string
+ }{
+ {name: "malformed json", limits: `{not-json}`},
+ {name: "negative limit", limits: `{"alipay":{"singleMax":-1}}`},
+ {name: "inverted range", limits: `{"alipay":{"singleMin":100,"singleMax":10}}`},
+ {name: "unknown field", limits: `{"alipay":{"singleMaximum":100}}`},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ svc := &PaymentConfigService{
+ entClient: client,
+ encryptionKey: []byte("0123456789abcdef0123456789abcdef"),
+ }
+
+ created, err := svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{
+ ProviderKey: payment.TypeEasyPay,
+ Name: "invalid-limits",
+ Config: validEasyPayProviderConfig(t),
+ SupportedTypes: []string{payment.TypeAlipay},
+ Enabled: true,
+ Limits: tt.limits,
+ })
+ require.Nil(t, created)
+ require.Equal(t, "INVALID_PAYMENT_LIMITS", infraerrors.Reason(err))
+ })
+ }
+}
+
func TestUpdateProviderInstanceAllowsEnablingVisibleMethodProviderFromDifferentSource(t *testing.T) {
t.Parallel()
@@ -506,6 +650,52 @@ func TestUpdateProviderInstanceAllowsSafeConfigChangesWhilePendingOrders(t *test
}
}
+func TestUpdateEasyPayAPIBaseRequiresExplicitPKeyReentry(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ svc := &PaymentConfigService{
+ entClient: client,
+ encryptionKey: []byte("0123456789abcdef0123456789abcdef"),
+ }
+ instance, err := svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{
+ ProviderKey: payment.TypeEasyPay,
+ Name: "bound-easypay",
+ Config: validEasyPayProviderConfig(t),
+ SupportedTypes: []string{payment.TypeAlipay},
+ Enabled: true,
+ })
+ require.NoError(t, err)
+
+ updated, err := svc.UpdateProviderInstance(ctx, instance.ID, UpdateProviderInstanceRequest{
+ Config: map[string]string{"apiBase": "https://attacker.example.com"},
+ })
+ require.Nil(t, updated)
+ require.Equal(t, "EASYPAY_PKEY_REENTRY_REQUIRED", infraerrors.Reason(err))
+ saved, err := client.PaymentProviderInstance.Get(ctx, instance.ID)
+ require.NoError(t, err)
+ savedConfig, err := svc.decryptConfig(saved.Config)
+ require.NoError(t, err)
+ require.Equal(t, "https://pay.example.com", savedConfig["apiBase"])
+ require.Equal(t, "pkey-test", savedConfig["pkey"])
+
+ updated, err = svc.UpdateProviderInstance(ctx, instance.ID, UpdateProviderInstanceRequest{
+ Config: map[string]string{
+ "apiBase": "https://payments-v2.example.com",
+ "pkey": "pkey-v2",
+ },
+ })
+ require.NoError(t, err)
+ require.NotNil(t, updated)
+ saved, err = client.PaymentProviderInstance.Get(ctx, instance.ID)
+ require.NoError(t, err)
+ savedConfig, err = svc.decryptConfig(saved.Config)
+ require.NoError(t, err)
+ require.Equal(t, "https://payments-v2.example.com", savedConfig["apiBase"])
+ require.Equal(t, "pkey-v2", savedConfig["pkey"])
+}
+
func createPendingProviderConfigOrder(t *testing.T, ctx context.Context, client *dbent.Client, instance *dbent.PaymentProviderInstance) {
t.Helper()
diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go
index 02d061aeeaa..a8b42d9a4e5 100644
--- a/backend/internal/service/payment_config_service.go
+++ b/backend/internal/service/payment_config_service.go
@@ -142,31 +142,39 @@ type UpdateProviderInstanceRequest struct {
AllowUserRefund *bool `json:"allow_user_refund"`
}
type CreatePlanRequest struct {
- GroupID int64 `json:"group_id"`
- Name string `json:"name"`
- Description string `json:"description"`
- Price float64 `json:"price"`
- OriginalPrice *float64 `json:"original_price"`
- ValidityDays int `json:"validity_days"`
- ValidityUnit string `json:"validity_unit"`
- Features string `json:"features"`
- ProductName string `json:"product_name"`
- ForSale bool `json:"for_sale"`
- SortOrder int `json:"sort_order"`
+ GroupID *int64 `json:"group_id"`
+ PlanGroupIDs []int64 `json:"plan_group_ids"`
+ WalletQuotaUSD *float64 `json:"wallet_quota_usd"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Price float64 `json:"price"`
+ OriginalPrice *float64 `json:"original_price"`
+ ValidityDays int `json:"validity_days"`
+ ValidityUnit string `json:"validity_unit"`
+ Features string `json:"features"`
+ ProductName string `json:"product_name"`
+ ForSale bool `json:"for_sale"`
+ SortOrder int `json:"sort_order"`
+ // PlanType 区分月卡 / 额度卡。空串默认 "subscription"。
+ // 取值:subscription | credits。见 docs/plans/2026-05-13-wallet-multikey-credits-design.md §2.2。
+ PlanType string `json:"plan_type"`
}
type UpdatePlanRequest struct {
- GroupID *int64 `json:"group_id"`
- Name *string `json:"name"`
- Description *string `json:"description"`
- Price *float64 `json:"price"`
- OriginalPrice *float64 `json:"original_price"`
- ValidityDays *int `json:"validity_days"`
- ValidityUnit *string `json:"validity_unit"`
- Features *string `json:"features"`
- ProductName *string `json:"product_name"`
- ForSale *bool `json:"for_sale"`
- SortOrder *int `json:"sort_order"`
+ GroupID *int64 `json:"group_id"`
+ PlanGroupIDs *[]int64 `json:"plan_group_ids"`
+ WalletQuotaUSD *float64 `json:"wallet_quota_usd"`
+ Name *string `json:"name"`
+ Description *string `json:"description"`
+ Price *float64 `json:"price"`
+ OriginalPrice *float64 `json:"original_price"`
+ ValidityDays *int `json:"validity_days"`
+ ValidityUnit *string `json:"validity_unit"`
+ Features *string `json:"features"`
+ ProductName *string `json:"product_name"`
+ ForSale *bool `json:"for_sale"`
+ SortOrder *int `json:"sort_order"`
+ PlanType *string `json:"plan_type"`
}
// PaymentConfigService manages payment configuration and CRUD for
@@ -278,52 +286,80 @@ func (s *PaymentConfigService) getStripePublishableKey(ctx context.Context) stri
// nil-check before serialisation — this is inherent to patch-style update patterns
// and cannot be meaningfully decomposed without introducing unnecessary abstraction.
func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req UpdatePaymentConfigRequest) error {
+ m, err := s.BuildPaymentConfigUpdates(req)
+ if err != nil {
+ return err
+ }
+ return s.settingRepo.SetMultiple(ctx, m)
+}
+
+// BuildPaymentConfigUpdates validates and serializes a payment-config patch
+// without persisting it, so callers can include it in a larger atomic settings write.
+func (s *PaymentConfigService) BuildPaymentConfigUpdates(req UpdatePaymentConfigRequest) (map[string]string, error) {
if req.BalanceRechargeMultiplier != nil {
if math.IsNaN(*req.BalanceRechargeMultiplier) || math.IsInf(*req.BalanceRechargeMultiplier, 0) || *req.BalanceRechargeMultiplier <= 0 {
- return infraerrors.BadRequest("INVALID_BALANCE_RECHARGE_MULTIPLIER", "balance recharge multiplier must be greater than 0")
+ return nil, infraerrors.BadRequest("INVALID_BALANCE_RECHARGE_MULTIPLIER", "balance recharge multiplier must be greater than 0")
}
}
if req.RechargeFeeRate != nil {
v := *req.RechargeFeeRate
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > 100 {
- return infraerrors.BadRequest("INVALID_RECHARGE_FEE_RATE", "recharge fee rate must be between 0 and 100")
+ return nil, infraerrors.BadRequest("INVALID_RECHARGE_FEE_RATE", "recharge fee rate must be between 0 and 100")
}
// Enforce max 2 decimal places
if math.Round(v*100) != v*100 {
- return infraerrors.BadRequest("INVALID_RECHARGE_FEE_RATE", "recharge fee rate allows at most 2 decimal places")
+ return nil, infraerrors.BadRequest("INVALID_RECHARGE_FEE_RATE", "recharge fee rate allows at most 2 decimal places")
+ }
+ }
+ m := make(map[string]string)
+ putBool := func(key string, value *bool) {
+ if value != nil {
+ m[key] = formatBoolOrEmpty(value)
}
}
- m := map[string]string{
- SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled),
- SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount),
- SettingMaxRechargeAmount: formatPositiveFloat(req.MaxAmount),
- SettingDailyRechargeLimit: formatPositiveFloat(req.DailyLimit),
- SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin),
- SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders),
- SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled),
- SettingBalanceRechargeMult: formatPositiveFloat(req.BalanceRechargeMultiplier),
- SettingRechargeFeeRate: formatNonNegativeFloat(req.RechargeFeeRate),
- SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy),
- SettingProductNamePrefix: derefStr(req.ProductNamePrefix),
- SettingProductNameSuffix: derefStr(req.ProductNameSuffix),
- SettingHelpImageURL: derefStr(req.HelpImageURL),
- SettingHelpText: derefStr(req.HelpText),
- SettingCancelRateLimitOn: formatBoolOrEmpty(req.CancelRateLimitEnabled),
- SettingCancelRateLimitMax: formatPositiveInt(req.CancelRateLimitMax),
- SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow),
- SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit),
- SettingCancelWindowMode: derefStr(req.CancelRateLimitMode),
- SettingPaymentVisibleMethodAlipaySource: derefStr(req.VisibleMethodAlipaySource),
- SettingPaymentVisibleMethodWxpaySource: derefStr(req.VisibleMethodWxpaySource),
- SettingPaymentVisibleMethodAlipayEnabled: formatBoolOrEmpty(req.VisibleMethodAlipayEnabled),
- SettingPaymentVisibleMethodWxpayEnabled: formatBoolOrEmpty(req.VisibleMethodWxpayEnabled),
+ putFloat := func(key string, value *float64, format func(*float64) string) {
+ if value != nil {
+ m[key] = format(value)
+ }
+ }
+ putInt := func(key string, value *int) {
+ if value != nil {
+ m[key] = formatPositiveInt(value)
+ }
}
+ putString := func(key string, value *string) {
+ if value != nil {
+ m[key] = derefStr(value)
+ }
+ }
+
+ putBool(SettingPaymentEnabled, req.Enabled)
+ putFloat(SettingMinRechargeAmount, req.MinAmount, formatPositiveFloat)
+ putFloat(SettingMaxRechargeAmount, req.MaxAmount, formatPositiveFloat)
+ putFloat(SettingDailyRechargeLimit, req.DailyLimit, formatPositiveFloat)
+ putInt(SettingOrderTimeoutMinutes, req.OrderTimeoutMin)
+ putInt(SettingMaxPendingOrders, req.MaxPendingOrders)
+ putBool(SettingBalancePayDisabled, req.BalanceDisabled)
+ putFloat(SettingBalanceRechargeMult, req.BalanceRechargeMultiplier, formatPositiveFloat)
+ putFloat(SettingRechargeFeeRate, req.RechargeFeeRate, formatNonNegativeFloat)
+ putString(SettingLoadBalanceStrategy, req.LoadBalanceStrategy)
+ putString(SettingProductNamePrefix, req.ProductNamePrefix)
+ putString(SettingProductNameSuffix, req.ProductNameSuffix)
+ putString(SettingHelpImageURL, req.HelpImageURL)
+ putString(SettingHelpText, req.HelpText)
+ putBool(SettingCancelRateLimitOn, req.CancelRateLimitEnabled)
+ putInt(SettingCancelRateLimitMax, req.CancelRateLimitMax)
+ putInt(SettingCancelWindowSize, req.CancelRateLimitWindow)
+ putString(SettingCancelWindowUnit, req.CancelRateLimitUnit)
+ putString(SettingCancelWindowMode, req.CancelRateLimitMode)
+ putString(SettingPaymentVisibleMethodAlipaySource, req.VisibleMethodAlipaySource)
+ putString(SettingPaymentVisibleMethodWxpaySource, req.VisibleMethodWxpaySource)
+ putBool(SettingPaymentVisibleMethodAlipayEnabled, req.VisibleMethodAlipayEnabled)
+ putBool(SettingPaymentVisibleMethodWxpayEnabled, req.VisibleMethodWxpayEnabled)
if req.EnabledTypes != nil {
m[SettingEnabledPaymentTypes] = strings.Join(req.EnabledTypes, ",")
- } else {
- m[SettingEnabledPaymentTypes] = ""
}
- return s.settingRepo.SetMultiple(ctx, m)
+ return m, nil
}
func formatBoolOrEmpty(v *bool) string {
diff --git a/backend/internal/service/payment_config_service_test.go b/backend/internal/service/payment_config_service_test.go
index f04f4697b1d..fdbbd09eb4f 100644
--- a/backend/internal/service/payment_config_service_test.go
+++ b/backend/internal/service/payment_config_service_test.go
@@ -432,6 +432,60 @@ func TestUpdatePaymentConfig_PersistsVisibleMethodRouting(t *testing.T) {
}
}
+func TestUpdatePaymentConfig_PartialPatchPreservesUnspecifiedControls(t *testing.T) {
+ maxAmount := "900.00"
+ dailyLimit := "2500.00"
+ repo := &paymentConfigSettingRepoStub{values: map[string]string{
+ SettingMaxRechargeAmount: maxAmount,
+ SettingDailyRechargeLimit: dailyLimit,
+ }}
+ svc := &PaymentConfigService{settingRepo: repo}
+ helpText := "updated help text"
+
+ err := svc.UpdatePaymentConfig(context.Background(), UpdatePaymentConfigRequest{
+ HelpText: &helpText,
+ })
+ if err != nil {
+ t.Fatalf("UpdatePaymentConfig returned error: %v", err)
+ }
+
+ if got := repo.values[SettingMaxRechargeAmount]; got != maxAmount {
+ t.Fatalf("max amount changed from %q to %q during unrelated patch", maxAmount, got)
+ }
+ if got := repo.values[SettingDailyRechargeLimit]; got != dailyLimit {
+ t.Fatalf("daily limit changed from %q to %q during unrelated patch", dailyLimit, got)
+ }
+ if len(repo.updates) != 1 || repo.updates[SettingHelpText] != helpText {
+ t.Fatalf("partial patch persisted unexpected keys: %#v", repo.updates)
+ }
+}
+
+func TestBuildPaymentConfigUpdates_DistinguishesOmittedFromExplicitClear(t *testing.T) {
+ svc := &PaymentConfigService{}
+ zero := 0.0
+ emptyTypes := []string{}
+
+ updates, err := svc.BuildPaymentConfigUpdates(UpdatePaymentConfigRequest{
+ MaxAmount: &zero,
+ EnabledTypes: emptyTypes,
+ })
+ if err != nil {
+ t.Fatalf("BuildPaymentConfigUpdates returned error: %v", err)
+ }
+ if len(updates) != 2 {
+ t.Fatalf("updates = %#v, want only two explicitly supplied fields", updates)
+ }
+ if value, ok := updates[SettingMaxRechargeAmount]; !ok || value != "" {
+ t.Fatalf("explicit max amount clear = %q, present=%v", value, ok)
+ }
+ if value, ok := updates[SettingEnabledPaymentTypes]; !ok || value != "" {
+ t.Fatalf("explicit enabled-types clear = %q, present=%v", value, ok)
+ }
+ if _, ok := updates[SettingDailyRechargeLimit]; ok {
+ t.Fatal("omitted daily limit must not be serialized")
+ }
+}
+
func paymentConfigStrPtr(value string) *string {
return &value
}
diff --git a/backend/internal/service/payment_control_security_test.go b/backend/internal/service/payment_control_security_test.go
new file mode 100644
index 00000000000..341c66ef21d
--- /dev/null
+++ b/backend/internal/service/payment_control_security_test.go
@@ -0,0 +1,213 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "entgo.io/ent/dialect"
+ entsql "entgo.io/ent/dialect/sql"
+ sqlmock "github.com/DATA-DOG/go-sqlmock"
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateCreateOrderPaymentMethodEnabled(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ enabledTypes []string
+ method string
+ settings map[string]string
+ wantReason string
+ }{
+ {
+ name: "rejects method omitted from enabled types",
+ enabledTypes: []string{payment.TypeStripe},
+ method: payment.TypeAlipay,
+ settings: map[string]string{SettingPaymentVisibleMethodAlipayEnabled: "true"},
+ wantReason: "PAYMENT_METHOD_DISABLED",
+ },
+ {
+ name: "rejects disabled visible method despite enabled type",
+ enabledTypes: []string{payment.TypeAlipay},
+ method: payment.TypeAlipay,
+ settings: map[string]string{SettingPaymentVisibleMethodAlipayEnabled: "false"},
+ wantReason: "PAYMENT_METHOD_DISABLED",
+ },
+ {
+ name: "allows enabled visible method",
+ enabledTypes: []string{payment.TypeAlipay},
+ method: payment.TypeAlipay,
+ settings: map[string]string{SettingPaymentVisibleMethodAlipayEnabled: "true"},
+ },
+ {
+ name: "allows enabled non-visible method",
+ enabledTypes: []string{payment.TypeStripe},
+ method: payment.TypeStripe,
+ settings: map[string]string{},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ svc := &PaymentConfigService{settingRepo: &paymentConfigSettingRepoStub{values: tt.settings}}
+ err := svc.validateCreateOrderPaymentMethodEnabled(
+ context.Background(),
+ &PaymentConfig{EnabledTypes: tt.enabledTypes},
+ tt.method,
+ )
+ if tt.wantReason == "" {
+ require.NoError(t, err)
+ return
+ }
+ require.Equal(t, tt.wantReason, infraerrors.Reason(err))
+ })
+ }
+}
+
+func TestCreateOrderEnforcesServerSideMethodPolicyBeforeProviderSelection(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ enabledTypes string
+ visibleFlag string
+ }{
+ {name: "method omitted from global allowlist", enabledTypes: payment.TypeStripe, visibleFlag: "true"},
+ {name: "visible method disabled", enabledTypes: payment.TypeAlipay, visibleFlag: "false"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ configService := &PaymentConfigService{settingRepo: &paymentConfigSettingRepoStub{values: map[string]string{
+ SettingPaymentEnabled: "true",
+ SettingEnabledPaymentTypes: tt.enabledTypes,
+ SettingPaymentVisibleMethodAlipayEnabled: tt.visibleFlag,
+ }}}
+ response, err := (&PaymentService{configService: configService}).CreateOrder(
+ context.Background(),
+ CreateOrderRequest{PaymentType: payment.TypeAlipay, Amount: 10},
+ )
+ require.Nil(t, response)
+ require.Equal(t, "PAYMENT_METHOD_DISABLED", infraerrors.Reason(err))
+ })
+ }
+}
+
+func TestProviderAdminProtectsLateRecoverableOrderStatuses(t *testing.T) {
+ t.Parallel()
+
+ for _, status := range []string{OrderStatusCancelled, OrderStatusExpired, OrderStatusFailed} {
+ status := status
+ t.Run(status, func(t *testing.T) {
+ t.Parallel()
+
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ svc := &PaymentConfigService{
+ entClient: client,
+ encryptionKey: []byte("0123456789abcdef0123456789abcdef"),
+ }
+ instance, err := svc.CreateProviderInstance(ctx, CreateProviderInstanceRequest{
+ ProviderKey: payment.TypeEasyPay,
+ Name: "late-recovery-" + strings.ToLower(status),
+ Config: validEasyPayProviderConfig(t),
+ SupportedTypes: []string{payment.TypeAlipay},
+ Enabled: true,
+ })
+ require.NoError(t, err)
+ createProviderControlOrder(t, ctx, client, instance, status)
+
+ updated, err := svc.UpdateProviderInstance(ctx, instance.ID, UpdateProviderInstanceRequest{
+ Config: map[string]string{"pid": "rotated-merchant"},
+ })
+ require.Nil(t, updated)
+ require.Equal(t, "PENDING_ORDERS", infraerrors.Reason(err))
+
+ updated, err = svc.UpdateProviderInstance(ctx, instance.ID, UpdateProviderInstanceRequest{
+ Enabled: boolPtrValue(false),
+ })
+ require.Nil(t, updated)
+ require.Equal(t, "PENDING_ORDERS", infraerrors.Reason(err))
+
+ err = svc.DeleteProviderInstance(ctx, instance.ID)
+ require.Equal(t, "PENDING_ORDERS", infraerrors.Reason(err))
+ })
+ }
+}
+
+func TestLockPaymentProviderMutationUsesPostgresRowLock(t *testing.T) {
+ t.Parallel()
+
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ driver := entsql.OpenDB(dialect.Postgres, db)
+ client := dbent.NewClient(dbent.Driver(driver))
+ t.Cleanup(func() { _ = client.Close() })
+
+ mock.ExpectQuery(`SELECT .* FROM "payment_provider_instances" WHERE "payment_provider_instances"\."id" = \$1 LIMIT 2 FOR UPDATE`).
+ WithArgs(int64(17)).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17)))
+
+ require.NoError(t, lockPaymentProviderMutation(context.Background(), client, 17))
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestGetDashboardStatsRejectsUnboundedDaysBeforeDatabaseAccess(t *testing.T) {
+ t.Parallel()
+
+ stats, err := (&PaymentService{}).GetDashboardStats(context.Background(), MaxPaymentDashboardDays+1)
+ require.Nil(t, stats)
+ require.Equal(t, "INVALID_DASHBOARD_DAYS", infraerrors.Reason(err))
+}
+
+func createProviderControlOrder(
+ t *testing.T,
+ ctx context.Context,
+ client *dbent.Client,
+ instance *dbent.PaymentProviderInstance,
+ status string,
+) {
+ t.Helper()
+
+ suffix := strings.ToLower(status)
+ user, err := client.User.Create().
+ SetEmail("provider-control-" + suffix + "@example.com").
+ SetPasswordHash("hash").
+ SetUsername("provider-control-" + suffix).
+ Save(ctx)
+ require.NoError(t, err)
+
+ instanceID := strconv.FormatInt(instance.ID, 10)
+ _, err = client.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(88).
+ SetPayAmount(88).
+ SetFeeRate(0).
+ SetRechargeCode("PROVIDER-CONTROL-" + status).
+ SetOutTradeNo("provider-control-" + suffix + "-" + instanceID).
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("").
+ SetOrderType(payment.OrderTypeBalance).
+ SetStatus(status).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ SetProviderInstanceID(instanceID).
+ SetProviderKey(instance.ProviderKey).
+ Save(ctx)
+ require.NoError(t, err)
+}
diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go
index 5df69aeae4a..b276db4c338 100644
--- a/backend/internal/service/payment_fulfillment.go
+++ b/backend/internal/service/payment_fulfillment.go
@@ -105,6 +105,16 @@ func (s *PaymentService) confirmPayment(ctx context.Context, oid int64, tradeNo
s.writeAuditLog(ctx, o.ID, "PAYMENT_AMOUNT_MISMATCH", pk, map[string]any{"expected": o.PayAmount, "paid": paid, "tradeNo": tradeNo})
return fmt.Errorf("amount mismatch: expected %.2f, got %.2f", o.PayAmount, paid)
}
+ tradeNo = strings.TrimSpace(tradeNo)
+ if tradeNo == "" {
+ tradeNo = strings.TrimSpace(o.PaymentTradeNo)
+ }
+ if tradeNo == "" {
+ s.writeAuditLog(ctx, o.ID, "PAYMENT_TRADE_EVIDENCE_MISSING", pk, map[string]any{
+ "paidAmount": paid,
+ })
+ return infraerrors.BadRequest("PAYMENT_TRADE_EVIDENCE_MISSING", "successful payment is missing provider trade evidence")
+ }
return s.toPaid(ctx, o, tradeNo, paid, pk)
}
@@ -134,16 +144,13 @@ func expectedNotificationProviderKey(registry *payment.Registry, orderPaymentTyp
func (s *PaymentService) toPaid(ctx context.Context, o *dbent.PaymentOrder, tradeNo string, paid float64, pk string) error {
previousStatus := o.Status
now := time.Now()
- grace := now.Add(-paymentGraceMinutes * time.Minute)
c, err := s.entClient.PaymentOrder.Update().Where(
paymentorder.IDEQ(o.ID),
paymentorder.Or(
paymentorder.StatusEQ(OrderStatusPending),
+ paymentorder.StatusEQ(OrderStatusFailed),
paymentorder.StatusEQ(OrderStatusCancelled),
- paymentorder.And(
- paymentorder.StatusEQ(OrderStatusExpired),
- paymentorder.UpdatedAtGTE(grace),
- ),
+ paymentorder.StatusEQ(OrderStatusExpired),
),
).SetStatus(OrderStatusPaid).SetPayAmount(paid).SetPaymentTradeNo(tradeNo).SetPaidAt(now).ClearFailedAt().ClearFailedReason().Save(ctx)
if err != nil {
@@ -167,7 +174,7 @@ func (s *PaymentService) toPaid(ctx context.Context, o *dbent.PaymentOrder, trad
})
}
s.writeAuditLog(ctx, o.ID, "ORDER_PAID", pk, map[string]any{"tradeNo": tradeNo, "paidAmount": paid})
- return s.executeFulfillment(ctx, o.ID)
+ return s.executeFulfillmentFromNotification(ctx, o.ID)
}
func (s *PaymentService) alreadyProcessed(ctx context.Context, o *dbent.PaymentOrder) error {
@@ -179,26 +186,48 @@ func (s *PaymentService) alreadyProcessed(ctx context.Context, o *dbent.PaymentO
case OrderStatusCompleted, OrderStatusRefunded:
return nil
case OrderStatusFailed:
- return s.executeFulfillment(ctx, o.ID)
- case OrderStatusPaid, OrderStatusRecharging:
- return fmt.Errorf("order %d is being processed", o.ID)
+ if !paymentOrderHasPaidEvidence(cur) {
+ return paymentNotConfirmedError()
+ }
+ return s.executeFulfillmentFromNotification(ctx, o.ID)
+ case OrderStatusPaid:
+ return s.executeFulfillmentFromNotification(ctx, o.ID)
+ case OrderStatusRecharging:
+ if !isFulfillmentLeaseStale(cur.UpdatedAt, time.Now()) {
+ // A duplicate successful webhook has already been accepted. Returning
+ // nil prevents the provider from retrying while the active worker owns
+ // the lease.
+ return nil
+ }
+ if err := s.executeFulfillmentFromNotification(ctx, o.ID); err != nil {
+ return err
+ }
+ return nil
case OrderStatusExpired:
- slog.Warn("webhook payment success for expired order beyond grace period",
+ slog.Error("expired paid order recovery lost a concurrent state transition",
"orderID", o.ID,
"status", cur.Status,
"updatedAt", cur.UpdatedAt,
)
- s.writeAuditLog(ctx, o.ID, "PAYMENT_AFTER_EXPIRY", "system", map[string]any{
+ s.writeAuditLog(ctx, o.ID, "PAYMENT_RECOVERY_CONFLICT", "system", map[string]any{
"status": cur.Status,
"updatedAt": cur.UpdatedAt,
- "reason": "payment arrived after expiry grace period",
+ "reason": "trusted paid evidence could not transition expired order",
})
- return nil
+ return infraerrors.Conflict("PAYMENT_RECOVERY_CONFLICT", "paid order recovery conflicted with a concurrent state change")
default:
return nil
}
}
+func (s *PaymentService) executeFulfillmentFromNotification(ctx context.Context, orderID int64) error {
+ err := s.executeFulfillment(ctx, orderID)
+ if infraerrors.Reason(err) == "FULFILLMENT_IN_PROGRESS" {
+ return nil
+ }
+ return err
+}
+
func (s *PaymentService) executeFulfillment(ctx context.Context, oid int64) error {
o, err := s.entClient.PaymentOrder.Get(ctx, oid)
if err != nil {
@@ -221,18 +250,21 @@ func (s *PaymentService) ExecuteBalanceFulfillment(ctx context.Context, oid int6
if psIsRefundStatus(o.Status) {
return infraerrors.BadRequest("INVALID_STATUS", "refund-related order cannot fulfill")
}
- if o.Status != OrderStatusPaid && o.Status != OrderStatusFailed {
+ if !paymentOrderHasPaidEvidence(o) {
+ return paymentNotConfirmedError()
+ }
+ if o.Status != OrderStatusPaid && o.Status != OrderStatusFailed && o.Status != OrderStatusRecharging {
return infraerrors.BadRequest("INVALID_STATUS", "order cannot fulfill in status "+o.Status)
}
- c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(oid), paymentorder.StatusIn(OrderStatusPaid, OrderStatusFailed)).SetStatus(OrderStatusRecharging).Save(ctx)
+ claimed, err := s.claimFulfillmentLease(ctx, o)
if err != nil {
- return fmt.Errorf("lock: %w", err)
+ return err
}
- if c == 0 {
+ if !claimed {
return nil
}
if err := s.doBalance(ctx, o); err != nil {
- s.markFailed(ctx, oid, err)
+ s.markFailed(ctx, o, err)
return err
}
return nil
@@ -282,7 +314,7 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e
case redeemActionRedeem:
// Code exists but unused — skip creation, proceed to redeem
}
- if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil {
+ if _, err := s.redeemService.Redeem(ContextSkipRedeemAffiliate(ctx), o.UserID, o.RechargeCode); err != nil {
return fmt.Errorf("redeem balance: %w", err)
}
if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil {
@@ -293,10 +325,17 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e
func (s *PaymentService) markCompleted(ctx context.Context, o *dbent.PaymentOrder, auditAction string) error {
now := time.Now()
- _, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(o.ID), paymentorder.StatusEQ(OrderStatusRecharging)).SetStatus(OrderStatusCompleted).SetCompletedAt(now).Save(ctx)
+ updated, err := s.entClient.PaymentOrder.Update().Where(
+ paymentorder.IDEQ(o.ID),
+ paymentorder.StatusEQ(OrderStatusRecharging),
+ paymentorder.UpdatedAtEQ(o.UpdatedAt),
+ ).SetStatus(OrderStatusCompleted).SetCompletedAt(now).Save(ctx)
if err != nil {
return fmt.Errorf("mark completed: %w", err)
}
+ if updated != 1 {
+ return fmt.Errorf("mark completed: status changed concurrently")
+ }
s.writeAuditLog(ctx, o.ID, auditAction, "system", map[string]any{
"rechargeCode": o.RechargeCode,
"creditedAmount": o.Amount,
@@ -316,53 +355,226 @@ func (s *PaymentService) ExecuteSubscriptionFulfillment(ctx context.Context, oid
if psIsRefundStatus(o.Status) {
return infraerrors.BadRequest("INVALID_STATUS", "refund-related order cannot fulfill")
}
- if o.Status != OrderStatusPaid && o.Status != OrderStatusFailed {
+ if !paymentOrderHasPaidEvidence(o) {
+ return paymentNotConfirmedError()
+ }
+ if o.Status != OrderStatusPaid && o.Status != OrderStatusFailed && o.Status != OrderStatusRecharging {
return infraerrors.BadRequest("INVALID_STATUS", "order cannot fulfill in status "+o.Status)
}
- if o.SubscriptionGroupID == nil || o.SubscriptionDays == nil {
+ if o.SubscriptionDays == nil || (o.SubscriptionGroupID == nil && o.PlanID == nil) {
return infraerrors.BadRequest("INVALID_STATUS", "missing subscription info")
}
- c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(oid), paymentorder.StatusIn(OrderStatusPaid, OrderStatusFailed)).SetStatus(OrderStatusRecharging).Save(ctx)
+ claimed, err := s.claimFulfillmentLease(ctx, o)
if err != nil {
- return fmt.Errorf("lock: %w", err)
+ return err
}
- if c == 0 {
+ if !claimed {
return nil
}
if err := s.doSub(ctx, o); err != nil {
- s.markFailed(ctx, oid, err)
+ s.markFailed(ctx, o, err)
return err
}
return nil
}
func (s *PaymentService) doSub(ctx context.Context, o *dbent.PaymentOrder) error {
- gid := *o.SubscriptionGroupID
days := *o.SubscriptionDays
- g, err := s.groupRepo.GetByID(ctx, gid)
- if err != nil || g.Status != payment.EntityStatusActive {
- return fmt.Errorf("group %d no longer exists or inactive", gid)
- }
// Idempotency: check audit log to see if subscription was already assigned.
// Prevents double-extension on retry after markCompleted fails.
- if s.hasAuditLog(ctx, o.ID, "SUBSCRIPTION_SUCCESS") {
- slog.Info("subscription already assigned for order, skipping", "orderID", o.ID, "groupID", gid)
+ alreadyAssigned, err := s.hasAuditLog(ctx, o.ID, "SUBSCRIPTION_SUCCESS")
+ if err != nil {
+ return fmt.Errorf("check subscription fulfillment audit: %w", err)
+ }
+ if alreadyAssigned {
+ slog.Info("subscription already assigned for order, skipping", "orderID", o.ID)
return s.markCompleted(ctx, o, "SUBSCRIPTION_SUCCESS")
}
orderNote := fmt.Sprintf("payment order %d", o.ID)
- _, _, err = s.subscriptionSvc.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{UserID: o.UserID, GroupID: gid, ValidityDays: days, AssignedBy: 0, Notes: orderNote})
+ var snapshot *planFulfillmentSnapshot
+ if o.PlanID != nil {
+ snapshot, err = loadPlanFulfillmentSnapshot(ctx, s.entClient, o.ID)
+ if err != nil {
+ return err
+ }
+ if err := snapshot.ValidateForOrder(o); err != nil {
+ return infraerrors.Conflict("FULFILLMENT_SNAPSHOT_INVALID", err.Error())
+ }
+ }
+
+ if snapshot != nil && snapshot.PlanType == PlanTypeCredits {
+ return s.doWalletSub(ctx, o, orderNote, snapshot)
+ }
+ if o.SubscriptionGroupID == nil {
+ return infraerrors.BadRequest("INVALID_STATUS", "missing subscription group snapshot")
+ }
+ return s.doGroupSub(ctx, o, days, orderNote, *o.SubscriptionGroupID, snapshot)
+}
+
+func (s *PaymentService) doGroupSub(ctx context.Context, o *dbent.PaymentOrder, days int, orderNote string, groupID int64, snapshot *planFulfillmentSnapshot) error {
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return fmt.Errorf("begin group fulfillment transaction: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ txCtx := dbent.NewTxContext(ctx, tx)
+ client := tx.Client()
+
+ g, err := s.groupRepo.GetByID(txCtx, groupID)
+ if err != nil || g == nil || g.Status != payment.EntityStatusActive {
+ return fmt.Errorf("group %d no longer exists or inactive", groupID)
+ }
+ assignInput := &AssignSubscriptionInput{
+ UserID: o.UserID, GroupID: groupID, ValidityDays: days, AssignedBy: 0, Notes: orderNote,
+ }
+ if snapshot != nil {
+ assignInput.PlanID = &snapshot.PlanID
+ assignInput.PlanType = snapshot.PlanType
+ }
+ sub, _, err := s.subscriptionSvc.AssignOrExtendSubscription(txCtx, assignInput)
if err != nil {
return fmt.Errorf("assign subscription: %w", err)
}
- return s.markCompleted(ctx, o, "SUBSCRIPTION_SUCCESS")
+ if snapshot != nil {
+ lockedRates := planSnapshotRates(snapshot)
+ if len(lockedRates) == 0 {
+ return errors.New("monthly plan snapshot has no locked rates")
+ }
+ updatedSub, updateErr := client.UserSubscription.UpdateOneID(sub.ID).
+ SetLockedRates(lockedRates).
+ Save(txCtx)
+ if updateErr != nil {
+ return fmt.Errorf("persist subscription locked rates: %w", updateErr)
+ }
+ sub.LockedRates = planSnapshotRates(snapshot)
+ sub.ExpiresAt = updatedSub.ExpiresAt
+ grantStartsAt, grantExpiresAt := planSnapshotGrantWindow(sub, snapshot, time.Now())
+ if err := attachPlanSnapshotGrant(txCtx, client, o.ID, sub.ID, grantStartsAt, grantExpiresAt); err != nil {
+ return err
+ }
+ }
+
+ now := time.Now()
+ updated, err := client.PaymentOrder.Update().
+ Where(
+ paymentorder.IDEQ(o.ID),
+ paymentorder.StatusEQ(OrderStatusRecharging),
+ paymentorder.UpdatedAtEQ(o.UpdatedAt),
+ ).
+ SetStatus(OrderStatusCompleted).
+ SetCompletedAt(now).
+ Save(txCtx)
+ if err != nil {
+ return fmt.Errorf("complete group order: %w", err)
+ }
+ if updated != 1 {
+ return fmt.Errorf("complete group order: status changed concurrently")
+ }
+ detail, _ := json.Marshal(map[string]any{
+ "subscriptionID": sub.ID,
+ "groupID": groupID,
+ "days": days,
+ "payAmount": o.PayAmount,
+ "planID": snapshotPlanID(snapshot),
+ "snapshotVersion": snapshotVersion(snapshot),
+ })
+ if _, err := client.PaymentAuditLog.Create().
+ SetOrderID(strconv.FormatInt(o.ID, 10)).
+ SetAction("SUBSCRIPTION_SUCCESS").
+ SetDetail(string(detail)).
+ SetOperator("system").
+ Save(txCtx); err != nil {
+ return fmt.Errorf("record group fulfillment audit: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit group fulfillment transaction: %w", err)
+ }
+ s.subscriptionSvc.invalidateSubscriptionCaches(ctx, o.UserID, groupID)
+ return nil
+}
+
+func (s *PaymentService) doWalletSub(ctx context.Context, o *dbent.PaymentOrder, orderNote string, snapshot *planFulfillmentSnapshot) error {
+ if snapshot == nil || snapshot.PlanType != PlanTypeCredits || snapshot.WalletQuotaUSD == nil {
+ return infraerrors.BadRequest("INVALID_STATUS", "missing credits plan fulfillment snapshot")
+ }
+
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return fmt.Errorf("begin wallet fulfillment transaction: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ txCtx := dbent.NewTxContext(ctx, tx)
+ client := tx.Client()
+
+ walletInitial := *snapshot.WalletQuotaUSD
+ planID := snapshot.PlanID
+ sub, err := s.subscriptionSvc.AssignSubscription(txCtx, &AssignSubscriptionInput{
+ UserID: o.UserID,
+ ValidityDays: snapshot.SubscriptionDays,
+ AssignedBy: 0,
+ Notes: orderNote,
+ WalletInitialUSD: &walletInitial,
+ PlanID: &planID,
+ PlanType: snapshot.PlanType,
+ PaymentOrderID: &o.ID,
+ })
+ if err != nil {
+ return fmt.Errorf("assign wallet subscription: %w", err)
+ }
+ grantStartsAt, grantExpiresAt := planSnapshotGrantWindow(sub, snapshot, time.Now())
+ if err := attachPlanSnapshotGrant(txCtx, client, o.ID, sub.ID, grantStartsAt, grantExpiresAt); err != nil {
+ return err
+ }
+
+ now := time.Now()
+ updated, err := client.PaymentOrder.Update().
+ Where(
+ paymentorder.IDEQ(o.ID),
+ paymentorder.StatusEQ(OrderStatusRecharging),
+ paymentorder.UpdatedAtEQ(o.UpdatedAt),
+ ).
+ SetStatus(OrderStatusCompleted).
+ SetCompletedAt(now).
+ Save(txCtx)
+ if err != nil {
+ return fmt.Errorf("complete wallet order: %w", err)
+ }
+ if updated != 1 {
+ return fmt.Errorf("complete wallet order: status changed concurrently")
+ }
+
+ detail, _ := json.Marshal(map[string]any{
+ "subscriptionID": sub.ID,
+ "creditedAmount": walletInitial,
+ "payAmount": o.PayAmount,
+ "planID": snapshot.PlanID,
+ "planType": snapshot.PlanType,
+ "snapshotVersion": snapshot.SchemaVersion,
+ })
+ if _, err := client.PaymentAuditLog.Create().
+ SetOrderID(strconv.FormatInt(o.ID, 10)).
+ SetAction("SUBSCRIPTION_SUCCESS").
+ SetDetail(string(detail)).
+ SetOperator("system").
+ Save(txCtx); err != nil {
+ return fmt.Errorf("record wallet fulfillment audit: %w", err)
+ }
+
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit wallet fulfillment transaction: %w", err)
+ }
+ return nil
}
-func (s *PaymentService) hasAuditLog(ctx context.Context, orderID int64, action string) bool {
+func (s *PaymentService) hasAuditLog(ctx context.Context, orderID int64, action string) (bool, error) {
oid := strconv.FormatInt(orderID, 10)
- c, _ := s.entClient.PaymentAuditLog.Query().
+ c, err := s.entClient.PaymentAuditLog.Query().
Where(paymentauditlog.OrderIDEQ(oid), paymentauditlog.ActionEQ(action)).
Limit(1).Count(ctx)
- return c > 0
+ if err != nil {
+ return false, fmt.Errorf("query payment audit log: %w", err)
+ }
+ return c > 0, nil
}
func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *dbent.PaymentOrder) error {
@@ -394,7 +606,8 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db
return nil
}
- rebateAmount, err := s.affiliateService.AccrueInviteRebate(txCtx, o.UserID, o.Amount)
+ sourceOrderID := o.ID
+ rebateAmount, err := s.affiliateService.AccrueInviteRebateForOrder(txCtx, o.UserID, o.Amount, &sourceOrderID)
if err != nil {
s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
"error": err.Error(),
@@ -501,20 +714,140 @@ func (s *PaymentService) updateClaimedAffiliateRebateAudit(ctx context.Context,
return nil
}
-func (s *PaymentService) markFailed(ctx context.Context, oid int64, cause error) {
+func (s *PaymentService) markFailed(ctx context.Context, order *dbent.PaymentOrder, cause error) {
+ if order == nil {
+ return
+ }
now := time.Now()
- r := psErrMsg(cause)
+ r := fulfillmentFailureReasonPrefix + psErrMsg(cause)
// Only mark FAILED if still in RECHARGING state — prevents overwriting
- // a COMPLETED order when markCompleted failed but fulfillment succeeded.
+ // a COMPLETED order or a lease reclaimed by another worker.
c, e := s.entClient.PaymentOrder.Update().
- Where(paymentorder.IDEQ(oid), paymentorder.StatusEQ(OrderStatusRecharging)).
+ Where(
+ paymentorder.IDEQ(order.ID),
+ paymentorder.StatusEQ(OrderStatusRecharging),
+ paymentorder.UpdatedAtEQ(order.UpdatedAt),
+ ).
SetStatus(OrderStatusFailed).SetFailedAt(now).SetFailedReason(r).Save(ctx)
if e != nil {
- slog.Error("mark FAILED", "orderID", oid, "error", e)
+ slog.Error("mark FAILED", "orderID", order.ID, "error", e)
}
if c > 0 {
- s.writeAuditLog(ctx, oid, "FULFILLMENT_FAILED", "system", map[string]any{"reason": r})
+ s.writeAuditLog(ctx, order.ID, "FULFILLMENT_FAILED", "system", map[string]any{"reason": r})
+ }
+}
+
+func isFulfillmentLeaseStale(updatedAt, now time.Time) bool {
+ return !updatedAt.After(now.Add(-paymentFulfillmentLeaseTimeout))
+}
+
+func paymentOrderHasPaidEvidence(order *dbent.PaymentOrder) bool {
+ return order != nil && order.PaidAt != nil && strings.TrimSpace(order.PaymentTradeNo) != ""
+}
+
+func paymentNotConfirmedError() error {
+ return infraerrors.BadRequest("PAYMENT_NOT_CONFIRMED", "order payment is not confirmed")
+}
+
+func (s *PaymentService) claimFulfillmentLease(ctx context.Context, order *dbent.PaymentOrder) (bool, error) {
+ if order == nil {
+ return false, infraerrors.NotFound("NOT_FOUND", "order not found")
+ }
+ if !paymentOrderHasPaidEvidence(order) {
+ return false, paymentNotConfirmedError()
+ }
+
+ now := time.Now().UTC().Truncate(time.Microsecond)
+ cutoff := now.Add(-paymentFulfillmentLeaseTimeout)
+ previousUpdatedAt := order.UpdatedAt
+ if order.Status == OrderStatusRecharging && !isFulfillmentLeaseStale(order.UpdatedAt, now) {
+ return false, infraerrors.Conflict("FULFILLMENT_IN_PROGRESS", "order is being processed")
+ }
+
+ eligible := paymentorder.StatusIn(OrderStatusPaid, OrderStatusFailed)
+ if order.Status == OrderStatusRecharging {
+ eligible = paymentorder.And(
+ paymentorder.StatusEQ(OrderStatusRecharging),
+ paymentorder.UpdatedAtLTE(cutoff),
+ )
+ }
+ updated, err := s.entClient.PaymentOrder.Update().
+ Where(paymentorder.IDEQ(order.ID), eligible).
+ SetStatus(OrderStatusRecharging).
+ SetUpdatedAt(now).
+ ClearFailedAt().
+ ClearFailedReason().
+ Save(ctx)
+ if err != nil {
+ return false, fmt.Errorf("claim fulfillment lease: %w", err)
+ }
+ if updated == 1 {
+ // Carry the fencing token through the rest of fulfillment. Every final
+ // order transition compares this exact updated_at value, so a worker
+ // whose lease was reclaimed cannot commit wallet/group side effects.
+ order.UpdatedAt = now
+ if order.Status == OrderStatusRecharging {
+ s.writeAuditLog(ctx, order.ID, "FULFILLMENT_LEASE_RECLAIMED", "system", map[string]any{
+ "previous_updated_at": previousUpdatedAt,
+ "lease_timeout": paymentFulfillmentLeaseTimeout.String(),
+ })
+ }
+ return true, nil
+ }
+
+ current, err := s.entClient.PaymentOrder.Get(ctx, order.ID)
+ if err != nil {
+ return false, fmt.Errorf("reload fulfillment lease: %w", err)
+ }
+ if current.Status == OrderStatusCompleted {
+ return false, nil
}
+ if current.Status == OrderStatusRecharging {
+ return false, infraerrors.Conflict("FULFILLMENT_IN_PROGRESS", "order is being processed")
+ }
+ return false, infraerrors.BadRequest("INVALID_STATUS", "order cannot fulfill in status "+current.Status)
+}
+
+// RecoverStaleFulfillments is the background crash-recovery path for paid
+// orders that never entered fulfillment and RECHARGING leases whose worker
+// disappeared. The per-order compare-and-swap in claimFulfillmentLease keeps
+// multiple application instances safe.
+func (s *PaymentService) RecoverStaleFulfillments(ctx context.Context, limit int) (int, error) {
+ if limit <= 0 {
+ limit = defaultStaleRecoveryBatchSize
+ }
+ if limit > maxStaleRecoveryBatchSize {
+ limit = maxStaleRecoveryBatchSize
+ }
+ cutoff := time.Now().Add(-paymentFulfillmentLeaseTimeout)
+ orders, err := s.entClient.PaymentOrder.Query().
+ Where(
+ paymentorder.StatusIn(OrderStatusPaid, OrderStatusRecharging),
+ paymentorder.PaidAtNotNil(),
+ paymentorder.PaymentTradeNoNEQ(""),
+ paymentorder.UpdatedAtLTE(cutoff),
+ ).
+ Order(dbent.Asc(paymentorder.FieldUpdatedAt)).
+ Limit(limit).
+ All(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("query stale fulfillments: %w", err)
+ }
+
+ recovered := 0
+ for _, order := range orders {
+ if err := s.executeFulfillment(ctx, order.ID); err != nil {
+ if infraerrors.Reason(err) != "FULFILLMENT_IN_PROGRESS" {
+ slog.Error("recover stale payment fulfillment failed", "orderID", order.ID, "status", order.Status, "error", err)
+ }
+ continue
+ }
+ current, err := s.entClient.PaymentOrder.Get(ctx, order.ID)
+ if err == nil && current.Status == OrderStatusCompleted {
+ recovered++
+ }
+ }
+ return recovered, nil
}
func (s *PaymentService) RetryFulfillment(ctx context.Context, oid int64) error {
@@ -522,25 +855,27 @@ func (s *PaymentService) RetryFulfillment(ctx context.Context, oid int64) error
if err != nil {
return infraerrors.NotFound("NOT_FOUND", "order not found")
}
- if o.PaidAt == nil {
- return infraerrors.BadRequest("INVALID_STATUS", "order is not paid")
+ if !paymentOrderHasPaidEvidence(o) {
+ return paymentNotConfirmedError()
}
if psIsRefundStatus(o.Status) {
return infraerrors.BadRequest("INVALID_STATUS", "refund-related order cannot retry")
}
- if o.Status == OrderStatusRecharging {
+ if o.Status == OrderStatusRecharging && !isFulfillmentLeaseStale(o.UpdatedAt, time.Now()) {
return infraerrors.Conflict("CONFLICT", "order is being processed")
}
if o.Status == OrderStatusCompleted {
return infraerrors.BadRequest("INVALID_STATUS", "order already completed")
}
- if o.Status != OrderStatusFailed && o.Status != OrderStatusPaid {
- return infraerrors.BadRequest("INVALID_STATUS", "only paid and failed orders can retry")
+ if o.Status != OrderStatusFailed && o.Status != OrderStatusPaid && o.Status != OrderStatusRecharging {
+ return infraerrors.BadRequest("INVALID_STATUS", "only paid, failed, and stale processing orders can retry")
}
- _, err = s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(oid), paymentorder.StatusIn(OrderStatusFailed, OrderStatusPaid)).SetStatus(OrderStatusPaid).ClearFailedAt().ClearFailedReason().Save(ctx)
- if err != nil {
- return fmt.Errorf("reset for retry: %w", err)
+ if err := s.executeFulfillment(ctx, oid); err != nil {
+ return err
}
- s.writeAuditLog(ctx, oid, "RECHARGE_RETRY", "admin", map[string]any{"detail": "admin manual retry"})
- return s.executeFulfillment(ctx, oid)
+ s.writeAuditLog(ctx, oid, "RECHARGE_RETRY", "admin", map[string]any{
+ "detail": "admin manual retry",
+ "previous_status": o.Status,
+ })
+ return nil
}
diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go
index abdb59deaca..a100d705c0d 100644
--- a/backend/internal/service/payment_fulfillment_test.go
+++ b/backend/internal/service/payment_fulfillment_test.go
@@ -7,12 +7,159 @@ import (
"errors"
"math"
"testing"
+ "time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
+func TestIsFulfillmentLeaseStale(t *testing.T) {
+ t.Parallel()
+ now := time.Date(2026, time.July, 11, 12, 0, 0, 0, time.UTC)
+ tests := []struct {
+ name string
+ updatedAt time.Time
+ want bool
+ }{
+ {name: "fresh", updatedAt: now, want: false},
+ {name: "inside lease window", updatedAt: now.Add(-paymentFulfillmentLeaseTimeout + time.Nanosecond), want: false},
+ {name: "exactly at cutoff", updatedAt: now.Add(-paymentFulfillmentLeaseTimeout), want: true},
+ {name: "older than cutoff", updatedAt: now.Add(-paymentFulfillmentLeaseTimeout - time.Second), want: true},
+ {name: "future timestamp", updatedAt: now.Add(time.Minute), want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.want, isFulfillmentLeaseStale(tt.updatedAt, now))
+ })
+ }
+}
+
+func TestPaymentOrderHasPaidEvidence(t *testing.T) {
+ t.Parallel()
+ paidAt := time.Date(2026, time.July, 12, 9, 0, 0, 0, time.UTC)
+ tests := []struct {
+ name string
+ order *dbent.PaymentOrder
+ want bool
+ }{
+ {name: "nil order", order: nil, want: false},
+ {name: "no evidence", order: &dbent.PaymentOrder{}, want: false},
+ {name: "paid timestamp only", order: &dbent.PaymentOrder{PaidAt: &paidAt}, want: false},
+ {name: "trade number only", order: &dbent.PaymentOrder{PaymentTradeNo: "trade-1"}, want: false},
+ {name: "blank trade number", order: &dbent.PaymentOrder{PaidAt: &paidAt, PaymentTradeNo: " "}, want: false},
+ {name: "complete evidence", order: &dbent.PaymentOrder{PaidAt: &paidAt, PaymentTradeNo: "trade-1"}, want: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.want, paymentOrderHasPaidEvidence(tt.order))
+ })
+ }
+}
+
+func TestExecuteSubscriptionFulfillmentRejectsFailedWithoutPaidEvidence(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ order := createPaymentEvidenceTestOrder(t, ctx, client, OrderStatusFailed, false)
+
+ err := (&PaymentService{entClient: client}).ExecuteSubscriptionFulfillment(ctx, order.ID)
+ require.ErrorContains(t, err, "payment is not confirmed")
+ reloaded, reloadErr := client.PaymentOrder.Get(ctx, order.ID)
+ require.NoError(t, reloadErr)
+ require.Equal(t, OrderStatusFailed, reloaded.Status)
+}
+
+func TestMarkPaymentCreateFailedStoresDistinctUnpaidFailure(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ order := createPaymentEvidenceTestOrder(t, ctx, client, OrderStatusPending, false)
+ svc := &PaymentService{entClient: client}
+
+ svc.markPaymentCreateFailed(ctx, order.ID, errors.New("provider unavailable"))
+ reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
+ require.NoError(t, err)
+ require.Equal(t, OrderStatusFailed, reloaded.Status)
+ require.Nil(t, reloaded.PaidAt)
+ require.Empty(t, reloaded.PaymentTradeNo)
+ require.NotNil(t, reloaded.FailedReason)
+ require.Equal(t, paymentCreateFailureReason, *reloaded.FailedReason)
+ require.NotNil(t, reloaded.FailedAt)
+ audit, err := client.PaymentAuditLog.Query().Only(ctx)
+ require.NoError(t, err)
+ require.Equal(t, "PAYMENT_CREATE_FAILED", audit.Action)
+}
+
+func TestMarkPaymentCreateFailedCannotOverwriteConfirmedPayment(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ order := createPaymentEvidenceTestOrder(t, ctx, client, OrderStatusPaid, true)
+ svc := &PaymentService{entClient: client}
+
+ svc.markPaymentCreateFailed(ctx, order.ID, errors.New("late provider response error"))
+ reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
+ require.NoError(t, err)
+ require.Equal(t, OrderStatusPaid, reloaded.Status)
+ require.NotNil(t, reloaded.PaidAt)
+ require.Equal(t, "confirmed-trade", reloaded.PaymentTradeNo)
+ count, err := client.PaymentAuditLog.Query().Count(ctx)
+ require.NoError(t, err)
+ require.Zero(t, count)
+}
+
+func createPaymentEvidenceTestOrder(t *testing.T, ctx context.Context, client *dbent.Client, status string, paid bool) *dbent.PaymentOrder {
+ t.Helper()
+ user, err := client.User.Create().
+ SetEmail("paid-evidence@example.com").
+ SetPasswordHash("hash").
+ SetUsername("paid-evidence").
+ Save(ctx)
+ require.NoError(t, err)
+ builder := client.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(30).
+ SetPayAmount(30).
+ SetFeeRate(0).
+ SetRechargeCode("PAID-EVIDENCE-CODE").
+ SetOutTradeNo("paid-evidence-order").
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("").
+ SetOrderType(payment.OrderTypeSubscription).
+ SetPlanID(1).
+ SetSubscriptionDays(36500).
+ SetStatus(status).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com")
+ if paid {
+ builder.SetPaidAt(time.Now()).SetPaymentTradeNo("confirmed-trade")
+ }
+ order, err := builder.Save(ctx)
+ require.NoError(t, err)
+ return order
+}
+
+func TestDoSubFailsClosedWhenAuditLookupFails(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ _, err := client.ExecContext(ctx, "DROP TABLE payment_audit_logs")
+ require.NoError(t, err)
+
+ days := 30
+ svc := &PaymentService{entClient: client}
+ err = svc.doSub(ctx, &dbent.PaymentOrder{
+ ID: 991,
+ SubscriptionDays: &days,
+ })
+ require.ErrorContains(t, err, "check subscription fulfillment audit")
+}
+
type paymentFulfillmentTestProvider struct {
key string
supportedTypes []payment.PaymentType
@@ -288,6 +435,25 @@ func TestValidateProviderNotificationMetadataRejectsWxpaySnapshotMismatch(t *tes
assert.ErrorContains(t, err, "wxpay appid mismatch")
}
+func TestValidateProviderNotificationMetadataRejectsEmptyMetadataWhenSnapshotRequiresEvidence(t *testing.T) {
+ t.Parallel()
+
+ order := &dbent.PaymentOrder{
+ PaymentType: payment.TypeWxpay,
+ ProviderSnapshot: map[string]any{
+ "schema_version": 2,
+ "merchant_app_id": "wx-app-expected",
+ "merchant_id": "mch-expected",
+ "currency": "CNY",
+ },
+ }
+
+ for _, metadata := range []map[string]string{nil, {}} {
+ err := validateProviderNotificationMetadata(order, payment.TypeWxpay, metadata)
+ assert.ErrorContains(t, err, "wxpay notification missing appid")
+ }
+}
+
func TestValidateProviderNotificationMetadataAllowsLegacyOrdersWithoutSnapshotFields(t *testing.T) {
t.Parallel()
@@ -366,3 +532,25 @@ func TestValidateProviderNotificationMetadataRejectsEasyPaySnapshotMismatch(t *t
})
assert.ErrorContains(t, err, "easypay pid mismatch")
}
+
+func TestValidateProviderNotificationMetadataRejectsStripeCurrencyMismatchOrOmission(t *testing.T) {
+ t.Parallel()
+
+ order := &dbent.PaymentOrder{
+ PaymentType: payment.TypeStripe,
+ ProviderSnapshot: map[string]any{
+ "schema_version": 2,
+ "provider_key": payment.TypeStripe,
+ "currency": "CNY",
+ },
+ }
+
+ for _, metadata := range []map[string]string{
+ {"currency": "USD"},
+ {},
+ } {
+ err := validateProviderNotificationMetadata(order, payment.TypeStripe, metadata)
+ assert.ErrorContains(t, err, "stripe currency")
+ }
+ assert.NoError(t, validateProviderNotificationMetadata(order, payment.TypeStripe, map[string]string{"currency": "CNY"}))
+}
diff --git a/backend/internal/service/payment_migration_183_contract_test.go b/backend/internal/service/payment_migration_183_contract_test.go
new file mode 100644
index 00000000000..d3f7ad7ffad
--- /dev/null
+++ b/backend/internal/service/payment_migration_183_contract_test.go
@@ -0,0 +1,38 @@
+package service
+
+import (
+ "strings"
+ "testing"
+
+ dbmigrations "github.com/Wei-Shaw/sub2api/migrations"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPlanSnapshotMigrationHasAtomicCutoverAndFailClosedImmutability(t *testing.T) {
+ content, err := dbmigrations.FS.ReadFile("183_subscription_plan_fulfillment_snapshots.sql")
+ require.NoError(t, err)
+ sql := string(content)
+
+ for _, required := range []string{
+ "LOCK TABLE payment_orders IN SHARE ROW EXCLUSIVE MODE",
+ "subscription_plan_snapshot_cutovers",
+ "max_legacy_payment_order_id",
+ "hfc_plan_fulfillment_snapshot_shape_valid",
+ "TG_OP = 'DELETE'",
+ "source_snapshot.source_plan_id IS DISTINCT FROM source_order.plan_id",
+ "NEW.user_subscription_id IS DISTINCT FROM OLD.user_subscription_id",
+ "legacy unsnapshotted orders are still fulfillable",
+ "snapshot.payment_order_id = po.id",
+ "snapshot.payment_order_id = NEW.id",
+ "Do not consult a mutable (or subsequently deleted) plan",
+ } {
+ require.Contains(t, sql, required)
+ }
+
+ require.NotContains(t, sql,
+ "AND (snapshot->>'schema_version')::integer = 1",
+ "raw JSON casts inside a CHECK are not a fail-closed shape validator",
+ )
+ require.GreaterOrEqual(t, strings.Count(sql, "IS DISTINCT FROM"), 10,
+ "nullable immutable facts must not be compared with SQL three-valued equality")
+}
diff --git a/backend/internal/service/payment_migration_contract_test.go b/backend/internal/service/payment_migration_contract_test.go
new file mode 100644
index 00000000000..7d6219b8ac2
--- /dev/null
+++ b/backend/internal/service/payment_migration_contract_test.go
@@ -0,0 +1,28 @@
+//go:build unit
+
+package service
+
+import (
+ "regexp"
+ "strconv"
+ "testing"
+
+ dbmigrations "github.com/Wei-Shaw/sub2api/migrations"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPaymentLifecycleDeletionGraceMatchesMigrationGuards(t *testing.T) {
+ content, err := dbmigrations.FS.ReadFile("176_enforce_monthly_group_not_wallet.sql")
+ require.NoError(t, err)
+
+ intervalPattern := regexp.MustCompile(`INTERVAL '([0-9]+) minutes'`)
+ matches := intervalPattern.FindAllSubmatch(content, -1)
+ require.NotEmpty(t, matches, "migration 176 must encode the payment recovery grace")
+
+ for _, match := range matches {
+ minutes, parseErr := strconv.Atoi(string(match[1]))
+ require.NoError(t, parseErr)
+ require.Equal(t, paymentGraceMinutes, minutes,
+ "database fulfillability guards drifted from recent-expiry deletion protection")
+ }
+}
diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go
index 15d4509d4a7..7d49c17cc86 100644
--- a/backend/internal/service/payment_order.go
+++ b/backend/internal/service/payment_order.go
@@ -11,8 +11,10 @@ import (
"strings"
"time"
+ "entgo.io/ent/dialect"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
+ dbuser "github.com/Wei-Shaw/sub2api/ent/user"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/payment/provider"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
@@ -20,6 +22,24 @@ import (
// --- Order Creation ---
+const (
+ paidTrialOncePlanID int64 = 5
+ paidTrialOncePlanName = "paid-trial-v3-30d"
+ paidTrialRedeemGroupID int64 = 13
+)
+
+var paidTrialOnceBlockingStatuses = []string{
+ OrderStatusPending,
+ OrderStatusPaid,
+ OrderStatusRecharging,
+ OrderStatusCompleted,
+ OrderStatusRefundRequested,
+ OrderStatusRefunding,
+ OrderStatusPartiallyRefunded,
+ OrderStatusRefunded,
+ OrderStatusRefundFailed,
+}
+
func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*CreateOrderResponse, error) {
if req.OrderType == "" {
req.OrderType = payment.OrderTypeBalance
@@ -27,6 +47,9 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
if normalized := NormalizeVisibleMethod(req.PaymentType); normalized != "" {
req.PaymentType = normalized
}
+ if err := validateWeChatResumeOrderBinding(req); err != nil {
+ return nil, err
+ }
cfg, err := s.configService.GetPaymentConfig(ctx)
if err != nil {
return nil, fmt.Errorf("get payment config: %w", err)
@@ -34,6 +57,9 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
if !cfg.Enabled {
return nil, infraerrors.Forbidden("PAYMENT_DISABLED", "payment system is disabled")
}
+ if err := s.configService.validateCreateOrderPaymentMethodEnabled(ctx, cfg, req.PaymentType); err != nil {
+ return nil, err
+ }
plan, err := s.validateOrderInput(ctx, req, cfg)
if err != nil {
return nil, err
@@ -79,14 +105,40 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest
}
resp, err := s.invokeProvider(ctx, order, req, cfg, limitAmount, payAmountStr, payAmount, plan, sel)
if err != nil {
- _, _ = s.entClient.PaymentOrder.UpdateOneID(order.ID).
- SetStatus(OrderStatusFailed).
- Save(ctx)
+ s.markPaymentCreateFailed(ctx, order.ID, err)
return nil, err
}
return resp, nil
}
+func (s *PaymentService) markPaymentCreateFailed(ctx context.Context, orderID int64, cause error) {
+ now := time.Now()
+ updated, err := s.entClient.PaymentOrder.Update().
+ Where(
+ paymentorder.IDEQ(orderID),
+ paymentorder.StatusEQ(OrderStatusPending),
+ paymentorder.PaidAtIsNil(),
+ ).
+ SetStatus(OrderStatusFailed).
+ SetFailedAt(now).
+ SetFailedReason(paymentCreateFailureReason).
+ Save(ctx)
+ if err != nil {
+ slog.Error("mark payment creation FAILED", "orderID", orderID, "error", err)
+ return
+ }
+ if updated != 1 {
+ return
+ }
+ errorReason := infraerrors.Reason(cause)
+ if errorReason == "" {
+ errorReason = paymentCreateFailureReason
+ }
+ s.writeAuditLog(ctx, orderID, "PAYMENT_CREATE_FAILED", "system", map[string]any{
+ "reason": errorReason,
+ })
+}
+
func (s *PaymentService) validateOrderInput(ctx context.Context, req CreateOrderRequest, cfg *PaymentConfig) (*dbent.SubscriptionPlan, error) {
if req.OrderType == payment.OrderTypeBalance && cfg.BalanceDisabled {
return nil, infraerrors.Forbidden("BALANCE_PAYMENT_DISABLED", "balance recharge has been disabled")
@@ -112,12 +164,19 @@ func (s *PaymentService) validateSubOrder(ctx context.Context, req CreateOrderRe
if err != nil || !plan.ForSale {
return nil, infraerrors.NotFound("PLAN_NOT_AVAILABLE", "plan not found or not for sale")
}
- group, err := s.groupRepo.GetByID(ctx, plan.GroupID)
- if err != nil || group.Status != payment.EntityStatusActive {
- return nil, infraerrors.NotFound("GROUP_NOT_FOUND", "subscription group is no longer available")
+ if plan.PlanType != PlanTypeCredits {
+ return nil, infraerrors.BadRequest("MONTHLY_PLANS_RETIRED", "monthly plans are no longer available for purchase")
}
- if !group.IsSubscriptionType() {
- return nil, infraerrors.BadRequest("GROUP_TYPE_MISMATCH", "group is not a subscription type")
+ // v3 单 group 订阅:校验绑定的 group 仍可用
+ // v4 钱包模式 (plan.GroupID == nil):跳过单 group 校验, group 关联走 subscription_plan_groups 表
+ if plan.GroupID != nil {
+ group, err := s.groupRepo.GetByID(ctx, *plan.GroupID)
+ if err != nil || group.Status != payment.EntityStatusActive {
+ return nil, infraerrors.NotFound("GROUP_NOT_FOUND", "subscription group is no longer available")
+ }
+ if !group.IsSubscriptionType() {
+ return nil, infraerrors.BadRequest("GROUP_TYPE_MISMATCH", "group is not a subscription type")
+ }
}
return plan, nil
}
@@ -128,10 +187,37 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq
return nil, fmt.Errorf("begin transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
+ if err := lockPaymentOrderAdmissionUser(ctx, tx.Client(), req.UserID); err != nil {
+ return nil, err
+ }
+ if err := s.reserveSelectedProviderCapacity(ctx, tx, req.PaymentType, payAmount, sel); err != nil {
+ return nil, err
+ }
if err := s.checkPendingLimit(ctx, tx, req.UserID, cfg.MaxPendingOrders); err != nil {
return nil, err
}
- if err := s.checkDailyLimit(ctx, tx, req.UserID, limitAmount, cfg.DailyLimit); err != nil {
+ dailyLimitAmount := limitAmount
+ if req.OrderType == payment.OrderTypeBalance {
+ dailyLimitAmount = payAmount
+ }
+ if err := s.checkDailyLimit(ctx, tx, req.UserID, dailyLimitAmount, cfg.DailyLimit); err != nil {
+ return nil, err
+ }
+ var planSnapshot *planFulfillmentSnapshot
+ purchaseLimitPlan := plan
+ if plan != nil {
+ planSnapshot, err = s.capturePlanFulfillmentSnapshot(ctx, tx.Client(), req.UserID, plan.ID)
+ if err != nil {
+ return nil, err
+ }
+ if err := validateCapturedPlanPrice(planSnapshot, orderAmount, limitAmount); err != nil {
+ return nil, err
+ }
+ lockedPlan := *plan
+ lockedPlan.Name = planSnapshot.PlanName
+ purchaseLimitPlan = &lockedPlan
+ }
+ if err := s.checkPlanPurchaseLimit(ctx, tx, req.UserID, purchaseLimitPlan); err != nil {
return nil, err
}
tm := cfg.OrderTimeoutMin
@@ -179,13 +265,20 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq
if providerSnapshot != nil {
b.SetProviderSnapshot(providerSnapshot)
}
- if plan != nil {
- b.SetPlanID(plan.ID).SetSubscriptionGroupID(plan.GroupID).SetSubscriptionDays(psComputeValidityDays(plan.ValidityDays, plan.ValidityUnit))
+ if planSnapshot != nil {
+ b.SetPlanID(planSnapshot.PlanID).
+ SetNillableSubscriptionGroupID(planSnapshot.GroupID).
+ SetSubscriptionDays(planSnapshot.SubscriptionDays)
}
order, err := b.Save(ctx)
if err != nil {
return nil, fmt.Errorf("create order: %w", err)
}
+ if planSnapshot != nil {
+ if err := persistPlanFulfillmentSnapshot(ctx, tx.Client(), order.ID, req.UserID, planSnapshot); err != nil {
+ return nil, err
+ }
+ }
code := fmt.Sprintf("PAY-%d-%d", order.ID, time.Now().UnixNano()%100000)
order, err = tx.PaymentOrder.UpdateOneID(order.ID).SetRechargeCode(code).Save(ctx)
if err != nil {
@@ -197,10 +290,30 @@ func (s *PaymentService) createOrderInTx(ctx context.Context, req CreateOrderReq
return order, nil
}
+func lockPaymentOrderAdmissionUser(ctx context.Context, client *dbent.Client, userID int64) error {
+ if client == nil || userID <= 0 {
+ return infraerrors.BadRequest("INVALID_USER", "payment order requires a valid user")
+ }
+ query := client.User.Query().Where(dbuser.IDEQ(userID))
+ if client.Driver().Dialect() == dialect.Postgres {
+ query = query.ForUpdate()
+ }
+ if _, err := query.OnlyID(ctx); err != nil {
+ if dbent.IsNotFound(err) {
+ return infraerrors.Conflict("PAYMENT_ADMISSION_USER_CHANGED", "user changed while creating the payment order")
+ }
+ return fmt.Errorf("lock payment order admission user: %w", err)
+ }
+ return nil
+}
+
func (s *PaymentService) allocateOutTradeNo(ctx context.Context, tx *dbent.Tx) (string, error) {
const maxAttempts = 5
for attempt := 0; attempt < maxAttempts; attempt++ {
- candidate := generateOutTradeNo()
+ candidate, err := generateOutTradeNo()
+ if err != nil {
+ return "", err
+ }
exists, err := tx.PaymentOrder.Query().Where(paymentorder.OutTradeNo(candidate)).Exist(ctx)
if err != nil {
return "", fmt.Errorf("check out_trade_no uniqueness: %w", err)
@@ -227,6 +340,36 @@ func (s *PaymentService) checkPendingLimit(ctx context.Context, tx *dbent.Tx, us
return nil
}
+func (s *PaymentService) checkPlanPurchaseLimit(ctx context.Context, tx *dbent.Tx, userID int64, plan *dbent.SubscriptionPlan) error {
+ if !isPaidTrialOncePlan(plan) {
+ return nil
+ }
+ exists, err := tx.PaymentOrder.Query().Where(
+ paymentorder.UserIDEQ(userID),
+ paymentorder.OrderTypeEQ(payment.OrderTypeSubscription),
+ paymentorder.PlanIDEQ(plan.ID),
+ paymentorder.StatusIn(paidTrialOnceBlockingStatuses...),
+ ).Exist(ctx)
+ if err != nil {
+ return fmt.Errorf("check plan purchase limit: %w", err)
+ }
+ if !exists {
+ return nil
+ }
+ return infraerrors.Conflict("PLAN_PURCHASE_LIMIT_REACHED", "this plan can only be purchased once per user").
+ WithMetadata(map[string]string{
+ "plan_id": strconv.FormatInt(plan.ID, 10),
+ "plan_name": plan.Name,
+ })
+}
+
+func isPaidTrialOncePlan(plan *dbent.SubscriptionPlan) bool {
+ if plan == nil {
+ return false
+ }
+ return strings.TrimSpace(plan.Name) == paidTrialOncePlanName
+}
+
func buildPaymentOrderProviderSnapshot(sel *payment.InstanceSelection, req CreateOrderRequest) map[string]any {
if sel == nil {
return nil
@@ -269,6 +412,9 @@ func buildPaymentOrderProviderSnapshot(sel *payment.InstanceSelection, req Creat
snapshot["merchant_id"] = merchantID
}
}
+ if providerKey == payment.TypeStripe {
+ snapshot["currency"] = "CNY"
+ }
if len(snapshot) == 1 {
return nil
@@ -291,23 +437,40 @@ func (s *PaymentService) checkDailyLimit(ctx context.Context, tx *dbent.Tx, user
return nil
}
ts := psStartOfDayUTC(time.Now())
- orders, err := tx.PaymentOrder.Query().Where(paymentorder.UserIDEQ(userID), paymentorder.StatusIn(OrderStatusPaid, OrderStatusRecharging, OrderStatusCompleted), paymentorder.PaidAtGTE(ts)).All(ctx)
- if err != nil {
- return fmt.Errorf("query daily usage: %w", err)
+ orders, err := tx.PaymentOrder.Query().Where(
+ paymentorder.UserIDEQ(userID),
+ paymentorder.Or(
+ paymentorder.StatusEQ(OrderStatusPending),
+ paymentorder.And(
+ paymentorder.StatusIn(OrderStatusPaid, OrderStatusRecharging, OrderStatusCompleted),
+ paymentorder.PaidAtGTE(ts),
+ ),
+ ),
+ ).All(ctx)
+ if err != nil {
+ return fmt.Errorf("query daily usage and reservations: %w", err)
+ }
+ exposure := sumPaymentOrderDailyLimitAmount(orders)
+ if exposure+amount > limit {
+ return infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", "daily_limit_exceeded").
+ WithMetadata(map[string]string{"remaining": fmt.Sprintf("%.2f", math.Max(0, limit-exposure))})
}
- var used float64
- for _, o := range orders {
- if o.OrderType == payment.OrderTypeBalance {
- used += o.PayAmount
+ return nil
+}
+
+func sumPaymentOrderDailyLimitAmount(orders []*dbent.PaymentOrder) float64 {
+ var total float64
+ for _, order := range orders {
+ if order == nil {
continue
}
- used += o.Amount
- }
- if used+amount > limit {
- return infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", "daily_limit_exceeded").
- WithMetadata(map[string]string{"remaining": fmt.Sprintf("%.2f", math.Max(0, limit-used))})
+ if order.OrderType == payment.OrderTypeBalance {
+ total += order.PayAmount
+ continue
+ }
+ total += order.Amount
}
- return nil
+ return total
}
func (s *PaymentService) selectCreateOrderInstance(ctx context.Context, req CreateOrderRequest, cfg *PaymentConfig, payAmount float64) (*payment.InstanceSelection, error) {
@@ -379,7 +542,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
}
subject := s.buildPaymentSubject(plan, limitAmount, cfg)
outTradeNo := order.OutTradeNo
- canonicalReturnURL, err := CanonicalizeReturnURL(req.ReturnURL, req.SrcHost, req.SrcURL)
+ canonicalReturnURL, err := CanonicalizeReturnURL(req.ReturnURL, req.TrustedFrontendURL)
if err != nil {
return nil, err
}
@@ -399,7 +562,7 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
}
}
}
- providerReturnURL, err := buildPaymentReturnURL(canonicalReturnURL, order.ID, outTradeNo, resumeToken)
+ providerReturnURL, err := buildPaymentReturnURL(canonicalReturnURL, order.ID, outTradeNo)
if err != nil {
return nil, err
}
@@ -418,6 +581,9 @@ func (s *PaymentService) invokeProvider(ctx context.Context, order *dbent.Paymen
}
return nil, classifyCreatePaymentError(req, sel.ProviderKey, err)
}
+ if err := validateStripeClientSecretBinding(sel.ProviderKey, outTradeNo, pr.ClientSecret); err != nil {
+ return nil, err
+ }
_, err = s.entClient.PaymentOrder.UpdateOneID(order.ID).
SetNillablePaymentTradeNo(psNilIfEmpty(pr.TradeNo)).
SetNillablePayURL(psNilIfEmpty(pr.PayURL)).
@@ -501,11 +667,16 @@ func (s *PaymentService) buildWeChatOAuthRequiredResponse(ctx context.Context, r
if err != nil {
return nil, err
}
- if err := s.paymentResume().ensureSigningKey(); err != nil {
+ resumeService := s.paymentResume()
+ if err := resumeService.ensureSigningKey(); err != nil {
+ return nil, err
+ }
+ subjectToken, err := resumeService.CreateWeChatPaymentOAuthSubjectToken(req.UserID)
+ if err != nil {
return nil, err
}
- authorizeURL, err := buildWeChatPaymentOAuthStartURL(req, "snsapi_base")
+ authorizeURL, err := buildWeChatPaymentOAuthStartURL(req, "snsapi_base", subjectToken)
if err != nil {
return nil, err
}
@@ -604,12 +775,29 @@ func buildCreateOrderResponse(order *dbent.PaymentOrder, req CreateOrderRequest,
}
}
-func buildWeChatPaymentOAuthStartURL(req CreateOrderRequest, scope string) (string, error) {
+func validateStripeClientSecretBinding(providerKey, outTradeNo, clientSecret string) error {
+ if payment.GetBasePaymentType(providerKey) != payment.TypeStripe {
+ return nil
+ }
+ outTradeNo = strings.TrimSpace(outTradeNo)
+ clientSecret = strings.TrimSpace(clientSecret)
+ if outTradeNo == "" || clientSecret == "" || !strings.HasPrefix(clientSecret, outTradeNo+"_secret_") {
+ return infraerrors.ServiceUnavailable("STRIPE_CLIENT_SECRET_MISMATCH", "stripe payment session binding failed")
+ }
+ return nil
+}
+
+func buildWeChatPaymentOAuthStartURL(req CreateOrderRequest, scope string, subjectToken string) (string, error) {
u, err := url.Parse("/api/v1/auth/oauth/wechat/payment/start")
if err != nil {
return "", fmt.Errorf("build wechat payment oauth start url: %w", err)
}
q := u.Query()
+ subjectToken = strings.TrimSpace(subjectToken)
+ if subjectToken == "" {
+ return "", infraerrors.ServiceUnavailable("PAYMENT_RESUME_NOT_CONFIGURED", "wechat payment oauth subject binding is unavailable")
+ }
+ q.Set("subject_token", subjectToken)
q.Set("payment_type", strings.TrimSpace(req.PaymentType))
if req.Amount > 0 {
q.Set("amount", strconv.FormatFloat(req.Amount, 'f', -1, 64))
@@ -630,6 +818,29 @@ func buildWeChatPaymentOAuthStartURL(req CreateOrderRequest, scope string) (stri
return u.String(), nil
}
+func validateWeChatResumeOrderBinding(req CreateOrderRequest) error {
+ source := NormalizePaymentSource(req.PaymentSource)
+ hasOpenID := strings.TrimSpace(req.OpenID) != ""
+ hasBinding := req.ResumeTokenUserID != 0 || strings.TrimSpace(req.ResumeTokenJTI) != ""
+
+ if !hasBinding {
+ if source == PaymentSourceWechatInAppResume && hasOpenID {
+ return infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat in-app payment requires a signed user-bound resume token")
+ }
+ return nil
+ }
+ if source != PaymentSourceWechatInAppResume || !hasOpenID {
+ return infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume binding is inconsistent")
+ }
+ if req.ResumeTokenUserID <= 0 || req.ResumeTokenUserID != req.UserID {
+ return infraerrors.Forbidden("WECHAT_PAYMENT_RESUME_USER_MISMATCH", "wechat payment resume token belongs to a different user")
+ }
+ if !isValidPaymentResumeJTI(req.ResumeTokenJTI) {
+ return infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token replay binding is invalid")
+ }
+ return nil
+}
+
func paymentRedirectPathFromURL(rawURL string) string {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
@@ -679,7 +890,7 @@ func (s *PaymentService) GetOrder(ctx context.Context, orderID, userID int64) (*
if o.UserID != userID {
return nil, infraerrors.Forbidden("FORBIDDEN", "no permission for this order")
}
- return o, nil
+ return s.reconcileVisiblePaymentOrder(ctx, o)
}
func (s *PaymentService) GetOrderByID(ctx context.Context, orderID int64) (*dbent.PaymentOrder, error) {
diff --git a/backend/internal/service/payment_order_admission_security_test.go b/backend/internal/service/payment_order_admission_security_test.go
new file mode 100644
index 00000000000..c5925ba4cef
--- /dev/null
+++ b/backend/internal/service/payment_order_admission_security_test.go
@@ -0,0 +1,168 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "strconv"
+ "testing"
+ "time"
+
+ "entgo.io/ent/dialect"
+ entsql "entgo.io/ent/dialect/sql"
+ sqlmock "github.com/DATA-DOG/go-sqlmock"
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCreateOrderInTxReservesDailyLimitForPendingOrders(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ user, err := client.User.Create().
+ SetEmail("payment-admission-daily@example.com").
+ SetPasswordHash("hash").
+ SetUsername("payment-admission-daily").
+ Save(ctx)
+ require.NoError(t, err)
+
+ svc := &PaymentService{entClient: client}
+ req := CreateOrderRequest{
+ UserID: user.ID,
+ PaymentType: payment.TypeAlipay,
+ OrderType: payment.OrderTypeBalance,
+ ClientIP: "127.0.0.1",
+ SrcHost: "app.example.com",
+ }
+ serviceUser := &User{ID: user.ID, Email: user.Email, Username: user.Username}
+ cfg := &PaymentConfig{
+ MaxPendingOrders: 3,
+ DailyLimit: 100,
+ OrderTimeoutMin: 30,
+ }
+
+ first, err := svc.createOrderInTx(ctx, req, serviceUser, nil, cfg, 60, 60, 0, 60, nil)
+ require.NoError(t, err)
+ require.Equal(t, OrderStatusPending, first.Status)
+
+ second, err := svc.createOrderInTx(ctx, req, serviceUser, nil, cfg, 60, 60, 0, 60, nil)
+ require.Nil(t, second)
+ require.Error(t, err)
+ require.Equal(t, "DAILY_LIMIT_EXCEEDED", infraerrors.Reason(err))
+
+ count, err := client.PaymentOrder.Query().Count(ctx)
+ require.NoError(t, err)
+ require.Equal(t, 1, count, "a rejected daily-limit reservation must not create another order")
+}
+
+func TestLockPaymentOrderAdmissionUserUsesPostgresRowLock(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ driver := entsql.OpenDB(dialect.Postgres, db)
+ client := dbent.NewClient(dbent.Driver(driver))
+ t.Cleanup(func() { _ = client.Close() })
+
+ mock.ExpectQuery(`SELECT .* FROM "users" WHERE "users"\."id" = \$1 AND "users"\."deleted_at" IS NULL LIMIT 2 FOR UPDATE`).
+ WithArgs(int64(42)).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(42)))
+
+ require.NoError(t, lockPaymentOrderAdmissionUser(context.Background(), client, 42))
+ require.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestReserveSelectedProviderCapacityRejectsOversubscription(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ user, err := client.User.Create().
+ SetEmail("payment-provider-capacity@example.com").
+ SetPasswordHash("hash").
+ SetUsername("payment-provider-capacity").
+ Save(ctx)
+ require.NoError(t, err)
+ instance, err := client.PaymentProviderInstance.Create().
+ SetProviderKey(payment.TypeEasyPay).
+ SetName("capacity-limited").
+ SetConfig("encrypted-config").
+ SetSupportedTypes(payment.TypeAlipay).
+ SetLimits(`{"alipay":{"dailyLimit":100}}`).
+ Save(ctx)
+ require.NoError(t, err)
+
+ _, err = client.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(80).
+ SetPayAmount(80).
+ SetFeeRate(0).
+ SetRechargeCode("CAPACITY-EXISTING").
+ SetOutTradeNo("capacity-existing").
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("").
+ SetOrderType(payment.OrderTypeBalance).
+ SetProviderInstanceID(strconv.FormatInt(instance.ID, 10)).
+ SetProviderKey(instance.ProviderKey).
+ SetStatus(OrderStatusPending).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("app.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+
+ tx, err := client.Tx(ctx)
+ require.NoError(t, err)
+ defer func() { _ = tx.Rollback() }()
+
+ sel := &payment.InstanceSelection{
+ InstanceID: strconv.FormatInt(instance.ID, 10),
+ ProviderKey: instance.ProviderKey,
+ SupportedTypes: instance.SupportedTypes,
+ }
+ svc := &PaymentService{}
+ err = svc.reserveSelectedProviderCapacity(ctx, tx, payment.TypeAlipay, 21, sel)
+ require.Error(t, err)
+ require.True(t, infraerrors.IsTooManyRequests(err))
+ require.Equal(t, "PAYMENT_PROVIDER_CAPACITY_EXCEEDED", infraerrors.Reason(err))
+
+ require.NoError(t, svc.reserveSelectedProviderCapacity(ctx, tx, payment.TypeAlipay, 20, sel))
+}
+
+func TestReserveSelectedProviderCapacityAllowsFirstReservation(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ instance, err := client.PaymentProviderInstance.Create().
+ SetProviderKey(payment.TypeEasyPay).
+ SetName("capacity-empty").
+ SetConfig("encrypted-config").
+ SetSupportedTypes(payment.TypeAlipay).
+ SetLimits(`{"alipay":{"dailyLimit":100}}`).
+ Save(ctx)
+ require.NoError(t, err)
+
+ tx, err := client.Tx(ctx)
+ require.NoError(t, err)
+ defer func() { _ = tx.Rollback() }()
+
+ sel := &payment.InstanceSelection{
+ InstanceID: strconv.FormatInt(instance.ID, 10),
+ ProviderKey: instance.ProviderKey,
+ SupportedTypes: instance.SupportedTypes,
+ }
+ require.NoError(t, (&PaymentService{}).reserveSelectedProviderCapacity(ctx, tx, payment.TypeAlipay, 20, sel))
+}
+
+func TestLockPaymentProviderAdmissionUsesPostgresRowLock(t *testing.T) {
+ db, mock, err := sqlmock.New()
+ require.NoError(t, err)
+ driver := entsql.OpenDB(dialect.Postgres, db)
+ client := dbent.NewClient(dbent.Driver(driver))
+ t.Cleanup(func() { _ = client.Close() })
+
+ mock.ExpectQuery(`SELECT .* FROM "payment_provider_instances" WHERE "payment_provider_instances"\."id" = \$1 AND "payment_provider_instances"\."enabled" LIMIT 2 FOR UPDATE`).
+ WithArgs(int64(17)).
+ WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17)))
+
+ require.NoError(t, lockPaymentProviderAdmission(context.Background(), client, 17))
+ require.NoError(t, mock.ExpectationsWereMet())
+}
diff --git a/backend/internal/service/payment_order_expiry_service.go b/backend/internal/service/payment_order_expiry_service.go
index b0cda3e5914..f714e59294d 100644
--- a/backend/internal/service/payment_order_expiry_service.go
+++ b/backend/internal/service/payment_order_expiry_service.go
@@ -59,15 +59,21 @@ func (s *PaymentOrderExpiryService) Stop() {
}
func (s *PaymentOrderExpiryService) runOnce() {
- ctx, cancel := context.WithTimeout(context.Background(), expiryCheckTimeout)
- defer cancel()
-
- expired, err := s.paymentSvc.ExpireTimedOutOrders(ctx)
+ expiryCtx, cancelExpiry := context.WithTimeout(context.Background(), expiryCheckTimeout)
+ expired, err := s.paymentSvc.ExpireTimedOutOrders(expiryCtx)
+ cancelExpiry()
if err != nil {
slog.Error("[PaymentOrderExpiry] failed to expire orders", "error", err)
- return
- }
- if expired > 0 {
+ } else if expired > 0 {
slog.Info("[PaymentOrderExpiry] expired timed-out orders", "count", expired)
}
+
+ recoveryCtx, cancelRecovery := context.WithTimeout(context.Background(), expiryCheckTimeout)
+ recovered, err := s.paymentSvc.RecoverStaleFulfillments(recoveryCtx, defaultStaleRecoveryBatchSize)
+ cancelRecovery()
+ if err != nil {
+ slog.Error("[PaymentOrderExpiry] failed to recover stale fulfillments", "error", err)
+ } else if recovered > 0 {
+ slog.Info("[PaymentOrderExpiry] recovered stale fulfillments", "count", recovered)
+ }
}
diff --git a/backend/internal/service/payment_order_jsapi_test.go b/backend/internal/service/payment_order_jsapi_test.go
index 8c5e4fc0e34..6eef5cb283d 100644
--- a/backend/internal/service/payment_order_jsapi_test.go
+++ b/backend/internal/service/payment_order_jsapi_test.go
@@ -24,7 +24,12 @@ func TestUsesOfficialWxpayVisibleMethodDerivesFromEnabledProviderInstance(t *tes
}
svc := &PaymentService{
- configService: &PaymentConfigService{entClient: client},
+ configService: &PaymentConfigService{
+ entClient: client,
+ settingRepo: &paymentConfigSettingRepoStub{values: map[string]string{
+ SettingPaymentVisibleMethodWxpayEnabled: "true",
+ }},
+ },
}
if !svc.usesOfficialWxpayVisibleMethod(ctx) {
@@ -84,7 +89,8 @@ func TestUsesOfficialWxpayVisibleMethodRespectsConfiguredSourceWhenMultipleProvi
entClient: client,
settingRepo: &paymentConfigSettingRepoStub{
values: map[string]string{
- SettingPaymentVisibleMethodWxpaySource: tt.source,
+ SettingPaymentVisibleMethodWxpaySource: tt.source,
+ SettingPaymentVisibleMethodWxpayEnabled: "true",
},
},
},
diff --git a/backend/internal/service/payment_order_lifecycle.go b/backend/internal/service/payment_order_lifecycle.go
index b627ced4ecc..4df8ff4ce8a 100644
--- a/backend/internal/service/payment_order_lifecycle.go
+++ b/backend/internal/service/payment_order_lifecycle.go
@@ -25,6 +25,7 @@ const (
rateLimitUnitHour = "hour"
rateLimitModeFixed = "fixed"
checkPaidResultAlreadyPaid = "already_paid"
+ checkPaidResultNotPaid = "not_paid"
checkPaidResultCancelled = "cancelled"
)
@@ -117,25 +118,49 @@ func (s *PaymentService) AdminCancelOrder(ctx context.Context, orderID int64) (s
func (s *PaymentService) cancelCore(ctx context.Context, o *dbent.PaymentOrder, fs, op, ad string) (string, error) {
if o.PaymentTradeNo != "" || o.PaymentType != "" {
- if s.checkPaid(ctx, o) == checkPaidResultAlreadyPaid {
+ paymentStatus := s.queryAndFulfillPaidOrder(ctx, o)
+ if paymentStatus == checkPaidResultAlreadyPaid {
return checkPaidResultAlreadyPaid, nil
}
+ if paymentStatus != checkPaidResultNotPaid {
+ return "", infraerrors.ServiceUnavailable(
+ "PAYMENT_STATUS_UNAVAILABLE",
+ "payment status could not be confirmed; please retry before cancelling",
+ )
+ }
+ if err := s.cancelProviderPayment(ctx, o); err != nil {
+ return "", err
+ }
}
c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(o.ID), paymentorder.StatusEQ(OrderStatusPending)).SetStatus(fs).Save(ctx)
if err != nil {
return "", fmt.Errorf("update order status: %w", err)
}
- if c > 0 {
- auditAction := "ORDER_CANCELLED"
- if fs == OrderStatusExpired {
- auditAction = "ORDER_EXPIRED"
+ if c == 0 {
+ current, reloadErr := s.entClient.PaymentOrder.Get(ctx, o.ID)
+ if reloadErr != nil {
+ return "", fmt.Errorf("reload order after cancellation race: %w", reloadErr)
+ }
+ if current.PaidAt != nil || current.Status == OrderStatusPaid || current.Status == OrderStatusRecharging || current.Status == OrderStatusCompleted {
+ return checkPaidResultAlreadyPaid, nil
}
- s.writeAuditLog(ctx, o.ID, auditAction, op, map[string]any{"detail": ad})
+ if current.Status == OrderStatusCancelled || current.Status == OrderStatusExpired || current.Status == fs {
+ return checkPaidResultCancelled, nil
+ }
+ return "", infraerrors.Conflict("PAYMENT_ORDER_STATE_CHANGED", "payment order state changed while cancelling; reload and retry")
+ }
+ auditAction := "ORDER_CANCELLED"
+ if fs == OrderStatusExpired {
+ auditAction = "ORDER_EXPIRED"
}
+ s.writeAuditLog(ctx, o.ID, auditAction, op, map[string]any{"detail": ad})
return checkPaidResultCancelled, nil
}
-func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) string {
+// queryAndFulfillPaidOrder is a read/reconciliation path. It must never cancel
+// an upstream payment: status polling happens while the customer may still be
+// completing the provider flow.
+func (s *PaymentService) queryAndFulfillPaidOrder(ctx context.Context, o *dbent.PaymentOrder) string {
prov, err := s.getOrderProvider(ctx, o)
if err != nil {
return ""
@@ -149,6 +174,10 @@ func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) s
slog.Warn("query upstream failed", "orderID", o.ID, "error", err)
return ""
}
+ if resp == nil {
+ slog.Warn("query upstream returned empty response", "orderID", o.ID)
+ return ""
+ }
if resp.Status == payment.ProviderStatusPaid {
if !isValidProviderAmount(resp.Amount) {
s.writeAuditLog(ctx, o.ID, "PAYMENT_INVALID_AMOUNT", prov.ProviderKey(), map[string]any{
@@ -182,10 +211,69 @@ func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) s
}
return checkPaidResultAlreadyPaid
}
- if cp, ok := prov.(payment.CancelableProvider); ok {
- _ = cp.CancelPayment(ctx, queryRef)
+ return checkPaidResultNotPaid
+}
+
+func (s *PaymentService) claimPaymentStatusQuery(orderID int64, now time.Time) bool {
+ if s == nil || orderID <= 0 {
+ return false
+ }
+ s.statusQueryMu.Lock()
+ defer s.statusQueryMu.Unlock()
+ if last, ok := s.lastStatusQuery[orderID]; ok && now.Sub(last) < paymentStatusQueryCooldown {
+ return false
+ }
+ if s.lastStatusQuery == nil {
+ s.lastStatusQuery = make(map[int64]time.Time)
+ }
+ if len(s.lastStatusQuery) > 4096 {
+ cutoff := now.Add(-2 * paymentStatusQueryCooldown)
+ for id, queriedAt := range s.lastStatusQuery {
+ if queriedAt.Before(cutoff) {
+ delete(s.lastStatusQuery, id)
+ }
+ }
+ }
+ s.lastStatusQuery[orderID] = now
+ return true
+}
+
+func (s *PaymentService) reconcileVisiblePaymentOrder(ctx context.Context, order *dbent.PaymentOrder) (*dbent.PaymentOrder, error) {
+ if order == nil || (order.Status != OrderStatusPending && order.Status != OrderStatusExpired) {
+ return order, nil
+ }
+ if !s.claimPaymentStatusQuery(order.ID, time.Now()) {
+ return order, nil
+ }
+ if s.queryAndFulfillPaidOrder(ctx, order) != checkPaidResultAlreadyPaid {
+ return order, nil
+ }
+ reloaded, err := s.entClient.PaymentOrder.Get(ctx, order.ID)
+ if err != nil {
+ return nil, fmt.Errorf("reload reconciled order: %w", err)
+ }
+ return reloaded, nil
+}
+
+func (s *PaymentService) cancelProviderPayment(ctx context.Context, o *dbent.PaymentOrder) error {
+ prov, err := s.getOrderProvider(ctx, o)
+ if err != nil {
+ slog.Warn("load upstream provider for cancellation failed", "orderID", o.ID, "error", err)
+ return infraerrors.ServiceUnavailable("PAYMENT_PROVIDER_CANCEL_UNAVAILABLE", "payment provider cancellation is unavailable").WithCause(err)
}
- return ""
+ queryRef := paymentOrderQueryReference(o, prov)
+ if queryRef == "" {
+ return infraerrors.ServiceUnavailable("PAYMENT_PROVIDER_CANCEL_UNAVAILABLE", "payment provider cancellation reference is unavailable")
+ }
+ cp, ok := prov.(payment.CancelableProvider)
+ if !ok {
+ return nil
+ }
+ if err := cp.CancelPayment(ctx, queryRef); err != nil {
+ slog.Warn("cancel upstream payment failed", "orderID", o.ID, "error", err)
+ return infraerrors.ServiceUnavailable("PAYMENT_PROVIDER_CANCEL_FAILED", "payment provider cancellation failed; please retry").WithCause(err)
+ }
+ return nil
}
func requeryPaidOrderOnce(ctx context.Context, prov payment.Provider, queryRef string) (*payment.QueryOrderResponse, bool) {
@@ -268,7 +356,7 @@ func (s *PaymentService) VerifyOrderByOutTradeNo(ctx context.Context, outTradeNo
}
// Only verify orders that are still pending or recently expired
if o.Status == OrderStatusPending || o.Status == OrderStatusExpired {
- result := s.checkPaid(ctx, o)
+ result := s.queryAndFulfillPaidOrder(ctx, o)
if result == checkPaidResultAlreadyPaid {
// Reload order to get updated status
o, err = s.entClient.PaymentOrder.Get(ctx, o.ID)
@@ -280,23 +368,6 @@ func (s *PaymentService) VerifyOrderByOutTradeNo(ctx context.Context, outTradeNo
return o, nil
}
-// VerifyOrderPublic returns the currently persisted public order state without
-// triggering any upstream reconciliation. Signed resume-token recovery is the
-// only public recovery path allowed to query upstream state.
-func (s *PaymentService) VerifyOrderPublic(ctx context.Context, outTradeNo string) (*dbent.PaymentOrder, error) {
- outTradeNo, err := normalizeOrderLookupOutTradeNo(outTradeNo)
- if err != nil {
- return nil, err
- }
- o, err := s.entClient.PaymentOrder.Query().
- Where(paymentorder.OutTradeNo(outTradeNo)).
- Only(ctx)
- if err != nil {
- return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
- }
- return o, nil
-}
-
func normalizeOrderLookupOutTradeNo(raw string) (string, error) {
outTradeNo := strings.TrimSpace(raw)
if outTradeNo == "" {
diff --git a/backend/internal/service/payment_order_lifecycle_test.go b/backend/internal/service/payment_order_lifecycle_test.go
index 8dfd2e7e01c..de14461cd6b 100644
--- a/backend/internal/service/payment_order_lifecycle_test.go
+++ b/backend/internal/service/payment_order_lifecycle_test.go
@@ -105,6 +105,14 @@ func (r *paymentOrderLifecycleRedeemRepo) Delete(context.Context, int64) error {
panic("unexpected call")
}
+func (r *paymentOrderLifecycleRedeemRepo) DeleteIfUnused(context.Context, int64) (bool, error) {
+ panic("unexpected call")
+}
+
+func (r *paymentOrderLifecycleRedeemRepo) ExpireIfUnused(context.Context, int64) (bool, error) {
+ panic("unexpected call")
+}
+
func (r *paymentOrderLifecycleRedeemRepo) Use(_ context.Context, id, userID int64) error {
for code, redeemCode := range r.codesByCode {
if redeemCode.ID != id {
@@ -144,7 +152,7 @@ func (r *paymentOrderLifecycleRedeemRepo) SumPositiveBalanceByUser(context.Conte
panic("unexpected call")
}
-func TestVerifyOrderByOutTradeNoBackfillsTradeNoFromPaidQuery(t *testing.T) {
+func TestVerifyOrderByOutTradeNoRecoversLongExpiredOrderAndBackfillsTradeNo(t *testing.T) {
ctx := context.Background()
client := newPaymentOrderLifecycleTestClient(t)
@@ -167,7 +175,8 @@ func TestVerifyOrderByOutTradeNoBackfillsTradeNoFromPaidQuery(t *testing.T) {
SetPaymentType(payment.TypeAlipay).
SetPaymentTradeNo("").
SetOrderType(payment.OrderTypeBalance).
- SetStatus(OrderStatusPending).
+ SetStatus(OrderStatusExpired).
+ SetUpdatedAt(time.Now().Add(-24 * time.Hour)).
SetExpiresAt(time.Now().Add(time.Hour)).
SetClientIP("127.0.0.1").
SetSrcHost("api.example.com").
@@ -208,6 +217,7 @@ func TestVerifyOrderByOutTradeNoBackfillsTradeNoFromPaidQuery(t *testing.T) {
nil,
client,
nil,
+ nil,
)
registry := payment.NewRegistry()
provider := &paymentOrderLifecycleQueryProvider{
@@ -308,6 +318,7 @@ func TestVerifyOrderByOutTradeNoRetriesZeroAmountPaidQueryOnce(t *testing.T) {
nil,
client,
nil,
+ nil,
)
registry := payment.NewRegistry()
provider := &paymentOrderLifecycleQueryProvider{
@@ -398,6 +409,7 @@ func TestVerifyOrderByOutTradeNoRejectsPaidQueryWithZeroAmount(t *testing.T) {
nil,
client,
nil,
+ nil,
)
registry := payment.NewRegistry()
provider := &paymentOrderLifecycleQueryProvider{
@@ -496,6 +508,7 @@ func TestVerifyOrderByOutTradeNoUsesOutTradeNoWhenPaymentTradeNoAlreadyExistsFor
nil,
client,
nil,
+ nil,
)
registry := payment.NewRegistry()
provider := &paymentOrderLifecycleQueryProvider{
@@ -558,6 +571,204 @@ func TestPaymentOrderQueryReferenceUsesOutTradeNoForOfficialProviders(t *testing
}))
}
+func TestCancelCoreCASMissReloadsAuthoritativePaidOrder(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentOrderLifecycleTestClient(t)
+
+ user, err := client.User.Create().
+ SetEmail("cancel-race-paid@example.com").
+ SetPasswordHash("hash").
+ SetUsername("cancel-race-paid-user").
+ Save(ctx)
+ require.NoError(t, err)
+
+ order, err := client.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(10).
+ SetPayAmount(10).
+ SetFeeRate(0).
+ SetRechargeCode("CANCEL-RACE-PAID").
+ SetOutTradeNo("sub2_cancel_race_paid").
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("").
+ SetOrderType(payment.OrderTypeBalance).
+ SetStatus(OrderStatusPending).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+
+ // cancelCore received this stale PENDING snapshot before a concurrent
+ // webhook completed the order. Clear the local payment type so this test
+ // isolates the compare-and-swap miss path from provider reconciliation.
+ stale := *order
+ stale.PaymentType = ""
+ paidAt := time.Now()
+ _, err = client.PaymentOrder.UpdateOneID(order.ID).
+ SetStatus(OrderStatusCompleted).
+ SetPaidAt(paidAt).
+ SetPaymentTradeNo("pi_paid_during_cancel").
+ Save(ctx)
+ require.NoError(t, err)
+
+ svc := &PaymentService{entClient: client}
+ result, err := svc.cancelCore(ctx, &stale, OrderStatusCancelled, "user:1", "user cancelled order")
+ require.NoError(t, err)
+ require.Equal(t, checkPaidResultAlreadyPaid, result)
+ authoritative, err := client.PaymentOrder.Get(ctx, order.ID)
+ require.NoError(t, err)
+ require.Equal(t, OrderStatusCompleted, authoritative.Status)
+ require.NotEqual(t, checkPaidResultCancelled, result)
+}
+
+func TestCancelCoreCASMissReloadsAuthoritativeCancelledOrder(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentOrderLifecycleTestClient(t)
+
+ user, err := client.User.Create().
+ SetEmail("cancel-race-cancelled@example.com").
+ SetPasswordHash("hash").
+ SetUsername("cancel-race-cancelled-user").
+ Save(ctx)
+ require.NoError(t, err)
+
+ order, err := client.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(10).
+ SetPayAmount(10).
+ SetFeeRate(0).
+ SetRechargeCode("CANCEL-RACE-CANCELLED").
+ SetOutTradeNo("sub2_cancel_race_cancelled").
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("").
+ SetOrderType(payment.OrderTypeBalance).
+ SetStatus(OrderStatusPending).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+
+ stale := *order
+ stale.PaymentType = ""
+ _, err = client.PaymentOrder.UpdateOneID(order.ID).SetStatus(OrderStatusCancelled).Save(ctx)
+ require.NoError(t, err)
+
+ svc := &PaymentService{entClient: client}
+ result, err := svc.cancelCore(ctx, &stale, OrderStatusCancelled, "user:1", "user cancelled order")
+ require.NoError(t, err)
+ require.Equal(t, checkPaidResultCancelled, result)
+ authoritative, err := client.PaymentOrder.Get(ctx, order.ID)
+ require.NoError(t, err)
+ require.Equal(t, OrderStatusCancelled, authoritative.Status)
+}
+
+func TestExecuteSubscriptionFulfillmentWalletPlanAssignsWalletSubscription(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentOrderLifecycleTestClient(t)
+ createPlanFulfillmentSnapshotTestTable(t, ctx, client)
+
+ user, err := client.User.Create().
+ SetEmail("wallet-order@example.com").
+ SetPasswordHash("hash").
+ SetUsername("wallet-order-user").
+ Save(ctx)
+ require.NoError(t, err)
+
+ walletQuota := 1500.0
+ plan, err := client.SubscriptionPlan.Create().
+ SetName("Standard Wallet").
+ SetPrice(299).
+ SetWalletQuotaUsd(walletQuota).
+ SetValidityDays(30).
+ SetValidityUnit("day").
+ SetPlanType(PlanTypeCredits).
+ Save(ctx)
+ require.NoError(t, err)
+
+ order, err := client.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(299).
+ SetPayAmount(299).
+ SetFeeRate(0).
+ SetRechargeCode("WALLET-ORDER-NO-CODE").
+ SetOutTradeNo("sub2_wallet_order_paid").
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("trade-wallet-paid").
+ SetOrderType(payment.OrderTypeSubscription).
+ SetPlanID(plan.ID).
+ SetSubscriptionDays(30).
+ SetStatus(OrderStatusPaid).
+ SetPaidAt(time.Now()).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+ require.Nil(t, order.SubscriptionGroupID)
+ require.NoError(t, persistPlanFulfillmentSnapshot(ctx, client, order.ID, user.ID, &planFulfillmentSnapshot{
+ SchemaVersion: planFulfillmentSnapshotSchemaVersion,
+ PlanID: plan.ID,
+ PlanType: PlanTypeCredits,
+ PlanName: plan.Name,
+ PlanPrice: plan.Price,
+ SubscriptionDays: 30,
+ WalletQuotaUSD: &walletQuota,
+ CoveredGroupIDs: []int64{},
+ CoveredGroups: []planFulfillmentSnapshotGroup{},
+ LockedRates: map[string]float64{},
+ CaptureSource: "unit-test",
+ }))
+
+ subRepo := newSubscriptionUserSubRepoStub()
+ subscriptionSvc := NewSubscriptionService(groupRepoNoop{}, subRepo, nil, nil, nil)
+ subscriptionSvc.SetWalletTopupService(&walletTopupServiceStub{})
+ svc := &PaymentService{
+ entClient: client,
+ subscriptionSvc: subscriptionSvc,
+ }
+
+ require.NoError(t, svc.ExecuteSubscriptionFulfillment(ctx, order.ID))
+
+ sub, err := subRepo.GetActiveWalletByUserID(ctx, user.ID)
+ require.NoError(t, err)
+ require.Nil(t, sub.GroupID)
+ require.NotNil(t, sub.WalletInitialUSD)
+ require.NotNil(t, sub.WalletBalanceUSD)
+ require.InDelta(t, walletQuota, *sub.WalletInitialUSD, 0.000001)
+ require.InDelta(t, walletQuota, *sub.WalletBalanceUSD, 0.000001)
+
+ reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
+ require.NoError(t, err)
+ require.Equal(t, OrderStatusCompleted, reloaded.Status)
+}
+
+func createPlanFulfillmentSnapshotTestTable(t *testing.T, ctx context.Context, client *dbent.Client) {
+ t.Helper()
+ _, err := client.ExecContext(ctx, `
+ CREATE TABLE IF NOT EXISTS subscription_plan_fulfillment_snapshots (
+ payment_order_id INTEGER PRIMARY KEY,
+ user_id INTEGER NOT NULL,
+ source_plan_id INTEGER NOT NULL,
+ user_subscription_id INTEGER,
+ snapshot TEXT NOT NULL,
+ grant_starts_at DATETIME,
+ grant_expires_at DATETIME,
+ attached_at DATETIME,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )
+ `)
+ require.NoError(t, err)
+}
+
func newPaymentOrderLifecycleTestClient(t *testing.T) *dbent.Client {
t.Helper()
diff --git a/backend/internal/service/payment_order_provider_capacity.go b/backend/internal/service/payment_order_provider_capacity.go
new file mode 100644
index 00000000000..a3ef1cd2793
--- /dev/null
+++ b/backend/internal/service/payment_order_provider_capacity.go
@@ -0,0 +1,129 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+ "time"
+
+ "entgo.io/ent/dialect"
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/paymentorder"
+ "github.com/Wei-Shaw/sub2api/ent/paymentproviderinstance"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+)
+
+// reserveSelectedProviderCapacity closes the gap between load-balancer
+// selection and order insertion. On PostgreSQL, every creator for one provider
+// instance serializes on that instance row, then re-reads committed PENDING and
+// paid exposure before inserting its own PENDING reservation in the same
+// transaction.
+func (s *PaymentService) reserveSelectedProviderCapacity(
+ ctx context.Context,
+ tx *dbent.Tx,
+ paymentType string,
+ orderAmount float64,
+ sel *payment.InstanceSelection,
+) error {
+ if sel == nil {
+ return nil
+ }
+ if tx == nil || math.IsNaN(orderAmount) || math.IsInf(orderAmount, 0) || orderAmount <= 0 {
+ return infraerrors.ServiceUnavailable("PAYMENT_PROVIDER_ADMISSION_INVALID", "payment provider admission is invalid")
+ }
+
+ instanceID, err := strconv.ParseInt(strings.TrimSpace(sel.InstanceID), 10, 64)
+ if err != nil || instanceID <= 0 {
+ return infraerrors.ServiceUnavailable("PAYMENT_PROVIDER_INSTANCE_CHANGED", "payment provider instance changed")
+ }
+ client := tx.Client()
+ if err := lockPaymentProviderAdmission(ctx, client, instanceID); err != nil {
+ return infraerrors.ServiceUnavailable("PAYMENT_PROVIDER_INSTANCE_CHANGED", "payment provider instance changed").WithCause(err)
+ }
+ instance, err := client.PaymentProviderInstance.Get(ctx, instanceID)
+ if err != nil {
+ return infraerrors.ServiceUnavailable("PAYMENT_PROVIDER_INSTANCE_CHANGED", "payment provider instance changed").WithCause(err)
+ }
+ if strings.TrimSpace(instance.ProviderKey) != strings.TrimSpace(sel.ProviderKey) ||
+ !providerInstanceSupportsSelectedType(instance, paymentType) {
+ return infraerrors.ServiceUnavailable("PAYMENT_PROVIDER_INSTANCE_CHANGED", "payment provider instance changed")
+ }
+
+ limits, err := payment.GetInstanceChannelLimits(instance, paymentType)
+ if err != nil {
+ return infraerrors.ServiceUnavailable("PAYMENT_PROVIDER_LIMITS_INVALID", "payment provider limits are invalid").WithCause(err)
+ }
+ if limits.SingleMin > 0 && orderAmount < limits.SingleMin ||
+ limits.SingleMax > 0 && orderAmount > limits.SingleMax {
+ return infraerrors.TooManyRequests("PAYMENT_PROVIDER_CAPACITY_EXCEEDED", "payment provider capacity is unavailable")
+ }
+ if limits.DailyLimit <= 0 {
+ return nil
+ }
+
+ used, err := selectedProviderDailyExposure(ctx, client, sel.InstanceID, time.Now())
+ if err != nil {
+ return infraerrors.ServiceUnavailable("PAYMENT_PROVIDER_USAGE_UNAVAILABLE", "payment provider capacity could not be verified").WithCause(err)
+ }
+ if used+orderAmount > limits.DailyLimit {
+ return infraerrors.TooManyRequests("PAYMENT_PROVIDER_CAPACITY_EXCEEDED", "payment provider capacity is unavailable").
+ WithMetadata(map[string]string{"remaining": fmt.Sprintf("%.2f", math.Max(0, limits.DailyLimit-used))})
+ }
+ return nil
+}
+
+func lockPaymentProviderAdmission(ctx context.Context, client *dbent.Client, instanceID int64) error {
+ if client == nil || instanceID <= 0 {
+ return fmt.Errorf("payment provider admission requires a valid instance")
+ }
+ query := client.PaymentProviderInstance.Query().Where(
+ paymentproviderinstance.IDEQ(instanceID),
+ paymentproviderinstance.EnabledEQ(true),
+ )
+ if client.Driver().Dialect() == dialect.Postgres {
+ query = query.ForUpdate()
+ }
+ if _, err := query.OnlyID(ctx); err != nil {
+ return fmt.Errorf("lock payment provider admission: %w", err)
+ }
+ return nil
+}
+
+func providerInstanceSupportsSelectedType(instance *dbent.PaymentProviderInstance, paymentType string) bool {
+ if instance == nil {
+ return false
+ }
+ if paymentType == payment.TypeStripe {
+ return instance.ProviderKey == payment.TypeStripe
+ }
+ return payment.InstanceSupportsType(instance.SupportedTypes, paymentType)
+}
+
+func selectedProviderDailyExposure(ctx context.Context, client *dbent.Client, instanceID string, now time.Time) (float64, error) {
+ start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
+ var rows []struct {
+ Sum float64 `json:"sum"`
+ }
+ err := client.PaymentOrder.Query().Where(
+ paymentorder.ProviderInstanceIDEQ(strings.TrimSpace(instanceID)),
+ paymentorder.StatusIn(
+ OrderStatusPending,
+ OrderStatusPaid,
+ OrderStatusCompleted,
+ OrderStatusRecharging,
+ ),
+ paymentorder.CreatedAtGTE(start),
+ ).
+ Aggregate(dbent.As(dbent.Sum(paymentorder.FieldPayAmount), "sum")).
+ Scan(ctx, &rows)
+ if err != nil {
+ return 0, fmt.Errorf("query selected provider daily exposure: %w", err)
+ }
+ if len(rows) == 0 {
+ return 0, nil
+ }
+ return rows[0].Sum, nil
+}
diff --git a/backend/internal/service/payment_order_provider_snapshot.go b/backend/internal/service/payment_order_provider_snapshot.go
index bb60f9e25b8..d27517c1134 100644
--- a/backend/internal/service/payment_order_provider_snapshot.go
+++ b/backend/internal/service/payment_order_provider_snapshot.go
@@ -127,7 +127,7 @@ func expectedNotificationProviderKeyForOrder(registry *payment.Registry, order *
}
func validateProviderSnapshotMetadata(order *dbent.PaymentOrder, providerKey string, metadata map[string]string) error {
- if order == nil || len(metadata) == 0 {
+ if order == nil {
return nil
}
@@ -188,6 +188,16 @@ func validateProviderSnapshotMetadata(order *dbent.PaymentOrder, providerKey str
return fmt.Errorf("easypay pid mismatch: expected %s, got %s", expected, actual)
}
}
+ case payment.TypeStripe:
+ if expected := strings.ToUpper(strings.TrimSpace(snapshot.Currency)); expected != "" {
+ actual := strings.ToUpper(strings.TrimSpace(metadata["currency"]))
+ if actual == "" {
+ return fmt.Errorf("stripe currency missing")
+ }
+ if expected != actual {
+ return fmt.Errorf("stripe currency mismatch: expected %s, got %s", expected, actual)
+ }
+ }
}
return nil
diff --git a/backend/internal/service/payment_order_provider_snapshot_test.go b/backend/internal/service/payment_order_provider_snapshot_test.go
index efa013b52dd..92930a3f116 100644
--- a/backend/internal/service/payment_order_provider_snapshot_test.go
+++ b/backend/internal/service/payment_order_provider_snapshot_test.go
@@ -164,6 +164,17 @@ func TestBuildPaymentOrderProviderSnapshot_IncludesEasyPayMerchantIdentity(t *te
require.NotContains(t, snapshot, "pkey")
}
+func TestBuildPaymentOrderProviderSnapshot_BindsStripeCurrency(t *testing.T) {
+ t.Parallel()
+
+ snapshot := buildPaymentOrderProviderSnapshot(&payment.InstanceSelection{
+ InstanceID: "77",
+ ProviderKey: payment.TypeStripe,
+ }, CreateOrderRequest{PaymentType: payment.TypeStripe})
+
+ require.Equal(t, "CNY", snapshot["currency"])
+}
+
func valueOrEmpty(v *string) string {
if v == nil {
return ""
diff --git a/backend/internal/service/payment_order_purchase_limit_test.go b/backend/internal/service/payment_order_purchase_limit_test.go
new file mode 100644
index 00000000000..f31d608d629
--- /dev/null
+++ b/backend/internal/service/payment_order_purchase_limit_test.go
@@ -0,0 +1,132 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCheckPlanPurchaseLimitBlocksPaidTrialRepeat(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentOrderLifecycleTestClient(t)
+
+ user := createPaymentLimitUser(t, ctx, client, "repeat")
+ plan := createPaymentLimitPlan(t, ctx, client, paidTrialOncePlanName)
+ createPaymentLimitOrder(t, ctx, client, user.ID, plan.ID, OrderStatusCompleted)
+
+ tx, err := client.Tx(ctx)
+ require.NoError(t, err)
+ defer func() { _ = tx.Rollback() }()
+
+ svc := &PaymentService{}
+ err = svc.checkPlanPurchaseLimit(ctx, tx, user.ID, plan)
+ require.Error(t, err)
+ require.True(t, infraerrors.IsConflict(err))
+ require.Equal(t, "PLAN_PURCHASE_LIMIT_REACHED", infraerrors.Reason(err))
+ require.Equal(t, paidTrialOncePlanName, infraerrors.FromError(err).Metadata["plan_name"])
+}
+
+func TestCheckPlanPurchaseLimitAllowsExpiredOrFailedPaidTrialRetry(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentOrderLifecycleTestClient(t)
+
+ user := createPaymentLimitUser(t, ctx, client, "retry")
+ plan := createPaymentLimitPlan(t, ctx, client, paidTrialOncePlanName)
+ createPaymentLimitOrder(t, ctx, client, user.ID, plan.ID, OrderStatusExpired)
+ createPaymentLimitOrder(t, ctx, client, user.ID, plan.ID, OrderStatusFailed)
+
+ tx, err := client.Tx(ctx)
+ require.NoError(t, err)
+ defer func() { _ = tx.Rollback() }()
+
+ svc := &PaymentService{}
+ require.NoError(t, svc.checkPlanPurchaseLimit(ctx, tx, user.ID, plan))
+}
+
+func TestCheckPlanPurchaseLimitIgnoresOtherPlans(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentOrderLifecycleTestClient(t)
+
+ user := createPaymentLimitUser(t, ctx, client, "standard")
+ trialPlan := createPaymentLimitPlan(t, ctx, client, paidTrialOncePlanName)
+ standardPlan := createPaymentLimitPlan(t, ctx, client, "paid-standard-v3-30d")
+ createPaymentLimitOrder(t, ctx, client, user.ID, trialPlan.ID, OrderStatusCompleted)
+
+ tx, err := client.Tx(ctx)
+ require.NoError(t, err)
+ defer func() { _ = tx.Rollback() }()
+
+ svc := &PaymentService{}
+ require.NoError(t, svc.checkPlanPurchaseLimit(ctx, tx, user.ID, standardPlan))
+}
+
+func createPaymentLimitUser(t *testing.T, ctx context.Context, client *dbent.Client, suffix string) *dbent.User {
+ t.Helper()
+ user, err := client.User.Create().
+ SetEmail("payment-limit-" + suffix + "@example.com").
+ SetPasswordHash("hash").
+ SetUsername("payment-limit-" + suffix).
+ Save(ctx)
+ require.NoError(t, err)
+ return user
+}
+
+func createPaymentLimitPlan(t *testing.T, ctx context.Context, client *dbent.Client, name string) *dbent.SubscriptionPlan {
+ t.Helper()
+ group := createPaymentLimitGroup(t, ctx, client, "payment-limit-group-"+name)
+ plan, err := client.SubscriptionPlan.Create().
+ SetGroupID(group.ID).
+ SetName(name).
+ SetProductName("Payment Limit " + name).
+ SetPrice(29.9).
+ SetValidityDays(30).
+ SetValidityUnit("day").
+ SetForSale(true).
+ Save(ctx)
+ require.NoError(t, err)
+ return plan
+}
+
+func createPaymentLimitGroup(t *testing.T, ctx context.Context, client *dbent.Client, name string) *dbent.Group {
+ t.Helper()
+ group, err := client.Group.Create().
+ SetName(name).
+ SetSubscriptionType(SubscriptionTypeSubscription).
+ SetMonthlyLimitUsd(100).
+ Save(ctx)
+ require.NoError(t, err)
+ return group
+}
+
+func createPaymentLimitOrder(t *testing.T, ctx context.Context, client *dbent.Client, userID, planID int64, status string) *dbent.PaymentOrder {
+ t.Helper()
+ order, err := client.PaymentOrder.Create().
+ SetUserID(userID).
+ SetUserEmail("payment-limit-order@example.com").
+ SetUserName("payment-limit-order").
+ SetAmount(29.9).
+ SetPayAmount(29.9).
+ SetFeeRate(0).
+ SetRechargeCode("PAYMENT-LIMIT").
+ SetOutTradeNo(fmt.Sprintf("sub2_payment_limit_%d_%s_%d", userID, status, time.Now().UnixNano())).
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("trade-payment-limit-" + status).
+ SetOrderType(payment.OrderTypeSubscription).
+ SetPlanID(planID).
+ SetSubscriptionDays(30).
+ SetStatus(status).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+ return order
+}
diff --git a/backend/internal/service/payment_order_result_test.go b/backend/internal/service/payment_order_result_test.go
index 2d7412e0612..8448d0517c7 100644
--- a/backend/internal/service/payment_order_result_test.go
+++ b/backend/internal/service/payment_order_result_test.go
@@ -2,6 +2,8 @@ package service
import (
"context"
+ "encoding/hex"
+ "net/url"
"strings"
"testing"
"time"
@@ -11,6 +13,33 @@ import (
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
+func TestGenerateOutTradeNoUses128BitRandomSuffix(t *testing.T) {
+ tradeNo, err := generateOutTradeNo()
+ if err != nil {
+ t.Fatalf("generateOutTradeNo: %v", err)
+ }
+ if len(tradeNo) != 32 {
+ t.Fatalf("out_trade_no length=%d, want 32", len(tradeNo))
+ }
+ if _, err := hex.DecodeString(tradeNo); err != nil {
+ t.Fatalf("random suffix is not 128-bit hexadecimal: %v", err)
+ }
+}
+
+func TestValidateStripeClientSecretBinding(t *testing.T) {
+ t.Parallel()
+
+ if err := validateStripeClientSecretBinding(payment.TypeStripe, "pi_order_123", "pi_order_123_secret_client"); err != nil {
+ t.Fatalf("valid Stripe order/session pair rejected: %v", err)
+ }
+ if err := validateStripeClientSecretBinding(payment.TypeStripe, "pi_order_123", "pi_attacker_secret_client"); err == nil {
+ t.Fatal("mismatched Stripe order/client_secret pair must be rejected server-side")
+ }
+ if err := validateStripeClientSecretBinding(payment.TypeAlipay, "trade-123", "opaque"); err != nil {
+ t.Fatalf("non-Stripe sessions should not use Stripe binding rules: %v", err)
+ }
+}
+
func TestBuildCreateOrderResponseDefaultsToOrderCreated(t *testing.T) {
t.Parallel()
@@ -105,6 +134,7 @@ func TestMaybeBuildWeChatOAuthRequiredResponse(t *testing.T) {
})
resp, err := svc.maybeBuildWeChatOAuthRequiredResponse(context.Background(), CreateOrderRequest{
+ UserID: 7,
Amount: 12.5,
PaymentType: payment.TypeWxpay,
IsWeChatBrowser: true,
@@ -132,8 +162,20 @@ func TestMaybeBuildWeChatOAuthRequiredResponse(t *testing.T) {
if resp.OAuth.RedirectURL != "/auth/wechat/payment/callback" {
t.Fatalf("redirect_url = %q, want %q", resp.OAuth.RedirectURL, "/auth/wechat/payment/callback")
}
- if resp.OAuth.AuthorizeURL != "/api/v1/auth/oauth/wechat/payment/start?amount=12.5&order_type=balance&payment_type=wxpay&redirect=%2Fpurchase%3Ffrom%3Dwechat&scope=snsapi_base" {
- t.Fatalf("authorize_url = %q", resp.OAuth.AuthorizeURL)
+ authorizeURL, err := url.Parse(resp.OAuth.AuthorizeURL)
+ if err != nil {
+ t.Fatalf("parse authorize_url: %v", err)
+ }
+ query := authorizeURL.Query()
+ if query.Get("amount") != "12.5" || query.Get("order_type") != payment.OrderTypeBalance || query.Get("payment_type") != payment.TypeWxpay || query.Get("scope") != "snsapi_base" {
+ t.Fatalf("authorize_url context = %q", resp.OAuth.AuthorizeURL)
+ }
+ subjectClaims, err := svc.paymentResume().ParseWeChatPaymentOAuthSubjectToken(query.Get("subject_token"))
+ if err != nil {
+ t.Fatalf("parse oauth subject token: %v", err)
+ }
+ if subjectClaims.UserID != 7 || subjectClaims.JTI == "" {
+ t.Fatalf("oauth subject token is not user-bound: %+v", subjectClaims)
}
}
@@ -143,6 +185,7 @@ func TestMaybeBuildWeChatOAuthRequiredResponseRequiresMPConfigInWeChat(t *testin
svc := newWeChatPaymentOAuthTestService(nil)
resp, err := svc.maybeBuildWeChatOAuthRequiredResponse(context.Background(), CreateOrderRequest{
+ UserID: 7,
Amount: 12.5,
PaymentType: payment.TypeWxpay,
IsWeChatBrowser: true,
@@ -182,6 +225,7 @@ func TestMaybeBuildWeChatOAuthRequiredResponseRequiresResumeSigningKey(t *testin
}
resp, err := svc.maybeBuildWeChatOAuthRequiredResponse(context.Background(), CreateOrderRequest{
+ UserID: 7,
Amount: 12.5,
PaymentType: payment.TypeWxpay,
IsWeChatBrowser: true,
@@ -219,6 +263,7 @@ func TestMaybeBuildWeChatOAuthRequiredResponseFallsBackToConfiguredLegacySigning
}
resp, err := svc.maybeBuildWeChatOAuthRequiredResponse(context.Background(), CreateOrderRequest{
+ UserID: 7,
Amount: 12.5,
PaymentType: payment.TypeWxpay,
IsWeChatBrowser: true,
@@ -251,6 +296,7 @@ func TestMaybeBuildWeChatOAuthRequiredResponseForSelectionSkipsEasyPayProvider(t
})
resp, err := svc.maybeBuildWeChatOAuthRequiredResponseForSelection(context.Background(), CreateOrderRequest{
+ UserID: 7,
Amount: 12.5,
PaymentType: payment.TypeWxpay,
IsWeChatBrowser: true,
diff --git a/backend/internal/service/payment_plan_snapshot_test.go b/backend/internal/service/payment_plan_snapshot_test.go
new file mode 100644
index 00000000000..da6925dc8f6
--- /dev/null
+++ b/backend/internal/service/payment_plan_snapshot_test.go
@@ -0,0 +1,115 @@
+//go:build unit
+
+package service
+
+import (
+ "testing"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPlanFulfillmentSnapshotValidate(t *testing.T) {
+ t.Parallel()
+
+ groupID := int64(22)
+ quota := 50.0
+ tests := []struct {
+ name string
+ snapshot *planFulfillmentSnapshot
+ wantErr string
+ }{
+ {
+ name: "monthly snapshot",
+ snapshot: &planFulfillmentSnapshot{
+ SchemaVersion: planFulfillmentSnapshotSchemaVersion,
+ PlanID: 11,
+ PlanType: PlanTypeSubscription,
+ PlanPrice: 20,
+ GroupID: &groupID,
+ SubscriptionDays: 30,
+ CoveredGroupIDs: []int64{3, 22},
+ LockedRates: map[string]float64{"3": 1, "22": 8.5},
+ },
+ },
+ {
+ name: "credits snapshot",
+ snapshot: &planFulfillmentSnapshot{
+ SchemaVersion: planFulfillmentSnapshotSchemaVersion,
+ PlanID: 12,
+ PlanType: PlanTypeCredits,
+ PlanPrice: 20,
+ SubscriptionDays: 36500,
+ WalletQuotaUSD: "a,
+ CoveredGroupIDs: []int64{3},
+ LockedRates: map[string]float64{"3": 1},
+ },
+ },
+ {
+ name: "monthly missing group",
+ snapshot: &planFulfillmentSnapshot{
+ SchemaVersion: planFulfillmentSnapshotSchemaVersion,
+ PlanID: 13,
+ PlanType: PlanTypeSubscription,
+ PlanPrice: 20,
+ SubscriptionDays: 30,
+ },
+ wantErr: "monthly snapshot requires group_id",
+ },
+ {
+ name: "credits missing quota",
+ snapshot: &planFulfillmentSnapshot{
+ SchemaVersion: planFulfillmentSnapshotSchemaVersion,
+ PlanID: 14,
+ PlanType: PlanTypeCredits,
+ PlanPrice: 20,
+ SubscriptionDays: 36500,
+ },
+ wantErr: "credits snapshot requires wallet_quota_usd",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.snapshot.Validate()
+ if tt.wantErr == "" {
+ require.NoError(t, err)
+ return
+ }
+ require.ErrorContains(t, err, tt.wantErr)
+ })
+ }
+}
+
+func TestPlanFulfillmentSnapshotMatchesOrder(t *testing.T) {
+ t.Parallel()
+
+ groupID := int64(22)
+ days := 30
+ snapshot := &planFulfillmentSnapshot{
+ SchemaVersion: planFulfillmentSnapshotSchemaVersion,
+ PlanID: 11,
+ PlanType: PlanTypeSubscription,
+ PlanPrice: 20,
+ GroupID: &groupID,
+ SubscriptionDays: days,
+ CoveredGroupIDs: []int64{22},
+ LockedRates: map[string]float64{"22": 8.5},
+ }
+ order := &dbent.PaymentOrder{
+ ID: 91,
+ OrderType: payment.OrderTypeSubscription,
+ Amount: 20,
+ PlanID: paymentPlanInt64Ptr(11),
+ SubscriptionGroupID: &groupID,
+ SubscriptionDays: &days,
+ }
+
+ require.NoError(t, snapshot.ValidateForOrder(order))
+ wrongDays := 7
+ order.SubscriptionDays = &wrongDays
+ require.ErrorContains(t, snapshot.ValidateForOrder(order), "subscription_days")
+}
+
+func paymentPlanInt64Ptr(value int64) *int64 { return &value }
diff --git a/backend/internal/service/payment_refund.go b/backend/internal/service/payment_refund.go
index 7521878c7dd..d5096e62a05 100644
--- a/backend/internal/service/payment_refund.go
+++ b/backend/internal/service/payment_refund.go
@@ -2,7 +2,6 @@ package service
import (
"context"
- "errors"
"fmt"
"log/slog"
"math"
@@ -203,8 +202,28 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float
if err != nil {
return nil, nil, infraerrors.NotFound("NOT_FOUND", "order not found")
}
- ok := []string{OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundFailed}
- if !psSliceContains(ok, o.Status) {
+ ok := []string{OrderStatusCompleted, OrderStatusRefundRequested}
+ isWalletRefund := isWalletCreditsPaymentOrder(o)
+ recovering := false
+ if o.Status == OrderStatusRefunding {
+ if !isRefundLeaseStale(o.UpdatedAt, time.Now()) {
+ return nil, nil, infraerrors.Conflict("REFUND_IN_PROGRESS", "refund is being processed")
+ }
+ if o.RefundAmount <= 0 {
+ return nil, nil, infraerrors.Conflict("REFUND_RECOVERY_MANUAL_REQUIRED", "legacy refund state has no immutable refund amount; manual reconciliation is required")
+ }
+ if amt > 0 && math.Abs(amt-o.RefundAmount) > amountToleranceCNY {
+ return nil, nil, infraerrors.Conflict("REFUND_RECOVERY_CONFLICT", "refund recovery amount does not match the claimed refund")
+ }
+ amt = o.RefundAmount
+ force = o.ForceRefund
+ if o.RefundReason != nil && strings.TrimSpace(*o.RefundReason) != "" {
+ reason = *o.RefundReason
+ }
+ recovering = true
+ } else if o.Status == OrderStatusRefundFailed {
+ return nil, nil, infraerrors.Conflict("REFUND_RECOVERY_MANUAL_REQUIRED", "a compensated or legacy failed refund requires manual reconciliation")
+ } else if !psSliceContains(ok, o.Status) {
return nil, nil, infraerrors.BadRequest("INVALID_STATUS", "order status does not allow refund")
}
// Check provider instance allows admin refund
@@ -220,6 +239,9 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float
if !inst.RefundEnabled {
return nil, nil, infraerrors.Forbidden("REFUND_DISABLED", "refund is not enabled for this provider")
}
+ if err := validateAutomaticRefundPreflight(o, inst); err != nil {
+ return nil, nil, err
+ }
if math.IsNaN(amt) || math.IsInf(amt, 0) {
return nil, nil, infraerrors.BadRequest("INVALID_AMOUNT", "invalid refund amount")
}
@@ -237,88 +259,65 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float
if rr == "" {
rr = fmt.Sprintf("refund order:%d", o.ID)
}
- p := &RefundPlan{OrderID: oid, Order: o, RefundAmount: amt, GatewayAmount: ga, Reason: rr, Force: force, DeductBalance: deduct, DeductionType: payment.DeductionTypeNone}
- if deduct {
- if er := s.prepDeduct(ctx, o, p, force); er != nil {
+ p := &RefundPlan{OrderID: oid, Order: o, RefundAmount: amt, GatewayAmount: ga, Reason: rr, Force: force, DeductBalance: deduct || isWalletRefund, DeductionType: payment.DeductionTypeNone}
+ if recovering {
+ if isWalletRefund {
+ err = s.restoreWalletRefundPlanFromAudit(ctx, p)
+ } else {
+ err = s.restoreStandardRefundPlanFromAudit(ctx, p)
+ }
+ if err != nil {
+ return nil, nil, err
+ }
+ } else if deduct || isWalletRefund {
+ if er, prepErr := s.prepDeduct(ctx, o, p, force); prepErr != nil {
+ return nil, nil, prepErr
+ } else if er != nil {
return nil, er, nil
}
}
return p, nil, nil
}
-func (s *PaymentService) prepDeduct(ctx context.Context, o *dbent.PaymentOrder, p *RefundPlan, force bool) *RefundResult {
+func (s *PaymentService) prepDeduct(ctx context.Context, o *dbent.PaymentOrder, p *RefundPlan, force bool) (*RefundResult, error) {
+ if isWalletCreditsPaymentOrder(o) {
+ return s.prepareWalletCreditDeduction(ctx, o, p, force)
+ }
if o.OrderType == payment.OrderTypeSubscription {
p.DeductionType = payment.DeductionTypeSubscription
if o.SubscriptionGroupID != nil && o.SubscriptionDays != nil {
- p.SubDaysToDeduct = *o.SubscriptionDays
+ p.SubDaysToDeduct = subscriptionDaysForRefund(*o.SubscriptionDays, p.RefundAmount, o.Amount)
sub, err := s.subscriptionSvc.GetActiveSubscription(ctx, o.UserID, *o.SubscriptionGroupID)
if err == nil && sub != nil {
p.SubscriptionID = sub.ID
} else if !force {
- return &RefundResult{Success: false, Warning: "cannot find active subscription for deduction, use force", RequireForce: true}
+ return &RefundResult{Success: false, Warning: "cannot find active subscription for deduction, use force", RequireForce: true}, nil
+ } else {
+ p.SubDaysToDeduct = 0
}
}
- return nil
+ return nil, nil
}
u, err := s.userRepo.GetByID(ctx, o.UserID)
if err != nil {
if !force {
- return &RefundResult{Success: false, Warning: "cannot fetch user balance, use force", RequireForce: true}
+ return &RefundResult{Success: false, Warning: "cannot fetch user balance, use force", RequireForce: true}, nil
}
- return nil
+ return nil, nil
+ }
+ if u.Balance+amountToleranceCNY < p.RefundAmount && !force {
+ return &RefundResult{Success: false, Warning: "user balance is lower than the refund amount, use force", RequireForce: true}, nil
}
p.DeductionType = payment.DeductionTypeBalance
- p.BalanceToDeduct = math.Min(p.RefundAmount, u.Balance)
- return nil
+ p.BalanceToDeduct = math.Min(p.RefundAmount, math.Max(0, u.Balance))
+ return nil, nil
}
func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*RefundResult, error) {
- c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(p.OrderID), paymentorder.StatusIn(OrderStatusCompleted, OrderStatusRefundRequested, OrderStatusRefundFailed)).SetStatus(OrderStatusRefunding).Save(ctx)
- if err != nil {
- return nil, fmt.Errorf("lock: %w", err)
- }
- if c == 0 {
- return nil, infraerrors.Conflict("CONFLICT", "order status changed")
- }
- if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 {
- // Skip balance deduction on retry if previous attempt already deducted
- // but failed to roll back (REFUND_ROLLBACK_FAILED in audit log).
- if !s.hasAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED") {
- if err := s.userRepo.DeductBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil {
- s.restoreStatus(ctx, p)
- return nil, fmt.Errorf("deduction: %w", err)
- }
- } else {
- slog.Warn("skipping balance deduction on retry (previous rollback failed)", "orderID", p.OrderID)
- p.BalanceToDeduct = 0
- }
- }
- if p.DeductionType == payment.DeductionTypeSubscription && p.SubDaysToDeduct > 0 && p.SubscriptionID > 0 {
- if !s.hasAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED") {
- _, err := s.subscriptionSvc.ExtendSubscription(ctx, p.SubscriptionID, -p.SubDaysToDeduct)
- if err != nil {
- if errors.Is(err, ErrAdjustWouldExpire) {
- // Deduction would expire the subscription — revoke it entirely
- slog.Info("subscription deduction would expire, revoking", "orderID", p.OrderID, "subID", p.SubscriptionID, "days", p.SubDaysToDeduct)
- if revokeErr := s.subscriptionSvc.RevokeSubscription(ctx, p.SubscriptionID); revokeErr != nil {
- s.restoreStatus(ctx, p)
- return nil, fmt.Errorf("revoke subscription: %w", revokeErr)
- }
- } else {
- // Other errors (DB failure, not found) — abort refund
- s.restoreStatus(ctx, p)
- return nil, fmt.Errorf("deduct subscription days: %w", err)
- }
- }
- } else {
- slog.Warn("skipping subscription deduction on retry (previous rollback failed)", "orderID", p.OrderID)
- p.SubDaysToDeduct = 0
- }
- }
- if err := s.gwRefund(ctx, p); err != nil {
- return s.handleGwFail(ctx, p, err)
+ if p != nil && p.DeductionType == payment.DeductionTypeWallet {
+ return s.executeWalletRefund(ctx, p)
}
- return s.markRefundOk(ctx, p)
+ return s.executeStandardRefund(ctx, p)
}
func (s *PaymentService) gwRefund(ctx context.Context, p *RefundPlan) error {
@@ -340,10 +339,11 @@ func (s *PaymentService) gwRefund(ctx context.Context, p *RefundPlan) error {
return err
}
_, err = prov.Refund(ctx, payment.RefundRequest{
- TradeNo: p.Order.PaymentTradeNo,
- OrderID: p.Order.OutTradeNo,
- Amount: strconv.FormatFloat(p.GatewayAmount, 'f', 2, 64),
- Reason: p.Reason,
+ TradeNo: p.Order.PaymentTradeNo,
+ OrderID: p.Order.OutTradeNo,
+ Amount: strconv.FormatFloat(p.GatewayAmount, 'f', 2, 64),
+ Reason: p.Reason,
+ IdempotencyKey: refundGatewayIdempotencyKey(p.OrderID),
})
return err
}
diff --git a/backend/internal/service/payment_refund_test.go b/backend/internal/service/payment_refund_test.go
index ca5b62cb28d..cfe3b44cc68 100644
--- a/backend/internal/service/payment_refund_test.go
+++ b/backend/internal/service/payment_refund_test.go
@@ -4,15 +4,309 @@ package service
import (
"context"
+ "errors"
+ "fmt"
"strconv"
"testing"
"time"
+ dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/stretchr/testify/require"
)
+func TestClassifyRefundGatewayResultSeparatesDefiniteRejectionFromAmbiguity(t *testing.T) {
+ tests := []struct {
+ name string
+ response *payment.RefundResponse
+ err error
+ want refundGatewayDisposition
+ }{
+ {name: "success", response: &payment.RefundResponse{Status: payment.ProviderStatusSuccess}, want: refundGatewaySucceeded},
+ {name: "pending", response: &payment.RefundResponse{Status: payment.ProviderStatusPending}, want: refundGatewayPending},
+ {name: "explicit failed", response: &payment.RefundResponse{Status: payment.ProviderStatusFailed}, want: refundGatewayRejected},
+ {name: "transport error", err: errors.New("connection reset after send"), want: refundGatewayAmbiguous},
+ {name: "nil response", want: refundGatewayAmbiguous},
+ {name: "unknown response", response: &payment.RefundResponse{Status: "mystery"}, want: refundGatewayAmbiguous},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.Equal(t, tt.want, classifyRefundGatewayResult(tt.response, tt.err))
+ })
+ }
+}
+
+func TestSubscriptionDaysForPartialRefundAreProportional(t *testing.T) {
+ require.Equal(t, 30, subscriptionDaysForRefund(30, 30, 30))
+ require.Equal(t, 15, subscriptionDaysForRefund(30, 15, 30))
+ require.Equal(t, 1, subscriptionDaysForRefund(30, 0.01, 30), "a positive partial refund must reverse at least one whole day")
+}
+
+func TestPrepareRefundFailsClosedBeforeDeductionWithoutSafeGatewayReplay(t *testing.T) {
+ tests := []struct {
+ name string
+ providerKey string
+ tradeNo string
+ }{
+ {name: "missing provider trade evidence", providerKey: payment.TypeAlipay, tradeNo: ""},
+ {name: "EasyPay lacks refund idempotency", providerKey: payment.TypeEasyPay, tradeNo: "provider-trade"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ user, err := client.User.Create().
+ SetEmail(fmt.Sprintf("refund-preflight-%s@example.com", tt.providerKey)).
+ SetPasswordHash("hash").
+ SetUsername("refund-preflight-" + tt.providerKey).
+ Save(ctx)
+ require.NoError(t, err)
+ inst, err := client.PaymentProviderInstance.Create().
+ SetProviderKey(tt.providerKey).
+ SetName("refund-preflight-" + tt.providerKey).
+ SetConfig("{}").
+ SetSupportedTypes(tt.providerKey).
+ SetEnabled(true).
+ SetRefundEnabled(true).
+ Save(ctx)
+ require.NoError(t, err)
+ builder := client.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(30).
+ SetPayAmount(30).
+ SetFeeRate(0).
+ SetRechargeCode("REFUND-PREFLIGHT-" + tt.providerKey).
+ SetOutTradeNo("sub2_refund_preflight_" + tt.providerKey).
+ SetPaymentType(tt.providerKey).
+ SetOrderType(payment.OrderTypeBalance).
+ SetStatus(OrderStatusCompleted).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetPaidAt(time.Now()).
+ SetCompletedAt(time.Now()).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ SetProviderInstanceID(strconv.FormatInt(inst.ID, 10)).
+ SetProviderKey(tt.providerKey)
+ builder.SetPaymentTradeNo(tt.tradeNo)
+ order, err := builder.Save(ctx)
+ require.NoError(t, err)
+
+ svc := &PaymentService{entClient: client}
+ plan, result, err := svc.PrepareRefund(ctx, order.ID, 0, "", false, false)
+ require.Nil(t, plan)
+ require.Nil(t, result)
+ require.Error(t, err)
+ require.Equal(t, "REFUND_MANUAL_REQUIRED", infraerrors.Reason(err))
+ })
+ }
+}
+
+type refundAuditFailClosedUserRepo struct {
+ UserRepository
+ deductCalls int
+}
+
+func (r *refundAuditFailClosedUserRepo) DeductBalance(context.Context, int64, float64) error {
+ r.deductCalls++
+ return nil
+}
+
+type refundBalanceUserRepo struct {
+ UserRepository
+ user *User
+}
+
+func (r *refundBalanceUserRepo) GetByID(context.Context, int64) (*User, error) {
+ return r.user, nil
+}
+
+func TestPrepDeductBalanceShortfallRequiresExplicitForce(t *testing.T) {
+ ctx := context.Background()
+ order := &dbent.PaymentOrder{
+ UserID: 42,
+ OrderType: payment.OrderTypeBalance,
+ }
+ svc := &PaymentService{
+ userRepo: &refundBalanceUserRepo{user: &User{ID: 42, Balance: 10}},
+ }
+
+ plan := &RefundPlan{RefundAmount: 100}
+ result, err := svc.prepDeduct(ctx, order, plan, false)
+ require.NoError(t, err)
+ require.NotNil(t, result)
+ require.True(t, result.RequireForce)
+ require.Zero(t, plan.BalanceToDeduct, "a non-forced shortfall must not produce an executable partial deduction plan")
+
+ forcedPlan := &RefundPlan{RefundAmount: 100}
+ result, err = svc.prepDeduct(ctx, order, forcedPlan, true)
+ require.NoError(t, err)
+ require.Nil(t, result)
+ require.Equal(t, 10.0, forcedPlan.BalanceToDeduct)
+}
+
+func TestExecuteRefundFailsClosedWhenRollbackAuditLookupFails(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ user, err := client.User.Create().
+ SetEmail("refund-audit-fail-closed@example.com").
+ SetPasswordHash("hash").
+ SetUsername("refund-audit-fail-closed").
+ Save(ctx)
+ require.NoError(t, err)
+ order, err := client.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(88).
+ SetPayAmount(88).
+ SetFeeRate(0).
+ SetRechargeCode("REFUND-AUDIT-FAIL-CLOSED").
+ SetOutTradeNo("sub2_refund_audit_fail_closed").
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("trade-refund-audit-fail-closed").
+ SetOrderType(payment.OrderTypeBalance).
+ SetStatus(OrderStatusCompleted).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetPaidAt(time.Now()).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+ _, err = client.ExecContext(ctx, "DROP TABLE payment_audit_logs")
+ require.NoError(t, err)
+
+ userRepo := &refundAuditFailClosedUserRepo{}
+ svc := &PaymentService{entClient: client, userRepo: userRepo}
+ result, err := svc.ExecuteRefund(ctx, &RefundPlan{
+ OrderID: order.ID,
+ Order: order,
+ RefundAmount: order.Amount,
+ GatewayAmount: order.Amount,
+ DeductionType: payment.DeductionTypeBalance,
+ BalanceToDeduct: order.Amount,
+ })
+ require.Nil(t, result)
+ require.ErrorContains(t, err, "check refund rollback audit")
+ require.Zero(t, userRepo.deductCalls, "refund must not deduct when idempotency evidence is unavailable")
+ reloaded, err := client.PaymentOrder.Get(ctx, order.ID)
+ require.NoError(t, err)
+ require.Equal(t, OrderStatusCompleted, reloaded.Status)
+}
+
+type refundTransactionalUserRepo struct {
+ UserRepository
+ client *dbent.Client
+}
+
+func (r *refundTransactionalUserRepo) DeductBalance(ctx context.Context, id int64, amount float64) error {
+ client := r.client
+ if tx := dbent.TxFromContext(ctx); tx != nil {
+ client = tx.Client()
+ }
+ _, err := client.User.UpdateOneID(id).AddBalance(-amount).Save(ctx)
+ return err
+}
+
+func newStandardRefundClaimFixture(t *testing.T, balance float64) (context.Context, *dbent.Client, *dbent.User, *dbent.PaymentOrder) {
+ t.Helper()
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ fixedNow := time.Date(2026, time.July, 14, 3, 30, 0, 0, time.UTC)
+ user, err := client.User.Create().
+ SetEmail(fmt.Sprintf("refund-claim-%g@example.com", balance)).
+ SetPasswordHash("hash").
+ SetUsername(fmt.Sprintf("refund-claim-%g", balance)).
+ SetBalance(balance).
+ Save(ctx)
+ require.NoError(t, err)
+ order, err := client.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(100).
+ SetPayAmount(100).
+ SetFeeRate(0).
+ SetRechargeCode(fmt.Sprintf("REFUND-CLAIM-%g", balance)).
+ SetOutTradeNo(fmt.Sprintf("sub2_refund_claim_%g", balance)).
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo(fmt.Sprintf("trade-refund-claim-%g", balance)).
+ SetOrderType(payment.OrderTypeBalance).
+ SetStatus(OrderStatusCompleted).
+ SetExpiresAt(fixedNow.Add(time.Hour)).
+ SetPaidAt(fixedNow).
+ SetCreatedAt(fixedNow).
+ SetUpdatedAt(fixedNow).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+ return ctx, client, user, order
+}
+
+func TestClaimStandardRefundRechecksNonForcedBalanceBeforeDeduction(t *testing.T) {
+ ctx, client, user, order := newStandardRefundClaimFixture(t, 0)
+ svc := &PaymentService{
+ entClient: client,
+ userRepo: &refundTransactionalUserRepo{client: client},
+ }
+ plan := &RefundPlan{
+ OrderID: order.ID,
+ Order: order,
+ RefundAmount: order.Amount,
+ GatewayAmount: order.Amount,
+ DeductionType: payment.DeductionTypeBalance,
+ BalanceToDeduct: order.Amount,
+ }
+
+ recovered, err := svc.claimStandardRefund(ctx, plan)
+ require.False(t, recovered)
+ require.Error(t, err)
+ require.Equal(t, "REFUND_BALANCE_CHANGED_REQUIRE_FORCE", infraerrors.Reason(err))
+
+ reloadedUser, err := client.User.Get(ctx, user.ID)
+ require.NoError(t, err)
+ require.Zero(t, reloadedUser.Balance, "failed non-forced claim must not overdraw the balance")
+ reloadedOrder, err := client.PaymentOrder.Get(ctx, order.ID)
+ require.NoError(t, err)
+ require.Equal(t, OrderStatusCompleted, reloadedOrder.Status)
+ auditCount, err := client.PaymentAuditLog.Query().Count(ctx)
+ require.NoError(t, err)
+ require.Zero(t, auditCount, "failed claim must not leave a deduction marker")
+}
+
+func TestClaimStandardRefundForcedDeductionUsesCurrentNonNegativeBalance(t *testing.T) {
+ ctx, client, user, order := newStandardRefundClaimFixture(t, 25)
+ svc := &PaymentService{
+ entClient: client,
+ userRepo: &refundTransactionalUserRepo{client: client},
+ }
+ plan := &RefundPlan{
+ OrderID: order.ID,
+ Order: order,
+ RefundAmount: order.Amount,
+ GatewayAmount: order.Amount,
+ Force: true,
+ DeductionType: payment.DeductionTypeBalance,
+ BalanceToDeduct: order.Amount,
+ }
+
+ recovered, err := svc.claimStandardRefund(ctx, plan)
+ require.NoError(t, err)
+ require.False(t, recovered)
+ require.Equal(t, 25.0, plan.BalanceToDeduct)
+
+ reloadedUser, err := client.User.Get(ctx, user.ID)
+ require.NoError(t, err)
+ require.Zero(t, reloadedUser.Balance, "forced refund may use the available balance but must not overdraw it")
+ detail, found, err := loadStandardRefundDeductionDetail(ctx, client, order.ID)
+ require.NoError(t, err)
+ require.True(t, found)
+ require.Equal(t, 25.0, detail.BalanceDeducted, "immutable audit evidence must record the actual deduction")
+}
+
func TestValidateRefundRequestRejectsLegacyGuessedProviderInstance(t *testing.T) {
ctx := context.Background()
client := newPaymentConfigServiceTestClient(t)
diff --git a/backend/internal/service/payment_resume_lookup.go b/backend/internal/service/payment_resume_lookup.go
index 1ff061e8cac..f5e1972a71e 100644
--- a/backend/internal/service/payment_resume_lookup.go
+++ b/backend/internal/service/payment_resume_lookup.go
@@ -22,7 +22,7 @@ func (s *PaymentService) GetPublicOrderByResumeToken(ctx context.Context, token
}
return nil, fmt.Errorf("get order by resume token: %w", err)
}
- if claims.UserID > 0 && order.UserID != claims.UserID {
+ if order.UserID != claims.UserID {
return nil, invalidResumeTokenMatchError()
}
snapshot := psOrderProviderSnapshot(order)
@@ -45,17 +45,7 @@ func (s *PaymentService) GetPublicOrderByResumeToken(ctx context.Context, token
if claims.PaymentType != "" && NormalizeVisibleMethod(order.PaymentType) != NormalizeVisibleMethod(claims.PaymentType) {
return nil, invalidResumeTokenMatchError()
}
- if order.Status == OrderStatusPending || order.Status == OrderStatusExpired {
- result := s.checkPaid(ctx, order)
- if result == checkPaidResultAlreadyPaid {
- order, err = s.entClient.PaymentOrder.Get(ctx, order.ID)
- if err != nil {
- return nil, fmt.Errorf("reload order by resume token: %w", err)
- }
- }
- }
-
- return order, nil
+ return s.reconcileVisiblePaymentOrder(ctx, order)
}
func invalidResumeTokenMatchError() error {
diff --git a/backend/internal/service/payment_resume_lookup_test.go b/backend/internal/service/payment_resume_lookup_test.go
index a7b5b7376c6..dc9479e78c0 100644
--- a/backend/internal/service/payment_resume_lookup_test.go
+++ b/backend/internal/service/payment_resume_lookup_test.go
@@ -258,58 +258,3 @@ func TestGetPublicOrderByResumeTokenChecksUpstreamForPendingOrder(t *testing.T)
require.Equal(t, order.ID, got.ID)
require.Equal(t, 1, provider.queryCount)
}
-
-func TestVerifyOrderPublicDoesNotCheckUpstreamForPendingOrder(t *testing.T) {
- ctx := context.Background()
- client := newPaymentConfigServiceTestClient(t)
- user, err := client.User.Create().
- SetEmail("public-verify@example.com").
- SetPasswordHash("hash").
- SetUsername("public-verify-user").
- Save(ctx)
- require.NoError(t, err)
-
- order, err := client.PaymentOrder.Create().
- SetUserID(user.ID).
- SetUserEmail(user.Email).
- SetUserName(user.Username).
- SetAmount(88).
- SetPayAmount(88).
- SetFeeRate(0).
- SetRechargeCode("PUBLIC-VERIFY").
- SetOutTradeNo("sub2_public_verify_pending").
- SetPaymentType(payment.TypeAlipay).
- SetPaymentTradeNo("trade-public-verify").
- SetOrderType(payment.OrderTypeBalance).
- SetStatus(OrderStatusPending).
- SetExpiresAt(time.Now().Add(time.Hour)).
- SetClientIP("127.0.0.1").
- SetSrcHost("api.example.com").
- Save(ctx)
- require.NoError(t, err)
-
- registry := payment.NewRegistry()
- provider := &paymentResumeLookupProvider{}
- registry.Register(provider)
-
- svc := &PaymentService{
- entClient: client,
- registry: registry,
- providersLoaded: true,
- }
-
- got, err := svc.VerifyOrderPublic(ctx, order.OutTradeNo)
- require.NoError(t, err)
- require.Equal(t, order.ID, got.ID)
- require.Equal(t, 0, provider.queryCount)
-}
-
-func TestVerifyOrderPublicRejectsBlankOutTradeNo(t *testing.T) {
- svc := &PaymentService{
- entClient: newPaymentConfigServiceTestClient(t),
- }
-
- _, err := svc.VerifyOrderPublic(context.Background(), " ")
- require.Error(t, err)
- require.Equal(t, "INVALID_OUT_TRADE_NO", infraerrors.Reason(err))
-}
diff --git a/backend/internal/service/payment_resume_security_test.go b/backend/internal/service/payment_resume_security_test.go
new file mode 100644
index 00000000000..7a3157255b0
--- /dev/null
+++ b/backend/internal/service/payment_resume_security_test.go
@@ -0,0 +1,95 @@
+package service
+
+import (
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWeChatPaymentResumeTokenBindsUserAndReplayJTI(t *testing.T) {
+ svc := NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
+ token, err := svc.CreateWeChatPaymentResumeToken(WeChatPaymentResumeClaims{
+ UserID: 17,
+ OpenID: "openid-17",
+ PaymentType: payment.TypeWxpay,
+ })
+ require.NoError(t, err)
+
+ claims, err := svc.ParseWeChatPaymentResumeToken(token)
+ require.NoError(t, err)
+ require.EqualValues(t, 17, claims.UserID)
+ require.Len(t, claims.JTI, 32)
+ require.Greater(t, claims.ExpiresAt, time.Now().Unix())
+}
+
+func TestWeChatPaymentResumeTokenRejectsLegacyUnboundClaims(t *testing.T) {
+ svc := NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
+ token, err := svc.createSignedToken(WeChatPaymentResumeClaims{
+ TokenType: wechatPaymentResumeTokenType,
+ OpenID: "legacy-openid",
+ PaymentType: payment.TypeWxpay,
+ ExpiresAt: time.Now().Add(time.Minute).Unix(),
+ })
+ require.NoError(t, err)
+
+ _, err = svc.ParseWeChatPaymentResumeToken(token)
+ require.Error(t, err)
+ require.Equal(t, "INVALID_WECHAT_PAYMENT_RESUME_TOKEN", infraerrors.Reason(err))
+}
+
+func TestWeChatPaymentOAuthSubjectTokenBindsUser(t *testing.T) {
+ svc := NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
+ token, err := svc.CreateWeChatPaymentOAuthSubjectToken(42)
+ require.NoError(t, err)
+
+ claims, err := svc.ParseWeChatPaymentOAuthSubjectToken(token)
+ require.NoError(t, err)
+ require.EqualValues(t, 42, claims.UserID)
+ require.Len(t, claims.JTI, 32)
+}
+
+func TestValidateWeChatResumeOrderBindingFailsClosed(t *testing.T) {
+ validJTI := "0123456789abcdef0123456789abcdef"
+ tests := []struct {
+ name string
+ req CreateOrderRequest
+ }{
+ {
+ name: "unsigned openid",
+ req: CreateOrderRequest{
+ UserID: 17,
+ OpenID: "openid-17",
+ PaymentSource: PaymentSourceWechatInAppResume,
+ },
+ },
+ {
+ name: "cross user",
+ req: CreateOrderRequest{
+ UserID: 18,
+ ResumeTokenUserID: 17,
+ ResumeTokenJTI: validJTI,
+ OpenID: "openid-17",
+ PaymentSource: PaymentSourceWechatInAppResume,
+ },
+ },
+ {
+ name: "malformed replay jti",
+ req: CreateOrderRequest{
+ UserID: 17,
+ ResumeTokenUserID: 17,
+ ResumeTokenJTI: "not-a-jti",
+ OpenID: "openid-17",
+ PaymentSource: PaymentSourceWechatInAppResume,
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ require.Error(t, validateWeChatResumeOrderBinding(tt.req))
+ })
+ }
+}
diff --git a/backend/internal/service/payment_resume_service.go b/backend/internal/service/payment_resume_service.go
index 9ae62fde33b..384a4aa62d1 100644
--- a/backend/internal/service/payment_resume_service.go
+++ b/backend/internal/service/payment_resume_service.go
@@ -4,8 +4,10 @@ import (
"bytes"
"context"
"crypto/hmac"
+ "crypto/rand"
"crypto/sha256"
"encoding/base64"
+ "encoding/hex"
"encoding/json"
"fmt"
"net"
@@ -34,13 +36,16 @@ const (
VisibleMethodSourceOfficialWechat = "official_wxpay"
VisibleMethodSourceEasyPayWechat = "easypay_wxpay"
- wechatPaymentResumeTokenType = "wechat_payment_resume"
+ wechatPaymentResumeTokenType = "wechat_payment_resume"
+ wechatPaymentOAuthSubjectTokenType = "wechat_payment_oauth_subject"
paymentResumeNotConfiguredCode = "PAYMENT_RESUME_NOT_CONFIGURED"
paymentResumeNotConfiguredMessage = "payment resume tokens require a configured signing key"
+ paymentResumeMinSigningKeyBytes = 32
- paymentResumeTokenTTL = 24 * time.Hour
- wechatPaymentResumeTokenTTL = 15 * time.Minute
+ paymentResumeTokenTTL = 24 * time.Hour
+ wechatPaymentResumeTokenTTL = 15 * time.Minute
+ wechatPaymentSubjectTokenTTL = 10 * time.Minute
)
type ResumeTokenClaims struct {
@@ -56,6 +61,8 @@ type ResumeTokenClaims struct {
type WeChatPaymentResumeClaims struct {
TokenType string `json:"tk,omitempty"`
+ UserID int64 `json:"uid"`
+ JTI string `json:"jti"`
OpenID string `json:"openid"`
PaymentType string `json:"pt,omitempty"`
Amount string `json:"amt,omitempty"`
@@ -67,6 +74,14 @@ type WeChatPaymentResumeClaims struct {
ExpiresAt int64 `json:"exp,omitempty"`
}
+type WeChatPaymentOAuthSubjectClaims struct {
+ TokenType string `json:"tk"`
+ UserID int64 `json:"uid"`
+ JTI string `json:"jti"`
+ IssuedAt int64 `json:"iat"`
+ ExpiresAt int64 `json:"exp"`
+}
+
type PaymentResumeService struct {
signingKey []byte
verifyKeys [][]byte
@@ -79,12 +94,12 @@ type visibleMethodLoadBalancer struct {
func NewPaymentResumeService(signingKey []byte, verifyFallbacks ...[]byte) *PaymentResumeService {
svc := &PaymentResumeService{}
- if len(signingKey) > 0 {
+ if len(signingKey) >= paymentResumeMinSigningKeyBytes {
svc.signingKey = append([]byte(nil), signingKey...)
svc.verifyKeys = append(svc.verifyKeys, svc.signingKey)
}
for _, fallback := range verifyFallbacks {
- if len(fallback) == 0 {
+ if len(fallback) < paymentResumeMinSigningKeyBytes {
continue
}
cloned := append([]byte(nil), fallback...)
@@ -232,17 +247,21 @@ func visibleMethodSourceSettingKey(method string) string {
}
}
-func CanonicalizeReturnURL(raw string, srcHost string, srcURL string) (string, error) {
+func CanonicalizeReturnURL(raw string, trustedFrontendURL string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
parsed, err := url.Parse(raw)
- if err != nil || !parsed.IsAbs() || parsed.Host == "" {
- return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must be an absolute http/https URL")
+ if err != nil || !parsed.IsAbs() || parsed.Host == "" || parsed.User != nil {
+ return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must be an absolute URL without credentials")
+ }
+ trusted, err := url.Parse(strings.TrimSpace(trustedFrontendURL))
+ if err != nil || !trusted.IsAbs() || trusted.Host == "" || trusted.User != nil {
+ return "", infraerrors.ServiceUnavailable("PAYMENT_RETURN_URL_NOT_CONFIGURED", "canonical frontend URL is not configured")
}
- if parsed.Scheme != "http" && parsed.Scheme != "https" {
- return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must use http or https")
+ if !isSecurePaymentReturnOrigin(parsed) || !isSecurePaymentReturnOrigin(trusted) {
+ return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must use HTTPS except for loopback development")
}
parsed.Fragment = ""
if parsed.Path == "" {
@@ -251,29 +270,54 @@ func CanonicalizeReturnURL(raw string, srcHost string, srcURL string) (string, e
if parsed.Path != paymentResultReturnPath {
return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must target the canonical internal payment result page")
}
- if !allowedReturnURLHost(parsed.Host, srcHost, srcURL) {
- return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must use the same host as the current site or browser origin")
+ if !sameURLOrigin(parsed, trusted) {
+ return "", infraerrors.BadRequest("INVALID_RETURN_URL", "return_url must use the configured frontend origin")
}
return parsed.String(), nil
}
-func allowedReturnURLHost(returnURLHost string, requestHost string, refererURL string) bool {
- if sameOriginHost(returnURLHost, requestHost) {
+func isSecurePaymentReturnOrigin(parsed *url.URL) bool {
+ if parsed == nil {
+ return false
+ }
+ if strings.EqualFold(parsed.Scheme, "https") {
+ return true
+ }
+ if !strings.EqualFold(parsed.Scheme, "http") {
+ return false
+ }
+ hostname := strings.TrimSpace(parsed.Hostname())
+ if strings.EqualFold(hostname, "localhost") || strings.HasSuffix(strings.ToLower(hostname), ".localhost") {
return true
}
+ ip := net.ParseIP(hostname)
+ return ip != nil && ip.IsLoopback()
+}
- refererURL = strings.TrimSpace(refererURL)
- if refererURL == "" {
+func sameURLOrigin(left, right *url.URL) bool {
+ if left == nil || right == nil || !strings.EqualFold(left.Scheme, right.Scheme) {
return false
}
- parsedReferer, err := url.Parse(refererURL)
- if err != nil || parsedReferer.Host == "" {
+ if !strings.EqualFold(left.Hostname(), right.Hostname()) {
return false
}
- return sameOriginHost(returnURLHost, parsedReferer.Host)
+ return effectiveURLPort(left) == effectiveURLPort(right)
}
-func buildPaymentReturnURL(base string, orderID int64, outTradeNo string, resumeToken string) (string, error) {
+func effectiveURLPort(parsed *url.URL) string {
+ if port := parsed.Port(); port != "" {
+ return port
+ }
+ if strings.EqualFold(parsed.Scheme, "https") {
+ return "443"
+ }
+ if strings.EqualFold(parsed.Scheme, "http") {
+ return "80"
+ }
+ return ""
+}
+
+func buildPaymentReturnURL(base string, orderID int64, outTradeNo string) (string, error) {
canonical := strings.TrimSpace(base)
if canonical == "" {
return "", nil
@@ -295,40 +339,12 @@ func buildPaymentReturnURL(base string, orderID int64, outTradeNo string, resume
if strings.TrimSpace(outTradeNo) != "" {
query.Set("out_trade_no", strings.TrimSpace(outTradeNo))
}
- if strings.TrimSpace(resumeToken) != "" {
- query.Set("resume_token", strings.TrimSpace(resumeToken))
- }
query.Set("status", "success")
parsed.RawQuery = query.Encode()
return parsed.String(), nil
}
-func sameOriginHost(returnURLHost string, requestHost string) bool {
- returnHost := strings.TrimSpace(returnURLHost)
- reqHost := strings.TrimSpace(requestHost)
- if returnHost == "" || reqHost == "" {
- return false
- }
- if strings.EqualFold(returnHost, reqHost) {
- return true
- }
-
- returnName, returnPort := splitHostPortDefault(returnHost)
- reqName, reqPort := splitHostPortDefault(reqHost)
- if returnName == "" || reqName == "" {
- return false
- }
- return strings.EqualFold(returnName, reqName) && returnPort == reqPort
-}
-
-func splitHostPortDefault(raw string) (string, string) {
- if host, port, err := net.SplitHostPort(raw); err == nil {
- return host, port
- }
- return raw, ""
-}
-
func (s *PaymentResumeService) CreateToken(claims ResumeTokenClaims) (string, error) {
if err := s.ensureSigningKey(); err != nil {
return "", err
@@ -336,6 +352,9 @@ func (s *PaymentResumeService) CreateToken(claims ResumeTokenClaims) (string, er
if claims.OrderID <= 0 {
return "", fmt.Errorf("resume token requires order id")
}
+ if claims.UserID <= 0 {
+ return "", fmt.Errorf("resume token requires user id")
+ }
if claims.IssuedAt == 0 {
claims.IssuedAt = time.Now().Unix()
}
@@ -346,6 +365,9 @@ func (s *PaymentResumeService) CreateToken(claims ResumeTokenClaims) (string, er
}
func (s *PaymentResumeService) ParseToken(token string) (*ResumeTokenClaims, error) {
+ if strings.TrimSpace(token) == "" {
+ return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token is required")
+ }
if err := s.ensureSigningKey(); err != nil {
return nil, err
}
@@ -356,6 +378,12 @@ func (s *PaymentResumeService) ParseToken(token string) (*ResumeTokenClaims, err
if claims.OrderID <= 0 {
return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token missing order id")
}
+ if claims.UserID <= 0 {
+ return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token missing user binding")
+ }
+ if claims.ExpiresAt <= 0 {
+ return nil, infraerrors.BadRequest("INVALID_RESUME_TOKEN", "resume token missing expiry")
+ }
if err := validatePaymentResumeExpiry(claims.ExpiresAt, "INVALID_RESUME_TOKEN", "resume token has expired"); err != nil {
return nil, err
}
@@ -366,6 +394,14 @@ func (s *PaymentResumeService) CreateWeChatPaymentResumeToken(claims WeChatPayme
if err := s.ensureSigningKey(); err != nil {
return "", err
}
+ if claims.UserID <= 0 {
+ return "", fmt.Errorf("wechat payment resume token requires user id")
+ }
+ var err error
+ claims.JTI, err = normalizeOrGeneratePaymentResumeJTI(claims.JTI)
+ if err != nil {
+ return "", err
+ }
claims.OpenID = strings.TrimSpace(claims.OpenID)
if claims.OpenID == "" {
return "", fmt.Errorf("wechat payment resume token requires openid")
@@ -400,10 +436,19 @@ func (s *PaymentResumeService) ParseWeChatPaymentResumeToken(token string) (*WeC
if claims.TokenType != wechatPaymentResumeTokenType {
return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token type mismatch")
}
+ if claims.UserID <= 0 {
+ return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token missing user binding")
+ }
+ if !isValidPaymentResumeJTI(claims.JTI) {
+ return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token missing replay binding")
+ }
claims.OpenID = strings.TrimSpace(claims.OpenID)
if claims.OpenID == "" {
return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token missing openid")
}
+ if claims.ExpiresAt <= 0 {
+ return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token missing expiry")
+ }
if err := validatePaymentResumeExpiry(claims.ExpiresAt, "INVALID_WECHAT_PAYMENT_RESUME_TOKEN", "wechat payment resume token has expired"); err != nil {
return nil, err
}
@@ -419,6 +464,71 @@ func (s *PaymentResumeService) ParseWeChatPaymentResumeToken(token string) (*WeC
return &claims, nil
}
+func (s *PaymentResumeService) CreateWeChatPaymentOAuthSubjectToken(userID int64) (string, error) {
+ if err := s.ensureSigningKey(); err != nil {
+ return "", err
+ }
+ if userID <= 0 {
+ return "", fmt.Errorf("wechat payment oauth subject token requires user id")
+ }
+ jti, err := normalizeOrGeneratePaymentResumeJTI("")
+ if err != nil {
+ return "", err
+ }
+ now := time.Now()
+ return s.createSignedToken(WeChatPaymentOAuthSubjectClaims{
+ TokenType: wechatPaymentOAuthSubjectTokenType,
+ UserID: userID,
+ JTI: jti,
+ IssuedAt: now.Unix(),
+ ExpiresAt: now.Add(wechatPaymentSubjectTokenTTL).Unix(),
+ })
+}
+
+func (s *PaymentResumeService) ParseWeChatPaymentOAuthSubjectToken(token string) (*WeChatPaymentOAuthSubjectClaims, error) {
+ if err := s.ensureSigningKey(); err != nil {
+ return nil, err
+ }
+ var claims WeChatPaymentOAuthSubjectClaims
+ if err := s.parseSignedToken(strings.TrimSpace(token), &claims); err != nil {
+ return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_OAUTH_SUBJECT", "wechat payment oauth subject token payload is invalid")
+ }
+ if claims.TokenType != wechatPaymentOAuthSubjectTokenType || claims.UserID <= 0 || !isValidPaymentResumeJTI(claims.JTI) {
+ return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_OAUTH_SUBJECT", "wechat payment oauth subject token binding is invalid")
+ }
+ if claims.ExpiresAt <= 0 {
+ return nil, infraerrors.BadRequest("INVALID_WECHAT_PAYMENT_OAUTH_SUBJECT", "wechat payment oauth subject token missing expiry")
+ }
+ if err := validatePaymentResumeExpiry(claims.ExpiresAt, "INVALID_WECHAT_PAYMENT_OAUTH_SUBJECT", "wechat payment oauth subject token has expired"); err != nil {
+ return nil, err
+ }
+ return &claims, nil
+}
+
+func normalizeOrGeneratePaymentResumeJTI(raw string) (string, error) {
+ jti := strings.TrimSpace(raw)
+ if jti == "" {
+ randomBytes := make([]byte, 16)
+ if _, err := rand.Read(randomBytes); err != nil {
+ return "", fmt.Errorf("generate payment resume jti: %w", err)
+ }
+ return hex.EncodeToString(randomBytes), nil
+ }
+ if !isValidPaymentResumeJTI(jti) {
+ return "", fmt.Errorf("payment resume jti must be a 128-bit hexadecimal value")
+ }
+ return strings.ToLower(jti), nil
+}
+
+func isValidPaymentResumeJTI(raw string) bool {
+ jti := strings.TrimSpace(raw)
+ if len(jti) != 32 {
+ return false
+ }
+ _, err := hex.DecodeString(jti)
+ return err == nil
+}
+
func (s *PaymentResumeService) createSignedToken(claims any) (string, error) {
payload, err := json.Marshal(claims)
if err != nil {
diff --git a/backend/internal/service/payment_resume_service_test.go b/backend/internal/service/payment_resume_service_test.go
index 7e0adc2de84..4a800c6c076 100644
--- a/backend/internal/service/payment_resume_service_test.go
+++ b/backend/internal/service/payment_resume_service_test.go
@@ -65,7 +65,7 @@ func TestNormalizePaymentSource(t *testing.T) {
func TestCanonicalizeReturnURL(t *testing.T) {
t.Parallel()
- got, err := CanonicalizeReturnURL("https://example.com/payment/result?b=2#a", "example.com", "")
+ got, err := CanonicalizeReturnURL("https://example.com/payment/result?b=2#a", "https://example.com")
if err != nil {
t.Fatalf("CanonicalizeReturnURL returned error: %v", err)
}
@@ -77,7 +77,7 @@ func TestCanonicalizeReturnURL(t *testing.T) {
func TestCanonicalizeReturnURLRejectsRelativeURL(t *testing.T) {
t.Parallel()
- if _, err := CanonicalizeReturnURL("/payment/result", "example.com", ""); err == nil {
+ if _, err := CanonicalizeReturnURL("/payment/result", "https://example.com"); err == nil {
t.Fatal("CanonicalizeReturnURL should reject relative URLs")
}
}
@@ -85,18 +85,36 @@ func TestCanonicalizeReturnURLRejectsRelativeURL(t *testing.T) {
func TestCanonicalizeReturnURLRejectsExternalHost(t *testing.T) {
t.Parallel()
- if _, err := CanonicalizeReturnURL("https://evil.example/payment/result", "app.example.com", ""); err == nil {
+ if _, err := CanonicalizeReturnURL("https://evil.example/payment/result", "https://app.example.com"); err == nil {
t.Fatal("CanonicalizeReturnURL should reject external hosts")
}
}
+func TestCanonicalizeReturnURLRejectsHTTPForNonLoopbackFrontend(t *testing.T) {
+ t.Parallel()
+
+ if _, err := CanonicalizeReturnURL("http://app.example.com/payment/result", "http://app.example.com"); err == nil {
+ t.Fatal("CanonicalizeReturnURL should reject a non-loopback HTTP return URL")
+ }
+}
+
+func TestCanonicalizeReturnURLDoesNotTrustAttackerReferer(t *testing.T) {
+ t.Parallel()
+
+ if _, err := CanonicalizeReturnURL(
+ "https://evil.example/payment/result",
+ "https://app.example.com",
+ ); err == nil {
+ t.Fatal("CanonicalizeReturnURL should not authorize a return origin from Referer")
+ }
+}
+
func TestCanonicalizeReturnURLAllowsConfiguredFrontendHost(t *testing.T) {
t.Parallel()
got, err := CanonicalizeReturnURL(
"https://app.example.com/payment/result?from=checkout",
- "api.example.com",
- "https://app.example.com/purchase",
+ "https://app.example.com",
)
if err != nil {
t.Fatalf("CanonicalizeReturnURL returned error: %v", err)
@@ -109,7 +127,7 @@ func TestCanonicalizeReturnURLAllowsConfiguredFrontendHost(t *testing.T) {
func TestCanonicalizeReturnURLRejectsNonCanonicalPath(t *testing.T) {
t.Parallel()
- if _, err := CanonicalizeReturnURL("https://app.example.com/orders/42", "app.example.com", ""); err == nil {
+ if _, err := CanonicalizeReturnURL("https://app.example.com/orders/42", "https://app.example.com"); err == nil {
t.Fatal("CanonicalizeReturnURL should reject non-canonical result paths")
}
}
@@ -117,7 +135,7 @@ func TestCanonicalizeReturnURLRejectsNonCanonicalPath(t *testing.T) {
func TestBuildPaymentReturnURL(t *testing.T) {
t.Parallel()
- got, err := buildPaymentReturnURL("https://example.com/payment/result?from=checkout#fragment", 42, "sub2_42", "resume-token")
+ got, err := buildPaymentReturnURL("https://example.com/payment/result?from=checkout#fragment", 42, "sub2_42")
if err != nil {
t.Fatalf("buildPaymentReturnURL returned error: %v", err)
}
@@ -139,8 +157,8 @@ func TestBuildPaymentReturnURL(t *testing.T) {
if query.Get("out_trade_no") != "sub2_42" {
t.Fatalf("out_trade_no = %q", query.Get("out_trade_no"))
}
- if query.Get("resume_token") != "resume-token" {
- t.Fatalf("resume_token = %q", query.Get("resume_token"))
+ if query.Get("resume_token") != "" {
+ t.Fatalf("resume_token = %q, want empty", query.Get("resume_token"))
}
if query.Get("status") != "success" {
t.Fatalf("status = %q", query.Get("status"))
@@ -150,7 +168,7 @@ func TestBuildPaymentReturnURL(t *testing.T) {
func TestBuildPaymentReturnURLWithoutResumeTokenStillIncludesOutTradeNo(t *testing.T) {
t.Parallel()
- got, err := buildPaymentReturnURL("https://example.com/payment/result", 42, "sub2_42", "")
+ got, err := buildPaymentReturnURL("https://example.com/payment/result", 42, "sub2_42")
if err != nil {
t.Fatalf("buildPaymentReturnURL returned error: %v", err)
}
@@ -171,10 +189,26 @@ func TestBuildPaymentReturnURLWithoutResumeTokenStillIncludesOutTradeNo(t *testi
}
}
+func TestBuildPaymentReturnURLNeverPropagatesResumeCapability(t *testing.T) {
+ t.Parallel()
+
+ got, err := buildPaymentReturnURL("https://example.com/payment/result", 42, "sub2_42")
+ if err != nil {
+ t.Fatalf("buildPaymentReturnURL returned error: %v", err)
+ }
+ parsed, err := url.Parse(got)
+ if err != nil {
+ t.Fatalf("parse return URL: %v", err)
+ }
+ if got := parsed.Query().Get("resume_token"); got != "" {
+ t.Fatalf("provider return URL leaked resume_token %q", got)
+ }
+}
+
func TestBuildPaymentReturnURLEmptyBase(t *testing.T) {
t.Parallel()
- got, err := buildPaymentReturnURL("", 42, "sub2_42", "resume-token")
+ got, err := buildPaymentReturnURL("", 42, "sub2_42")
if err != nil {
t.Fatalf("buildPaymentReturnURL returned error: %v", err)
}
@@ -261,6 +295,7 @@ func TestWeChatPaymentResumeTokenRoundTrip(t *testing.T) {
svc := NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
token, err := svc.CreateWeChatPaymentResumeToken(WeChatPaymentResumeClaims{
+ UserID: 7,
OpenID: "openid-123",
PaymentType: payment.TypeWxpay,
Amount: "12.50",
@@ -281,6 +316,9 @@ func TestWeChatPaymentResumeTokenRoundTrip(t *testing.T) {
if claims.OpenID != "openid-123" || claims.PaymentType != payment.TypeWxpay {
t.Fatalf("claims mismatch: %+v", claims)
}
+ if claims.UserID != 7 || claims.JTI == "" {
+ t.Fatalf("claims must be user-bound and one-time: %+v", claims)
+ }
if claims.Amount != "12.50" || claims.OrderType != payment.OrderTypeSubscription || claims.PlanID != 7 {
t.Fatalf("claims payment context mismatch: %+v", claims)
}
@@ -289,6 +327,36 @@ func TestWeChatPaymentResumeTokenRoundTrip(t *testing.T) {
}
}
+func TestCreateWeChatPaymentResumeTokenRejectsMissingUserBinding(t *testing.T) {
+ t.Parallel()
+
+ svc := NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
+ _, err := svc.CreateWeChatPaymentResumeToken(WeChatPaymentResumeClaims{
+ OpenID: "openid-123",
+ PaymentType: payment.TypeWxpay,
+ })
+ if err == nil {
+ t.Fatal("CreateWeChatPaymentResumeToken should require an HFC user binding")
+ }
+}
+
+func TestWeChatPaymentOAuthSubjectTokenRoundTrip(t *testing.T) {
+ t.Parallel()
+
+ svc := NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
+ token, err := svc.CreateWeChatPaymentOAuthSubjectToken(42)
+ if err != nil {
+ t.Fatalf("CreateWeChatPaymentOAuthSubjectToken returned error: %v", err)
+ }
+ claims, err := svc.ParseWeChatPaymentOAuthSubjectToken(token)
+ if err != nil {
+ t.Fatalf("ParseWeChatPaymentOAuthSubjectToken returned error: %v", err)
+ }
+ if claims.UserID != 42 || claims.JTI == "" {
+ t.Fatalf("subject claims mismatch: %+v", claims)
+ }
+}
+
func TestCreateWeChatPaymentResumeTokenRejectsMissingSigningKey(t *testing.T) {
t.Parallel()
@@ -319,6 +387,7 @@ func TestParseWeChatPaymentResumeTokenRejectsExpiredToken(t *testing.T) {
svc := NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
token, err := svc.CreateWeChatPaymentResumeToken(WeChatPaymentResumeClaims{
+ UserID: 7,
OpenID: "openid-123",
PaymentType: payment.TypeWxpay,
IssuedAt: time.Now().Add(-30 * time.Minute).Unix(),
@@ -338,6 +407,7 @@ func TestPaymentServiceParseWeChatPaymentResumeTokenUsesExplicitSigningKey(t *te
t.Setenv("PAYMENT_RESUME_SIGNING_KEY", "explicit-payment-resume-signing-key")
token, err := NewPaymentResumeService([]byte("explicit-payment-resume-signing-key")).CreateWeChatPaymentResumeToken(WeChatPaymentResumeClaims{
+ UserID: 7,
OpenID: "openid-explicit-key",
PaymentType: payment.TypeWxpay,
})
@@ -362,9 +432,11 @@ func TestPaymentServiceParseWeChatPaymentResumeTokenUsesExplicitSigningKey(t *te
func TestPaymentServiceParseWeChatPaymentResumeTokenAcceptsLegacyEncryptionKeyDuringMigration(t *testing.T) {
t.Setenv("PAYMENT_RESUME_SIGNING_KEY", "explicit-payment-resume-signing-key")
+ t.Setenv("PAYMENT_RESUME_LEGACY_VERIFY_UNTIL", time.Now().Add(time.Hour).UTC().Format(time.RFC3339))
legacyKey := []byte("0123456789abcdef0123456789abcdef")
token, err := NewPaymentResumeService(legacyKey).CreateWeChatPaymentResumeToken(WeChatPaymentResumeClaims{
+ UserID: 7,
OpenID: "openid-legacy-key",
PaymentType: payment.TypeWxpay,
})
@@ -389,11 +461,13 @@ func TestPaymentServiceParseWeChatPaymentResumeTokenAcceptsLegacyEncryptionKeyDu
func TestNewConfiguredPaymentResumeServicePrefersExplicitSigningKeyAndKeepsLegacyVerificationFallback(t *testing.T) {
t.Setenv("PAYMENT_RESUME_SIGNING_KEY", "explicit-payment-resume-signing-key")
+ t.Setenv("PAYMENT_RESUME_LEGACY_VERIFY_UNTIL", time.Now().Add(time.Hour).UTC().Format(time.RFC3339))
legacyKey := []byte("0123456789abcdef0123456789abcdef")
svc := newLegacyAwarePaymentResumeService(legacyKey)
explicitToken, err := svc.CreateWeChatPaymentResumeToken(WeChatPaymentResumeClaims{
+ UserID: 7,
OpenID: "openid-explicit-key",
PaymentType: payment.TypeWxpay,
})
@@ -410,6 +484,7 @@ func TestNewConfiguredPaymentResumeServicePrefersExplicitSigningKeyAndKeepsLegac
}
legacyToken, err := NewPaymentResumeService(legacyKey).CreateWeChatPaymentResumeToken(WeChatPaymentResumeClaims{
+ UserID: 7,
OpenID: "openid-legacy-key",
PaymentType: payment.TypeWxpay,
})
@@ -426,6 +501,70 @@ func TestNewConfiguredPaymentResumeServicePrefersExplicitSigningKeyAndKeepsLegac
}
}
+func TestPaymentResumeLegacyVerificationDisabledByDefault(t *testing.T) {
+ t.Setenv("PAYMENT_RESUME_SIGNING_KEY", "explicit-payment-resume-signing-key")
+ t.Setenv("PAYMENT_RESUME_LEGACY_VERIFY_UNTIL", "")
+
+ legacyKey := []byte("0123456789abcdef0123456789abcdef")
+ token, err := NewPaymentResumeService(legacyKey).CreateWeChatPaymentResumeToken(WeChatPaymentResumeClaims{
+ UserID: 7,
+ OpenID: "openid-legacy-key",
+ PaymentType: payment.TypeWxpay,
+ })
+ if err != nil {
+ t.Fatalf("CreateWeChatPaymentResumeToken returned error: %v", err)
+ }
+
+ svc := newLegacyAwarePaymentResumeService(legacyKey)
+ if _, err := svc.ParseWeChatPaymentResumeToken(token); err == nil {
+ t.Fatal("legacy token should be rejected when no bounded verification window is configured")
+ }
+}
+
+func TestPaymentResumeSigningKeyRejectsWeakConfiguration(t *testing.T) {
+ t.Setenv("PAYMENT_RESUME_SIGNING_KEY", "short-key")
+
+ svc := newLegacyAwarePaymentResumeService(nil)
+ if _, err := svc.CreateWeChatPaymentOAuthSubjectToken(7); err == nil {
+ t.Fatal("weak payment resume signing key should leave token signing unavailable")
+ }
+}
+
+func TestPaymentResumeLegacyVerificationWindowBounds(t *testing.T) {
+ now := time.Date(2026, time.July, 13, 10, 0, 0, 0, time.UTC)
+
+ tests := []struct {
+ name string
+ raw string
+ allowed bool
+ wantErr bool
+ }{
+ {name: "missing", raw: "", allowed: false},
+ {name: "expired", raw: now.Add(-time.Minute).Format(time.RFC3339), allowed: false},
+ {name: "within maximum", raw: now.Add(24 * time.Hour).Format(time.RFC3339), allowed: true},
+ {name: "too far", raw: now.Add(24*time.Hour + time.Second).Format(time.RFC3339), wantErr: true},
+ {name: "malformed", raw: "tomorrow", wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ allowed, err := paymentResumeLegacyVerificationAllowed(tt.raw, now)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("paymentResumeLegacyVerificationAllowed returned error: %v", err)
+ }
+ if allowed != tt.allowed {
+ t.Fatalf("allowed = %v, want %v", allowed, tt.allowed)
+ }
+ })
+ }
+}
+
func TestNormalizeVisibleMethodSource(t *testing.T) {
t.Parallel()
@@ -500,6 +639,9 @@ func TestVisibleMethodLoadBalancerUsesEnabledProviderInstance(t *testing.T) {
inner := &captureLoadBalancer{}
configService := &PaymentConfigService{
entClient: client,
+ settingRepo: &paymentConfigSettingRepoStub{values: map[string]string{
+ SettingPaymentVisibleMethodAlipayEnabled: "true",
+ }},
}
lb := newVisibleMethodLoadBalancer(inner, configService)
@@ -608,7 +750,8 @@ func TestVisibleMethodLoadBalancerUsesConfiguredSourceWhenMultipleProvidersEnabl
entClient: client,
settingRepo: &paymentConfigSettingRepoStub{
values: map[string]string{
- visibleMethodSourceSettingKey(tt.method): tt.sourceSetting,
+ visibleMethodSourceSettingKey(tt.method): tt.sourceSetting,
+ visibleMethodEnabledSettingKey(tt.method): "true",
},
},
}
@@ -660,7 +803,8 @@ func TestVisibleMethodLoadBalancerPreservesLegacyCrossProviderRoutingWhenSourceM
entClient: client,
settingRepo: &paymentConfigSettingRepoStub{
values: map[string]string{
- visibleMethodSourceSettingKey(payment.TypeAlipay): "",
+ visibleMethodSourceSettingKey(payment.TypeAlipay): "",
+ visibleMethodEnabledSettingKey(payment.TypeAlipay): "true",
},
},
}
@@ -744,7 +888,8 @@ func TestVisibleMethodLoadBalancerRejectsInvalidSourceWhenMultipleProvidersEnabl
entClient: client,
settingRepo: &paymentConfigSettingRepoStub{
values: map[string]string{
- visibleMethodSourceSettingKey(tt.method): tt.sourceValue,
+ visibleMethodSourceSettingKey(tt.method): tt.sourceValue,
+ visibleMethodEnabledSettingKey(tt.method): "true",
},
},
}
@@ -770,6 +915,9 @@ func TestVisibleMethodLoadBalancerRejectsMissingEnabledVisibleMethodProvider(t *
inner := &captureLoadBalancer{}
configService := &PaymentConfigService{
entClient: newPaymentConfigServiceTestClient(t),
+ settingRepo: &paymentConfigSettingRepoStub{values: map[string]string{
+ SettingPaymentVisibleMethodWxpayEnabled: "true",
+ }},
}
lb := newVisibleMethodLoadBalancer(inner, configService)
diff --git a/backend/internal/service/payment_service.go b/backend/internal/service/payment_service.go
index aa121e41246..331e5853ded 100644
--- a/backend/internal/service/payment_service.go
+++ b/backend/internal/service/payment_service.go
@@ -3,10 +3,10 @@ package service
import (
"bytes"
"context"
+ "crypto/rand"
"encoding/hex"
"fmt"
"log/slog"
- "math/rand/v2"
"os"
"strings"
"sync"
@@ -38,51 +38,67 @@ const (
const (
// defaultMaxPendingOrders and defaultOrderTimeoutMin are defined in
// payment_config_service.go alongside other payment configuration defaults.
+ // paymentGraceMinutes protects recent expired/cancelled rows from plan or
+ // group deletion while settlement is still likely. Trusted paid evidence is
+ // accepted after this window too; late fulfillment may then require admin
+ // remediation if its historical target was removed.
paymentGraceMinutes = 5
+ // Read-only status endpoints may reconcile a missed webhook, but must not
+ // hammer the provider on every UI poll.
+ paymentStatusQueryCooldown = 5 * time.Second
defaultPageSize = 20
maxPageSize = 100
topUsersLimit = 10
amountToleranceCNY = 0.01
+ // A RECHARGING row is a lightweight fulfillment lease. Fulfillment is
+ // expected to be local database work; after this window another worker may
+ // safely reclaim it using a compare-and-swap on status + updated_at.
+ paymentFulfillmentLeaseTimeout = 5 * time.Minute
+ defaultStaleRecoveryBatchSize = 100
+ maxStaleRecoveryBatchSize = 500
+ paymentCreateFailureReason = "payment_create_failed"
+ fulfillmentFailureReasonPrefix = "fulfillment_failed: "
+
orderIDPrefix = "sub2_"
)
-const paymentResumeSigningKeyEnv = "PAYMENT_RESUME_SIGNING_KEY"
+const (
+ paymentResumeSigningKeyEnv = "PAYMENT_RESUME_SIGNING_KEY"
+ paymentResumeLegacyVerifyUntilEnv = "PAYMENT_RESUME_LEGACY_VERIFY_UNTIL"
+ paymentResumeLegacyMaxWindow = 24 * time.Hour
+)
// --- Types ---
-// generateOutTradeNo creates a unique external order ID for payment providers.
-// Format: sub2_20250409aB3kX9mQ (prefix + date + 8-char random)
-func generateOutTradeNo() string {
- date := time.Now().Format("20060102")
- rnd := generateRandomString(8)
- return orderIDPrefix + date + rnd
-}
-
-func generateRandomString(n int) string {
- const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
- b := make([]byte, n)
- for i := range b {
- b[i] = charset[rand.IntN(len(charset))]
+// generateOutTradeNo creates a 128-bit, non-guessable provider order ID. The
+// 32 hexadecimal characters fit WeChat Pay's strict out_trade_no limit.
+func generateOutTradeNo() (string, error) {
+ randomBytes := make([]byte, 16)
+ if _, err := rand.Read(randomBytes); err != nil {
+ return "", fmt.Errorf("generate out_trade_no randomness: %w", err)
}
- return string(b)
+ return hex.EncodeToString(randomBytes), nil
}
type CreateOrderRequest struct {
- UserID int64
- Amount float64
- PaymentType string
- OpenID string
- ClientIP string
- IsMobile bool
- IsWeChatBrowser bool
- SrcHost string
- SrcURL string
- ReturnURL string
- PaymentSource string
- OrderType string
- PlanID int64
+ UserID int64
+ ResumeTokenUserID int64
+ ResumeTokenJTI string
+ Amount float64
+ PaymentType string
+ OpenID string
+ ClientIP string
+ IsMobile bool
+ IsWeChatBrowser bool
+ SrcHost string
+ SrcURL string
+ ReturnURL string
+ TrustedFrontendURL string
+ PaymentSource string
+ OrderType string
+ PlanID int64
}
type CreateOrderResponse struct {
@@ -115,25 +131,31 @@ type OrderListParams struct {
}
type RefundPlan struct {
- OrderID int64
- Order *dbent.PaymentOrder
- RefundAmount float64
- GatewayAmount float64
- Reason string
- Force bool
- DeductBalance bool
- DeductionType string
- BalanceToDeduct float64
- SubDaysToDeduct int
- SubscriptionID int64
+ OrderID int64
+ Order *dbent.PaymentOrder
+ RefundAmount float64
+ GatewayAmount float64
+ Reason string
+ Force bool
+ DeductBalance bool
+ DeductionType string
+ BalanceToDeduct float64
+ SubDaysToDeduct int
+ SubscriptionID int64
+ WalletCreditToDeduct float64
+ WalletFulfillmentAuditID int64
+ WalletFulfilledAt time.Time
+ RefundLeaseUpdatedAt time.Time
+ WalletRefundRecovered bool
}
type RefundResult struct {
- Success bool `json:"success"`
- Warning string `json:"warning,omitempty"`
- RequireForce bool `json:"require_force,omitempty"`
- BalanceDeducted float64 `json:"balance_deducted,omitempty"`
- SubDaysDeducted int `json:"subscription_days_deducted,omitempty"`
+ Success bool `json:"success"`
+ Warning string `json:"warning,omitempty"`
+ RequireForce bool `json:"require_force,omitempty"`
+ BalanceDeducted float64 `json:"balance_deducted,omitempty"`
+ SubDaysDeducted int `json:"subscription_days_deducted,omitempty"`
+ WalletCreditDeducted float64 `json:"wallet_credit_deducted,omitempty"`
}
type DashboardStats struct {
@@ -171,6 +193,8 @@ type TopUserStat struct {
type PaymentService struct {
providerMu sync.Mutex
+ statusQueryMu sync.Mutex
+ lastStatusQuery map[int64]time.Time
providersLoaded bool
entClient *dbent.Client
registry *payment.Registry
@@ -278,7 +302,11 @@ func psNewPaymentResumeService(configService *PaymentConfigService) *PaymentResu
}
func newLegacyAwarePaymentResumeService(legacyKey []byte) *PaymentResumeService {
- signingKey, verifyFallbacks := resolvePaymentResumeSigningKeys(legacyKey)
+ signingKey, verifyFallbacks, err := resolvePaymentResumeSigningKeys(legacyKey, time.Now())
+ if err != nil {
+ slog.Error("payment resume signing configuration is invalid", "error", err)
+ return NewPaymentResumeService(nil)
+ }
return NewPaymentResumeService(signingKey, verifyFallbacks...)
}
@@ -289,31 +317,71 @@ func psResumeLegacyVerificationKey(configService *PaymentConfigService) []byte {
return configService.encryptionKey
}
-func resolvePaymentResumeSigningKeys(legacyKey []byte) ([]byte, [][]byte) {
- signingKey := parsePaymentResumeSigningKey(os.Getenv(paymentResumeSigningKeyEnv))
+func resolvePaymentResumeSigningKeys(legacyKey []byte, now time.Time) ([]byte, [][]byte, error) {
+ signingKey, err := parsePaymentResumeSigningKey(os.Getenv(paymentResumeSigningKeyEnv))
+ if err != nil {
+ return nil, nil, err
+ }
if len(signingKey) == 0 {
if len(legacyKey) == 0 {
- return nil, nil
+ return nil, nil, nil
+ }
+ if len(legacyKey) < paymentResumeMinSigningKeyBytes {
+ return nil, nil, fmt.Errorf("legacy payment resume key must be at least %d bytes", paymentResumeMinSigningKeyBytes)
}
- return legacyKey, nil
+ return legacyKey, nil, nil
}
if len(legacyKey) == 0 || bytes.Equal(legacyKey, signingKey) {
- return signingKey, nil
+ return signingKey, nil, nil
+ }
+ legacyAllowed, err := paymentResumeLegacyVerificationAllowed(os.Getenv(paymentResumeLegacyVerifyUntilEnv), now)
+ if err != nil {
+ return nil, nil, err
+ }
+ if !legacyAllowed {
+ return signingKey, nil, nil
}
- return signingKey, [][]byte{legacyKey}
+ if len(legacyKey) < paymentResumeMinSigningKeyBytes {
+ return nil, nil, fmt.Errorf("legacy payment resume verification key must be at least %d bytes", paymentResumeMinSigningKeyBytes)
+ }
+ return signingKey, [][]byte{legacyKey}, nil
}
-func parsePaymentResumeSigningKey(raw string) []byte {
+func parsePaymentResumeSigningKey(raw string) ([]byte, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
- return nil
+ return nil, nil
}
if len(raw) >= 64 && len(raw)%2 == 0 {
- if decoded, err := hex.DecodeString(raw); err == nil && len(decoded) > 0 {
- return decoded
+ if decoded, err := hex.DecodeString(raw); err == nil {
+ if len(decoded) < paymentResumeMinSigningKeyBytes {
+ return nil, fmt.Errorf("payment resume signing key must be at least %d bytes", paymentResumeMinSigningKeyBytes)
+ }
+ return decoded, nil
}
}
- return []byte(raw)
+ if len([]byte(raw)) < paymentResumeMinSigningKeyBytes {
+ return nil, fmt.Errorf("payment resume signing key must be at least %d bytes", paymentResumeMinSigningKeyBytes)
+ }
+ return []byte(raw), nil
+}
+
+func paymentResumeLegacyVerificationAllowed(raw string, now time.Time) (bool, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return false, nil
+ }
+ deadline, err := time.Parse(time.RFC3339, raw)
+ if err != nil {
+ return false, fmt.Errorf("%s must be an RFC3339 timestamp", paymentResumeLegacyVerifyUntilEnv)
+ }
+ if !deadline.After(now) {
+ return false, nil
+ }
+ if deadline.After(now.Add(paymentResumeLegacyMaxWindow)) {
+ return false, fmt.Errorf("%s must be no more than %s in the future", paymentResumeLegacyVerifyUntilEnv, paymentResumeLegacyMaxWindow)
+ }
+ return true, nil
}
func psSliceContains(sl []string, s string) bool {
diff --git a/backend/internal/service/payment_standard_refund.go b/backend/internal/service/payment_standard_refund.go
new file mode 100644
index 00000000000..64a2d144fe2
--- /dev/null
+++ b/backend/internal/service/payment_standard_refund.go
@@ -0,0 +1,611 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+ "time"
+
+ "entgo.io/ent/dialect"
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
+ "github.com/Wei-Shaw/sub2api/ent/paymentorder"
+ dbuser "github.com/Wei-Shaw/sub2api/ent/user"
+ "github.com/Wei-Shaw/sub2api/ent/usersubscription"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+)
+
+const (
+ standardRefundDeductionAppliedAction = "REFUND_DEDUCTION_APPLIED"
+ standardRefundDeductionCompensatedAction = "REFUND_DEDUCTION_COMPENSATED"
+ standardRefundGatewayStartedAction = "REFUND_GATEWAY_STARTED"
+ standardRefundGatewayAcceptedAction = "REFUND_GATEWAY_ACCEPTED"
+)
+
+type refundGatewayDisposition int
+
+const (
+ refundGatewayAmbiguous refundGatewayDisposition = iota
+ refundGatewaySucceeded
+ refundGatewayPending
+ refundGatewayRejected
+)
+
+type standardRefundDeductionDetail struct {
+ DeductionType string `json:"deductionType"`
+ UserID int64 `json:"userID"`
+ BalanceDeducted float64 `json:"balanceDeducted,omitempty"`
+ SubscriptionID int64 `json:"subscriptionID,omitempty"`
+ SubscriptionDaysDeducted int `json:"subscriptionDaysDeducted,omitempty"`
+ SubscriptionPreviousExpiry time.Time `json:"subscriptionPreviousExpiry,omitempty"`
+ SubscriptionPreviousStatus string `json:"subscriptionPreviousStatus,omitempty"`
+}
+
+func classifyRefundGatewayResult(response *payment.RefundResponse, callErr error) refundGatewayDisposition {
+ if callErr != nil || response == nil {
+ return refundGatewayAmbiguous
+ }
+ switch strings.TrimSpace(response.Status) {
+ case payment.ProviderStatusSuccess, payment.ProviderStatusRefunded:
+ return refundGatewaySucceeded
+ case payment.ProviderStatusPending:
+ return refundGatewayPending
+ case payment.ProviderStatusFailed:
+ return refundGatewayRejected
+ default:
+ return refundGatewayAmbiguous
+ }
+}
+
+func validateAutomaticRefundPreflight(order *dbent.PaymentOrder, instance *dbent.PaymentProviderInstance) error {
+ if order == nil || instance == nil {
+ return infraerrors.Conflict("REFUND_MANUAL_REQUIRED", "refund provider evidence is incomplete; manual reconciliation is required")
+ }
+ if strings.TrimSpace(order.PaymentTradeNo) == "" {
+ return infraerrors.Conflict("REFUND_MANUAL_REQUIRED", "provider trade evidence is missing; automatic refund is unsafe")
+ }
+ if !refundProviderSupportsStableIdempotency(instance.ProviderKey) {
+ return infraerrors.Conflict("REFUND_MANUAL_REQUIRED", "this provider does not support idempotent automatic refunds; reconcile it manually")
+ }
+ return nil
+}
+
+// subscriptionDaysForRefund converts a monetary partial refund into whole
+// entitlement days. We round upward so a refunded fraction can never leave the
+// user with more paid entitlement than the retained payment covers.
+func subscriptionDaysForRefund(totalDays int, refundAmount, orderAmount float64) int {
+ if totalDays <= 0 || !isFinitePositive(refundAmount) || !isFinitePositive(orderAmount) {
+ return 0
+ }
+ if refundAmount >= orderAmount-amountToleranceCNY {
+ return totalDays
+ }
+ days := int(math.Ceil(float64(totalDays) * refundAmount / orderAmount))
+ if days < 1 {
+ return 1
+ }
+ if days > totalDays {
+ return totalDays
+ }
+ return days
+}
+
+func (s *PaymentService) executeStandardRefund(ctx context.Context, plan *RefundPlan) (*RefundResult, error) {
+ recovered, err := s.claimStandardRefund(ctx, plan)
+ if err != nil {
+ return nil, err
+ }
+
+ disposition, attempted, _, gatewayErr := s.executeRefundGatewayCall(
+ ctx,
+ plan,
+ standardRefundGatewayStartedAction,
+ standardRefundGatewayAcceptedAction,
+ recovered,
+ )
+ if !attempted {
+ if compensationErr := s.compensateStandardRefund(ctx, plan, gatewayErr); compensationErr != nil {
+ return nil, fmt.Errorf("refund failed before gateway: %v; compensation failed: %w", gatewayErr, compensationErr)
+ }
+ return nil, infraerrors.InternalServer("REFUND_PRE_GATEWAY_FAILED", psErrMsg(gatewayErr))
+ }
+
+ switch disposition {
+ case refundGatewaySucceeded:
+ return s.markStandardRefundOK(ctx, plan)
+ case refundGatewayPending:
+ return &RefundResult{
+ Success: false,
+ Warning: "refund was accepted by the provider and remains pending; retry after the lease expires to reconcile status",
+ BalanceDeducted: plan.BalanceToDeduct,
+ SubDaysDeducted: plan.SubDaysToDeduct,
+ }, nil
+ case refundGatewayRejected:
+ if compensationErr := s.compensateStandardRefund(ctx, plan, gatewayErr); compensationErr != nil {
+ return nil, fmt.Errorf("provider rejected refund; compensation failed: %w", compensationErr)
+ }
+ return &RefundResult{Success: false, Warning: "provider rejected the refund; local deduction was compensated and manual review is required"}, nil
+ default:
+ return nil, infraerrors.InternalServer("REFUND_GATEWAY_AMBIGUOUS", "refund gateway result is ambiguous; local deduction remains fenced for idempotent recovery")
+ }
+}
+
+func (s *PaymentService) claimStandardRefund(ctx context.Context, plan *RefundPlan) (bool, error) {
+ if plan == nil || plan.Order == nil || plan.DeductionType == payment.DeductionTypeWallet {
+ return false, infraerrors.BadRequest("INVALID_REFUND_PLAN", "refund plan is invalid")
+ }
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return false, fmt.Errorf("begin refund transaction: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ txCtx := dbent.NewTxContext(ctx, tx)
+ client := tx.Client()
+ current, err := lockPaymentOrderForRefund(txCtx, client, plan.OrderID)
+ if err != nil {
+ return false, err
+ }
+
+ detail, applied, err := loadStandardRefundDeductionDetail(txCtx, client, plan.OrderID)
+ if err != nil {
+ return false, fmt.Errorf("check refund rollback audit: %w", err)
+ }
+ compensated, err := paymentAuditActionExists(txCtx, client, plan.OrderID, standardRefundDeductionCompensatedAction)
+ if err != nil {
+ return false, fmt.Errorf("check refund compensation audit: %w", err)
+ }
+ if compensated {
+ return false, infraerrors.Conflict("REFUND_RECOVERY_MANUAL_REQUIRED", "refund deduction was already compensated; manual reconciliation is required")
+ }
+
+ now := time.Now().UTC().Truncate(time.Microsecond)
+ recovered := false
+ switch current.Status {
+ case OrderStatusCompleted, OrderStatusRefundRequested:
+ if applied {
+ return false, infraerrors.Conflict("REFUND_RECOVERY_MANUAL_REQUIRED", "refund deduction marker conflicts with the order status")
+ }
+ case OrderStatusRefunding:
+ if !isRefundLeaseStale(current.UpdatedAt, now) {
+ return false, infraerrors.Conflict("REFUND_IN_PROGRESS", "refund is being processed")
+ }
+ if !applied {
+ return false, infraerrors.Conflict("REFUND_RECOVERY_MANUAL_REQUIRED", "REFUNDING order has no immutable deduction marker")
+ }
+ if err := applyStandardRefundDetailToPlan(plan, detail); err != nil {
+ return false, err
+ }
+ recovered = true
+ default:
+ return false, infraerrors.BadRequest("INVALID_STATUS", "order status does not allow refund")
+ }
+
+ actualBalanceDeducted := plan.BalanceToDeduct
+ if !applied {
+ actualBalanceDeducted, err = s.applyStandardRefundDeduction(txCtx, client, current, plan)
+ if err != nil {
+ return false, err
+ }
+ }
+ updated, err := client.PaymentOrder.Update().
+ Where(paymentorder.IDEQ(plan.OrderID), paymentorder.StatusEQ(current.Status), paymentorder.UpdatedAtEQ(current.UpdatedAt)).
+ SetStatus(OrderStatusRefunding).
+ SetRefundAmount(plan.RefundAmount).
+ SetRefundReason(plan.Reason).
+ SetForceRefund(plan.Force).
+ SetUpdatedAt(now).
+ Save(txCtx)
+ if err != nil {
+ return false, fmt.Errorf("claim refund lease: %w", err)
+ }
+ if updated != 1 {
+ return false, infraerrors.Conflict("REFUND_IN_PROGRESS", "refund state changed concurrently")
+ }
+ if err := tx.Commit(); err != nil {
+ return false, fmt.Errorf("commit refund claim: %w", err)
+ }
+ if !applied && plan.DeductionType == payment.DeductionTypeBalance {
+ plan.BalanceToDeduct = actualBalanceDeducted
+ }
+ plan.RefundLeaseUpdatedAt = now
+ return recovered, nil
+}
+
+func (s *PaymentService) applyStandardRefundDeduction(ctx context.Context, client *dbent.Client, order *dbent.PaymentOrder, plan *RefundPlan) (float64, error) {
+ detail := standardRefundDeductionDetail{DeductionType: plan.DeductionType, UserID: order.UserID}
+ switch plan.DeductionType {
+ case payment.DeductionTypeBalance:
+ actual, err := deductStandardRefundBalance(ctx, client, order.UserID, plan)
+ if err != nil {
+ return 0, err
+ }
+ detail.BalanceDeducted = actual
+ case payment.DeductionTypeSubscription:
+ if plan.SubDaysToDeduct > 0 && plan.SubscriptionID > 0 {
+ sub, err := lockSubscriptionForRefund(ctx, client, plan.SubscriptionID)
+ if err != nil {
+ return 0, err
+ }
+ if sub.UserID != order.UserID {
+ return 0, infraerrors.Conflict("REFUND_DEDUCTION_EVIDENCE_CHANGED", "subscription owner changed after refund preparation")
+ }
+ detail.SubscriptionID = sub.ID
+ detail.SubscriptionDaysDeducted = plan.SubDaysToDeduct
+ detail.SubscriptionPreviousExpiry = sub.ExpiresAt
+ detail.SubscriptionPreviousStatus = sub.Status
+
+ newExpiry := sub.ExpiresAt.AddDate(0, 0, -plan.SubDaysToDeduct)
+ newStatus := sub.Status
+ now := time.Now().UTC().Truncate(time.Microsecond)
+ if !newExpiry.After(now) {
+ newExpiry = now
+ newStatus = SubscriptionStatusExpired
+ }
+ if _, err := client.UserSubscription.UpdateOneID(sub.ID).
+ SetExpiresAt(newExpiry).
+ SetStatus(newStatus).
+ Save(ctx); err != nil {
+ return 0, fmt.Errorf("deduct subscription entitlement: %w", err)
+ }
+ }
+ case payment.DeductionTypeNone, "":
+ default:
+ return 0, infraerrors.BadRequest("INVALID_REFUND_PLAN", "unsupported refund deduction type")
+ }
+ if err := createPaymentAuditLog(ctx, client, plan.OrderID, standardRefundDeductionAppliedAction, "admin", detail.asMap()); err != nil {
+ return 0, err
+ }
+ return detail.BalanceDeducted, nil
+}
+
+func deductStandardRefundBalance(ctx context.Context, client *dbent.Client, userID int64, plan *RefundPlan) (float64, error) {
+ if client == nil || plan == nil || !isFinitePositive(plan.RefundAmount) ||
+ math.IsNaN(plan.BalanceToDeduct) || math.IsInf(plan.BalanceToDeduct, 0) {
+ return 0, infraerrors.BadRequest("INVALID_REFUND_PLAN", "refund balance deduction plan is invalid")
+ }
+ if plan.BalanceToDeduct < 0 || plan.BalanceToDeduct > plan.RefundAmount+amountToleranceCNY {
+ return 0, infraerrors.BadRequest("INVALID_REFUND_PLAN", "refund balance deduction exceeds the refund amount")
+ }
+ if !plan.Force && math.Abs(plan.BalanceToDeduct-plan.RefundAmount) > amountToleranceCNY {
+ return 0, infraerrors.BadRequest("INVALID_REFUND_PLAN", "non-forced refund must deduct the full refund amount")
+ }
+
+ query := client.User.Query().Where(dbuser.IDEQ(userID))
+ if client.Driver().Dialect() == dialect.Postgres {
+ query = query.ForUpdate()
+ }
+ current, err := query.Only(ctx)
+ if err != nil {
+ if dbent.IsNotFound(err) {
+ return 0, infraerrors.Conflict("REFUND_DEDUCTION_EVIDENCE_CHANGED", "refund user no longer exists")
+ }
+ return 0, fmt.Errorf("lock refund user balance: %w", err)
+ }
+ if math.IsNaN(current.Balance) || math.IsInf(current.Balance, 0) {
+ return 0, infraerrors.Conflict("REFUND_DEDUCTION_EVIDENCE_CHANGED", "refund user balance is invalid")
+ }
+ if !plan.Force && current.Balance < plan.RefundAmount {
+ return 0, infraerrors.Conflict("REFUND_BALANCE_CHANGED_REQUIRE_FORCE", "user balance changed after refund preparation; review and retry with force")
+ }
+
+ available := math.Max(0, current.Balance)
+ target := plan.RefundAmount
+ if plan.Force {
+ target = plan.BalanceToDeduct
+ }
+ actual := math.Min(target, available)
+ if actual <= 0 {
+ return 0, nil
+ }
+ updated, err := client.User.Update().
+ Where(dbuser.IDEQ(userID), dbuser.BalanceGTE(actual)).
+ AddBalance(-actual).
+ Save(ctx)
+ if err != nil {
+ return 0, fmt.Errorf("deduct refund balance: %w", err)
+ }
+ if updated != 1 {
+ if !plan.Force {
+ return 0, infraerrors.Conflict("REFUND_BALANCE_CHANGED_REQUIRE_FORCE", "user balance changed during refund claim; review and retry with force")
+ }
+ return 0, infraerrors.Conflict("REFUND_BALANCE_CHANGED_RETRY", "user balance changed during forced refund claim; prepare the refund again")
+ }
+ return actual, nil
+}
+
+func (d standardRefundDeductionDetail) asMap() map[string]any {
+ return map[string]any{
+ "deductionType": d.DeductionType,
+ "userID": d.UserID,
+ "balanceDeducted": d.BalanceDeducted,
+ "subscriptionID": d.SubscriptionID,
+ "subscriptionDaysDeducted": d.SubscriptionDaysDeducted,
+ "subscriptionPreviousExpiry": d.SubscriptionPreviousExpiry,
+ "subscriptionPreviousStatus": d.SubscriptionPreviousStatus,
+ }
+}
+
+func lockSubscriptionForRefund(ctx context.Context, client *dbent.Client, subscriptionID int64) (*dbent.UserSubscription, error) {
+ query := client.UserSubscription.Query().Where(usersubscription.IDEQ(subscriptionID))
+ if client.Driver().Dialect() == dialect.Postgres {
+ query = query.ForUpdate()
+ }
+ sub, err := query.Only(ctx)
+ if err != nil {
+ if dbent.IsNotFound(err) {
+ return nil, infraerrors.Conflict("REFUND_DEDUCTION_EVIDENCE_CHANGED", "subscription no longer exists")
+ }
+ return nil, fmt.Errorf("lock subscription for refund: %w", err)
+ }
+ return sub, nil
+}
+
+func (s *PaymentService) restoreStandardRefundPlanFromAudit(ctx context.Context, plan *RefundPlan) error {
+ if plan == nil {
+ return infraerrors.BadRequest("INVALID_REFUND_PLAN", "refund plan is invalid")
+ }
+ compensated, err := paymentAuditActionExists(ctx, s.entClient, plan.OrderID, standardRefundDeductionCompensatedAction)
+ if err != nil {
+ return fmt.Errorf("check refund compensation audit: %w", err)
+ }
+ if compensated {
+ return infraerrors.Conflict("REFUND_RECOVERY_MANUAL_REQUIRED", "refund deduction was compensated; manual reconciliation is required")
+ }
+ detail, found, err := loadStandardRefundDeductionDetail(ctx, s.entClient, plan.OrderID)
+ if err != nil {
+ return fmt.Errorf("load refund deduction evidence: %w", err)
+ }
+ if !found {
+ return infraerrors.Conflict("REFUND_RECOVERY_MANUAL_REQUIRED", "REFUNDING order has no immutable deduction marker")
+ }
+ return applyStandardRefundDetailToPlan(plan, detail)
+}
+
+func applyStandardRefundDetailToPlan(plan *RefundPlan, detail standardRefundDeductionDetail) error {
+ if plan == nil || plan.Order == nil || detail.UserID != plan.Order.UserID {
+ return infraerrors.Conflict("REFUND_RECOVERY_MANUAL_REQUIRED", "refund deduction marker does not match the payment order")
+ }
+ plan.DeductionType = detail.DeductionType
+ plan.BalanceToDeduct = detail.BalanceDeducted
+ plan.SubscriptionID = detail.SubscriptionID
+ plan.SubDaysToDeduct = detail.SubscriptionDaysDeducted
+ return nil
+}
+
+func loadStandardRefundDeductionDetail(ctx context.Context, client *dbent.Client, orderID int64) (standardRefundDeductionDetail, bool, error) {
+ var detail standardRefundDeductionDetail
+ log, err := client.PaymentAuditLog.Query().
+ Where(
+ paymentauditlog.OrderIDEQ(strconv.FormatInt(orderID, 10)),
+ paymentauditlog.ActionEQ(standardRefundDeductionAppliedAction),
+ ).
+ Only(ctx)
+ if dbent.IsNotFound(err) {
+ return detail, false, nil
+ }
+ if err != nil {
+ return detail, false, err
+ }
+ if err := json.Unmarshal([]byte(log.Detail), &detail); err != nil {
+ return detail, false, fmt.Errorf("decode refund deduction marker: %w", err)
+ }
+ return detail, true, nil
+}
+
+func paymentAuditActionExists(ctx context.Context, client *dbent.Client, orderID int64, action string) (bool, error) {
+ return client.PaymentAuditLog.Query().
+ Where(
+ paymentauditlog.OrderIDEQ(strconv.FormatInt(orderID, 10)),
+ paymentauditlog.ActionEQ(action),
+ ).
+ Exist(ctx)
+}
+
+func (s *PaymentService) executeRefundGatewayCall(
+ ctx context.Context,
+ plan *RefundPlan,
+ startedAction string,
+ acceptedAction string,
+ recovered bool,
+) (refundGatewayDisposition, bool, *payment.RefundResponse, error) {
+ provider, err := s.getRefundProvider(ctx, plan.Order)
+ if err != nil {
+ return refundGatewayAmbiguous, false, nil, fmt.Errorf("get refund provider: %w", err)
+ }
+ if err := validateProviderSnapshotMetadata(plan.Order, provider.ProviderKey(), providerMerchantIdentityMetadata(provider)); err != nil {
+ return refundGatewayAmbiguous, false, nil, err
+ }
+ if recovered && !refundProviderSupportsStableIdempotency(provider.ProviderKey()) {
+ return refundGatewayAmbiguous, false, nil, infraerrors.Conflict("REFUND_RECOVERY_MANUAL_REQUIRED", "provider does not support safe automatic refund replay")
+ }
+ key := refundGatewayIdempotencyKey(plan.OrderID)
+ if err := putPaymentAuditLog(ctx, s.entClient, plan.OrderID, startedAction, "admin", map[string]any{
+ "idempotencyKey": key,
+ "providerKey": provider.ProviderKey(),
+ "recovered": recovered,
+ }); err != nil {
+ return refundGatewayAmbiguous, false, nil, fmt.Errorf("record refund gateway attempt: %w", err)
+ }
+
+ response, callErr := provider.Refund(ctx, payment.RefundRequest{
+ TradeNo: strings.TrimSpace(plan.Order.PaymentTradeNo),
+ OrderID: plan.Order.OutTradeNo,
+ Amount: strconv.FormatFloat(plan.GatewayAmount, 'f', 2, 64),
+ Reason: plan.Reason,
+ IdempotencyKey: key,
+ })
+ disposition := classifyRefundGatewayResult(response, callErr)
+ if disposition == refundGatewayAmbiguous {
+ return disposition, true, response, callErr
+ }
+ detail := map[string]any{
+ "idempotencyKey": key,
+ "providerStatus": response.Status,
+ "refundID": response.RefundID,
+ }
+ if err := putPaymentAuditLog(ctx, s.entClient, plan.OrderID, acceptedAction, "admin", detail); err != nil {
+ return refundGatewayAmbiguous, true, response, fmt.Errorf("record refund gateway response: %w", err)
+ }
+ return disposition, true, response, callErr
+}
+
+func putPaymentAuditLog(ctx context.Context, client *dbent.Client, orderID int64, action, operator string, detail map[string]any) error {
+ encoded, err := json.Marshal(detail)
+ if err != nil {
+ return fmt.Errorf("encode payment audit %s: %w", action, err)
+ }
+ orderIDText := strconv.FormatInt(orderID, 10)
+ existing, err := client.PaymentAuditLog.Query().
+ Where(paymentauditlog.OrderIDEQ(orderIDText), paymentauditlog.ActionEQ(action)).
+ Only(ctx)
+ if err == nil {
+ _, err = client.PaymentAuditLog.UpdateOneID(existing.ID).
+ SetDetail(string(encoded)).
+ SetOperator(operator).
+ Save(ctx)
+ if err != nil {
+ return fmt.Errorf("update payment audit %s: %w", action, err)
+ }
+ return nil
+ }
+ if !dbent.IsNotFound(err) {
+ return fmt.Errorf("query payment audit %s: %w", action, err)
+ }
+ return createPaymentAuditLog(ctx, client, orderID, action, operator, detail)
+}
+
+func (s *PaymentService) compensateStandardRefund(ctx context.Context, plan *RefundPlan, cause error) error {
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return fmt.Errorf("begin refund compensation: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ txCtx := dbent.NewTxContext(ctx, tx)
+ client := tx.Client()
+ current, err := lockPaymentOrderForRefund(txCtx, client, plan.OrderID)
+ if err != nil {
+ return err
+ }
+ if current.Status != OrderStatusRefunding || !current.UpdatedAt.Equal(plan.RefundLeaseUpdatedAt) {
+ return infraerrors.Conflict("REFUND_LEASE_LOST", "cannot compensate a refund owned by another worker")
+ }
+ detail, found, err := loadStandardRefundDeductionDetail(txCtx, client, plan.OrderID)
+ if err != nil {
+ return err
+ }
+ if !found {
+ return infraerrors.Conflict("REFUND_RECOVERY_MANUAL_REQUIRED", "refund deduction marker is missing")
+ }
+ alreadyCompensated, err := paymentAuditActionExists(txCtx, client, plan.OrderID, standardRefundDeductionCompensatedAction)
+ if err != nil {
+ return err
+ }
+ if alreadyCompensated {
+ return nil
+ }
+ switch detail.DeductionType {
+ case payment.DeductionTypeBalance:
+ if detail.BalanceDeducted > 0 {
+ if _, err := client.User.UpdateOneID(detail.UserID).AddBalance(detail.BalanceDeducted).Save(txCtx); err != nil {
+ return fmt.Errorf("compensate refund balance: %w", err)
+ }
+ }
+ case payment.DeductionTypeSubscription:
+ if detail.SubscriptionID > 0 && !detail.SubscriptionPreviousExpiry.IsZero() {
+ if _, err := client.UserSubscription.UpdateOneID(detail.SubscriptionID).
+ SetExpiresAt(detail.SubscriptionPreviousExpiry).
+ SetStatus(detail.SubscriptionPreviousStatus).
+ Save(txCtx); err != nil {
+ return fmt.Errorf("compensate subscription entitlement: %w", err)
+ }
+ }
+ }
+ if err := createPaymentAuditLog(txCtx, client, plan.OrderID, standardRefundDeductionCompensatedAction, "admin", map[string]any{
+ "reason": psErrMsg(cause),
+ }); err != nil {
+ return err
+ }
+ now := time.Now().UTC().Truncate(time.Microsecond)
+ updated, err := client.PaymentOrder.Update().
+ Where(paymentorder.IDEQ(plan.OrderID), paymentorder.StatusEQ(OrderStatusRefunding), paymentorder.UpdatedAtEQ(plan.RefundLeaseUpdatedAt)).
+ SetStatus(OrderStatusRefundFailed).
+ SetFailedAt(now).
+ SetFailedReason(psErrMsg(cause)).
+ SetUpdatedAt(now).
+ Save(txCtx)
+ if err != nil {
+ return fmt.Errorf("mark compensated refund failed: %w", err)
+ }
+ if updated != 1 {
+ return infraerrors.Conflict("REFUND_LEASE_LOST", "refund lease changed during compensation")
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit refund compensation: %w", err)
+ }
+ if detail.SubscriptionID > 0 && plan.Order.SubscriptionGroupID != nil && s.subscriptionSvc != nil {
+ s.subscriptionSvc.invalidateSubscriptionCaches(ctx, plan.Order.UserID, *plan.Order.SubscriptionGroupID)
+ }
+ return nil
+}
+
+func (s *PaymentService) markStandardRefundOK(ctx context.Context, plan *RefundPlan) (*RefundResult, error) {
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("begin refund completion: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ txCtx := dbent.NewTxContext(ctx, tx)
+ client := tx.Client()
+ current, err := lockPaymentOrderForRefund(txCtx, client, plan.OrderID)
+ if err != nil {
+ return nil, err
+ }
+ if current.Status != OrderStatusRefunding || !current.UpdatedAt.Equal(plan.RefundLeaseUpdatedAt) {
+ return nil, infraerrors.Conflict("REFUND_LEASE_LOST", "refund lease was reclaimed by another worker")
+ }
+ finalStatus := OrderStatusRefunded
+ if plan.RefundAmount < plan.Order.Amount-amountToleranceCNY {
+ finalStatus = OrderStatusPartiallyRefunded
+ }
+ now := time.Now().UTC().Truncate(time.Microsecond)
+ updated, err := client.PaymentOrder.Update().
+ Where(paymentorder.IDEQ(plan.OrderID), paymentorder.StatusEQ(OrderStatusRefunding), paymentorder.UpdatedAtEQ(plan.RefundLeaseUpdatedAt)).
+ SetStatus(finalStatus).
+ SetRefundAmount(plan.RefundAmount).
+ SetRefundReason(plan.Reason).
+ SetRefundAt(now).
+ SetForceRefund(plan.Force).
+ SetUpdatedAt(now).
+ Save(txCtx)
+ if err != nil {
+ return nil, fmt.Errorf("mark refund complete: %w", err)
+ }
+ if updated != 1 {
+ return nil, infraerrors.Conflict("REFUND_LEASE_LOST", "refund lease changed during completion")
+ }
+ if err := createPaymentAuditLog(txCtx, client, plan.OrderID, "REFUND_SUCCESS", "admin", map[string]any{
+ "refundAmount": plan.RefundAmount,
+ "reason": plan.Reason,
+ "balanceDeducted": plan.BalanceToDeduct,
+ "subDaysDeducted": plan.SubDaysToDeduct,
+ "force": plan.Force,
+ "idempotencyKey": refundGatewayIdempotencyKey(plan.OrderID),
+ }); err != nil {
+ return nil, err
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, fmt.Errorf("commit refund completion: %w", err)
+ }
+ if plan.SubscriptionID > 0 && plan.Order.SubscriptionGroupID != nil && s.subscriptionSvc != nil {
+ s.subscriptionSvc.invalidateSubscriptionCaches(ctx, plan.Order.UserID, *plan.Order.SubscriptionGroupID)
+ }
+ return &RefundResult{
+ Success: true,
+ BalanceDeducted: plan.BalanceToDeduct,
+ SubDaysDeducted: plan.SubDaysToDeduct,
+ }, nil
+}
diff --git a/backend/internal/service/payment_stats.go b/backend/internal/service/payment_stats.go
index d206b271717..dfd94c5188b 100644
--- a/backend/internal/service/payment_stats.go
+++ b/backend/internal/service/payment_stats.go
@@ -12,14 +12,25 @@ import (
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
"github.com/Wei-Shaw/sub2api/ent/paymentorder"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
// --- Dashboard & Analytics ---
+// MaxPaymentDashboardDays bounds both the database range and the daily-series
+// allocation performed for one admin dashboard request.
+const MaxPaymentDashboardDays = 366
+
func (s *PaymentService) GetDashboardStats(ctx context.Context, days int) (*DashboardStats, error) {
if days <= 0 {
days = 30
}
+ if days > MaxPaymentDashboardDays {
+ return nil, infraerrors.BadRequest(
+ "INVALID_DASHBOARD_DAYS",
+ "payment dashboard range exceeds the maximum of 366 days",
+ )
+ }
now := time.Now()
since := now.AddDate(0, 0, -days)
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
diff --git a/backend/internal/service/payment_status_query_security_test.go b/backend/internal/service/payment_status_query_security_test.go
new file mode 100644
index 00000000000..cd8ec60dacf
--- /dev/null
+++ b/backend/internal/service/payment_status_query_security_test.go
@@ -0,0 +1,170 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ "github.com/stretchr/testify/require"
+)
+
+type paymentStatusQuerySpyProvider struct {
+ queryCalls int
+ cancelCalls int
+ queryErr error
+ cancelErr error
+}
+
+func (p *paymentStatusQuerySpyProvider) Name() string { return "status-query-spy" }
+func (p *paymentStatusQuerySpyProvider) ProviderKey() string { return payment.TypeAlipay }
+func (p *paymentStatusQuerySpyProvider) SupportedTypes() []payment.PaymentType {
+ return []payment.PaymentType{payment.TypeAlipay}
+}
+func (p *paymentStatusQuerySpyProvider) CreatePayment(context.Context, payment.CreatePaymentRequest) (*payment.CreatePaymentResponse, error) {
+ panic("unexpected CreatePayment")
+}
+func (p *paymentStatusQuerySpyProvider) QueryOrder(context.Context, string) (*payment.QueryOrderResponse, error) {
+ p.queryCalls++
+ if p.queryErr != nil {
+ return nil, p.queryErr
+ }
+ return &payment.QueryOrderResponse{Status: payment.ProviderStatusPending}, nil
+}
+func (p *paymentStatusQuerySpyProvider) VerifyNotification(context.Context, string, map[string]string) (*payment.PaymentNotification, error) {
+ panic("unexpected VerifyNotification")
+}
+func (p *paymentStatusQuerySpyProvider) Refund(context.Context, payment.RefundRequest) (*payment.RefundResponse, error) {
+ panic("unexpected Refund")
+}
+func (p *paymentStatusQuerySpyProvider) CancelPayment(context.Context, string) error {
+ p.cancelCalls++
+ return p.cancelErr
+}
+
+func TestPaymentCancellationFailsClosedOnAmbiguousProviderState(t *testing.T) {
+ tests := []struct {
+ name string
+ provider *paymentStatusQuerySpyProvider
+ wantErrorCode string
+ }{
+ {
+ name: "query failure",
+ provider: &paymentStatusQuerySpyProvider{queryErr: errors.New("provider unavailable")},
+ wantErrorCode: "PAYMENT_STATUS_UNAVAILABLE",
+ },
+ {
+ name: "provider cancel failure",
+ provider: &paymentStatusQuerySpyProvider{cancelErr: errors.New("cancel unavailable")},
+ wantErrorCode: "PAYMENT_PROVIDER_CANCEL_FAILED",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ user, err := client.User.Create().
+ SetEmail("cancel-ambiguous@example.com").
+ SetPasswordHash("hash").
+ SetUsername("cancel-ambiguous-user").
+ Save(ctx)
+ require.NoError(t, err)
+ order, err := client.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(10).
+ SetPayAmount(10).
+ SetFeeRate(0).
+ SetRechargeCode("CANCEL-AMBIGUOUS").
+ SetOutTradeNo("abcdef0123456789abcdef0123456789").
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("").
+ SetOrderType(payment.OrderTypeBalance).
+ SetStatus(OrderStatusPending).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+
+ registry := payment.NewRegistry()
+ registry.Register(tt.provider)
+ svc := &PaymentService{entClient: client, registry: registry, providersLoaded: true}
+
+ _, err = svc.CancelOrder(ctx, order.ID, user.ID)
+ require.Error(t, err)
+ require.Equal(t, tt.wantErrorCode, infraerrorsReason(err))
+ persisted, reloadErr := client.PaymentOrder.Get(ctx, order.ID)
+ require.NoError(t, reloadErr)
+ require.Equal(t, OrderStatusPending, persisted.Status, "ambiguous upstream state must not become a local cancellation")
+ })
+ }
+}
+
+func TestPaymentStatusQueriesNeverCancelPendingProviderOrder(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ user, err := client.User.Create().
+ SetEmail("status-query@example.com").
+ SetPasswordHash("hash").
+ SetUsername("status-query-user").
+ Save(ctx)
+ require.NoError(t, err)
+
+ order, err := client.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(88).
+ SetPayAmount(88).
+ SetFeeRate(0).
+ SetRechargeCode("STATUS-QUERY").
+ SetOutTradeNo("0123456789abcdef0123456789abcdef").
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("").
+ SetOrderType(payment.OrderTypeBalance).
+ SetStatus(OrderStatusPending).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+
+ resumeService := NewPaymentResumeService([]byte("0123456789abcdef0123456789abcdef"))
+ token, err := resumeService.CreateToken(ResumeTokenClaims{
+ OrderID: order.ID,
+ UserID: user.ID,
+ PaymentType: payment.TypeAlipay,
+ })
+ require.NoError(t, err)
+
+ provider := &paymentStatusQuerySpyProvider{}
+ registry := payment.NewRegistry()
+ registry.Register(provider)
+ svc := &PaymentService{
+ entClient: client,
+ registry: registry,
+ resumeService: resumeService,
+ providersLoaded: true,
+ }
+
+ _, err = svc.GetPublicOrderByResumeToken(ctx, token)
+ require.NoError(t, err)
+ require.Equal(t, 1, provider.queryCalls, "signed public status lookup should reconcile a missed webhook")
+ require.Zero(t, provider.cancelCalls, "public status polling must never close an in-flight payment")
+ _, err = svc.GetPublicOrderByResumeToken(ctx, token)
+ require.NoError(t, err)
+ require.Equal(t, 1, provider.queryCalls, "per-order cooldown should suppress rapid repeated provider queries")
+
+ _, err = svc.VerifyOrderByOutTradeNo(ctx, order.OutTradeNo, user.ID)
+ require.NoError(t, err)
+ require.Equal(t, 2, provider.queryCalls)
+ require.Zero(t, provider.cancelCalls, "authenticated verification must never close an in-flight payment")
+
+ _, err = svc.CancelOrder(ctx, order.ID, user.ID)
+ require.NoError(t, err)
+ require.Equal(t, 3, provider.queryCalls)
+ require.Equal(t, 1, provider.cancelCalls, "only an explicit cancellation path may close the provider order")
+}
diff --git a/backend/internal/service/payment_visible_method_instances.go b/backend/internal/service/payment_visible_method_instances.go
index 899bd7a0203..02afe86f147 100644
--- a/backend/internal/service/payment_visible_method_instances.go
+++ b/backend/internal/service/payment_visible_method_instances.go
@@ -177,6 +177,60 @@ func (s *PaymentConfigService) resolveVisibleMethodSourceProviderKey(ctx context
return providerKey, nil
}
+func (s *PaymentConfigService) visibleMethodEnabled(ctx context.Context, method string) (bool, error) {
+ key := visibleMethodEnabledSettingKey(method)
+ if key == "" {
+ return true, nil
+ }
+ if s == nil || s.settingRepo == nil {
+ return false, nil
+ }
+ value, err := s.settingRepo.GetValue(ctx, key)
+ if err != nil {
+ if errors.Is(err, ErrSettingNotFound) {
+ return false, nil
+ }
+ return false, fmt.Errorf("get %s: %w", key, err)
+ }
+ return strings.EqualFold(strings.TrimSpace(value), "true"), nil
+}
+
+func (s *PaymentConfigService) validateCreateOrderPaymentMethodEnabled(
+ ctx context.Context,
+ cfg *PaymentConfig,
+ method string,
+) error {
+ method = NormalizeVisibleMethod(method)
+ if cfg == nil || method == "" {
+ return infraerrors.Forbidden("PAYMENT_METHOD_DISABLED", "payment method is disabled")
+ }
+
+ allowed := false
+ for _, enabledType := range cfg.EnabledTypes {
+ if NormalizeVisibleMethod(enabledType) == method {
+ allowed = true
+ break
+ }
+ }
+ if !allowed {
+ return infraerrors.Forbidden("PAYMENT_METHOD_DISABLED", "payment method is disabled").
+ WithMetadata(map[string]string{"payment_type": method})
+ }
+
+ visibleEnabled, err := s.visibleMethodEnabled(ctx, method)
+ if err != nil {
+ return infraerrors.ServiceUnavailable(
+ "PAYMENT_METHOD_POLICY_UNAVAILABLE",
+ "payment method policy is unavailable",
+ ).WithCause(err)
+ }
+ if !visibleEnabled {
+ return infraerrors.Forbidden("PAYMENT_METHOD_DISABLED", "payment method is disabled").
+ WithMetadata(map[string]string{"payment_type": method})
+ }
+ return nil
+}
+
func (s *PaymentConfigService) resolveVisibleMethodProviderKey(
ctx context.Context,
method string,
@@ -218,6 +272,13 @@ func (s *PaymentConfigService) resolveEnabledVisibleMethodInstance(
if method != payment.TypeAlipay && method != payment.TypeWxpay {
return nil, nil
}
+ methodEnabled, err := s.visibleMethodEnabled(ctx, method)
+ if err != nil {
+ return nil, err
+ }
+ if !methodEnabled {
+ return nil, nil
+ }
instances, err := s.entClient.PaymentProviderInstance.Query().
Where(paymentproviderinstance.EnabledEQ(true)).
diff --git a/backend/internal/service/payment_wallet_refund.go b/backend/internal/service/payment_wallet_refund.go
new file mode 100644
index 00000000000..3030120503b
--- /dev/null
+++ b/backend/internal/service/payment_wallet_refund.go
@@ -0,0 +1,648 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math"
+ "strconv"
+ "strings"
+ "time"
+
+ "entgo.io/ent/dialect"
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/paymentauditlog"
+ "github.com/Wei-Shaw/sub2api/ent/paymentorder"
+ "github.com/Wei-Shaw/sub2api/ent/schema/mixins"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
+ "github.com/Wei-Shaw/sub2api/ent/usersubscription"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+)
+
+const (
+ walletRefundDebitAppliedAction = "WALLET_REFUND_DEBIT_APPLIED"
+ walletRefundDebitCompensatedAction = "WALLET_REFUND_DEBIT_COMPENSATED"
+ walletRefundGatewayStartedAction = "WALLET_REFUND_GATEWAY_STARTED"
+ walletRefundGatewayAcceptedAction = "WALLET_REFUND_GATEWAY_ACCEPTED"
+
+ refundLeaseTimeout = 5 * time.Minute
+)
+
+type walletFulfillmentEvidence struct {
+ AuditID int64
+ SubscriptionID int64
+ CreditedAmount float64
+ PlanID int64
+ PlanType string
+ FulfilledAt time.Time
+}
+
+type walletFulfillmentAuditDetail struct {
+ SubscriptionID int64 `json:"subscriptionID"`
+ CreditedAmount float64 `json:"creditedAmount"`
+ PlanID int64 `json:"planID"`
+ PlanType string `json:"planType"`
+}
+
+type walletRefundDebitAuditDetail struct {
+ SubscriptionID int64 `json:"subscriptionID"`
+ FulfillmentAuditID int64 `json:"fulfillmentAuditID"`
+ CreditDeducted float64 `json:"creditDeducted"`
+ RefundAmount float64 `json:"refundAmount"`
+}
+
+func isWalletCreditsPaymentOrder(order *dbent.PaymentOrder) bool {
+ return order != nil &&
+ order.OrderType == payment.OrderTypeSubscription &&
+ order.SubscriptionGroupID == nil &&
+ order.PlanID != nil
+}
+
+func isRefundLeaseStale(updatedAt, now time.Time) bool {
+ return !updatedAt.After(now.Add(-refundLeaseTimeout))
+}
+
+func refundGatewayIdempotencyKey(orderID int64) string {
+ return "sub2-refund-" + strconv.FormatInt(orderID, 10)
+}
+
+func (s *PaymentService) prepareWalletCreditDeduction(ctx context.Context, order *dbent.PaymentOrder, plan *RefundPlan, force bool) (*RefundResult, error) {
+ evidence, err := s.loadWalletFulfillmentEvidence(ctx, order)
+ if err != nil {
+ return nil, err
+ }
+ creditToReverse, err := walletCreditForRefund(evidence.CreditedAmount, plan.RefundAmount, order.Amount)
+ if err != nil {
+ return nil, err
+ }
+ wallet, err := s.entClient.UserSubscription.Get(mixins.SkipSoftDelete(ctx), evidence.SubscriptionID)
+ if err != nil {
+ return nil, infraerrors.Conflict("WALLET_REFUND_EVIDENCE_INVALID", "fulfilled wallet no longer exists; manual reconciliation is required")
+ }
+ if wallet.UserID != order.UserID || wallet.WalletBalanceUsd == nil || wallet.WalletInitialUsd == nil {
+ return nil, infraerrors.Conflict("WALLET_REFUND_EVIDENCE_INVALID", "fulfillment evidence does not identify this user's wallet")
+ }
+ if *wallet.WalletInitialUsd+amountToleranceCNY < creditToReverse {
+ return nil, infraerrors.Conflict("WALLET_REFUND_INITIAL_MISMATCH", "wallet cumulative credit is lower than this order's immutable refund delta")
+ }
+ usedAfterFulfillment, err := s.walletUsageAfterFulfillment(ctx, s.entClient, evidence)
+ if err != nil {
+ return nil, fmt.Errorf("check wallet usage after fulfillment: %w", err)
+ }
+
+ plan.DeductionType = payment.DeductionTypeWallet
+ plan.SubscriptionID = evidence.SubscriptionID
+ plan.WalletCreditToDeduct = creditToReverse
+ plan.WalletFulfillmentAuditID = evidence.AuditID
+ plan.WalletFulfilledAt = evidence.FulfilledAt
+ if !force && (usedAfterFulfillment || *wallet.WalletBalanceUsd+amountToleranceCNY < creditToReverse) {
+ return &RefundResult{
+ Success: false,
+ Warning: "wallet credits may have been consumed after this purchase; use force to create an explicit wallet debt",
+ RequireForce: true,
+ }, nil
+ }
+ return nil, nil
+}
+
+func (s *PaymentService) loadWalletFulfillmentEvidence(ctx context.Context, order *dbent.PaymentOrder) (*walletFulfillmentEvidence, error) {
+ if s == nil || s.entClient == nil || !isWalletCreditsPaymentOrder(order) {
+ return nil, infraerrors.BadRequest("INVALID_ORDER_TYPE", "order is not a credits wallet purchase")
+ }
+ return loadWalletFulfillmentEvidenceFromClient(ctx, s.entClient, order)
+}
+
+func loadWalletFulfillmentEvidenceFromClient(ctx context.Context, client *dbent.Client, order *dbent.PaymentOrder) (*walletFulfillmentEvidence, error) {
+ if client == nil || !isWalletCreditsPaymentOrder(order) {
+ return nil, infraerrors.BadRequest("INVALID_ORDER_TYPE", "order is not a credits wallet purchase")
+ }
+ credits, err := client.SubscriptionWalletLedger.Query().
+ Where(
+ subscriptionwalletledger.PaymentOrderIDEQ(order.ID),
+ subscriptionwalletledger.ReasonIn(WalletLedgerReasonActivation, WalletLedgerReasonTopup),
+ subscriptionwalletledger.DeltaUsdGT(0),
+ ).
+ All(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("query wallet payment source: %w", err)
+ }
+ if len(credits) != 1 {
+ return nil, infraerrors.Conflict("WALLET_REFUND_SOURCE_UNPROVEN", "payment order has no unique immutable wallet credit source; manual reconciliation is required")
+ }
+ logs, err := client.PaymentAuditLog.Query().
+ Where(
+ paymentauditlog.OrderIDEQ(strconv.FormatInt(order.ID, 10)),
+ paymentauditlog.ActionEQ("SUBSCRIPTION_SUCCESS"),
+ ).
+ Order(paymentauditlog.ByCreatedAt(), paymentauditlog.ByID()).
+ All(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("query wallet fulfillment evidence: %w", err)
+ }
+ if len(logs) != 1 {
+ return nil, infraerrors.Conflict("WALLET_REFUND_EVIDENCE_AMBIGUOUS", "exactly one wallet fulfillment audit is required; manual reconciliation is required")
+ }
+ var detail walletFulfillmentAuditDetail
+ if err := json.Unmarshal([]byte(logs[0].Detail), &detail); err != nil {
+ return nil, infraerrors.Conflict("WALLET_REFUND_EVIDENCE_INVALID", "wallet fulfillment audit is malformed; manual reconciliation is required")
+ }
+ if detail.SubscriptionID <= 0 || !isFinitePositive(detail.CreditedAmount) {
+ return nil, infraerrors.Conflict("WALLET_REFUND_EVIDENCE_INVALID", "wallet fulfillment audit is missing the applied credit delta")
+ }
+ if detail.SubscriptionID != credits[0].SubscriptionID || math.Abs(detail.CreditedAmount-credits[0].DeltaUsd) > 0.0000000001 {
+ return nil, infraerrors.Conflict("WALLET_REFUND_EVIDENCE_INVALID", "wallet source ledger and fulfillment audit disagree")
+ }
+ if detail.PlanID > 0 && order.PlanID != nil && detail.PlanID != *order.PlanID {
+ return nil, infraerrors.Conflict("WALLET_REFUND_EVIDENCE_INVALID", "wallet fulfillment plan snapshot does not match the payment order")
+ }
+ if detail.PlanType != "" && detail.PlanType != PlanTypeCredits {
+ return nil, infraerrors.Conflict("WALLET_REFUND_EVIDENCE_INVALID", "wallet fulfillment snapshot is not a credits purchase")
+ }
+ return &walletFulfillmentEvidence{
+ AuditID: logs[0].ID,
+ SubscriptionID: credits[0].SubscriptionID,
+ CreditedAmount: credits[0].DeltaUsd,
+ PlanID: detail.PlanID,
+ PlanType: detail.PlanType,
+ FulfilledAt: credits[0].CreatedAt,
+ }, nil
+}
+
+func walletCreditForRefund(creditedAmount, refundAmount, orderAmount float64) (float64, error) {
+ if !isFinitePositive(creditedAmount) || !isFinitePositive(refundAmount) || !isFinitePositive(orderAmount) {
+ return 0, infraerrors.BadRequest("INVALID_AMOUNT", "wallet refund amounts must be finite and positive")
+ }
+ if refundAmount-orderAmount > amountToleranceCNY {
+ return 0, infraerrors.BadRequest("REFUND_AMOUNT_EXCEEDED", "refund amount exceeds wallet purchase")
+ }
+ if math.Abs(refundAmount-orderAmount) <= amountToleranceCNY {
+ return creditedAmount, nil
+ }
+ credit := math.Round((creditedAmount*refundAmount/orderAmount)*1e10) / 1e10
+ if !isFinitePositive(credit) || credit-creditedAmount > 0.0000000001 {
+ return 0, infraerrors.BadRequest("INVALID_AMOUNT", "wallet refund credit delta is invalid")
+ }
+ return credit, nil
+}
+
+func isFinitePositive(value float64) bool {
+ return value > 0 && !math.IsNaN(value) && !math.IsInf(value, 0)
+}
+
+func (s *PaymentService) walletUsageAfterFulfillment(ctx context.Context, client *dbent.Client, evidence *walletFulfillmentEvidence) (bool, error) {
+ if client == nil || evidence == nil {
+ return false, fmt.Errorf("wallet fulfillment evidence is unavailable")
+ }
+ return client.SubscriptionWalletLedger.Query().
+ Where(
+ subscriptionwalletledger.SubscriptionIDEQ(evidence.SubscriptionID),
+ subscriptionwalletledger.ReasonEQ(WalletLedgerReasonUsage),
+ subscriptionwalletledger.DeltaUsdLT(0),
+ subscriptionwalletledger.CreatedAtGT(evidence.FulfilledAt),
+ ).
+ Exist(ctx)
+}
+
+func (s *PaymentService) executeWalletRefund(ctx context.Context, plan *RefundPlan) (*RefundResult, error) {
+ if err := s.claimWalletRefund(ctx, plan); err != nil {
+ return nil, err
+ }
+ disposition, attempted, _, gatewayErr := s.executeRefundGatewayCall(
+ ctx,
+ plan,
+ walletRefundGatewayStartedAction,
+ walletRefundGatewayAcceptedAction,
+ plan.WalletRefundRecovered,
+ )
+ if !attempted || disposition == refundGatewayRejected {
+ cause := gatewayErr
+ if cause == nil {
+ cause = fmt.Errorf("provider rejected wallet refund")
+ }
+ if compensationErr := s.compensateWalletRefundBeforeGateway(ctx, plan, cause); compensationErr != nil {
+ return nil, fmt.Errorf("wallet refund failed definitively: %v; compensation failed: %w", cause, compensationErr)
+ }
+ if disposition == refundGatewayRejected {
+ return &RefundResult{Success: false, Warning: "provider rejected the refund; wallet debit was compensated and manual review is required"}, nil
+ }
+ return nil, infraerrors.InternalServer("REFUND_PRE_GATEWAY_FAILED", psErrMsg(cause))
+ }
+ switch disposition {
+ case refundGatewaySucceeded:
+ return s.markWalletRefundOK(ctx, plan)
+ case refundGatewayPending:
+ return &RefundResult{
+ Success: false,
+ Warning: "refund was accepted by the provider and remains pending; retry after the lease expires to reconcile status",
+ WalletCreditDeducted: plan.WalletCreditToDeduct,
+ }, nil
+ default:
+ return nil, infraerrors.InternalServer("REFUND_GATEWAY_AMBIGUOUS", "wallet refund gateway result is ambiguous; debit remains fenced for idempotent recovery")
+ }
+}
+
+func (s *PaymentService) claimWalletRefund(ctx context.Context, plan *RefundPlan) error {
+ if plan == nil || plan.Order == nil || plan.DeductionType != payment.DeductionTypeWallet {
+ return infraerrors.BadRequest("INVALID_REFUND_PLAN", "wallet refund plan is invalid")
+ }
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return fmt.Errorf("begin wallet refund transaction: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ txCtx := dbent.NewTxContext(ctx, tx)
+ client := tx.Client()
+ current, err := lockPaymentOrderForRefund(txCtx, client, plan.OrderID)
+ if err != nil {
+ return err
+ }
+
+ activeDebit, err := walletRefundDebitIsActive(txCtx, client, plan.OrderID)
+ if err != nil {
+ return err
+ }
+ compensated, err := paymentAuditActionExists(txCtx, client, plan.OrderID, walletRefundDebitCompensatedAction)
+ if err != nil {
+ return fmt.Errorf("check wallet refund compensation state: %w", err)
+ }
+ if compensated {
+ return infraerrors.Conflict("REFUND_RECOVERY_MANUAL_REQUIRED", "wallet refund debit was compensated; manual reconciliation is required")
+ }
+ now := time.Now().UTC().Truncate(time.Microsecond)
+ switch current.Status {
+ case OrderStatusCompleted, OrderStatusRefundRequested:
+ case OrderStatusRefunding:
+ if !isRefundLeaseStale(current.UpdatedAt, now) {
+ return infraerrors.Conflict("REFUND_IN_PROGRESS", "refund is being processed")
+ }
+ if !activeDebit {
+ return infraerrors.Conflict("REFUND_RECOVERY_MANUAL_REQUIRED", "legacy REFUNDING order has no idempotent wallet debit marker; manual reconciliation is required")
+ }
+ default:
+ return infraerrors.BadRequest("INVALID_STATUS", "order status does not allow wallet refund")
+ }
+
+ if activeDebit {
+ plan.WalletRefundRecovered = true
+ } else if err := s.applyWalletRefundDebit(txCtx, client, current, plan); err != nil {
+ return err
+ }
+
+ updated, err := client.PaymentOrder.Update().
+ Where(paymentorder.IDEQ(plan.OrderID), paymentorder.StatusEQ(current.Status), paymentorder.UpdatedAtEQ(current.UpdatedAt)).
+ SetStatus(OrderStatusRefunding).
+ SetRefundAmount(plan.RefundAmount).
+ SetRefundReason(plan.Reason).
+ SetForceRefund(plan.Force).
+ SetUpdatedAt(now).
+ Save(txCtx)
+ if err != nil {
+ return fmt.Errorf("claim wallet refund lease: %w", err)
+ }
+ if updated != 1 {
+ return infraerrors.Conflict("REFUND_IN_PROGRESS", "refund state changed concurrently")
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit wallet refund claim: %w", err)
+ }
+ plan.RefundLeaseUpdatedAt = now
+ return nil
+}
+
+func lockPaymentOrderForRefund(ctx context.Context, client *dbent.Client, orderID int64) (*dbent.PaymentOrder, error) {
+ query := client.PaymentOrder.Query().Where(paymentorder.IDEQ(orderID))
+ if client.Driver().Dialect() == dialect.Postgres {
+ query = query.ForUpdate()
+ }
+ order, err := query.Only(ctx)
+ if dbent.IsNotFound(err) {
+ return nil, infraerrors.NotFound("NOT_FOUND", "order not found")
+ }
+ if err != nil {
+ return nil, fmt.Errorf("lock payment order for refund: %w", err)
+ }
+ return order, nil
+}
+
+func walletRefundDebitIsActive(ctx context.Context, client *dbent.Client, orderID int64) (bool, error) {
+ rows, err := client.SubscriptionWalletLedger.Query().
+ Where(
+ subscriptionwalletledger.PaymentOrderIDEQ(orderID),
+ subscriptionwalletledger.ReasonEQ(WalletLedgerReasonRefund),
+ ).
+ All(ctx)
+ if err != nil {
+ return false, fmt.Errorf("query wallet refund source state: %w", err)
+ }
+ negative, positive, total := 0, 0, 0.0
+ for _, row := range rows {
+ total += row.DeltaUsd
+ if row.DeltaUsd < 0 {
+ negative++
+ } else if row.DeltaUsd > 0 {
+ positive++
+ }
+ }
+ if negative > 1 || positive > 1 || positive > negative || total > 0.0000000001 {
+ return false, infraerrors.Conflict("WALLET_REFUND_STATE_INVALID", "wallet refund source ledger is inconsistent; manual reconciliation is required")
+ }
+ return total < -0.0000000001, nil
+}
+
+func (s *PaymentService) applyWalletRefundDebit(ctx context.Context, client *dbent.Client, order *dbent.PaymentOrder, plan *RefundPlan) error {
+ evidence, err := s.loadWalletFulfillmentEvidenceWithClient(ctx, client, order)
+ if err != nil {
+ return err
+ }
+ if evidence.AuditID != plan.WalletFulfillmentAuditID || evidence.SubscriptionID != plan.SubscriptionID {
+ return infraerrors.Conflict("WALLET_REFUND_EVIDENCE_CHANGED", "wallet fulfillment evidence changed after refund preparation")
+ }
+ expectedCredit, err := walletCreditForRefund(evidence.CreditedAmount, plan.RefundAmount, order.Amount)
+ if err != nil {
+ return err
+ }
+ if math.Abs(expectedCredit-plan.WalletCreditToDeduct) > 0.0000000001 {
+ return infraerrors.Conflict("WALLET_REFUND_EVIDENCE_CHANGED", "wallet refund credit delta changed after preparation")
+ }
+
+ userID, initial, balance, err := lockWalletForRefund(ctx, client, plan.SubscriptionID)
+ if err != nil {
+ return err
+ }
+ if userID != order.UserID {
+ return infraerrors.Conflict("WALLET_REFUND_EVIDENCE_INVALID", "wallet owner does not match payment order")
+ }
+ if initial+amountToleranceCNY < plan.WalletCreditToDeduct {
+ return infraerrors.Conflict("WALLET_REFUND_INITIAL_MISMATCH", "wallet cumulative credit is lower than this order's immutable refund delta")
+ }
+ usedAfterFulfillment, err := s.walletUsageAfterFulfillment(ctx, client, evidence)
+ if err != nil {
+ return fmt.Errorf("check wallet usage after fulfillment: %w", err)
+ }
+ if !plan.Force && (usedAfterFulfillment || balance+amountToleranceCNY < plan.WalletCreditToDeduct) {
+ return infraerrors.Conflict("WALLET_REFUND_FORCE_REQUIRED", "wallet credits may have been consumed; force is required to create wallet debt")
+ }
+
+ newInitial := initial - plan.WalletCreditToDeduct
+ if newInitial < 0 && math.Abs(newInitial) <= amountToleranceCNY {
+ newInitial = 0
+ }
+ newBalance := balance - plan.WalletCreditToDeduct
+ if !plan.Force && newBalance < -amountToleranceCNY {
+ return infraerrors.Conflict("WALLET_REFUND_FORCE_REQUIRED", "wallet balance is lower than the credit being refunded")
+ }
+ if _, err := client.UserSubscription.UpdateOneID(plan.SubscriptionID).
+ SetWalletInitialUsd(newInitial).
+ SetWalletBalanceUsd(newBalance).
+ Save(ctx); err != nil {
+ return fmt.Errorf("reverse wallet credit: %w", err)
+ }
+ if _, err := client.SubscriptionWalletLedger.Create().
+ SetSubscriptionID(plan.SubscriptionID).
+ SetPaymentOrderID(plan.OrderID).
+ SetDeltaUsd(-plan.WalletCreditToDeduct).
+ SetBalanceAfter(newBalance).
+ SetReason(WalletLedgerReasonRefund).
+ SetNotes(fmt.Sprintf("payment order %d credits refund debit", plan.OrderID)).
+ Save(ctx); err != nil {
+ return fmt.Errorf("record wallet refund ledger: %w", err)
+ }
+ return createPaymentAuditLog(ctx, client, plan.OrderID, walletRefundDebitAppliedAction, "admin", map[string]any{
+ "subscriptionID": plan.SubscriptionID,
+ "fulfillmentAuditID": plan.WalletFulfillmentAuditID,
+ "creditedAmount": evidence.CreditedAmount,
+ "creditDeducted": plan.WalletCreditToDeduct,
+ "refundAmount": plan.RefundAmount,
+ "balanceBefore": balance,
+ "balanceAfter": newBalance,
+ "initialBefore": initial,
+ "initialAfter": newInitial,
+ "force": plan.Force,
+ })
+}
+
+func (s *PaymentService) loadWalletFulfillmentEvidenceWithClient(ctx context.Context, client *dbent.Client, order *dbent.PaymentOrder) (*walletFulfillmentEvidence, error) {
+ return loadWalletFulfillmentEvidenceFromClient(ctx, client, order)
+}
+
+func (s *PaymentService) restoreWalletRefundPlanFromAudit(ctx context.Context, plan *RefundPlan) error {
+ if plan == nil || plan.Order == nil {
+ return infraerrors.BadRequest("INVALID_REFUND_PLAN", "wallet refund plan is invalid")
+ }
+ compensated, err := paymentAuditActionExists(ctx, s.entClient, plan.OrderID, walletRefundDebitCompensatedAction)
+ if err != nil {
+ return fmt.Errorf("check wallet refund compensation audit: %w", err)
+ }
+ if compensated {
+ return infraerrors.Conflict("REFUND_RECOVERY_MANUAL_REQUIRED", "wallet refund debit was compensated; manual reconciliation is required")
+ }
+ active, err := walletRefundDebitIsActive(ctx, s.entClient, plan.OrderID)
+ if err != nil {
+ return err
+ }
+ if !active {
+ return infraerrors.Conflict("REFUND_RECOVERY_MANUAL_REQUIRED", "REFUNDING wallet order has no active immutable wallet debit")
+ }
+ log, err := s.entClient.PaymentAuditLog.Query().
+ Where(
+ paymentauditlog.OrderIDEQ(strconv.FormatInt(plan.OrderID, 10)),
+ paymentauditlog.ActionEQ(walletRefundDebitAppliedAction),
+ ).
+ Only(ctx)
+ if dbent.IsNotFound(err) {
+ return infraerrors.Conflict("REFUND_RECOVERY_MANUAL_REQUIRED", "REFUNDING wallet order has no immutable wallet debit marker")
+ }
+ if err != nil {
+ return fmt.Errorf("load wallet refund debit marker: %w", err)
+ }
+ var detail walletRefundDebitAuditDetail
+ if err := json.Unmarshal([]byte(log.Detail), &detail); err != nil {
+ return fmt.Errorf("decode wallet refund debit marker: %w", err)
+ }
+ if detail.SubscriptionID <= 0 ||
+ detail.FulfillmentAuditID <= 0 ||
+ !isFinitePositive(detail.CreditDeducted) ||
+ math.Abs(detail.RefundAmount-plan.RefundAmount) > amountToleranceCNY {
+ return infraerrors.Conflict("REFUND_RECOVERY_MANUAL_REQUIRED", "wallet refund debit marker does not match the claimed refund")
+ }
+ plan.DeductionType = payment.DeductionTypeWallet
+ plan.SubscriptionID = detail.SubscriptionID
+ plan.WalletFulfillmentAuditID = detail.FulfillmentAuditID
+ plan.WalletCreditToDeduct = detail.CreditDeducted
+ plan.WalletRefundRecovered = true
+ return nil
+}
+
+func lockWalletForRefund(ctx context.Context, client *dbent.Client, subscriptionID int64) (int64, float64, float64, error) {
+ query := client.UserSubscription.Query().Where(usersubscription.IDEQ(subscriptionID))
+ if client.Driver().Dialect() == dialect.Postgres {
+ query = query.ForUpdate()
+ }
+ wallet, err := query.Only(mixins.SkipSoftDelete(ctx))
+ if dbent.IsNotFound(err) {
+ return 0, 0, 0, ErrWalletNotFound
+ }
+ if err != nil {
+ return 0, 0, 0, fmt.Errorf("lock wallet for refund: %w", err)
+ }
+ if wallet.WalletInitialUsd == nil || wallet.WalletBalanceUsd == nil {
+ return 0, 0, 0, ErrWalletNotFound
+ }
+ return wallet.UserID, *wallet.WalletInitialUsd, *wallet.WalletBalanceUsd, nil
+}
+
+func refundProviderSupportsStableIdempotency(providerKey string) bool {
+ switch payment.GetBasePaymentType(strings.TrimSpace(providerKey)) {
+ case payment.TypeAlipay, payment.TypeWxpay, payment.TypeStripe:
+ return true
+ default:
+ return false
+ }
+}
+
+func (s *PaymentService) markWalletRefundOK(ctx context.Context, plan *RefundPlan) (*RefundResult, error) {
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("begin wallet refund completion: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ txCtx := dbent.NewTxContext(ctx, tx)
+ client := tx.Client()
+ current, err := lockPaymentOrderForRefund(txCtx, client, plan.OrderID)
+ if err != nil {
+ return nil, err
+ }
+ if current.Status != OrderStatusRefunding || !current.UpdatedAt.Equal(plan.RefundLeaseUpdatedAt) {
+ return nil, infraerrors.Conflict("REFUND_LEASE_LOST", "wallet refund lease was reclaimed by another worker")
+ }
+ active, err := walletRefundDebitIsActive(txCtx, client, plan.OrderID)
+ if err != nil {
+ return nil, err
+ }
+ if !active {
+ return nil, infraerrors.Conflict("WALLET_REFUND_STATE_INVALID", "wallet refund debit is not active")
+ }
+ finalStatus := OrderStatusRefunded
+ if plan.RefundAmount < plan.Order.Amount-amountToleranceCNY {
+ finalStatus = OrderStatusPartiallyRefunded
+ }
+ now := time.Now()
+ updated, err := client.PaymentOrder.Update().
+ Where(paymentorder.IDEQ(plan.OrderID), paymentorder.StatusEQ(OrderStatusRefunding), paymentorder.UpdatedAtEQ(plan.RefundLeaseUpdatedAt)).
+ SetStatus(finalStatus).
+ SetRefundAmount(plan.RefundAmount).
+ SetRefundReason(plan.Reason).
+ SetRefundAt(now).
+ SetForceRefund(plan.Force).
+ Save(txCtx)
+ if err != nil {
+ return nil, fmt.Errorf("mark wallet refund: %w", err)
+ }
+ if updated != 1 {
+ return nil, infraerrors.Conflict("REFUND_LEASE_LOST", "wallet refund lease was reclaimed by another worker")
+ }
+ if err := createPaymentAuditLog(txCtx, client, plan.OrderID, "REFUND_SUCCESS", "admin", map[string]any{
+ "refundAmount": plan.RefundAmount,
+ "reason": plan.Reason,
+ "force": plan.Force,
+ "walletSubscriptionID": plan.SubscriptionID,
+ "walletCreditDeducted": plan.WalletCreditToDeduct,
+ "fulfillmentAuditID": plan.WalletFulfillmentAuditID,
+ "idempotencyKey": refundGatewayIdempotencyKey(plan.OrderID),
+ }); err != nil {
+ return nil, fmt.Errorf("record wallet refund success: %w", err)
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, fmt.Errorf("commit wallet refund completion: %w", err)
+ }
+ return &RefundResult{Success: true, WalletCreditDeducted: plan.WalletCreditToDeduct}, nil
+}
+
+func (s *PaymentService) compensateWalletRefundBeforeGateway(ctx context.Context, plan *RefundPlan, cause error) error {
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return fmt.Errorf("begin wallet refund compensation: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+ txCtx := dbent.NewTxContext(ctx, tx)
+ client := tx.Client()
+ current, err := lockPaymentOrderForRefund(txCtx, client, plan.OrderID)
+ if err != nil {
+ return err
+ }
+ if current.Status != OrderStatusRefunding || !current.UpdatedAt.Equal(plan.RefundLeaseUpdatedAt) {
+ return infraerrors.Conflict("REFUND_LEASE_LOST", "cannot compensate a wallet refund owned by another worker")
+ }
+ active, err := walletRefundDebitIsActive(txCtx, client, plan.OrderID)
+ if err != nil {
+ return err
+ }
+ if !active {
+ return nil
+ }
+ _, initial, balance, err := lockWalletForRefund(txCtx, client, plan.SubscriptionID)
+ if err != nil {
+ return err
+ }
+ newInitial := initial + plan.WalletCreditToDeduct
+ newBalance := balance + plan.WalletCreditToDeduct
+ if _, err := client.UserSubscription.UpdateOneID(plan.SubscriptionID).
+ SetWalletInitialUsd(newInitial).
+ SetWalletBalanceUsd(newBalance).
+ Save(txCtx); err != nil {
+ return fmt.Errorf("compensate wallet refund credit: %w", err)
+ }
+ if _, err := client.SubscriptionWalletLedger.Create().
+ SetSubscriptionID(plan.SubscriptionID).
+ SetPaymentOrderID(plan.OrderID).
+ SetDeltaUsd(plan.WalletCreditToDeduct).
+ SetBalanceAfter(newBalance).
+ SetReason(WalletLedgerReasonRefund).
+ SetNotes(fmt.Sprintf("payment order %d credits refund pre-gateway compensation", plan.OrderID)).
+ Save(txCtx); err != nil {
+ return fmt.Errorf("record wallet refund compensation ledger: %w", err)
+ }
+ if err := createPaymentAuditLog(txCtx, client, plan.OrderID, walletRefundDebitCompensatedAction, "admin", map[string]any{
+ "subscriptionID": plan.SubscriptionID,
+ "creditRestored": plan.WalletCreditToDeduct,
+ "reason": cause.Error(),
+ }); err != nil {
+ return err
+ }
+ restoreStatus := plan.Order.Status
+ if restoreStatus == OrderStatusRefunding {
+ restoreStatus = OrderStatusRefundFailed
+ }
+ updated, err := client.PaymentOrder.Update().
+ Where(paymentorder.IDEQ(plan.OrderID), paymentorder.StatusEQ(OrderStatusRefunding), paymentorder.UpdatedAtEQ(plan.RefundLeaseUpdatedAt)).
+ SetStatus(restoreStatus).
+ SetRefundAmount(0).
+ ClearRefundReason().
+ SetForceRefund(false).
+ Save(txCtx)
+ if err != nil {
+ return fmt.Errorf("restore wallet refund order: %w", err)
+ }
+ if updated != 1 {
+ return infraerrors.Conflict("REFUND_LEASE_LOST", "cannot restore wallet refund order after lease loss")
+ }
+ return tx.Commit()
+}
+
+func createPaymentAuditLog(ctx context.Context, client *dbent.Client, orderID int64, action, operator string, detail map[string]any) error {
+ encoded, err := json.Marshal(detail)
+ if err != nil {
+ return fmt.Errorf("encode payment audit %s: %w", action, err)
+ }
+ _, err = client.PaymentAuditLog.Create().
+ SetOrderID(strconv.FormatInt(orderID, 10)).
+ SetAction(action).
+ SetDetail(string(encoded)).
+ SetOperator(operator).
+ Save(ctx)
+ if err != nil {
+ return fmt.Errorf("record payment audit %s: %w", action, err)
+ }
+ return nil
+}
diff --git a/backend/internal/service/payment_wallet_refund_test.go b/backend/internal/service/payment_wallet_refund_test.go
new file mode 100644
index 00000000000..50d5574e52e
--- /dev/null
+++ b/backend/internal/service/payment_wallet_refund_test.go
@@ -0,0 +1,216 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strconv"
+ "testing"
+ "time"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/schema/mixins"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionwalletledger"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPrepareWalletRefundUsesFulfillmentCreditDeltaNotCumulativeInitial(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ user := createWalletRefundTestUser(t, ctx, client, "exact-delta")
+
+ // The plan has since changed and the wallet contains earlier credits. Neither
+ // value is authoritative for this order's refund. Fulfillment recorded the
+ // exact $50 delta applied by this purchase.
+ plan, err := client.SubscriptionPlan.Create().
+ SetName("mutated credits plan").
+ SetPrice(30).
+ SetWalletQuotaUsd(999).
+ SetValidityDays(36500).
+ SetValidityUnit("day").
+ SetPlanType(PlanTypeCredits).
+ Save(ctx)
+ require.NoError(t, err)
+ wallet := createWalletRefundTestWallet(t, ctx, client, user.ID, 150, 150)
+ order := createWalletRefundTestOrder(t, ctx, client, user, plan.ID, 30)
+ createWalletFulfillmentAudit(t, ctx, client, order.ID, wallet.ID, 50)
+
+ svc := &PaymentService{entClient: client}
+ refund := &RefundPlan{OrderID: order.ID, Order: order, RefundAmount: order.Amount}
+ early, err := svc.prepDeduct(ctx, order, refund, false)
+
+ require.NoError(t, err)
+ require.Nil(t, early)
+ require.Equal(t, "wallet", refund.DeductionType)
+ require.Equal(t, wallet.ID, refund.SubscriptionID)
+ require.InDelta(t, 50, refund.WalletCreditToDeduct, 0.0000001,
+ "refund must reverse this order's fulfillment delta, not the mutable plan or cumulative wallet initial")
+}
+
+func TestWalletRefundDebitAndCompensationCarryImmutablePaymentOrderSource(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ user := createWalletRefundTestUser(t, ctx, client, "refund-ledger-source")
+ plan, err := client.SubscriptionPlan.Create().
+ SetName("wallet refund source plan").
+ SetPrice(30).
+ SetWalletQuotaUsd(50).
+ SetValidityDays(36500).
+ SetValidityUnit("day").
+ SetPlanType(PlanTypeCredits).
+ Save(ctx)
+ require.NoError(t, err)
+ wallet := createWalletRefundTestWallet(t, ctx, client, user.ID, 50, 50)
+ order := createWalletRefundTestOrder(t, ctx, client, user, plan.ID, 30)
+ createWalletFulfillmentAudit(t, ctx, client, order.ID, wallet.ID, 50)
+
+ svc := &PaymentService{entClient: client}
+ refundPlan := &RefundPlan{
+ OrderID: order.ID,
+ Order: order,
+ RefundAmount: order.Amount,
+ GatewayAmount: order.PayAmount,
+ Reason: "test immutable source",
+ }
+ early, err := svc.prepDeduct(ctx, order, refundPlan, false)
+ require.NoError(t, err)
+ require.Nil(t, early)
+ require.NoError(t, svc.claimWalletRefund(ctx, refundPlan))
+
+ debits, err := client.SubscriptionWalletLedger.Query().
+ Where(subscriptionwalletledger.PaymentOrderIDEQ(order.ID), subscriptionwalletledger.ReasonEQ(WalletLedgerReasonRefund)).
+ All(ctx)
+ require.NoError(t, err)
+ require.Len(t, debits, 1)
+ require.NotNil(t, debits[0].PaymentOrderID)
+ require.Equal(t, order.ID, *debits[0].PaymentOrderID)
+ require.Less(t, debits[0].DeltaUsd, 0.0)
+
+ require.NoError(t, svc.compensateWalletRefundBeforeGateway(ctx, refundPlan, errors.New("definite pre-gateway rejection")))
+ rows, err := client.SubscriptionWalletLedger.Query().
+ Where(subscriptionwalletledger.PaymentOrderIDEQ(order.ID), subscriptionwalletledger.ReasonEQ(WalletLedgerReasonRefund)).
+ All(ctx)
+ require.NoError(t, err)
+ require.Len(t, rows, 2)
+ for _, row := range rows {
+ require.NotNil(t, row.PaymentOrderID)
+ require.Equal(t, order.ID, *row.PaymentOrderID)
+ }
+}
+
+func TestWalletRefundCanReverseSoftDeletedWalletFromImmutableEvidence(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ user := createWalletRefundTestUser(t, ctx, client, "soft-deleted-wallet")
+ plan, err := client.SubscriptionPlan.Create().
+ SetName("soft deleted wallet refund plan").
+ SetPrice(30).
+ SetWalletQuotaUsd(50).
+ SetValidityDays(36500).
+ SetValidityUnit("day").
+ SetPlanType(PlanTypeCredits).
+ Save(ctx)
+ require.NoError(t, err)
+ wallet := createWalletRefundTestWallet(t, ctx, client, user.ID, 50, 50)
+ order := createWalletRefundTestOrder(t, ctx, client, user, plan.ID, 30)
+ createWalletFulfillmentAudit(t, ctx, client, order.ID, wallet.ID, 50)
+ require.NoError(t, client.UserSubscription.DeleteOneID(wallet.ID).Exec(ctx))
+
+ svc := &PaymentService{entClient: client}
+ refundPlan := &RefundPlan{
+ OrderID: order.ID,
+ Order: order,
+ RefundAmount: order.Amount,
+ GatewayAmount: order.PayAmount,
+ Reason: "reverse historical wallet credit",
+ }
+ early, err := svc.prepDeduct(ctx, order, refundPlan, false)
+ require.NoError(t, err)
+ require.Nil(t, early)
+ require.NoError(t, svc.claimWalletRefund(ctx, refundPlan))
+
+ got, err := client.UserSubscription.Get(mixins.SkipSoftDelete(ctx), wallet.ID)
+ require.NoError(t, err)
+ require.NotNil(t, got.DeletedAt, "refund must preserve the soft-delete lifecycle state")
+ require.InDelta(t, 0, *got.WalletInitialUsd, 0.0000001)
+ require.InDelta(t, 0, *got.WalletBalanceUsd, 0.0000001)
+}
+
+func createWalletRefundTestUser(t *testing.T, ctx context.Context, client *dbent.Client, label string) *dbent.User {
+ t.Helper()
+ user, err := client.User.Create().
+ SetEmail(fmt.Sprintf("wallet-refund-%s-%d@example.com", label, time.Now().UnixNano())).
+ SetPasswordHash("hash").
+ SetUsername("wallet-refund-" + label).
+ Save(ctx)
+ require.NoError(t, err)
+ return user
+}
+
+func createWalletRefundTestWallet(t *testing.T, ctx context.Context, client *dbent.Client, userID int64, initial, balance float64) *dbent.UserSubscription {
+ t.Helper()
+ wallet, err := client.UserSubscription.Create().
+ SetUserID(userID).
+ SetStartsAt(time.Now().Add(-time.Hour)).
+ SetExpiresAt(MaxExpiresAt).
+ SetStatus(SubscriptionStatusActive).
+ SetWalletInitialUsd(initial).
+ SetWalletBalanceUsd(balance).
+ Save(ctx)
+ require.NoError(t, err)
+ return wallet
+}
+
+func createWalletRefundTestOrder(t *testing.T, ctx context.Context, client *dbent.Client, user *dbent.User, planID int64, amount float64) *dbent.PaymentOrder {
+ t.Helper()
+ suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
+ updatedAt := time.Now().UTC().Truncate(time.Microsecond)
+ order, err := client.PaymentOrder.Create().
+ SetUserID(user.ID).
+ SetUserEmail(user.Email).
+ SetUserName(user.Username).
+ SetAmount(amount).
+ SetPayAmount(amount).
+ SetFeeRate(0).
+ SetRechargeCode("WR-" + suffix).
+ SetOutTradeNo("wallet-refund-" + suffix).
+ SetPaymentType(payment.TypeAlipay).
+ SetPaymentTradeNo("trade-wallet-refund-" + suffix).
+ SetOrderType(payment.OrderTypeSubscription).
+ SetPlanID(planID).
+ SetSubscriptionDays(36500).
+ SetStatus(OrderStatusCompleted).
+ SetExpiresAt(time.Now().Add(time.Hour)).
+ SetPaidAt(time.Now().Add(-time.Minute)).
+ SetCompletedAt(time.Now()).
+ SetUpdatedAt(updatedAt).
+ SetClientIP("127.0.0.1").
+ SetSrcHost("api.example.com").
+ Save(ctx)
+ require.NoError(t, err)
+ return order
+}
+
+func createWalletFulfillmentAudit(t *testing.T, ctx context.Context, client *dbent.Client, orderID, subscriptionID int64, creditedAmount float64) *dbent.PaymentAuditLog {
+ t.Helper()
+ _, err := client.SubscriptionWalletLedger.Create().
+ SetSubscriptionID(subscriptionID).
+ SetDeltaUsd(creditedAmount).
+ SetBalanceAfter(creditedAmount).
+ SetReason(WalletLedgerReasonActivation).
+ SetPaymentOrderID(orderID).
+ SetNotes("wallet payment fulfillment source").
+ Save(ctx)
+ require.NoError(t, err)
+ audit, err := client.PaymentAuditLog.Create().
+ SetOrderID(strconv.FormatInt(orderID, 10)).
+ SetAction("SUBSCRIPTION_SUCCESS").
+ SetDetail(fmt.Sprintf(`{"subscriptionID":%d,"creditedAmount":%.10f,"planType":"credits"}`, subscriptionID, creditedAmount)).
+ SetOperator("system").
+ Save(ctx)
+ require.NoError(t, err)
+ return audit
+}
diff --git a/backend/internal/service/plan_fulfillment_snapshot.go b/backend/internal/service/plan_fulfillment_snapshot.go
new file mode 100644
index 00000000000..993b3cb7c4a
--- /dev/null
+++ b/backend/internal/service/plan_fulfillment_snapshot.go
@@ -0,0 +1,485 @@
+package service
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ entgroup "github.com/Wei-Shaw/sub2api/ent/group"
+ "github.com/Wei-Shaw/sub2api/ent/paymentorder"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+)
+
+const planFulfillmentSnapshotSchemaVersion = 1
+
+type planFulfillmentSnapshotGroup struct {
+ GroupID int64 `json:"group_id"`
+ Name string `json:"name"`
+ Platform string `json:"platform"`
+ RateMultiplier float64 `json:"rate_multiplier"`
+}
+
+// planFulfillmentSnapshot contains every mutable product fact needed after an
+// order has been created. Source plan rows remain useful administration
+// metadata, but fulfillment and subscription authorization never consult them.
+type planFulfillmentSnapshot struct {
+ SchemaVersion int `json:"schema_version"`
+ PlanID int64 `json:"plan_id"`
+ PlanType string `json:"plan_type"`
+ PlanName string `json:"plan_name"`
+ ProductName string `json:"product_name"`
+ PlanPrice float64 `json:"plan_price"`
+ GroupID *int64 `json:"group_id"`
+ SubscriptionDays int `json:"subscription_days"`
+ WalletQuotaUSD *float64 `json:"wallet_quota_usd"`
+ CoveredGroupIDs []int64 `json:"covered_group_ids"`
+ CoveredGroups []planFulfillmentSnapshotGroup `json:"covered_groups"`
+ LockedRates map[string]float64 `json:"locked_rates"`
+ CaptureSource string `json:"capture_source"`
+}
+
+func (s *planFulfillmentSnapshot) Validate() error {
+ if s == nil {
+ return errors.New("plan fulfillment snapshot is nil")
+ }
+ if s.SchemaVersion != planFulfillmentSnapshotSchemaVersion {
+ return fmt.Errorf("unsupported plan snapshot schema_version %d", s.SchemaVersion)
+ }
+ if s.PlanID <= 0 {
+ return errors.New("plan snapshot requires plan_id")
+ }
+ if s.SubscriptionDays <= 0 || s.SubscriptionDays > MaxValidityDays {
+ return errors.New("plan snapshot subscription_days is out of range")
+ }
+ if !isFinitePositivePlanSnapshotNumber(s.PlanPrice) {
+ return errors.New("plan snapshot requires a finite positive plan_price")
+ }
+ switch s.PlanType {
+ case PlanTypeSubscription:
+ if s.GroupID == nil || *s.GroupID <= 0 {
+ return errors.New("monthly snapshot requires group_id")
+ }
+ if s.WalletQuotaUSD != nil {
+ return errors.New("monthly snapshot cannot contain wallet_quota_usd")
+ }
+ case PlanTypeCredits:
+ if s.GroupID != nil {
+ return errors.New("credits snapshot cannot contain group_id")
+ }
+ if s.WalletQuotaUSD == nil || !isFinitePositivePlanSnapshotNumber(*s.WalletQuotaUSD) {
+ return errors.New("credits snapshot requires wallet_quota_usd")
+ }
+ default:
+ return fmt.Errorf("invalid plan snapshot plan_type %q", s.PlanType)
+ }
+
+ seen := make(map[int64]struct{}, len(s.CoveredGroupIDs))
+ for _, groupID := range s.CoveredGroupIDs {
+ if groupID <= 0 {
+ return errors.New("plan snapshot covered_group_ids must be positive")
+ }
+ if _, exists := seen[groupID]; exists {
+ return fmt.Errorf("plan snapshot covered_group_ids contains duplicate %d", groupID)
+ }
+ seen[groupID] = struct{}{}
+ }
+ if s.PlanType == PlanTypeSubscription {
+ if _, ok := seen[*s.GroupID]; !ok {
+ return errors.New("monthly snapshot covered_group_ids must contain group_id")
+ }
+ }
+ for groupID, rate := range s.LockedRates {
+ parsed, err := strconv.ParseInt(groupID, 10, 64)
+ if err != nil || parsed <= 0 {
+ return fmt.Errorf("plan snapshot locked_rates has invalid group id %q", groupID)
+ }
+ if math.IsNaN(rate) || math.IsInf(rate, 0) || rate < 0 {
+ return fmt.Errorf("plan snapshot locked_rates[%s] must be finite and non-negative", groupID)
+ }
+ }
+ return nil
+}
+
+func (s *planFulfillmentSnapshot) ValidateForOrder(order *dbent.PaymentOrder) error {
+ if err := s.Validate(); err != nil {
+ return err
+ }
+ if order == nil || order.OrderType != payment.OrderTypeSubscription {
+ return errors.New("plan snapshot requires a subscription order")
+ }
+ if order.PlanID == nil || *order.PlanID != s.PlanID {
+ return errors.New("plan snapshot plan_id does not match order")
+ }
+ if !sameOptionalInt64(order.SubscriptionGroupID, s.GroupID) {
+ return errors.New("plan snapshot group_id does not match order")
+ }
+ if order.SubscriptionDays == nil || *order.SubscriptionDays != s.SubscriptionDays {
+ return errors.New("plan snapshot subscription_days does not match order")
+ }
+ if math.Abs(order.Amount-s.PlanPrice) > 0.000001 {
+ return errors.New("plan snapshot plan_price does not match order amount")
+ }
+ return nil
+}
+
+func isFinitePositivePlanSnapshotNumber(value float64) bool {
+ return value > 0 && !math.IsNaN(value) && !math.IsInf(value, 0)
+}
+
+func sameOptionalInt64(left, right *int64) bool {
+ if left == nil || right == nil {
+ return left == nil && right == nil
+ }
+ return *left == *right
+}
+
+func (s *PaymentService) capturePlanFulfillmentSnapshot(ctx context.Context, client *dbent.Client, userID, planID int64) (*planFulfillmentSnapshot, error) {
+ if client == nil || planID <= 0 || userID <= 0 {
+ return nil, infraerrors.BadRequest("PLAN_SNAPSHOT_INPUT_INVALID", "invalid plan snapshot input")
+ }
+ plan, err := client.SubscriptionPlan.Query().
+ Where(subscriptionplan.IDEQ(planID)).
+ ForShare().
+ Only(ctx)
+ if dbent.IsNotFound(err) {
+ return nil, infraerrors.Conflict("PLAN_CHANGED_RETRY", "subscription plan changed; create the order again")
+ }
+ if err != nil {
+ return nil, fmt.Errorf("lock subscription plan: %w", err)
+ }
+ if !plan.ForSale {
+ return nil, infraerrors.Conflict("PLAN_CHANGED_RETRY", "subscription plan is no longer for sale")
+ }
+
+ planType, err := validatePlanType(plan.PlanType)
+ if err != nil {
+ return nil, err
+ }
+ groupIDs, err := capturePlanGroupIDs(ctx, client, plan)
+ if err != nil {
+ return nil, err
+ }
+ groups, err := capturePlanGroups(ctx, client, groupIDs, planType)
+ if err != nil {
+ return nil, err
+ }
+
+ coveredGroups := make([]planFulfillmentSnapshotGroup, 0, len(groups))
+ lockedRates := make(map[string]float64, len(groups))
+ for _, group := range groups {
+ rate := group.RateMultiplier
+ if fixed, ok := monthlyLockedRateForGroup(group.Name, group.Platform); ok && planType == PlanTypeSubscription {
+ rate = fixed
+ } else if userRate, ok, rateErr := captureUserGroupRate(ctx, client, userID, group.ID); rateErr != nil {
+ return nil, rateErr
+ } else if ok {
+ rate = userRate
+ }
+ if math.IsNaN(rate) || math.IsInf(rate, 0) || rate < 0 {
+ return nil, fmt.Errorf("group %d has invalid effective rate multiplier", group.ID)
+ }
+ lockedRates[strconv.FormatInt(group.ID, 10)] = rate
+ coveredGroups = append(coveredGroups, planFulfillmentSnapshotGroup{
+ GroupID: group.ID,
+ Name: group.Name,
+ Platform: group.Platform,
+ RateMultiplier: rate,
+ })
+ }
+
+ snapshot := &planFulfillmentSnapshot{
+ SchemaVersion: planFulfillmentSnapshotSchemaVersion,
+ PlanID: plan.ID,
+ PlanType: planType,
+ PlanName: plan.Name,
+ ProductName: plan.ProductName,
+ PlanPrice: plan.Price,
+ GroupID: cloneInt64Pointer(plan.GroupID),
+ SubscriptionDays: psComputeValidityDays(plan.ValidityDays, plan.ValidityUnit),
+ WalletQuotaUSD: cloneFloat64Pointer(plan.WalletQuotaUsd),
+ CoveredGroupIDs: append([]int64(nil), groupIDs...),
+ CoveredGroups: coveredGroups,
+ LockedRates: lockedRates,
+ CaptureSource: "native",
+ }
+ if err := snapshot.Validate(); err != nil {
+ return nil, fmt.Errorf("invalid captured plan snapshot: %w", err)
+ }
+ return snapshot, nil
+}
+
+func capturePlanGroupIDs(ctx context.Context, client *dbent.Client, plan *dbent.SubscriptionPlan) ([]int64, error) {
+ seen := make(map[int64]struct{})
+ groupIDs := make([]int64, 0)
+ if plan.GroupID != nil && *plan.GroupID > 0 {
+ seen[*plan.GroupID] = struct{}{}
+ groupIDs = append(groupIDs, *plan.GroupID)
+ }
+ rows, err := client.SubscriptionPlanGroup.Query().
+ Where(subscriptionplangroup.PlanIDEQ(plan.ID)).
+ ForShare().
+ All(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("lock subscription plan groups: %w", err)
+ }
+ for _, row := range rows {
+ if row.GroupID <= 0 {
+ continue
+ }
+ if _, exists := seen[row.GroupID]; exists {
+ continue
+ }
+ seen[row.GroupID] = struct{}{}
+ groupIDs = append(groupIDs, row.GroupID)
+ }
+ sort.Slice(groupIDs, func(i, j int) bool { return groupIDs[i] < groupIDs[j] })
+ return groupIDs, nil
+}
+
+func capturePlanGroups(ctx context.Context, client *dbent.Client, groupIDs []int64, planType string) ([]*dbent.Group, error) {
+ if len(groupIDs) == 0 {
+ return nil, nil
+ }
+ groups, err := client.Group.Query().Where(entgroup.IDIn(groupIDs...)).ForShare().All(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("lock plan coverage groups: %w", err)
+ }
+ if len(groups) != len(groupIDs) {
+ return nil, infraerrors.Conflict("PLAN_CHANGED_RETRY", "subscription plan coverage changed; create the order again")
+ }
+ if err := validateCapturedPlanCoverageGroups(planType, groups); err != nil {
+ return nil, err
+ }
+ sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID })
+ return groups, nil
+}
+
+func validateCapturedPlanCoverageGroups(planType string, groups []*dbent.Group) error {
+ if planType != PlanTypeCredits {
+ return nil
+ }
+ for _, candidate := range groups {
+ if candidate == nil ||
+ candidate.Status != StatusActive ||
+ candidate.SubscriptionType != SubscriptionTypeStandard ||
+ candidate.IsExclusive ||
+ candidate.Name == WalletDefaultVIPGroupName ||
+ candidate.DeletedAt != nil {
+ return infraerrors.Conflict("PLAN_CHANGED_RETRY", "subscription plan coverage is no longer available; create the order again")
+ }
+ }
+ return nil
+}
+
+func captureUserGroupRate(ctx context.Context, client *dbent.Client, userID, groupID int64) (float64, bool, error) {
+ var rate sql.NullFloat64
+ rows, err := client.QueryContext(ctx, `
+ SELECT rate_multiplier
+ FROM user_group_rate_multipliers
+ WHERE user_id = $1 AND group_id = $2
+ FOR SHARE
+ `, userID, groupID)
+ if err != nil {
+ return 0, false, fmt.Errorf("read user group rate for snapshot: %w", err)
+ }
+ defer rows.Close()
+ if !rows.Next() {
+ return 0, false, nil
+ }
+ if err := rows.Scan(&rate); err != nil {
+ return 0, false, fmt.Errorf("read user group rate for snapshot: %w", err)
+ }
+ if err := rows.Err(); err != nil {
+ return 0, false, fmt.Errorf("read user group rate for snapshot: %w", err)
+ }
+ if !rate.Valid {
+ return 0, false, nil
+ }
+ return rate.Float64, true, nil
+}
+
+func cloneInt64Pointer(value *int64) *int64 {
+ if value == nil {
+ return nil
+ }
+ cloned := *value
+ return &cloned
+}
+
+func cloneFloat64Pointer(value *float64) *float64 {
+ if value == nil {
+ return nil
+ }
+ cloned := *value
+ return &cloned
+}
+
+func persistPlanFulfillmentSnapshot(ctx context.Context, client *dbent.Client, orderID, userID int64, snapshot *planFulfillmentSnapshot) error {
+ if client == nil || orderID <= 0 || userID <= 0 {
+ return errors.New("invalid plan snapshot persistence input")
+ }
+ if err := snapshot.Validate(); err != nil {
+ return err
+ }
+ payload, err := json.Marshal(snapshot)
+ if err != nil {
+ return fmt.Errorf("marshal plan fulfillment snapshot: %w", err)
+ }
+ _, err = client.ExecContext(ctx, `
+ INSERT INTO subscription_plan_fulfillment_snapshots (
+ payment_order_id, user_id, source_plan_id, snapshot
+ ) VALUES ($1, $2, $3, $4)
+ ON CONFLICT (payment_order_id) DO NOTHING
+ `, orderID, userID, snapshot.PlanID, string(payload))
+ if err != nil {
+ return fmt.Errorf("persist plan fulfillment snapshot: %w", err)
+ }
+
+ stored, err := loadPlanFulfillmentSnapshot(ctx, client, orderID)
+ if err != nil {
+ return err
+ }
+ storedPayload, err := json.Marshal(stored)
+ if err != nil {
+ return err
+ }
+ if string(storedPayload) != string(payload) {
+ return infraerrors.Conflict("PLAN_SNAPSHOT_CONFLICT", "payment order already has a different plan snapshot")
+ }
+ return nil
+}
+
+func loadPlanFulfillmentSnapshot(ctx context.Context, client *dbent.Client, orderID int64) (*planFulfillmentSnapshot, error) {
+ if client == nil || orderID <= 0 {
+ return nil, errors.New("invalid plan snapshot lookup input")
+ }
+ var raw []byte
+ rows, err := client.QueryContext(ctx, `
+ SELECT snapshot
+ FROM subscription_plan_fulfillment_snapshots
+ WHERE payment_order_id = $1
+ `, orderID)
+ if err != nil {
+ return nil, fmt.Errorf("load plan fulfillment snapshot: %w", err)
+ }
+ defer rows.Close()
+ if !rows.Next() {
+ return nil, infraerrors.Conflict("FULFILLMENT_SNAPSHOT_MISSING", "payment order plan snapshot is missing")
+ }
+ if err := rows.Scan(&raw); err != nil {
+ return nil, fmt.Errorf("load plan fulfillment snapshot: %w", err)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("load plan fulfillment snapshot: %w", err)
+ }
+ var snapshot planFulfillmentSnapshot
+ if err := json.Unmarshal(raw, &snapshot); err != nil {
+ return nil, fmt.Errorf("decode plan fulfillment snapshot: %w", err)
+ }
+ if err := snapshot.Validate(); err != nil {
+ return nil, fmt.Errorf("invalid persisted plan fulfillment snapshot: %w", err)
+ }
+ return &snapshot, nil
+}
+
+func attachPlanSnapshotGrant(ctx context.Context, client *dbent.Client, orderID, subscriptionID int64, startsAt, expiresAt time.Time) error {
+ if client == nil || orderID <= 0 || subscriptionID <= 0 || startsAt.IsZero() || !expiresAt.After(startsAt) {
+ return errors.New("invalid subscription grant attachment")
+ }
+ attachedAt := time.Now().UTC()
+ result, err := client.ExecContext(ctx, `
+ UPDATE subscription_plan_fulfillment_snapshots
+ SET user_subscription_id = $2,
+ grant_starts_at = $3,
+ grant_expires_at = $4,
+ attached_at = $5
+ WHERE payment_order_id = $1
+ AND (
+ user_subscription_id IS NULL
+ OR (
+ user_subscription_id = $2
+ AND grant_starts_at = $3
+ AND grant_expires_at = $4
+ )
+ )
+ `, orderID, subscriptionID, startsAt, expiresAt, attachedAt)
+ if err != nil {
+ return fmt.Errorf("attach plan snapshot grant: %w", err)
+ }
+ rows, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if rows != 1 {
+ return infraerrors.Conflict("SUBSCRIPTION_GRANT_CONFLICT", "payment order is already attached to another subscription grant")
+ }
+ return nil
+}
+
+func planSnapshotGrantWindow(sub *UserSubscription, snapshot *planFulfillmentSnapshot, now time.Time) (time.Time, time.Time) {
+ if sub == nil || snapshot == nil {
+ return time.Time{}, time.Time{}
+ }
+ if snapshot.PlanType == PlanTypeCredits {
+ return now, sub.ExpiresAt
+ }
+ startsAt := sub.ExpiresAt.AddDate(0, 0, -snapshot.SubscriptionDays)
+ if startsAt.Before(sub.StartsAt) {
+ startsAt = sub.StartsAt
+ }
+ return startsAt, sub.ExpiresAt
+}
+
+func planSnapshotRates(snapshot *planFulfillmentSnapshot) map[string]float64 {
+ if snapshot == nil || len(snapshot.LockedRates) == 0 {
+ return nil
+ }
+ out := make(map[string]float64, len(snapshot.LockedRates))
+ for groupID, rate := range snapshot.LockedRates {
+ out[groupID] = rate
+ }
+ return out
+}
+
+func snapshotPlanID(snapshot *planFulfillmentSnapshot) int64 {
+ if snapshot == nil {
+ return 0
+ }
+ return snapshot.PlanID
+}
+
+func snapshotVersion(snapshot *planFulfillmentSnapshot) int {
+ if snapshot == nil {
+ return 0
+ }
+ return snapshot.SchemaVersion
+}
+
+func validateCapturedPlanPrice(snapshot *planFulfillmentSnapshot, orderAmount, limitAmount float64) error {
+ if snapshot == nil {
+ return errors.New("plan snapshot is nil")
+ }
+ if math.Abs(snapshot.PlanPrice-orderAmount) > 0.000001 || math.Abs(snapshot.PlanPrice-limitAmount) > 0.000001 {
+ return infraerrors.Conflict("PLAN_CHANGED_RETRY", "subscription plan price changed; create the order again")
+ }
+ return nil
+}
+
+func isPlanSnapshotMissing(err error) bool {
+ return strings.EqualFold(infraerrors.Reason(err), "FULFILLMENT_SNAPSHOT_MISSING")
+}
+
+// Keep the generated paymentorder import tied to this file's plan-order
+// contract. It also catches accidental renames during Ent regeneration.
+var _ = paymentorder.FieldPlanID
diff --git a/backend/internal/service/platform_scheduling_compat.go b/backend/internal/service/platform_scheduling_compat.go
new file mode 100644
index 00000000000..54f44a286af
--- /dev/null
+++ b/backend/internal/service/platform_scheduling_compat.go
@@ -0,0 +1,42 @@
+package service
+
+func useMixedSchedulingForPlatform(platform string, hasForcePlatform bool) bool {
+ if hasForcePlatform {
+ return false
+ }
+ switch normalizePlatform(platform) {
+ case PlatformAnthropic, PlatformGemini:
+ return true
+ default:
+ return false
+ }
+}
+
+func mixedSchedulingPlatforms(platform string) []string {
+ switch normalizePlatform(platform) {
+ case PlatformAnthropic:
+ return []string{PlatformAnthropic, PlatformAntigravity, PlatformKiro}
+ case PlatformGemini:
+ return []string{PlatformGemini, PlatformAntigravity}
+ default:
+ return []string{platform}
+ }
+}
+
+func isAccountAllowedInMixedScheduling(account *Account, platform string) bool {
+ if account == nil {
+ return false
+ }
+ accountPlatform := normalizePlatform(account.Platform)
+ platform = normalizePlatform(platform)
+ if accountPlatform == platform {
+ return true
+ }
+ if platform == PlatformAnthropic && accountPlatform == PlatformKiro {
+ return true
+ }
+ if accountPlatform == PlatformAntigravity {
+ return account.IsMixedSchedulingEnabled() && (platform == PlatformAnthropic || platform == PlatformGemini)
+ }
+ return false
+}
diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go
index 91a02901da5..fe81ef867e5 100644
--- a/backend/internal/service/pricing_service.go
+++ b/backend/internal/service/pricing_service.go
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
+ "math"
"os"
"path/filepath"
"regexp"
@@ -23,6 +24,35 @@ import (
var (
openAIModelDatePattern = regexp.MustCompile(`-\d{8}$`)
openAIModelBasePattern = regexp.MustCompile(`^(gpt-\d+(?:\.\d+)?)(?:-|$)`)
+ openAIGPT56FallbackPricing = map[string]LiteLLMModelPricing{
+ "gpt-5.6-sol": {
+ InputCostPerToken: 5e-6,
+ OutputCostPerToken: 30e-6,
+ CacheCreationInputTokenCost: 6.25e-6,
+ CacheReadInputTokenCost: 0.5e-6,
+ LiteLLMProvider: "openai",
+ Mode: "responses",
+ SupportsPromptCaching: true,
+ },
+ "gpt-5.6-terra": {
+ InputCostPerToken: 2.5e-6,
+ OutputCostPerToken: 15e-6,
+ CacheCreationInputTokenCost: 3.125e-6,
+ CacheReadInputTokenCost: 0.25e-6,
+ LiteLLMProvider: "openai",
+ Mode: "responses",
+ SupportsPromptCaching: true,
+ },
+ "gpt-5.6-luna": {
+ InputCostPerToken: 1e-6,
+ OutputCostPerToken: 6e-6,
+ CacheCreationInputTokenCost: 1.25e-6,
+ CacheReadInputTokenCost: 0.1e-6,
+ LiteLLMProvider: "openai",
+ Mode: "responses",
+ SupportsPromptCaching: true,
+ },
+ }
openAIGPT54FallbackPricing = &LiteLLMModelPricing{
InputCostPerToken: 2.5e-06, // $2.5 per MTok
OutputCostPerToken: 1.5e-05, // $15 per MTok
@@ -126,7 +156,7 @@ func NewPricingService(cfg *config.Config, remoteClient PricingRemoteClient) *Pr
// Initialize 初始化价格服务
func (s *PricingService) Initialize() error {
// 确保数据目录存在
- if err := os.MkdirAll(s.cfg.Pricing.DataDir, 0755); err != nil {
+ if err := os.MkdirAll(s.cfg.Pricing.DataDir, 0o700); err != nil {
logger.LegacyPrintf("service.pricing", "[Pricing] Failed to create data directory: %v", err)
}
@@ -191,8 +221,10 @@ func (s *PricingService) checkAndUpdatePricing() error {
return s.downloadPricingData()
}
- // 先加载本地文件(确保服务可用),再检查是否需要更新
- if err := s.loadPricingData(pricingFile); err != nil {
+ // Only activate a cached download when its persisted SHA-256 sidecar still
+ // matches. If either file is missing or inconsistent, obtain a fresh signed-
+ // by-hash pair or fall back to the image-bundled pricing file.
+ if err := s.loadVerifiedLocalPricingData(pricingFile); err != nil {
logger.LegacyPrintf("service.pricing", "[Pricing] Failed to load local file, downloading: %v", err)
return s.downloadPricingData()
}
@@ -290,13 +322,12 @@ func (s *PricingService) downloadPricingData() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
- // 获取远程哈希(用于同步锚点,不作为完整性校验)
- var remoteHash string
- if strings.TrimSpace(s.cfg.Pricing.HashURL) != "" {
- remoteHash, err = s.fetchRemoteHash()
- if err != nil {
- logger.LegacyPrintf("service.pricing", "[Pricing] Failed to fetch remote hash (continuing): %v", err)
- }
+ if strings.TrimSpace(s.cfg.Pricing.HashURL) == "" {
+ return fmt.Errorf("pricing.hash_url is required for verified remote pricing updates")
+ }
+ remoteHash, err := s.fetchRemoteHash()
+ if err != nil {
+ return fmt.Errorf("fetch remote pricing hash: %w", err)
}
body, err := s.remoteClient.FetchPricingJSON(ctx, remoteURL)
@@ -304,13 +335,10 @@ func (s *PricingService) downloadPricingData() error {
return fmt.Errorf("download failed: %w", err)
}
- // 哈希校验:不匹配时仅告警,不阻止更新
- // 远程哈希文件可能与数据文件不同步(如维护者更新了数据但未更新哈希文件)
dataHash := sha256.Sum256(body)
dataHashStr := hex.EncodeToString(dataHash[:])
- if remoteHash != "" && !strings.EqualFold(remoteHash, dataHashStr) {
- logger.LegacyPrintf("service.pricing", "[Pricing] Hash mismatch warning: remote=%s data=%s (hash file may be out of sync)",
- remoteHash[:min(8, len(remoteHash))], dataHashStr[:8])
+ if !strings.EqualFold(remoteHash, dataHashStr) {
+ return fmt.Errorf("pricing hash mismatch: remote=%s data=%s", remoteHash[:8], dataHashStr[:8])
}
// 解析JSON数据(使用灵活的解析方式)
@@ -319,28 +347,22 @@ func (s *PricingService) downloadPricingData() error {
return fmt.Errorf("parse pricing data: %w", err)
}
- // 保存到本地文件
- pricingFile := s.getPricingFilePath()
- if err := os.WriteFile(pricingFile, body, 0644); err != nil {
- logger.LegacyPrintf("service.pricing", "[Pricing] Failed to save file: %v", err)
- }
-
- // 使用远程哈希作为同步锚点,防止重复下载
- // 当远程哈希不可用时,回退到数据本身的哈希
- syncHash := dataHashStr
- if remoteHash != "" {
- syncHash = remoteHash
- }
+ // Persist the marker first and the verified data last. Both writes are
+ // atomic and private; a failed write never activates unpersisted prices.
hashFile := s.getHashFilePath()
- if err := os.WriteFile(hashFile, []byte(syncHash+"\n"), 0644); err != nil {
- logger.LegacyPrintf("service.pricing", "[Pricing] Failed to save hash: %v", err)
+ if err := writePrivateFileAtomically(hashFile, []byte(dataHashStr+"\n")); err != nil {
+ return fmt.Errorf("persist pricing hash: %w", err)
+ }
+ pricingFile := s.getPricingFilePath()
+ if err := writePrivateFileAtomically(pricingFile, body); err != nil {
+ return fmt.Errorf("persist pricing data: %w", err)
}
// 更新内存数据
s.mu.Lock()
s.pricingData = data
s.lastUpdated = time.Now()
- s.localHash = syncHash
+ s.localHash = dataHashStr
s.mu.Unlock()
logger.LegacyPrintf("service.pricing", "[Pricing] Downloaded %d models successfully", len(data))
@@ -370,6 +392,9 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel
skipped++
continue
}
+ if err := validateNonNegativePricingEntry(modelName, &entry); err != nil {
+ return nil, err
+ }
// 只保留有有效价格的条目
if entry.InputCostPerToken == nil && entry.OutputCostPerToken == nil {
@@ -434,6 +459,31 @@ func (s *PricingService) loadPricingData(filePath string) error {
if err != nil {
return fmt.Errorf("read file failed: %w", err)
}
+ return s.activatePricingData(filePath, data)
+}
+
+func (s *PricingService) loadVerifiedLocalPricingData(filePath string) error {
+ data, err := os.ReadFile(filePath)
+ if err != nil {
+ return fmt.Errorf("read local pricing file: %w", err)
+ }
+ hashText, err := os.ReadFile(s.getHashFilePath())
+ if err != nil {
+ return fmt.Errorf("read local pricing hash: %w", err)
+ }
+ expectedHash, err := normalizePricingSHA256(string(hashText))
+ if err != nil {
+ return fmt.Errorf("validate local pricing hash: %w", err)
+ }
+ actualSum := sha256.Sum256(data)
+ actualHash := hex.EncodeToString(actualSum[:])
+ if !strings.EqualFold(expectedHash, actualHash) {
+ return fmt.Errorf("local pricing hash mismatch: marker=%s data=%s", expectedHash[:8], actualHash[:8])
+ }
+ return s.activatePricingData(filePath, data)
+}
+
+func (s *PricingService) activatePricingData(filePath string, data []byte) error {
// 使用灵活的解析方式
pricingData, err := s.parsePricingData(data)
@@ -476,10 +526,18 @@ func (s *PricingService) useFallbackPricing() error {
if err != nil {
return fmt.Errorf("read fallback failed: %w", err)
}
+ if _, err := s.parsePricingData(data); err != nil {
+ return fmt.Errorf("parse fallback pricing data: %w", err)
+ }
pricingFile := s.getPricingFilePath()
- if err := os.WriteFile(pricingFile, data, 0644); err != nil {
- logger.LegacyPrintf("service.pricing", "[Pricing] Failed to copy fallback: %v", err)
+ hash := sha256.Sum256(data)
+ hashText := hex.EncodeToString(hash[:]) + "\n"
+ if err := writePrivateFileAtomically(s.getHashFilePath(), []byte(hashText)); err != nil {
+ return fmt.Errorf("persist fallback pricing hash: %w", err)
+ }
+ if err := writePrivateFileAtomically(pricingFile, data); err != nil {
+ return fmt.Errorf("persist fallback pricing data: %w", err)
}
return s.loadPricingData(fallbackFile)
@@ -499,21 +557,92 @@ func (s *PricingService) fetchRemoteHash() (string, error) {
if err != nil {
return "", err
}
- return strings.TrimSpace(hash), nil
+ return normalizePricingSHA256(hash)
+}
+
+func normalizePricingSHA256(raw string) (string, error) {
+ hash := strings.ToLower(strings.TrimSpace(raw))
+ if len(hash) != sha256.Size*2 {
+ return "", fmt.Errorf("invalid remote pricing hash length: got %d, want %d", len(hash), sha256.Size*2)
+ }
+ decoded, err := hex.DecodeString(hash)
+ if err != nil || len(decoded) != sha256.Size {
+ return "", fmt.Errorf("invalid remote pricing hash: expected hexadecimal SHA-256")
+ }
+ return hash, nil
+}
+
+func validateNonNegativePricingEntry(modelName string, entry *LiteLLMRawEntry) error {
+ values := []struct {
+ name string
+ value *float64
+ }{
+ {"input_cost_per_token", entry.InputCostPerToken},
+ {"input_cost_per_token_priority", entry.InputCostPerTokenPriority},
+ {"output_cost_per_token", entry.OutputCostPerToken},
+ {"output_cost_per_token_priority", entry.OutputCostPerTokenPriority},
+ {"cache_creation_input_token_cost", entry.CacheCreationInputTokenCost},
+ {"cache_creation_input_token_cost_above_1hr", entry.CacheCreationInputTokenCostAbove1hr},
+ {"cache_read_input_token_cost", entry.CacheReadInputTokenCost},
+ {"cache_read_input_token_cost_priority", entry.CacheReadInputTokenCostPriority},
+ {"output_cost_per_image", entry.OutputCostPerImage},
+ {"output_cost_per_image_token", entry.OutputCostPerImageToken},
+ }
+ for _, item := range values {
+ if item.value != nil && (*item.value < 0 || math.IsNaN(*item.value) || math.IsInf(*item.value, 0)) {
+ return fmt.Errorf("negative pricing value is not allowed: model=%q field=%s", modelName, item.name)
+ }
+ }
+ return nil
+}
+
+func writePrivateFileAtomically(path string, data []byte) (err error) {
+ dir := filepath.Dir(path)
+ tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*")
+ if err != nil {
+ return err
+ }
+ tmpPath := tmp.Name()
+ defer func() {
+ _ = tmp.Close()
+ _ = os.Remove(tmpPath)
+ }()
+ if err := tmp.Chmod(0o600); err != nil {
+ return err
+ }
+ if _, err := tmp.Write(data); err != nil {
+ return err
+ }
+ if err := tmp.Sync(); err != nil {
+ return err
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ if err := os.Rename(tmpPath, path); err != nil {
+ return err
+ }
+ return os.Chmod(path, 0o600)
}
func (s *PricingService) validatePricingURL(raw string) (string, error) {
- if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled {
- normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
+ if s.cfg == nil {
+ return "", fmt.Errorf("invalid pricing url: configuration is unavailable")
+ }
+ urlPolicy := s.cfg.Security.URLAllowlist
+ if !urlPolicy.Enabled {
+ normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
+ AllowPrivate: urlPolicy.AllowPrivateHosts,
+ })
if err != nil {
return "", fmt.Errorf("invalid pricing url: %w", err)
}
return normalized, nil
}
normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
- AllowedHosts: s.cfg.Security.URLAllowlist.PricingHosts,
+ AllowedHosts: urlPolicy.PricingHosts,
RequireAllowlist: true,
- AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
+ AllowPrivate: urlPolicy.AllowPrivateHosts,
})
if err != nil {
return "", fmt.Errorf("invalid pricing url: %w", err)
@@ -532,6 +661,18 @@ func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing
// 标准化模型名称(同时兼容 "models/xxx"、VertexAI 资源名等前缀)
modelLower := strings.ToLower(strings.TrimSpace(modelName))
+ if normalizedGPT56, isGPT56Family := classifyOpenAIGPT56PreviewModel(modelLower); isGPT56Family {
+ if normalizedGPT56 == "" {
+ return nil
+ }
+ pricing, ok := openAIGPT56FallbackPricing[normalizedGPT56]
+ if !ok {
+ return nil
+ }
+ // Preview tier pricing is a policy lock: dynamic/local data must not
+ // override it, and callers receive a fresh copy they cannot mutate.
+ return cloneLiteLLMModelPricing(pricing)
+ }
lookupCandidates := s.buildModelLookupCandidates(modelLower)
// 1. 精确匹配
@@ -576,6 +717,11 @@ func (s *PricingService) GetModelPricing(modelName string) *LiteLLMModelPricing
return nil
}
+func cloneLiteLLMModelPricing(pricing LiteLLMModelPricing) *LiteLLMModelPricing {
+ cloned := pricing
+ return &cloned
+}
+
func (s *PricingService) buildModelLookupCandidates(modelLower string) []string {
// Prefer canonical model name first (this also improves billing compatibility with "models/xxx").
candidates := []string{
@@ -625,6 +771,9 @@ func normalizeModelNameForPricing(model string) string {
}
model = strings.TrimLeft(model, "/")
+ if canonical := canonicalizeOpenAIModelAliasSpelling(model); canonical != "" {
+ return canonical
+ }
return model
}
diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go
index e2bd7cf33a5..2686d0ef23a 100644
--- a/backend/internal/service/pricing_service_test.go
+++ b/backend/internal/service/pricing_service_test.go
@@ -1,12 +1,262 @@
package service
import (
+ "context"
+ "crypto/sha256"
"encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
"testing"
+ "github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
+type pricingRemoteClientStub struct {
+ body []byte
+ hash string
+ bodyErr error
+ hashErr error
+ bodyCalls int
+ hashCalls int
+}
+
+func (s *pricingRemoteClientStub) FetchPricingJSON(context.Context, string) ([]byte, error) {
+ s.bodyCalls++
+ return s.body, s.bodyErr
+}
+
+func (s *pricingRemoteClientStub) FetchHashText(context.Context, string) (string, error) {
+ s.hashCalls++
+ return s.hash, s.hashErr
+}
+
+func newPricingIntegrityTestService(t *testing.T, remote *pricingRemoteClientStub) *PricingService {
+ t.Helper()
+ return NewPricingService(&config.Config{
+ Pricing: config.PricingConfig{
+ RemoteURL: "https://raw.githubusercontent.com/example/pricing.json",
+ HashURL: "https://raw.githubusercontent.com/example/pricing.sha256",
+ DataDir: t.TempDir(),
+ },
+ }, remote)
+}
+
+func TestPricingDownloadFailsClosedWhenHashFetchFails(t *testing.T) {
+ remote := &pricingRemoteClientStub{
+ body: []byte(`{"gpt-test":{"input_cost_per_token":0.1}}`),
+ hashErr: errors.New("hash unavailable"),
+ }
+ svc := newPricingIntegrityTestService(t, remote)
+
+ err := svc.downloadPricingData()
+
+ require.ErrorContains(t, err, "fetch remote pricing hash")
+ require.Zero(t, remote.bodyCalls, "unverifiable pricing data must not be downloaded or activated")
+ require.Empty(t, svc.pricingData)
+}
+
+func TestPricingDownloadRejectsMalformedOrMismatchedHash(t *testing.T) {
+ body := []byte(`{"gpt-test":{"input_cost_per_token":0.1}}`)
+
+ t.Run("malformed hash", func(t *testing.T) {
+ remote := &pricingRemoteClientStub{body: body, hash: "not-a-sha256"}
+ svc := newPricingIntegrityTestService(t, remote)
+
+ err := svc.downloadPricingData()
+
+ require.ErrorContains(t, err, "invalid remote pricing hash")
+ require.Zero(t, remote.bodyCalls)
+ require.Empty(t, svc.pricingData)
+ })
+
+ t.Run("hash mismatch", func(t *testing.T) {
+ remote := &pricingRemoteClientStub{body: body, hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
+ svc := newPricingIntegrityTestService(t, remote)
+
+ err := svc.downloadPricingData()
+
+ require.ErrorContains(t, err, "pricing hash mismatch")
+ require.Equal(t, 1, remote.bodyCalls)
+ require.Empty(t, svc.pricingData)
+ _, statErr := os.Stat(svc.getPricingFilePath())
+ require.ErrorIs(t, statErr, os.ErrNotExist)
+ })
+}
+
+func TestPricingDownloadPersistsVerifiedDataWithPrivatePermissions(t *testing.T) {
+ body := []byte(`{"gpt-test":{"input_cost_per_token":0.1}}`)
+ hash := sha256.Sum256(body)
+ remote := &pricingRemoteClientStub{body: body, hash: fmt.Sprintf("%x", hash)}
+ svc := newPricingIntegrityTestService(t, remote)
+
+ require.NoError(t, svc.downloadPricingData())
+ require.NotNil(t, svc.pricingData["gpt-test"])
+ require.Equal(t, remote.hash, svc.localHash)
+
+ for _, path := range []string{svc.getPricingFilePath(), svc.getHashFilePath()} {
+ info, err := os.Stat(path)
+ require.NoError(t, err)
+ require.Equal(t, os.FileMode(0o600), info.Mode().Perm(), path)
+ }
+}
+
+func TestPricingLocalLoadRequiresMatchingPersistedHash(t *testing.T) {
+ body := []byte(`{"gpt-test":{"input_cost_per_token":0.1}}`)
+ hash := sha256.Sum256(body)
+
+ tests := []struct {
+ name string
+ hashMarker string
+ writeMarker bool
+ wantError string
+ }{
+ {name: "missing marker", wantError: "read local pricing hash"},
+ {name: "malformed marker", hashMarker: "not-a-hash", writeMarker: true, wantError: "validate local pricing hash"},
+ {name: "mismatched marker", hashMarker: strings.Repeat("a", 64), writeMarker: true, wantError: "local pricing hash mismatch"},
+ {name: "matching marker", hashMarker: fmt.Sprintf("%x", hash), writeMarker: true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ svc := newPricingIntegrityTestService(t, &pricingRemoteClientStub{})
+ require.NoError(t, os.WriteFile(svc.getPricingFilePath(), body, 0o600))
+ if tt.writeMarker {
+ require.NoError(t, os.WriteFile(svc.getHashFilePath(), []byte(tt.hashMarker+"\n"), 0o600))
+ }
+
+ err := svc.loadVerifiedLocalPricingData(svc.getPricingFilePath())
+ if tt.wantError != "" {
+ require.ErrorContains(t, err, tt.wantError)
+ require.Empty(t, svc.pricingData)
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, fmt.Sprintf("%x", hash), svc.localHash)
+ require.NotNil(t, svc.pricingData["gpt-test"])
+ })
+ }
+}
+
+func TestPricingURLAlwaysRequiresHTTPS(t *testing.T) {
+ svc := NewPricingService(&config.Config{
+ Security: config.SecurityConfig{
+ URLAllowlist: config.URLAllowlistConfig{
+ Enabled: false,
+ AllowInsecureHTTP: true,
+ },
+ },
+ }, &pricingRemoteClientStub{})
+
+ _, err := svc.validatePricingURL("http://pricing.example.test/model.json")
+ require.ErrorContains(t, err, "invalid url scheme")
+ normalized, err := svc.validatePricingURL("https://pricing.example.test/model.json")
+ require.NoError(t, err)
+ require.Equal(t, "https://pricing.example.test/model.json", normalized)
+}
+
+func TestParsePricingDataRejectsNegativeCosts(t *testing.T) {
+ svc := &PricingService{}
+
+ _, err := svc.parsePricingData([]byte(`{
+ "gpt-test": {
+ "input_cost_per_token": -0.1,
+ "output_cost_per_token": 0.2
+ }
+ }`))
+
+ require.ErrorContains(t, err, "negative pricing value")
+}
+
+func TestPricingServiceGetModelPricing_GPT56ExactFallbacks(t *testing.T) {
+ svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{
+ "gpt-5.4": {InputCostPerToken: 99},
+ "gpt-5.6": {InputCostPerToken: 98},
+ "gpt-5.6-sol": {
+ InputCostPerToken: 101,
+ OutputCostPerToken: 102,
+ CacheCreationInputTokenCost: 103,
+ CacheReadInputTokenCost: 104,
+ },
+ "gpt-5.6-terra": {
+ InputCostPerToken: 201,
+ OutputCostPerToken: 202,
+ CacheCreationInputTokenCost: 203,
+ CacheReadInputTokenCost: 204,
+ },
+ "gpt-5.6-luna": {
+ InputCostPerToken: 301,
+ OutputCostPerToken: 302,
+ CacheCreationInputTokenCost: 303,
+ CacheReadInputTokenCost: 304,
+ },
+ "gpt-5.6-unknown": {InputCostPerToken: 97},
+ }}
+ tests := []struct {
+ model string
+ input float64
+ output float64
+ cacheWrite float64
+ cacheRead float64
+ }{
+ {model: "gpt-5.6-sol", input: 5e-6, output: 30e-6, cacheWrite: 6.25e-6, cacheRead: 0.5e-6},
+ {model: "gpt-5.6-terra", input: 2.5e-6, output: 15e-6, cacheWrite: 3.125e-6, cacheRead: 0.25e-6},
+ {model: "gpt-5.6-luna", input: 1e-6, output: 6e-6, cacheWrite: 1.25e-6, cacheRead: 0.1e-6},
+ {model: "openai/gpt-5.6-luna-high", input: 1e-6, output: 6e-6, cacheWrite: 1.25e-6, cacheRead: 0.1e-6},
+ }
+ for _, tt := range tests {
+ t.Run(tt.model, func(t *testing.T) {
+ pricing := svc.GetModelPricing(tt.model)
+ require.NotNil(t, pricing)
+ require.InDelta(t, tt.input, pricing.InputCostPerToken, 1e-12)
+ require.InDelta(t, tt.output, pricing.OutputCostPerToken, 1e-12)
+ require.InDelta(t, tt.cacheWrite, pricing.CacheCreationInputTokenCost, 1e-12)
+ require.InDelta(t, tt.cacheRead, pricing.CacheReadInputTokenCost, 1e-12)
+
+ pricing.InputCostPerToken = 401
+ pricing.OutputCostPerToken = 402
+ pricing.CacheCreationInputTokenCost = 403
+ pricing.CacheReadInputTokenCost = 404
+
+ fresh := svc.GetModelPricing(tt.model)
+ require.NotSame(t, pricing, fresh)
+ require.InDelta(t, tt.input, fresh.InputCostPerToken, 1e-12)
+ require.InDelta(t, tt.output, fresh.OutputCostPerToken, 1e-12)
+ require.InDelta(t, tt.cacheWrite, fresh.CacheCreationInputTokenCost, 1e-12)
+ require.InDelta(t, tt.cacheRead, fresh.CacheReadInputTokenCost, 1e-12)
+ })
+ }
+
+ require.Nil(t, svc.GetModelPricing("gpt-5.6"))
+ require.Nil(t, svc.GetModelPricing("gpt-5.6-unknown"))
+}
+
+func TestBundledModelPricing_GPT56ExactPrices(t *testing.T) {
+ t.Parallel()
+
+ body, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json"))
+ require.NoError(t, err)
+ svc := &PricingService{}
+ pricingData, err := svc.parsePricingData(body)
+ require.NoError(t, err)
+
+ tests := map[string][4]float64{
+ "gpt-5.6-sol": {5e-6, 30e-6, 6.25e-6, 0.5e-6},
+ "gpt-5.6-terra": {2.5e-6, 15e-6, 3.125e-6, 0.25e-6},
+ "gpt-5.6-luna": {1e-6, 6e-6, 1.25e-6, 0.1e-6},
+ }
+ for model, want := range tests {
+ pricing := pricingData[model]
+ require.NotNil(t, pricing, model)
+ require.InDelta(t, want[0], pricing.InputCostPerToken, 1e-12)
+ require.InDelta(t, want[1], pricing.OutputCostPerToken, 1e-12)
+ require.InDelta(t, want[2], pricing.CacheCreationInputTokenCost, 1e-12)
+ require.InDelta(t, want[3], pricing.CacheReadInputTokenCost, 1e-12)
+ }
+}
+
func TestParsePricingData_ParsesPriorityAndServiceTierFields(t *testing.T) {
svc := &PricingService{}
body := []byte(`{
@@ -98,6 +348,19 @@ func TestGetModelPricing_Gpt54UsesStaticFallbackWhenRemoteMissing(t *testing.T)
require.InDelta(t, 1.5, got.LongContextOutputCostMultiplier, 1e-12)
}
+func TestGetModelPricing_OpenAICompactAliasUsesStaticFallback(t *testing.T) {
+ svc := &PricingService{
+ pricingData: map[string]*LiteLLMModelPricing{
+ "gpt-5.1-codex": {InputCostPerToken: 1.25e-6},
+ },
+ }
+
+ got := svc.GetModelPricing("openai/gpt5.5")
+ require.NotNil(t, got)
+ require.InDelta(t, 2.5e-6, got.InputCostPerToken, 1e-12)
+ require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12)
+}
+
func TestGetModelPricing_Gpt54MiniUsesDedicatedStaticFallbackWhenRemoteMissing(t *testing.T) {
svc := &PricingService{
pricingData: map[string]*LiteLLMModelPricing{
diff --git a/backend/internal/service/promo_service.go b/backend/internal/service/promo_service.go
index 5ff63bdc50c..995c6f498a6 100644
--- a/backend/internal/service/promo_service.go
+++ b/backend/internal/service/promo_service.go
@@ -152,11 +152,9 @@ func (s *PromoService) ApplyPromoCode(ctx context.Context, userID int64, code st
// 失效余额缓存
if s.billingCacheService != nil {
- go func() {
- cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- _ = s.billingCacheService.InvalidateUserBalance(cacheCtx, userID)
- }()
+ invalidateBillingCacheAfterCommit(ctx, "promo balance", func(cacheCtx context.Context) error {
+ return s.billingCacheService.InvalidateUserBalance(cacheCtx, userID)
+ })
}
return nil
diff --git a/backend/internal/service/proxy.go b/backend/internal/service/proxy.go
index a2896d6c14d..0b99d0c611f 100644
--- a/backend/internal/service/proxy.go
+++ b/backend/internal/service/proxy.go
@@ -35,6 +35,18 @@ func (p *Proxy) URL() string {
return u.String()
}
+// LogURL returns only the non-secret proxy endpoint. Authentication material
+// must remain available to transports but must never be written to logs.
+func (p *Proxy) LogURL() string {
+ if p == nil {
+ return ""
+ }
+ return (&url.URL{
+ Scheme: p.Protocol,
+ Host: net.JoinHostPort(p.Host, strconv.Itoa(p.Port)),
+ }).String()
+}
+
type ProxyWithAccountCount struct {
Proxy
AccountCount int64
diff --git a/backend/internal/service/proxy_test.go b/backend/internal/service/proxy_test.go
index da6d1236a12..afd85b12a69 100644
--- a/backend/internal/service/proxy_test.go
+++ b/backend/internal/service/proxy_test.go
@@ -93,3 +93,19 @@ func TestProxyURL_SpecialCharactersRoundTrip(t *testing.T) {
t.Fatalf("password mismatch after parse: got=%q want=%q", pass, proxy.Password)
}
}
+
+func TestProxyLogURLNeverContainsCredentials(t *testing.T) {
+ t.Parallel()
+
+ proxy := Proxy{
+ Protocol: "http",
+ Host: "proxy.example.com",
+ Port: 3128,
+ Username: "log-user",
+ Password: "log-secret",
+ }
+
+ if got, want := proxy.LogURL(), "http://proxy.example.com:3128"; got != want {
+ t.Fatalf("Proxy.LogURL() mismatch: got=%q want=%q", got, want)
+ }
+}
diff --git a/backend/internal/service/rate_limit_429_cooldown_test.go b/backend/internal/service/rate_limit_429_cooldown_test.go
new file mode 100644
index 00000000000..fb7e0dd7afd
--- /dev/null
+++ b/backend/internal/service/rate_limit_429_cooldown_test.go
@@ -0,0 +1,113 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/stretchr/testify/require"
+)
+
+type rateLimit429AccountRepoStub struct {
+ mockAccountRepoForGemini
+ rateLimitCalls int
+ lastRateLimitID int64
+ lastRateLimitReset time.Time
+}
+
+func (r *rateLimit429AccountRepoStub) SetRateLimited(_ context.Context, id int64, resetAt time.Time) error {
+ r.rateLimitCalls++
+ r.lastRateLimitID = id
+ r.lastRateLimitReset = resetAt
+ return nil
+}
+
+func TestGetRateLimit429CooldownSettings_DefaultsWhenNotSet(t *testing.T) {
+ repo := newMockSettingRepo()
+ svc := NewSettingService(repo, &config.Config{})
+
+ settings, err := svc.GetRateLimit429CooldownSettings(context.Background())
+ require.NoError(t, err)
+ require.True(t, settings.Enabled)
+ require.Equal(t, 5, settings.CooldownSeconds)
+}
+
+func TestGetRateLimit429CooldownSettings_ReadsFromDB(t *testing.T) {
+ repo := newMockSettingRepo()
+ data, _ := json.Marshal(RateLimit429CooldownSettings{Enabled: false, CooldownSeconds: 12})
+ repo.data[SettingKeyRateLimit429CooldownSettings] = string(data)
+ svc := NewSettingService(repo, &config.Config{})
+
+ settings, err := svc.GetRateLimit429CooldownSettings(context.Background())
+ require.NoError(t, err)
+ require.False(t, settings.Enabled)
+ require.Equal(t, 12, settings.CooldownSeconds)
+}
+
+func TestSetRateLimit429CooldownSettings_EnabledRejectsOutOfRange(t *testing.T) {
+ svc := NewSettingService(newMockSettingRepo(), &config.Config{})
+
+ for _, seconds := range []int{0, -1, 7201, 99999} {
+ err := svc.SetRateLimit429CooldownSettings(context.Background(), &RateLimit429CooldownSettings{
+ Enabled: true, CooldownSeconds: seconds,
+ })
+ require.Error(t, err, "should reject enabled=true + cooldown_seconds=%d", seconds)
+ require.Contains(t, err.Error(), "cooldown_seconds must be between 1-7200")
+ }
+}
+
+func TestHandle429_FallbackUsesDBSeconds(t *testing.T) {
+ accountRepo := &rateLimit429AccountRepoStub{}
+ settingRepo := newMockSettingRepo()
+ data, _ := json.Marshal(RateLimit429CooldownSettings{Enabled: true, CooldownSeconds: 12})
+ settingRepo.data[SettingKeyRateLimit429CooldownSettings] = string(data)
+
+ settingSvc := NewSettingService(settingRepo, &config.Config{})
+ svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil)
+ svc.SetSettingService(settingSvc)
+
+ account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
+ before := time.Now()
+ svc.handle429(context.Background(), account, http.Header{}, []byte(`{"error":{"type":"rate_limit_error","message":"slow down"}}`))
+ after := time.Now()
+
+ require.Equal(t, 1, accountRepo.rateLimitCalls)
+ require.Equal(t, int64(42), accountRepo.lastRateLimitID)
+ require.True(t, !accountRepo.lastRateLimitReset.Before(before.Add(12*time.Second)) && !accountRepo.lastRateLimitReset.After(after.Add(12*time.Second)))
+}
+
+func TestHandle429_FallbackDisabledSkipsLocalMark(t *testing.T) {
+ accountRepo := &rateLimit429AccountRepoStub{}
+ settingRepo := newMockSettingRepo()
+ data, _ := json.Marshal(RateLimit429CooldownSettings{Enabled: false, CooldownSeconds: 12})
+ settingRepo.data[SettingKeyRateLimit429CooldownSettings] = string(data)
+
+ settingSvc := NewSettingService(settingRepo, &config.Config{})
+ svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil)
+ svc.SetSettingService(settingSvc)
+
+ account := &Account{ID: 43, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
+ svc.handle429(context.Background(), account, http.Header{}, []byte(`{"error":{"type":"rate_limit_error","message":"slow down"}}`))
+
+ require.Zero(t, accountRepo.rateLimitCalls)
+}
+
+func TestHandle429_FallbackUsesDefaultSecondsWhenSettingServiceMissing(t *testing.T) {
+ accountRepo := &rateLimit429AccountRepoStub{}
+ cfg := &config.Config{}
+ svc := NewRateLimitService(accountRepo, nil, cfg, nil, nil)
+
+ account := &Account{ID: 44, Platform: PlatformGemini, Type: AccountTypeAPIKey}
+ before := time.Now()
+ svc.handle429(context.Background(), account, http.Header{}, []byte(`{"error":{"message":"slow down"}}`))
+ after := time.Now()
+
+ require.Equal(t, 1, accountRepo.rateLimitCalls)
+ require.Equal(t, int64(44), accountRepo.lastRateLimitID)
+ require.True(t, !accountRepo.lastRateLimitReset.Before(before.Add(5*time.Second)) && !accountRepo.lastRateLimitReset.After(after.Add(5*time.Second)))
+}
diff --git a/backend/internal/service/rate_multiplier_resolution.go b/backend/internal/service/rate_multiplier_resolution.go
new file mode 100644
index 00000000000..f6e6a2e90b5
--- /dev/null
+++ b/backend/internal/service/rate_multiplier_resolution.go
@@ -0,0 +1,68 @@
+package service
+
+import "context"
+
+const (
+ RateMultiplierSourceSystemDefault = "system_default"
+ RateMultiplierSourceGroupDefault = "group_default"
+ RateMultiplierSourceUserGroup = "user_group_rate"
+ RateMultiplierSourceLockedRates = "locked_rates"
+)
+
+type RateMultiplierResolution struct {
+ Multiplier float64
+ Source string
+}
+
+func resolveGroupRateContext(groupID *int64, group *Group, systemDefault float64) (int64, float64, string) {
+ defaultRate := systemDefault
+ source := RateMultiplierSourceSystemDefault
+ if group != nil {
+ if group.RateMultiplier >= 0 {
+ defaultRate = group.RateMultiplier
+ source = RateMultiplierSourceGroupDefault
+ }
+ if group.ID > 0 {
+ return group.ID, defaultRate, source
+ }
+ }
+ if groupID != nil && *groupID > 0 {
+ return *groupID, defaultRate, source
+ }
+ return 0, defaultRate, source
+}
+
+func resolveEffectiveRateMultiplier(
+ ctx context.Context,
+ resolver *userGroupRateResolver,
+ userID int64,
+ groupID *int64,
+ group *Group,
+ subscription *UserSubscription,
+ systemDefault float64,
+) RateMultiplierResolution {
+ resolvedGroupID, defaultRate, defaultSource := resolveGroupRateContext(groupID, group, systemDefault)
+ if resolvedGroupID > 0 {
+ if lockedRate, ok := subscription.LockedRateForGroup(resolvedGroupID); ok {
+ return RateMultiplierResolution{Multiplier: lockedRate, Source: RateMultiplierSourceLockedRates}
+ }
+ }
+ if resolver != nil && userID > 0 && resolvedGroupID > 0 {
+ resolved := resolver.Resolve(ctx, userID, resolvedGroupID, defaultRate)
+ if resolved != defaultRate {
+ return RateMultiplierResolution{Multiplier: resolved, Source: RateMultiplierSourceUserGroup}
+ }
+ return RateMultiplierResolution{Multiplier: resolved, Source: defaultSource}
+ }
+ return RateMultiplierResolution{Multiplier: defaultRate, Source: defaultSource}
+}
+
+func ResolveSubscriptionDisplayRateMultiplier(groupID *int64, group *Group, subscription *UserSubscription, systemDefault float64) RateMultiplierResolution {
+ resolvedGroupID, defaultRate, defaultSource := resolveGroupRateContext(groupID, group, systemDefault)
+ if resolvedGroupID > 0 {
+ if lockedRate, ok := subscription.LockedRateForGroup(resolvedGroupID); ok {
+ return RateMultiplierResolution{Multiplier: lockedRate, Source: RateMultiplierSourceLockedRates}
+ }
+ }
+ return RateMultiplierResolution{Multiplier: defaultRate, Source: defaultSource}
+}
diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go
index 9344de47d86..97dd6b19adc 100644
--- a/backend/internal/service/ratelimit_service.go
+++ b/backend/internal/service/ratelimit_service.go
@@ -55,10 +55,16 @@ type geminiUsageTotalsBatchProvider interface {
const geminiPrecheckCacheTTL = time.Minute
+const (
+ defaultRateLimit429CooldownSeconds = 5
+ maxRateLimit429CooldownSeconds = 7200
+)
+
const (
openAI403CooldownMinutesDefault = 10
openAI403DisableThreshold = 3
openAI403CounterWindowMinutes = 180
+ openAIGPT56Model403Cooldown = 180 * time.Minute
)
// NewRateLimitService 创建RateLimitService实例
@@ -255,14 +261,14 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
case 403:
logger.LegacyPrintf(
"service.ratelimit",
- "[HandleUpstreamErrorRaw] account_id=%d platform=%s type=%s status=403 request_id=%s cf_ray=%s upstream_msg=%s raw_body=%s",
+ "[HandleUpstreamError] account_id=%d platform=%s type=%s status=403 request_id=%s cf_ray=%s upstream_msg=%s response_bytes=%d",
account.ID,
account.Platform,
account.Type,
strings.TrimSpace(headers.Get("x-request-id")),
strings.TrimSpace(headers.Get("cf-ray")),
upstreamMsg,
- truncateForLog(responseBody, 1024),
+ len(responseBody),
)
shouldDisable = s.handle403(ctx, account, upstreamMsg, responseBody)
case 429:
@@ -290,6 +296,74 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
return shouldDisable
}
+// HandleUpstreamErrorForModel applies model-aware isolation before the legacy
+// account-wide policy. An exact GPT-5.6 tier returning 403 means that this
+// account lacks access to that model; it must never disable or temporarily
+// unschedule the account for unrelated OpenAI models.
+func (s *RateLimitService) HandleUpstreamErrorForModel(
+ ctx context.Context,
+ account *Account,
+ model string,
+ statusCode int,
+ headers http.Header,
+ responseBody []byte,
+) (shouldFailover bool) {
+ if statusCode == http.StatusForbidden && account != nil && account.Platform == PlatformOpenAI {
+ if modelKey, ok := resolveOpenAIGPT56ModelIsolationKey(account, model); ok {
+ return s.handleOpenAIGPT56Model403(ctx, account, modelKey, responseBody)
+ }
+ }
+ return s.HandleUpstreamError(ctx, account, statusCode, headers, responseBody)
+}
+
+func resolveOpenAIGPT56ModelIsolationKey(account *Account, model string) (string, bool) {
+ if account == nil {
+ return "", false
+ }
+ normalizedTier, isFamily := classifyOpenAIGPT56PreviewModel(model)
+ if !isFamily || normalizedTier == "" {
+ return "", false
+ }
+ mappedModel, matched := account.ResolveMappedModel(normalizedTier)
+ if !matched {
+ return "", false
+ }
+ modelKey := strings.TrimSpace(mappedModel)
+ mappedTier, mappedIsFamily := classifyOpenAIGPT56PreviewModel(modelKey)
+ if !mappedIsFamily || mappedTier != normalizedTier {
+ return "", false
+ }
+ return modelKey, true
+}
+
+func (s *RateLimitService) handleOpenAIGPT56Model403(
+ ctx context.Context,
+ account *Account,
+ modelKey string,
+ responseBody []byte,
+) bool {
+ resetAt := time.Now().Add(openAIGPT56Model403Cooldown)
+ if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, modelKey, resetAt); err != nil {
+ // Fail over the current request, but never fall back to SetError or an
+ // account-wide cooldown when model isolation persistence is unavailable.
+ slog.Error("openai_gpt56_model_403_isolation_failed",
+ "account_id", account.ID,
+ "model", modelKey,
+ "error", err,
+ )
+ return true
+ }
+
+ upstreamMsg := sanitizeUpstreamErrorMessage(extractUpstreamErrorMessage(responseBody))
+ slog.Warn("openai_gpt56_model_403_isolated",
+ "account_id", account.ID,
+ "model", modelKey,
+ "reset_at", resetAt,
+ "upstream_message", truncateForLog([]byte(upstreamMsg), 256),
+ )
+ return true
+}
+
// PreCheckUsage proactively checks local quota before dispatching a request.
// Returns false when the account should be skipped.
func (s *RateLimitService) PreCheckUsage(ctx context.Context, account *Account, requestedModel string) (bool, error) {
@@ -891,12 +965,8 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head
return
}
- // 其他平台:没有重置时间,使用默认5分钟
- resetAt := time.Now().Add(5 * time.Minute)
- slog.Warn("rate_limit_no_reset_time", "account_id", account.ID, "platform", account.Platform, "using_default", "5m")
- if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil {
- slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
- }
+ // 其他平台:没有重置时间,使用可配置的秒级默认回避,避免误伤长时间不可调度。
+ s.apply429FallbackRateLimit(ctx, account, "no_reset_time")
return
}
@@ -904,10 +974,7 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head
ts, err := strconv.ParseInt(resetTimestamp, 10, 64)
if err != nil {
slog.Warn("rate_limit_reset_parse_failed", "reset_timestamp", resetTimestamp, "error", err)
- resetAt := time.Now().Add(5 * time.Minute)
- if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil {
- slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
- }
+ s.apply429FallbackRateLimit(ctx, account, "reset_parse_failed")
return
}
@@ -929,6 +996,48 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head
slog.Info("account_rate_limited", "account_id", account.ID, "reset_at", resetAt)
}
+func (s *RateLimitService) apply429FallbackRateLimit(ctx context.Context, account *Account, reason string) {
+ cooldown, enabled := s.get429FallbackCooldown(ctx, account)
+ if !enabled {
+ slog.Info("rate_limit_429_fallback_ignored", "account_id", account.ID, "platform", account.Platform, "reason", reason)
+ return
+ }
+
+ resetAt := time.Now().Add(cooldown)
+ slog.Warn("rate_limit_429_fallback_used", "account_id", account.ID, "platform", account.Platform, "reason", reason, "using_default", cooldown.String())
+ if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil {
+ slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
+ }
+}
+
+func (s *RateLimitService) get429FallbackCooldown(ctx context.Context, account *Account) (time.Duration, bool) {
+ if s.settingService != nil {
+ settings, err := s.settingService.GetRateLimit429CooldownSettings(ctx)
+ if err == nil && settings != nil {
+ if !settings.Enabled {
+ return 0, false
+ }
+ seconds := clampRateLimit429CooldownSeconds(settings.CooldownSeconds)
+ return time.Duration(seconds) * time.Second, true
+ }
+ slog.Warn("rate_limit_429_settings_read_failed", "account_id", account.ID, "error", err)
+ }
+
+ seconds := defaultRateLimit429CooldownSeconds
+ seconds = clampRateLimit429CooldownSeconds(seconds)
+ return time.Duration(seconds) * time.Second, true
+}
+
+func clampRateLimit429CooldownSeconds(seconds int) int {
+ if seconds < 1 {
+ return 1
+ }
+ if seconds > maxRateLimit429CooldownSeconds {
+ return maxRateLimit429CooldownSeconds
+ }
+ return seconds
+}
+
// calculateOpenAI429ResetTime 从 OpenAI 429 响应头计算正确的重置时间
// 返回 nil 表示无法从响应头中确定重置时间
func calculateOpenAI429ResetTime(headers http.Header) *time.Time {
@@ -1101,7 +1210,9 @@ func (s *RateLimitService) persistOpenAICodexSnapshot(ctx context.Context, accou
}
if err := s.accountRepo.UpdateExtra(ctx, account.ID, updates); err != nil {
slog.Warn("openai_codex_snapshot_persist_failed", "account_id", account.ID, "error", err)
+ return
}
+ applyOpenAIQuotaGuardFromUpdates(ctx, s.accountRepo, account.ID, updates, time.Now())
}
// parseOpenAIRateLimitResetTime 解析 OpenAI 格式的 429 响应,返回重置时间的 Unix 时间戳
diff --git a/backend/internal/service/ratelimit_service_401_test.go b/backend/internal/service/ratelimit_service_401_test.go
index 73b7849fbac..230127311e9 100644
--- a/backend/internal/service/ratelimit_service_401_test.go
+++ b/backend/internal/service/ratelimit_service_401_test.go
@@ -17,7 +17,11 @@ type rateLimitAccountRepoStub struct {
mockAccountRepoForGemini
setErrorCalls int
tempCalls int
+ modelRateLimitCalls int
updateCredentialsCalls int
+ lastModelRateLimitKey string
+ lastModelRateLimitAt time.Time
+ modelRateLimitErr error
lastCredentials map[string]any
lastErrorMsg string
lastTempReason string
@@ -35,6 +39,13 @@ func (r *rateLimitAccountRepoStub) SetTempUnschedulable(ctx context.Context, id
return nil
}
+func (r *rateLimitAccountRepoStub) SetModelRateLimit(_ context.Context, _ int64, scope string, resetAt time.Time) error {
+ r.modelRateLimitCalls++
+ r.lastModelRateLimitKey = scope
+ r.lastModelRateLimitAt = resetAt
+ return r.modelRateLimitErr
+}
+
func (r *rateLimitAccountRepoStub) UpdateCredentials(ctx context.Context, id int64, credentials map[string]any) error {
r.updateCredentialsCalls++
r.lastCredentials = cloneCredentials(credentials)
@@ -49,10 +60,12 @@ type tokenCacheInvalidatorRecorder struct {
type openAI403CounterCacheStub struct {
counts []int64
resetCalls []int64
+ increments int
err error
}
func (s *openAI403CounterCacheStub) IncrementOpenAI403Count(_ context.Context, _ int64, _ int) (int64, error) {
+ s.increments++
if s.err != nil {
return 0, s.err
}
diff --git a/backend/internal/service/ratelimit_service_403_test.go b/backend/internal/service/ratelimit_service_403_test.go
index 2fd11b71690..e0df8d0d981 100644
--- a/backend/internal/service/ratelimit_service_403_test.go
+++ b/backend/internal/service/ratelimit_service_403_test.go
@@ -4,13 +4,106 @@ package service
import (
"context"
+ "errors"
"net/http"
"testing"
+ "time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
+func TestRateLimitService_HandleUpstreamErrorForModel_OpenAIGPT56UsesModelIsolationOnly(t *testing.T) {
+ tests := []struct {
+ name string
+ model string
+ mapping map[string]any
+ wantScope string
+ repoErr error
+ }{
+ {
+ name: "exact tier",
+ model: "gpt-5.6-sol",
+ mapping: map[string]any{
+ "gpt-5.6-sol": "openai/gpt-5.6-sol-high",
+ },
+ wantScope: "openai/gpt-5.6-sol-high",
+ },
+ {
+ name: "repository failure never falls back to account disable",
+ model: "gpt-5.6-terra",
+ mapping: map[string]any{
+ "gpt-5.6-terra": "gpt-5.6-terra",
+ },
+ wantScope: "gpt-5.6-terra",
+ repoErr: errors.New("database unavailable"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ repo := &rateLimitAccountRepoStub{modelRateLimitErr: tt.repoErr}
+ counter := &openAI403CounterCacheStub{counts: []int64{3}}
+ service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
+ service.SetOpenAI403CounterCache(counter)
+ account := &Account{
+ ID: 401, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true,
+ Credentials: map[string]any{"model_mapping": tt.mapping},
+ }
+
+ shouldFailover := service.HandleUpstreamErrorForModel(
+ context.Background(), account, tt.model, http.StatusForbidden, http.Header{},
+ []byte(`{"error":{"message":"model access denied"}}`),
+ )
+
+ require.True(t, shouldFailover)
+ require.Equal(t, 1, repo.modelRateLimitCalls)
+ require.Equal(t, tt.wantScope, repo.lastModelRateLimitKey)
+ require.Zero(t, repo.setErrorCalls)
+ require.Zero(t, repo.tempCalls)
+ require.Zero(t, counter.increments)
+ if tt.repoErr == nil {
+ require.WithinDuration(t, time.Now().Add(180*time.Minute), repo.lastModelRateLimitAt, 5*time.Second)
+ account.Extra = map[string]any{
+ modelRateLimitsKey: map[string]any{
+ tt.wantScope: map[string]any{"rate_limit_reset_at": repo.lastModelRateLimitAt.Format(time.RFC3339)},
+ },
+ }
+ require.False(t, account.IsSchedulableForModel(tt.model))
+ require.True(t, account.IsSchedulableForModel("gpt-5.4"))
+ }
+ })
+ }
+}
+
+func TestRateLimitService_HandleUpstreamErrorForModel_UnentitledOrInvalidGPT56FallsBackToExistingAccountPolicy(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ model string
+ }{
+ {name: "exact tier without entitlement", model: "gpt-5.6-sol"},
+ {name: "invalid tier", model: "gpt-5.6-unknown"},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ repo := &rateLimitAccountRepoStub{}
+ counter := &openAI403CounterCacheStub{counts: []int64{1}}
+ service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
+ service.SetOpenAI403CounterCache(counter)
+ account := &Account{ID: 402, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
+
+ shouldDisable := service.HandleUpstreamErrorForModel(
+ context.Background(), account, tt.model, http.StatusForbidden, http.Header{},
+ []byte(`{"error":{"message":"workspace forbidden"},"secret":"must-not-be-logged-raw"}`),
+ )
+
+ require.True(t, shouldDisable)
+ require.Zero(t, repo.modelRateLimitCalls)
+ require.Equal(t, 1, repo.tempCalls)
+ require.Equal(t, 1, counter.increments)
+ })
+ }
+}
+
func TestRateLimitService_HandleUpstreamError_OpenAI403FirstHitTempUnschedulable(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
counter := &openAI403CounterCacheStub{counts: []int64{1}}
diff --git a/backend/internal/service/redeem_code.go b/backend/internal/service/redeem_code.go
index a66b53bad37..d4e2ac64c7e 100644
--- a/backend/internal/service/redeem_code.go
+++ b/backend/internal/service/redeem_code.go
@@ -20,6 +20,10 @@ type RedeemCode struct {
GroupID *int64
ValidityDays int
+ // PlanID 钱包模式额度卡:兑换时按 plan.WalletQuotaUsd 创建 wallet 订阅。
+ // Type=RedeemTypeWallet 时必填;其它 type 为 nil。链动小铺 credits SKU 走此路径(B2.7)。
+ PlanID *int64
+
User *User
Group *Group
}
diff --git a/backend/internal/service/redeem_monthly_retirement_test.go b/backend/internal/service/redeem_monthly_retirement_test.go
new file mode 100644
index 00000000000..d5227278b6b
--- /dev/null
+++ b/backend/internal/service/redeem_monthly_retirement_test.go
@@ -0,0 +1,48 @@
+package service
+
+import (
+ "context"
+ "testing"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateNewRedeemCodeTypeRejectsMonthlySubscription(t *testing.T) {
+ err := validateNewRedeemCodeType(RedeemTypeSubscription)
+ require.Error(t, err)
+ require.Equal(t, "MONTHLY_PLANS_RETIRED", infraerrors.Reason(err))
+
+ require.NoError(t, validateNewRedeemCodeType(RedeemTypeWallet))
+ require.NoError(t, validateNewRedeemCodeType(RedeemTypeBalance))
+}
+
+func TestMonthlyRedeemCodeCreationPathsAreRetired(t *testing.T) {
+ ctx := context.Background()
+ redeemSvc := &RedeemService{}
+
+ _, err := redeemSvc.GenerateCodes(ctx, GenerateCodesRequest{
+ Count: 1,
+ Value: 30,
+ Type: RedeemTypeSubscription,
+ })
+ require.Error(t, err)
+ require.Equal(t, "MONTHLY_PLANS_RETIRED", infraerrors.Reason(err))
+
+ err = redeemSvc.CreateCode(ctx, &RedeemCode{
+ Code: "RETIRED-MONTHLY-CODE",
+ Type: RedeemTypeSubscription,
+ Value: 30,
+ })
+ require.Error(t, err)
+ require.Equal(t, "MONTHLY_PLANS_RETIRED", infraerrors.Reason(err))
+
+ adminSvc := &adminServiceImpl{}
+ _, err = adminSvc.GenerateRedeemCodes(ctx, &GenerateRedeemCodesInput{
+ Count: 1,
+ Type: RedeemTypeSubscription,
+ Value: 30,
+ })
+ require.Error(t, err)
+ require.Equal(t, "MONTHLY_PLANS_RETIRED", infraerrors.Reason(err))
+}
diff --git a/backend/internal/service/redeem_purchase_limit_test.go b/backend/internal/service/redeem_purchase_limit_test.go
new file mode 100644
index 00000000000..4e6f49686bd
--- /dev/null
+++ b/backend/internal/service/redeem_purchase_limit_test.go
@@ -0,0 +1,167 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCheckPaidTrialRedeemLimitBlocksExistingUsedTrialCode(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentOrderLifecycleTestClient(t)
+ group := createPaidTrialRedeemLimitGroup(t, ctx, client)
+ user := createPaymentLimitUser(t, ctx, client, "redeem-used")
+ createPaidTrialRedeemLimitUsedCode(t, ctx, client, group.ID, user.ID, "trial-used-1")
+ code := createPaidTrialRedeemLimitCode(t, ctx, client, group.ID, "trial-current-1", 30)
+
+ err := checkPaidTrialRedeemLimitForTest(t, ctx, client, user.ID, code)
+
+ require.Error(t, err)
+ require.True(t, infraerrors.IsConflict(err))
+ require.Equal(t, "PLAN_PURCHASE_LIMIT_REACHED", infraerrors.Reason(err))
+ require.Equal(t, paidTrialOncePlanName, infraerrors.FromError(err).Metadata["plan_name"])
+ require.Equal(t, "redeem_code", infraerrors.FromError(err).Metadata["source"])
+}
+
+func TestCheckPaidTrialRedeemLimitBlocksExistingTrialSubscription(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentOrderLifecycleTestClient(t)
+ group := createPaidTrialRedeemLimitGroup(t, ctx, client)
+ user := createPaymentLimitUser(t, ctx, client, "redeem-sub")
+ createPaidTrialRedeemLimitSubscription(t, ctx, client, group.ID, user.ID)
+ code := createPaidTrialRedeemLimitCode(t, ctx, client, group.ID, "trial-current-2", 30)
+
+ err := checkPaidTrialRedeemLimitForTest(t, ctx, client, user.ID, code)
+
+ require.Error(t, err)
+ require.True(t, infraerrors.IsConflict(err))
+ require.Equal(t, "subscription", infraerrors.FromError(err).Metadata["source"])
+}
+
+func TestCheckPaidTrialRedeemLimitBlocksExistingTrialPaymentOrder(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentOrderLifecycleTestClient(t)
+ group := createPaidTrialRedeemLimitGroup(t, ctx, client)
+ user := createPaymentLimitUser(t, ctx, client, "redeem-order")
+ plan := createPaidTrialRedeemLimitPlan(t, ctx, client)
+ createPaymentLimitOrder(t, ctx, client, user.ID, plan.ID, OrderStatusCompleted)
+ code := createPaidTrialRedeemLimitCode(t, ctx, client, group.ID, "trial-current-3", 30)
+
+ err := checkPaidTrialRedeemLimitForTest(t, ctx, client, user.ID, code)
+
+ require.Error(t, err)
+ require.True(t, infraerrors.IsConflict(err))
+ require.Equal(t, "payment_order", infraerrors.FromError(err).Metadata["source"])
+}
+
+func TestCheckPaidTrialRedeemLimitIgnoresOtherGroupsAndRefundAdjustments(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentOrderLifecycleTestClient(t)
+ group := createPaidTrialRedeemLimitGroup(t, ctx, client)
+ otherGroup := createRedeemLimitGroup(t, ctx, client, "redeem-other-group")
+ user := createPaymentLimitUser(t, ctx, client, "redeem-other")
+ createPaidTrialRedeemLimitUsedCode(t, ctx, client, group.ID, user.ID, "trial-used-2")
+ otherGroupCode := createPaidTrialRedeemLimitCode(t, ctx, client, otherGroup.ID, "trial-current-4", 30)
+ refundAdjustmentCode := createPaidTrialRedeemLimitCode(t, ctx, client, group.ID, "trial-current-5", -30)
+
+ require.NoError(t, checkPaidTrialRedeemLimitForTest(t, ctx, client, user.ID, otherGroupCode))
+ require.NoError(t, checkPaidTrialRedeemLimitForTest(t, ctx, client, user.ID, refundAdjustmentCode))
+}
+
+func checkPaidTrialRedeemLimitForTest(t *testing.T, ctx context.Context, client *dbent.Client, userID int64, code *RedeemCode) error {
+ t.Helper()
+ tx, err := client.Tx(ctx)
+ require.NoError(t, err)
+ defer func() { _ = tx.Rollback() }()
+
+ return (&RedeemService{}).checkPaidTrialRedeemLimit(ctx, tx, userID, code)
+}
+
+func createPaidTrialRedeemLimitGroup(t *testing.T, ctx context.Context, client *dbent.Client) *dbent.Group {
+ t.Helper()
+ var group *dbent.Group
+ for i := int64(1); i <= paidTrialRedeemGroupID; i++ {
+ group = createRedeemLimitGroup(t, ctx, client, fmt.Sprintf("paid-trial-redeem-limit-%d", i))
+ }
+ require.Equal(t, paidTrialRedeemGroupID, group.ID)
+ return group
+}
+
+func createRedeemLimitGroup(t *testing.T, ctx context.Context, client *dbent.Client, name string) *dbent.Group {
+ t.Helper()
+ group, err := client.Group.Create().
+ SetName(name).
+ SetSubscriptionType(SubscriptionTypeSubscription).
+ SetMonthlyLimitUsd(100).
+ Save(ctx)
+ require.NoError(t, err)
+ return group
+}
+
+func createPaidTrialRedeemLimitPlan(t *testing.T, ctx context.Context, client *dbent.Client) *dbent.SubscriptionPlan {
+ t.Helper()
+ var plan *dbent.SubscriptionPlan
+ for i := int64(1); i <= paidTrialOncePlanID; i++ {
+ name := fmt.Sprintf("paid-trial-redeem-limit-plan-%d", i)
+ if i == paidTrialOncePlanID {
+ name = paidTrialOncePlanName
+ }
+ plan = createPaymentLimitPlan(t, ctx, client, name)
+ }
+ require.Equal(t, paidTrialOncePlanID, plan.ID)
+ return plan
+}
+
+func createPaidTrialRedeemLimitCode(t *testing.T, ctx context.Context, client *dbent.Client, groupID int64, code string, validityDays int) *RedeemCode {
+ t.Helper()
+ redeemCode, err := client.RedeemCode.Create().
+ SetCode(code).
+ SetType(RedeemTypeSubscription).
+ SetStatus(StatusUnused).
+ SetGroupID(groupID).
+ SetValidityDays(validityDays).
+ Save(ctx)
+ require.NoError(t, err)
+ return &RedeemCode{
+ ID: redeemCode.ID,
+ Code: redeemCode.Code,
+ Type: redeemCode.Type,
+ Status: redeemCode.Status,
+ GroupID: redeemCode.GroupID,
+ ValidityDays: redeemCode.ValidityDays,
+ }
+}
+
+func createPaidTrialRedeemLimitUsedCode(t *testing.T, ctx context.Context, client *dbent.Client, groupID, userID int64, code string) {
+ t.Helper()
+ _, err := client.RedeemCode.Create().
+ SetCode(code).
+ SetType(RedeemTypeSubscription).
+ SetStatus(StatusUsed).
+ SetGroupID(groupID).
+ SetUsedBy(userID).
+ SetUsedAt(time.Now()).
+ SetValidityDays(30).
+ Save(ctx)
+ require.NoError(t, err)
+}
+
+func createPaidTrialRedeemLimitSubscription(t *testing.T, ctx context.Context, client *dbent.Client, groupID, userID int64) {
+ t.Helper()
+ now := time.Now()
+ _, err := client.UserSubscription.Create().
+ SetUserID(userID).
+ SetGroupID(groupID).
+ SetStartsAt(now.Add(-time.Hour)).
+ SetExpiresAt(now.Add(30 * 24 * time.Hour)).
+ SetStatus(SubscriptionStatusActive).
+ Save(ctx)
+ require.NoError(t, err)
+}
diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go
index 9ced62016f0..4dca60cf8e2 100644
--- a/backend/internal/service/redeem_service.go
+++ b/backend/internal/service/redeem_service.go
@@ -6,20 +6,30 @@ import (
"encoding/hex"
"errors"
"fmt"
+ "strconv"
"strings"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/paymentorder"
+ "github.com/Wei-Shaw/sub2api/ent/redeemcode"
+ "github.com/Wei-Shaw/sub2api/ent/usersubscription"
+ "github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
)
var (
- ErrRedeemCodeNotFound = infraerrors.NotFound("REDEEM_CODE_NOT_FOUND", "redeem code not found")
- ErrRedeemCodeUsed = infraerrors.Conflict("REDEEM_CODE_USED", "redeem code already used")
- ErrInsufficientBalance = infraerrors.BadRequest("INSUFFICIENT_BALANCE", "insufficient balance")
- ErrRedeemRateLimited = infraerrors.TooManyRequests("REDEEM_RATE_LIMITED", "too many failed attempts, please try again later")
- ErrRedeemCodeLocked = infraerrors.Conflict("REDEEM_CODE_LOCKED", "redeem code is being processed, please try again")
+ ErrRedeemCodeNotFound = infraerrors.NotFound("REDEEM_CODE_NOT_FOUND", "redeem code not found")
+ ErrRedeemCodeUsed = infraerrors.Conflict("REDEEM_CODE_USED", "redeem code already used")
+ ErrInsufficientBalance = infraerrors.BadRequest("INSUFFICIENT_BALANCE", "insufficient balance")
+ ErrRedeemRateLimited = infraerrors.TooManyRequests("REDEEM_RATE_LIMITED", "too many failed attempts, please try again later")
+ ErrRedeemCodeLocked = infraerrors.Conflict("REDEEM_CODE_LOCKED", "redeem code is being processed, please try again")
+ ErrRedeemCodeDeleteUsed = infraerrors.Conflict("REDEEM_CODE_DELETE_USED", "cannot delete used redeem code")
+ ErrRedeemCodeDeleteState = infraerrors.Conflict("REDEEM_CODE_DELETE_INVALID_STATE", "redeem code cannot be deleted in its current state")
+ ErrRedeemCodeExpireUsed = infraerrors.Conflict("REDEEM_CODE_EXPIRE_USED", "cannot expire used redeem code")
+ ErrRedeemCodeExpireState = infraerrors.Conflict("REDEEM_CODE_EXPIRE_INVALID_STATE", "redeem code cannot be expired in its current state")
)
const (
@@ -28,6 +38,22 @@ const (
redeemLockDuration = 10 * time.Second // 锁超时时间,防止死锁
)
+func validateNewRedeemCodeType(codeType string) error {
+ if codeType == RedeemTypeSubscription {
+ return infraerrors.BadRequest("MONTHLY_PLANS_RETIRED", "monthly redeem codes can no longer be created; use wallet credits instead")
+ }
+ return nil
+}
+
+type ctxKeySkipRedeemAffiliate struct{}
+
+// ContextSkipRedeemAffiliate returns a context that suppresses the redeem-level
+// affiliate rebate. Used by payment fulfillment which handles rebate separately
+// via applyAffiliateRebateForOrder (with audit-log deduplication).
+func ContextSkipRedeemAffiliate(ctx context.Context) context.Context {
+ return context.WithValue(ctx, ctxKeySkipRedeemAffiliate{}, true)
+}
+
// RedeemCache defines cache operations for redeem service
type RedeemCache interface {
GetRedeemAttemptCount(ctx context.Context, userID int64) (int, error)
@@ -43,7 +69,8 @@ type RedeemCodeRepository interface {
GetByID(ctx context.Context, id int64) (*RedeemCode, error)
GetByCode(ctx context.Context, code string) (*RedeemCode, error)
Update(ctx context.Context, code *RedeemCode) error
- Delete(ctx context.Context, id int64) error
+ DeleteIfUnused(ctx context.Context, id int64) (bool, error)
+ ExpireIfUnused(ctx context.Context, id int64) (bool, error)
Use(ctx context.Context, id, userID int64) error
List(ctx context.Context, params pagination.PaginationParams) ([]RedeemCode, *pagination.PaginationResult, error)
@@ -80,6 +107,7 @@ type RedeemService struct {
billingCacheService *BillingCacheService
entClient *dbent.Client
authCacheInvalidator APIKeyAuthCacheInvalidator
+ affiliateService *AffiliateService
}
// NewRedeemService 创建兑换码服务实例
@@ -91,6 +119,7 @@ func NewRedeemService(
billingCacheService *BillingCacheService,
entClient *dbent.Client,
authCacheInvalidator APIKeyAuthCacheInvalidator,
+ affiliateService *AffiliateService,
) *RedeemService {
return &RedeemService{
redeemRepo: redeemRepo,
@@ -100,6 +129,7 @@ func NewRedeemService(
billingCacheService: billingCacheService,
entClient: entClient,
authCacheInvalidator: authCacheInvalidator,
+ affiliateService: affiliateService,
}
}
@@ -144,6 +174,9 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ
if codeType == "" {
codeType = RedeemTypeBalance
}
+ if err := validateNewRedeemCodeType(codeType); err != nil {
+ return nil, err
+ }
// 邀请码类型的 value 设为 0
value := req.Value
@@ -188,6 +221,9 @@ func (s *RedeemService) CreateCode(ctx context.Context, code *RedeemCode) error
if code.Type == "" {
code.Type = RedeemTypeBalance
}
+ if err := validateNewRedeemCodeType(code.Type); err != nil {
+ return err
+ }
if code.Type != RedeemTypeInvitation && code.Value == 0 {
return errors.New("value must not be zero")
}
@@ -286,6 +322,9 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (
if redeemCode.Type == RedeemTypeSubscription && redeemCode.GroupID == nil {
return nil, infraerrors.BadRequest("REDEEM_CODE_INVALID", "invalid subscription redeem code: missing group_id")
}
+ if redeemCode.Type == RedeemTypeWallet && redeemCode.PlanID == nil {
+ return nil, infraerrors.BadRequest("REDEEM_CODE_INVALID", "invalid wallet redeem code: missing plan_id")
+ }
// 获取用户信息
user, err := s.userRepo.GetByID(ctx, userID)
@@ -303,12 +342,19 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (
// 将事务放入 context,使 repository 方法能够使用同一事务
txCtx := dbent.NewTxContext(ctx, tx)
+ if err := s.checkPaidTrialRedeemLimit(txCtx, tx, userID, redeemCode); err != nil {
+ return nil, err
+ }
+
// 【关键】先标记兑换码为已使用,确保并发安全
// 利用数据库乐观锁(WHERE status = 'unused')保证原子性
if err := s.redeemRepo.Use(txCtx, redeemCode.ID, userID); err != nil {
if errors.Is(err, ErrRedeemCodeNotFound) || errors.Is(err, ErrRedeemCodeUsed) {
return nil, ErrRedeemCodeUsed
}
+ if isPaidTrialRedeemCode(redeemCode) && dbent.IsConstraintError(err) {
+ return nil, paidTrialRedeemLimitReachedError("redeem_code_constraint", *redeemCode.GroupID, redeemCode.ID)
+ }
return nil, fmt.Errorf("mark code as used: %w", err)
}
@@ -357,6 +403,32 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (
}
}
+ case RedeemTypeWallet:
+ // 钱包模式额度卡(B2.7):按 plan.WalletQuotaUsd 创建 wallet 订阅。
+ // plan_type='credits' → 永久 expires_at(B2.3 已在 service 内处理)。
+ // 已有 active 钱包 → topup 叠加(B2.4 已在 service 内处理)。
+ plan, err := s.entClient.SubscriptionPlan.Get(txCtx, *redeemCode.PlanID)
+ if err != nil {
+ return nil, fmt.Errorf("wallet plan %d not found: %w", *redeemCode.PlanID, err)
+ }
+ if plan.WalletQuotaUsd == nil || *plan.WalletQuotaUsd <= 0 {
+ return nil, infraerrors.BadRequest("REDEEM_CODE_INVALID", "redeem plan is not a wallet plan")
+ }
+ walletInitial := *plan.WalletQuotaUsd
+ planID := plan.ID
+ _, err = s.subscriptionService.AssignSubscription(txCtx, &AssignSubscriptionInput{
+ UserID: userID,
+ ValidityDays: plan.ValidityDays,
+ AssignedBy: 0,
+ Notes: fmt.Sprintf("通过兑换码 %s 兑换额度卡", redeemCode.Code),
+ WalletInitialUSD: &walletInitial,
+ PlanID: &planID,
+ PlanType: plan.PlanType,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("assign wallet subscription: %w", err)
+ }
+
default:
return nil, fmt.Errorf("unsupported redeem type: %s", redeemCode.Type)
}
@@ -369,6 +441,17 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (
// 事务提交成功后失效缓存
s.invalidateRedeemCaches(ctx, userID, redeemCode)
+ // 邀请返利触发(best-effort,失败不影响兑换结果)
+ // 触发范围:balance / wallet / subscription 正数码,subscription 缩短/退款码(ValidityDays<0)除外。
+ if redeemCode.Value > 0 && isAffiliateRebateTriggerRedeem(redeemCode) {
+ override := inviterRebateOverrideForRedeem(redeemCode)
+ s.tryAccrueAffiliateRebateForRedeemWithOverride(ctx, userID, redeemCode.Value, override)
+ // 新人首单 5% 仅 wallet 类型(余额卡/¥99)触发;月卡 subscription 整档零佣金,不触发首单
+ if redeemCode.Type == RedeemTypeWallet {
+ s.tryAccrueInviteeFirstOrderRebateForRedeem(ctx, userID, redeemCode.Value)
+ }
+ }
+
// 重新获取更新后的兑换码
redeemCode, err = s.redeemRepo.GetByID(ctx, redeemCode.ID)
if err != nil {
@@ -378,6 +461,78 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (
return redeemCode, nil
}
+func (s *RedeemService) checkPaidTrialRedeemLimit(ctx context.Context, tx *dbent.Tx, userID int64, code *RedeemCode) error {
+ if !isPaidTrialRedeemCode(code) {
+ return nil
+ }
+
+ groupID := *code.GroupID
+ usedCodeExists, err := tx.RedeemCode.Query().
+ Where(
+ redeemcode.TypeEQ(RedeemTypeSubscription),
+ redeemcode.GroupIDEQ(groupID),
+ redeemcode.StatusEQ(StatusUsed),
+ redeemcode.UsedByEQ(userID),
+ redeemcode.IDNEQ(code.ID),
+ ).
+ Exist(ctx)
+ if err != nil {
+ return fmt.Errorf("check paid trial redeem history: %w", err)
+ }
+ if usedCodeExists {
+ return paidTrialRedeemLimitReachedError("redeem_code", groupID, code.ID)
+ }
+
+ subExists, err := tx.UserSubscription.Query().
+ Where(
+ usersubscription.UserIDEQ(userID),
+ usersubscription.GroupIDEQ(groupID),
+ usersubscription.DeletedAtIsNil(),
+ ).
+ Exist(ctx)
+ if err != nil {
+ return fmt.Errorf("check paid trial subscription history: %w", err)
+ }
+ if subExists {
+ return paidTrialRedeemLimitReachedError("subscription", groupID, code.ID)
+ }
+
+ orderExists, err := tx.PaymentOrder.Query().
+ Where(
+ paymentorder.UserIDEQ(userID),
+ paymentorder.OrderTypeEQ(payment.OrderTypeSubscription),
+ paymentorder.PlanIDEQ(paidTrialOncePlanID),
+ paymentorder.StatusIn(paidTrialOnceBlockingStatuses...),
+ ).
+ Exist(ctx)
+ if err != nil {
+ return fmt.Errorf("check paid trial order history: %w", err)
+ }
+ if orderExists {
+ return paidTrialRedeemLimitReachedError("payment_order", groupID, code.ID)
+ }
+
+ return nil
+}
+
+func isPaidTrialRedeemCode(code *RedeemCode) bool {
+ return code != nil &&
+ code.Type == RedeemTypeSubscription &&
+ code.GroupID != nil &&
+ *code.GroupID == paidTrialRedeemGroupID &&
+ code.ValidityDays >= 0
+}
+
+func paidTrialRedeemLimitReachedError(source string, groupID, codeID int64) error {
+ return infraerrors.Conflict("PLAN_PURCHASE_LIMIT_REACHED", "this plan can only be purchased once per user").
+ WithMetadata(map[string]string{
+ "plan_name": paidTrialOncePlanName,
+ "group_id": strconv.FormatInt(groupID, 10),
+ "redeem_code_id": strconv.FormatInt(codeID, 10),
+ "source": source,
+ })
+}
+
// invalidateRedeemCaches 失效兑换相关的缓存
func (s *RedeemService) invalidateRedeemCaches(ctx context.Context, userID int64, redeemCode *RedeemCode) {
switch redeemCode.Type {
@@ -388,11 +543,9 @@ func (s *RedeemService) invalidateRedeemCaches(ctx context.Context, userID int64
if s.billingCacheService == nil {
return
}
- go func() {
- cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- _ = s.billingCacheService.InvalidateUserBalance(cacheCtx, userID)
- }()
+ invalidateBillingCacheAfterCommit(ctx, "redeem balance", func(cacheCtx context.Context) error {
+ return s.billingCacheService.InvalidateUserBalance(cacheCtx, userID)
+ })
case RedeemTypeConcurrency:
if s.authCacheInvalidator != nil {
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
@@ -409,15 +562,86 @@ func (s *RedeemService) invalidateRedeemCaches(ctx context.Context, userID int64
}
if redeemCode.GroupID != nil {
groupID := *redeemCode.GroupID
- go func() {
- cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- _ = s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID)
- }()
+ invalidateBillingCacheAfterCommit(ctx, "redeem subscription", func(cacheCtx context.Context) error {
+ return s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID)
+ })
}
}
}
+// isAffiliateRebateTriggerRedeem 判断该兑换码是否触发邀请返利。
+// subscription 缩短/退款码(ValidityDays<0)不触发。
+func isAffiliateRebateTriggerRedeem(code *RedeemCode) bool {
+ switch code.Type {
+ case RedeemTypeBalance, RedeemTypeWallet:
+ return true
+ case RedeemTypeSubscription:
+ return code.ValidityDays >= 0
+ default:
+ return false
+ }
+}
+
+// inviterRebateOverrideForRedeem 根据兑换码类型返回邀请人差异化比例 override。
+// balance 类返回 nil(走全局 20%),wallet/subscription 类返回对应比例。
+func inviterRebateOverrideForRedeem(code *RedeemCode) *float64 {
+ switch code.Type {
+ case RedeemTypeWallet:
+ if code.PlanID != nil && affiliateCreditsPlanIDs[*code.PlanID] {
+ rate := AffiliateRebateCreditsCardRate // 余额卡 10%
+ return &rate
+ }
+ rate := AffiliateRebatePackageRate // plan18 ¥99 → 5%
+ return &rate
+ case RedeemTypeSubscription:
+ rate := AffiliateRebateSubscriptionRate // 月卡 0%(返回 0 而非 nil,避免落回全局 20%)
+ return &rate
+ default:
+ return nil // balance → 全局率
+ }
+}
+
+func (s *RedeemService) tryAccrueAffiliateRebateForRedeemWithOverride(ctx context.Context, userID int64, amount float64, override *float64) {
+ if ctx.Value(ctxKeySkipRedeemAffiliate{}) != nil {
+ return
+ }
+ if s.affiliateService == nil {
+ return
+ }
+ if !s.affiliateService.IsEnabled(ctx) {
+ return
+ }
+ rebate, err := s.affiliateService.AccrueInviteRebateForOrderWithOverride(ctx, userID, amount, override, nil)
+ if err != nil {
+ logger.LegacyPrintf("service.redeem", "[Redeem] affiliate rebate failed for user %d amount %.2f: %v", userID, amount, err)
+ return
+ }
+ if rebate > 0 {
+ logger.LegacyPrintf("service.redeem", "[Redeem] affiliate rebate accrued %.8f for inviter of user %d", rebate, userID)
+ }
+}
+
+// tryAccrueInviteeFirstOrderRebateForRedeem 新人首单 5%:被邀请人兑换首张正价链动卡时,
+// 按面值 5% 入本人余额。per-user 锁防重入,只触发一次。
+func (s *RedeemService) tryAccrueInviteeFirstOrderRebateForRedeem(ctx context.Context, userID int64, amount float64) {
+ if ctx.Value(ctxKeySkipRedeemAffiliate{}) != nil {
+ return
+ }
+ if s.affiliateService == nil || !s.affiliateService.IsEnabled(ctx) {
+ return
+ }
+ // 检查是否已有过返利(首单判定:此前无其他 used 且 value>0 的非 balance 码)
+ // 用 redis / DB 锁,这里通过 AffiliateService 的首单专属接口实现
+ rebate, err := s.affiliateService.AccrueInviteeFirstOrderRebate(ctx, userID, amount)
+ if err != nil {
+ logger.LegacyPrintf("service.redeem", "[Redeem] invitee first-order rebate failed for user %d: %v", userID, err)
+ return
+ }
+ if rebate > 0 {
+ logger.LegacyPrintf("service.redeem", "[Redeem] invitee first-order rebate %.8f accrued for user %d", rebate, userID)
+ }
+}
+
// GetByID 根据ID获取兑换码
func (s *RedeemService) GetByID(ctx context.Context, id int64) (*RedeemCode, error) {
code, err := s.redeemRepo.GetByID(ctx, id)
@@ -447,22 +671,31 @@ func (s *RedeemService) List(ctx context.Context, params pagination.PaginationPa
// Delete 删除兑换码(管理员功能)
func (s *RedeemService) Delete(ctx context.Context, id int64) error {
- // 检查兑换码是否存在
- code, err := s.redeemRepo.GetByID(ctx, id)
- if err != nil {
- return fmt.Errorf("get redeem code: %w", err)
+ if _, err := deleteRedeemCodeIfUnused(ctx, s.redeemRepo, id); err != nil {
+ return fmt.Errorf("delete redeem code: %w", err)
}
+ return nil
+}
- // 不允许删除已使用的兑换码
- if code.IsUsed() {
- return infraerrors.Conflict("REDEEM_CODE_DELETE_USED", "cannot delete used redeem code")
+func deleteRedeemCodeIfUnused(ctx context.Context, repo RedeemCodeRepository, id int64) (bool, error) {
+ deleted, err := repo.DeleteIfUnused(ctx, id)
+ if err != nil {
+ return false, err
}
-
- if err := s.redeemRepo.Delete(ctx, id); err != nil {
- return fmt.Errorf("delete redeem code: %w", err)
+ if deleted {
+ return true, nil
}
-
- return nil
+ current, err := repo.GetByID(ctx, id)
+ if errors.Is(err, ErrRedeemCodeNotFound) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ if current.IsUsed() {
+ return false, ErrRedeemCodeDeleteUsed
+ }
+ return false, ErrRedeemCodeDeleteState
}
// GetStats 获取兑换码统计信息
diff --git a/backend/internal/service/refresh_token_cache.go b/backend/internal/service/refresh_token_cache.go
index 91b3924fc5c..250af1c745a 100644
--- a/backend/internal/service/refresh_token_cache.go
+++ b/backend/internal/service/refresh_token_cache.go
@@ -39,6 +39,10 @@ type RefreshTokenCache interface {
// 返回 (nil, err) 如果发生其他错误
GetRefreshToken(ctx context.Context, tokenHash string) (*RefreshTokenData, error)
+ // ConsumeRefreshToken atomically returns and deletes one refresh token.
+ // Exactly one concurrent caller may receive the stored token data.
+ ConsumeRefreshToken(ctx context.Context, tokenHash string) (*RefreshTokenData, error)
+
// DeleteRefreshToken 删除单个Refresh Token
// 用于Token轮转时使旧Token失效
DeleteRefreshToken(ctx context.Context, tokenHash string) error
diff --git a/backend/internal/service/reserved_group_policy.go b/backend/internal/service/reserved_group_policy.go
new file mode 100644
index 00000000000..610b72903ef
--- /dev/null
+++ b/backend/internal/service/reserved_group_policy.go
@@ -0,0 +1,118 @@
+package service
+
+import (
+ "fmt"
+ "strings"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+)
+
+const reservedGroupPolicyViolationReason = "RESERVED_GROUP_POLICY_VIOLATION"
+
+type reservedBusinessGroupPolicy struct {
+ platform string
+ isExclusive bool
+}
+
+func reservedBusinessGroupPolicyFor(name string) (reservedBusinessGroupPolicy, bool) {
+ switch name {
+ case WalletDefaultOpenAIGroupName:
+ return reservedBusinessGroupPolicy{
+ platform: PlatformOpenAI,
+ isExclusive: false,
+ }, true
+ case WalletDefaultVIPGroupName:
+ return reservedBusinessGroupPolicy{
+ platform: PlatformAnthropic,
+ isExclusive: true,
+ }, true
+ default:
+ return reservedBusinessGroupPolicy{}, false
+ }
+}
+
+func validateReservedBusinessGroupCreate(group *Group) error {
+ return validateReservedBusinessGroupState(group, false)
+}
+
+func validateReservedBusinessGroupUpdate(current, candidate *Group) error {
+ if _, reserved := reservedBusinessGroupPolicyFor(current.Name); reserved {
+ if candidate.Name != current.Name {
+ return reservedGroupPolicyError(
+ current.Name,
+ "update",
+ []string{"cannot be renamed"},
+ )
+ }
+ }
+ if _, reserved := reservedBusinessGroupPolicyFor(candidate.Name); reserved && candidate.Name != current.Name {
+ return reservedGroupPolicyError(
+ candidate.Name,
+ "update",
+ []string{"cannot be assigned by renaming another group"},
+ )
+ }
+ return validateReservedBusinessGroupState(candidate, true)
+}
+
+func rejectReservedBusinessGroupDelete(group *Group) error {
+ if group == nil {
+ return nil
+ }
+ if _, reserved := reservedBusinessGroupPolicyFor(group.Name); !reserved {
+ return nil
+ }
+ return reservedGroupPolicyError(group.Name, "delete", []string{"cannot be deleted"})
+}
+
+func validateReservedBusinessGroupState(group *Group, requireHydrated bool) error {
+ if group == nil {
+ return nil
+ }
+ policy, reserved := reservedBusinessGroupPolicyFor(group.Name)
+ if !reserved {
+ return nil
+ }
+
+ violations := make([]string, 0, 8)
+ if group.Status != StatusActive {
+ violations = append(violations, "status must be active")
+ }
+ if requireHydrated && !group.Hydrated {
+ violations = append(violations, "hydrated must be true")
+ }
+ if group.SubscriptionType != SubscriptionTypeStandard {
+ violations = append(violations, "subscription_type must be standard")
+ }
+ if group.IsExclusive != policy.isExclusive {
+ violations = append(violations, fmt.Sprintf("is_exclusive must be %t", policy.isExclusive))
+ }
+ if group.Platform != policy.platform {
+ violations = append(violations, fmt.Sprintf("platform must be %s", policy.platform))
+ }
+ if group.ClaudeCodeOnly {
+ violations = append(violations, "claude_code_only must be false")
+ }
+ if group.FallbackGroupID != nil {
+ violations = append(violations, "fallback_group_id must be unset")
+ }
+ if group.FallbackGroupIDOnInvalidRequest != nil {
+ violations = append(violations, "fallback_group_id_on_invalid_request must be unset")
+ }
+ if len(violations) == 0 {
+ return nil
+ }
+ return reservedGroupPolicyError(group.Name, "write", violations)
+}
+
+func reservedGroupPolicyError(groupName, operation string, violations []string) error {
+ detail := strings.Join(violations, "; ")
+ return infraerrors.BadRequest(
+ reservedGroupPolicyViolationReason,
+ fmt.Sprintf("reserved group %q policy violation: %s", groupName, detail),
+ ).WithMetadata(map[string]string{
+ "group": groupName,
+ "operation": operation,
+ "violations": detail,
+ })
+}
diff --git a/backend/internal/service/reserved_group_policy_test.go b/backend/internal/service/reserved_group_policy_test.go
new file mode 100644
index 00000000000..4339240ab79
--- /dev/null
+++ b/backend/internal/service/reserved_group_policy_test.go
@@ -0,0 +1,417 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "testing"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
+ "github.com/stretchr/testify/require"
+)
+
+type reservedGroupRepoStub struct {
+ group *Group
+ createCalls int
+ updateCalls int
+ deleteCalls int
+ deleteCascadeCalls int
+}
+
+func (s *reservedGroupRepoStub) Create(_ context.Context, group *Group) error {
+ s.createCalls++
+ s.group = group
+ return nil
+}
+
+func (s *reservedGroupRepoStub) GetByID(_ context.Context, _ int64) (*Group, error) {
+ if s.group == nil {
+ return nil, ErrGroupNotFound
+ }
+ return s.group, nil
+}
+
+func (s *reservedGroupRepoStub) GetByIDLite(ctx context.Context, id int64) (*Group, error) {
+ return s.GetByID(ctx, id)
+}
+
+func (s *reservedGroupRepoStub) Update(_ context.Context, group *Group) error {
+ s.updateCalls++
+ s.group = group
+ return nil
+}
+
+func (s *reservedGroupRepoStub) Delete(_ context.Context, _ int64) error {
+ s.deleteCalls++
+ return nil
+}
+
+func (s *reservedGroupRepoStub) DeleteCascade(_ context.Context, _ int64) ([]int64, error) {
+ s.deleteCascadeCalls++
+ return nil, nil
+}
+
+func (s *reservedGroupRepoStub) List(context.Context, pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) {
+ panic("unexpected List call")
+}
+
+func (s *reservedGroupRepoStub) ListWithFilters(context.Context, pagination.PaginationParams, string, string, string, *bool) ([]Group, *pagination.PaginationResult, error) {
+ panic("unexpected ListWithFilters call")
+}
+
+func (s *reservedGroupRepoStub) ListActive(context.Context) ([]Group, error) {
+ panic("unexpected ListActive call")
+}
+
+func (s *reservedGroupRepoStub) ListActiveByPlatform(context.Context, string) ([]Group, error) {
+ panic("unexpected ListActiveByPlatform call")
+}
+
+func (s *reservedGroupRepoStub) ExistsByName(context.Context, string) (bool, error) {
+ return false, nil
+}
+
+func (s *reservedGroupRepoStub) GetAccountCount(context.Context, int64) (int64, int64, error) {
+ return 0, 0, nil
+}
+
+func (s *reservedGroupRepoStub) DeleteAccountGroupsByGroupID(context.Context, int64) (int64, error) {
+ panic("unexpected DeleteAccountGroupsByGroupID call")
+}
+
+func (s *reservedGroupRepoStub) GetAccountIDsByGroupIDs(context.Context, []int64) ([]int64, error) {
+ panic("unexpected GetAccountIDsByGroupIDs call")
+}
+
+func (s *reservedGroupRepoStub) BindAccountsToGroup(context.Context, int64, []int64) error {
+ panic("unexpected BindAccountsToGroup call")
+}
+
+func (s *reservedGroupRepoStub) UpdateSortOrders(context.Context, []GroupSortOrderUpdate) error {
+ panic("unexpected UpdateSortOrders call")
+}
+
+func validReservedGroup(name string) *Group {
+ group := &Group{
+ ID: 3,
+ Name: name,
+ Status: StatusActive,
+ Hydrated: true,
+ SubscriptionType: SubscriptionTypeStandard,
+ RateMultiplier: 1,
+ }
+ if name == WalletDefaultVIPGroupName {
+ group.ID = 22
+ group.Platform = PlatformAnthropic
+ group.IsExclusive = true
+ } else {
+ group.Platform = PlatformOpenAI
+ }
+ return group
+}
+
+func requireReservedGroupPolicyError(t *testing.T, err error, detail string) {
+ t.Helper()
+ require.Error(t, err)
+ require.Equal(t, "RESERVED_GROUP_POLICY_VIOLATION", infraerrors.Reason(err))
+ require.Contains(t, infraerrors.Message(err), detail)
+}
+
+func TestAdminService_CreateGroup_RejectsReservedPolicyDrift(t *testing.T) {
+ fallbackID := int64(99)
+ tests := []struct {
+ name string
+ input *CreateGroupInput
+ detail string
+ }{
+ {
+ name: "openai default wrong platform",
+ input: &CreateGroupInput{
+ Name: WalletDefaultOpenAIGroupName, Platform: PlatformAnthropic,
+ RateMultiplier: 1, SubscriptionType: SubscriptionTypeStandard,
+ },
+ detail: "platform must be openai",
+ },
+ {
+ name: "openai default exclusive",
+ input: &CreateGroupInput{
+ Name: WalletDefaultOpenAIGroupName, Platform: PlatformOpenAI,
+ RateMultiplier: 1, SubscriptionType: SubscriptionTypeStandard, IsExclusive: true,
+ },
+ detail: "is_exclusive must be false",
+ },
+ {
+ name: "openai default subscription",
+ input: &CreateGroupInput{
+ Name: WalletDefaultOpenAIGroupName, Platform: PlatformOpenAI,
+ RateMultiplier: 1, SubscriptionType: SubscriptionTypeSubscription,
+ },
+ detail: "subscription_type must be standard",
+ },
+ {
+ name: "vip wrong platform",
+ input: &CreateGroupInput{
+ Name: WalletDefaultVIPGroupName, Platform: PlatformOpenAI,
+ RateMultiplier: 1, SubscriptionType: SubscriptionTypeStandard, IsExclusive: true,
+ },
+ detail: "platform must be anthropic",
+ },
+ {
+ name: "vip not exclusive",
+ input: &CreateGroupInput{
+ Name: WalletDefaultVIPGroupName, Platform: PlatformAnthropic,
+ RateMultiplier: 1, SubscriptionType: SubscriptionTypeStandard,
+ },
+ detail: "is_exclusive must be true",
+ },
+ {
+ name: "vip subscription",
+ input: &CreateGroupInput{
+ Name: WalletDefaultVIPGroupName, Platform: PlatformAnthropic,
+ RateMultiplier: 1, SubscriptionType: SubscriptionTypeSubscription, IsExclusive: true,
+ },
+ detail: "subscription_type must be standard",
+ },
+ {
+ name: "openai default claude code only",
+ input: &CreateGroupInput{
+ Name: WalletDefaultOpenAIGroupName, Platform: PlatformOpenAI,
+ RateMultiplier: 1, SubscriptionType: SubscriptionTypeStandard, ClaudeCodeOnly: true,
+ },
+ detail: "claude_code_only must be false",
+ },
+ {
+ name: "vip client fallback",
+ input: &CreateGroupInput{
+ Name: WalletDefaultVIPGroupName, Platform: PlatformAnthropic,
+ RateMultiplier: 1, SubscriptionType: SubscriptionTypeStandard, IsExclusive: true,
+ FallbackGroupID: &fallbackID,
+ },
+ detail: "fallback_group_id must be unset",
+ },
+ {
+ name: "vip invalid request fallback",
+ input: &CreateGroupInput{
+ Name: WalletDefaultVIPGroupName, Platform: PlatformAnthropic,
+ RateMultiplier: 1, SubscriptionType: SubscriptionTypeStandard, IsExclusive: true,
+ FallbackGroupIDOnInvalidRequest: &fallbackID,
+ },
+ detail: "fallback_group_id_on_invalid_request must be unset",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ repo := &reservedGroupRepoStub{}
+ svc := &adminServiceImpl{groupRepo: repo}
+
+ _, err := svc.CreateGroup(context.Background(), tt.input)
+
+ requireReservedGroupPolicyError(t, err, tt.detail)
+ require.Zero(t, repo.createCalls)
+ })
+ }
+}
+
+func TestAdminService_CreateGroup_AllowsExactReservedPolicies(t *testing.T) {
+ tests := []struct {
+ name string
+ platform string
+ exclusive bool
+ }{
+ {name: WalletDefaultOpenAIGroupName, platform: PlatformOpenAI, exclusive: false},
+ {name: WalletDefaultVIPGroupName, platform: PlatformAnthropic, exclusive: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ repo := &reservedGroupRepoStub{}
+ svc := &adminServiceImpl{groupRepo: repo}
+
+ _, err := svc.CreateGroup(context.Background(), &CreateGroupInput{
+ Name: tt.name, Platform: tt.platform, RateMultiplier: 1,
+ SubscriptionType: SubscriptionTypeStandard, IsExclusive: tt.exclusive,
+ })
+
+ require.NoError(t, err)
+ require.Equal(t, 1, repo.createCalls)
+ })
+ }
+}
+
+func TestAdminService_UpdateGroup_RejectsReservedPolicyDrift(t *testing.T) {
+ wrongPlatform := PlatformAnthropic
+ wrongVIPPlatform := PlatformOpenAI
+ wrongSubscriptionType := SubscriptionTypeSubscription
+ disabled := StatusDisabled
+ exclusive := true
+ nonExclusive := false
+ claudeCodeOnly := true
+ fallbackID := int64(99)
+
+ tests := []struct {
+ name string
+ groupName string
+ input *UpdateGroupInput
+ detail string
+ }{
+ {name: "rename openai default", groupName: WalletDefaultOpenAIGroupName, input: &UpdateGroupInput{Name: "renamed"}, detail: "cannot be renamed"},
+ {name: "disable openai default", groupName: WalletDefaultOpenAIGroupName, input: &UpdateGroupInput{Status: disabled}, detail: "status must be active"},
+ {name: "change openai default platform", groupName: WalletDefaultOpenAIGroupName, input: &UpdateGroupInput{Platform: wrongPlatform}, detail: "platform must be openai"},
+ {name: "change openai default subscription type", groupName: WalletDefaultOpenAIGroupName, input: &UpdateGroupInput{SubscriptionType: wrongSubscriptionType}, detail: "subscription_type must be standard"},
+ {name: "make openai default exclusive", groupName: WalletDefaultOpenAIGroupName, input: &UpdateGroupInput{IsExclusive: &exclusive}, detail: "is_exclusive must be false"},
+ {name: "rename vip", groupName: WalletDefaultVIPGroupName, input: &UpdateGroupInput{Name: "renamed"}, detail: "cannot be renamed"},
+ {name: "disable vip", groupName: WalletDefaultVIPGroupName, input: &UpdateGroupInput{Status: disabled}, detail: "status must be active"},
+ {name: "change vip platform", groupName: WalletDefaultVIPGroupName, input: &UpdateGroupInput{Platform: wrongVIPPlatform}, detail: "platform must be anthropic"},
+ {name: "change vip subscription type", groupName: WalletDefaultVIPGroupName, input: &UpdateGroupInput{SubscriptionType: wrongSubscriptionType}, detail: "subscription_type must be standard"},
+ {name: "make vip nonexclusive", groupName: WalletDefaultVIPGroupName, input: &UpdateGroupInput{IsExclusive: &nonExclusive}, detail: "is_exclusive must be true"},
+ {name: "make openai default claude code only", groupName: WalletDefaultOpenAIGroupName, input: &UpdateGroupInput{ClaudeCodeOnly: &claudeCodeOnly}, detail: "claude_code_only must be false"},
+ {name: "set vip client fallback", groupName: WalletDefaultVIPGroupName, input: &UpdateGroupInput{FallbackGroupID: &fallbackID}, detail: "fallback_group_id must be unset"},
+ {name: "set vip invalid request fallback", groupName: WalletDefaultVIPGroupName, input: &UpdateGroupInput{FallbackGroupIDOnInvalidRequest: &fallbackID}, detail: "fallback_group_id_on_invalid_request must be unset"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ repo := &reservedGroupRepoStub{group: validReservedGroup(tt.groupName)}
+ svc := &adminServiceImpl{groupRepo: repo}
+
+ _, err := svc.UpdateGroup(context.Background(), repo.group.ID, tt.input)
+
+ requireReservedGroupPolicyError(t, err, tt.detail)
+ require.Zero(t, repo.updateCalls)
+ })
+ }
+}
+
+func TestAdminService_UpdateGroup_RejectsAnyRenameIntoReservedName(t *testing.T) {
+ tests := []struct {
+ name string
+ newName string
+ platform string
+ isExclusive bool
+ }{
+ {name: "rename anthropic group into openai default", newName: WalletDefaultOpenAIGroupName, platform: PlatformAnthropic},
+ {name: "rename nonexclusive group into vip", newName: WalletDefaultVIPGroupName, platform: PlatformAnthropic},
+ {name: "rename otherwise compliant group into openai default", newName: WalletDefaultOpenAIGroupName, platform: PlatformOpenAI},
+ {name: "rename otherwise compliant group into vip", newName: WalletDefaultVIPGroupName, platform: PlatformAnthropic, isExclusive: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ repo := &reservedGroupRepoStub{group: &Group{
+ ID: 99,
+ Name: "ordinary-group",
+ Platform: tt.platform,
+ IsExclusive: tt.isExclusive,
+ Status: StatusActive,
+ Hydrated: true,
+ SubscriptionType: SubscriptionTypeStandard,
+ RateMultiplier: 1,
+ }}
+ svc := &adminServiceImpl{groupRepo: repo}
+
+ _, err := svc.UpdateGroup(context.Background(), repo.group.ID, &UpdateGroupInput{Name: tt.newName})
+
+ requireReservedGroupPolicyError(t, err, "cannot be assigned by renaming another group")
+ require.Zero(t, repo.updateCalls)
+ })
+ }
+}
+
+func TestAdminService_UpdateGroup_RejectsUnhydratedReservedGroup(t *testing.T) {
+ group := validReservedGroup(WalletDefaultVIPGroupName)
+ group.Hydrated = false
+ repo := &reservedGroupRepoStub{group: group}
+ svc := &adminServiceImpl{groupRepo: repo}
+
+ _, err := svc.UpdateGroup(context.Background(), group.ID, &UpdateGroupInput{Description: "safe metadata change"})
+
+ requireReservedGroupPolicyError(t, err, "hydrated must be true")
+ require.Zero(t, repo.updateCalls)
+}
+
+func TestAdminService_UpdateGroup_AllowsSafeReservedMetadataChange(t *testing.T) {
+ group := validReservedGroup(WalletDefaultVIPGroupName)
+ repo := &reservedGroupRepoStub{group: group}
+ svc := &adminServiceImpl{groupRepo: repo}
+
+ updated, err := svc.UpdateGroup(context.Background(), group.ID, &UpdateGroupInput{Description: "operator note"})
+
+ require.NoError(t, err)
+ require.Equal(t, 1, repo.updateCalls)
+ require.Equal(t, "operator note", updated.Description)
+}
+
+func TestAdminService_UpdateGroup_AllowsRepairingReservedFallbackDrift(t *testing.T) {
+ fallbackID := int64(99)
+ group := validReservedGroup(WalletDefaultVIPGroupName)
+ group.ClaudeCodeOnly = true
+ group.FallbackGroupID = &fallbackID
+ group.FallbackGroupIDOnInvalidRequest = &fallbackID
+ repo := &reservedGroupRepoStub{group: group}
+ svc := &adminServiceImpl{groupRepo: repo}
+ clear := int64(0)
+ claudeCodeOnly := false
+
+ updated, err := svc.UpdateGroup(context.Background(), group.ID, &UpdateGroupInput{
+ ClaudeCodeOnly: &claudeCodeOnly,
+ FallbackGroupID: &clear,
+ FallbackGroupIDOnInvalidRequest: &clear,
+ })
+
+ require.NoError(t, err)
+ require.Equal(t, 1, repo.updateCalls)
+ require.False(t, updated.ClaudeCodeOnly)
+ require.Nil(t, updated.FallbackGroupID)
+ require.Nil(t, updated.FallbackGroupIDOnInvalidRequest)
+}
+
+func TestAdminService_DeleteGroup_RejectsReservedGroups(t *testing.T) {
+ for _, name := range []string{WalletDefaultOpenAIGroupName, WalletDefaultVIPGroupName} {
+ t.Run(name, func(t *testing.T) {
+ repo := &reservedGroupRepoStub{group: validReservedGroup(name)}
+ svc := &adminServiceImpl{groupRepo: repo}
+
+ err := svc.DeleteGroup(context.Background(), repo.group.ID)
+
+ requireReservedGroupPolicyError(t, err, "cannot be deleted")
+ require.Zero(t, repo.deleteCascadeCalls)
+ })
+ }
+}
+
+func TestGroupService_RejectsReservedGroupCreateUpdateAndDeleteDrift(t *testing.T) {
+ t.Run("create openai-default through anthropic-only legacy path", func(t *testing.T) {
+ repo := &reservedGroupRepoStub{}
+ svc := NewGroupService(repo, nil)
+
+ _, err := svc.Create(context.Background(), CreateGroupRequest{
+ Name: WalletDefaultOpenAIGroupName, RateMultiplier: 1,
+ })
+
+ requireReservedGroupPolicyError(t, err, "platform must be openai")
+ require.Zero(t, repo.createCalls)
+ })
+
+ t.Run("disable vip", func(t *testing.T) {
+ repo := &reservedGroupRepoStub{group: validReservedGroup(WalletDefaultVIPGroupName)}
+ svc := NewGroupService(repo, nil)
+
+ status := StatusDisabled
+ _, err := svc.Update(context.Background(), repo.group.ID, UpdateGroupRequest{Status: &status})
+
+ requireReservedGroupPolicyError(t, err, "status must be active")
+ require.Zero(t, repo.updateCalls)
+ })
+
+ t.Run("delete vip", func(t *testing.T) {
+ repo := &reservedGroupRepoStub{group: validReservedGroup(WalletDefaultVIPGroupName)}
+ svc := NewGroupService(repo, nil)
+
+ err := svc.Delete(context.Background(), repo.group.ID)
+
+ requireReservedGroupPolicyError(t, err, "cannot be deleted")
+ require.Zero(t, repo.deleteCalls)
+ })
+}
diff --git a/backend/internal/service/scheduler_cache.go b/backend/internal/service/scheduler_cache.go
index f9794c82143..584da2afd86 100644
--- a/backend/internal/service/scheduler_cache.go
+++ b/backend/internal/service/scheduler_cache.go
@@ -9,9 +9,10 @@ import (
)
const (
- SchedulerModeSingle = "single"
- SchedulerModeMixed = "mixed"
- SchedulerModeForced = "forced"
+ SchedulerModeSingle = "single"
+ SchedulerModeMixed = "mixed"
+ SchedulerModeForced = "forced"
+ SchedulerMetadataAPIKeyConfigured = "_scheduler_api_key_configured"
)
type SchedulerBucket struct {
diff --git a/backend/internal/service/scheduler_snapshot_hydration_test.go b/backend/internal/service/scheduler_snapshot_hydration_test.go
index 0b32c2ade96..8746c53abc6 100644
--- a/backend/internal/service/scheduler_snapshot_hydration_test.go
+++ b/backend/internal/service/scheduler_snapshot_hydration_test.go
@@ -13,6 +13,16 @@ type snapshotHydrationCache struct {
accounts map[int64]*Account
}
+func TestSchedulerAccountHasConfiguredAPIKeyAcceptsEncryptedCacheMarker(t *testing.T) {
+ metadata := &Account{Credentials: map[string]any{SchedulerMetadataAPIKeyConfigured: true}}
+ if !schedulerAccountHasConfiguredAPIKey(metadata) {
+ t.Fatal("scheduler metadata marker should preserve API-key account ranking without storing the key")
+ }
+ if schedulerAccountHasConfiguredAPIKey(&Account{Credentials: map[string]any{}}) {
+ t.Fatal("missing key and marker must not be treated as configured")
+ }
+}
+
func (c *snapshotHydrationCache) GetSnapshot(ctx context.Context, bucket SchedulerBucket) ([]*Account, bool, error) {
return c.snapshot, true, nil
}
diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go
index a68cdf0c775..ceb17224b07 100644
--- a/backend/internal/service/scheduler_snapshot_service.go
+++ b/backend/internal/service/scheduler_snapshot_service.go
@@ -105,7 +105,7 @@ func (s *SchedulerSnapshotService) Stop() {
}
func (s *SchedulerSnapshotService) ListSchedulableAccounts(ctx context.Context, groupID *int64, platform string, hasForcePlatform bool) ([]Account, bool, error) {
- useMixed := (platform == PlatformAnthropic || platform == PlatformGemini) && !hasForcePlatform
+ useMixed := useMixedSchedulingForPlatform(platform, hasForcePlatform)
mode := s.resolveMode(platform, hasForcePlatform)
bucket := s.bucketFor(groupID, platform, mode)
@@ -465,6 +465,11 @@ func (s *SchedulerSnapshotService) rebuildByAccount(ctx context.Context, account
if err := s.rebuildBucketsForPlatform(ctx, account.Platform, groupIDs, reason, seen); err != nil && firstErr == nil {
firstErr = err
}
+ if account.Platform == PlatformKiro {
+ if err := s.rebuildBucketsForPlatform(ctx, PlatformAnthropic, groupIDs, reason, seen); err != nil && firstErr == nil {
+ firstErr = err
+ }
+ }
if account.Platform == PlatformAntigravity && account.IsMixedSchedulingEnabled() {
if err := s.rebuildBucketsForPlatform(ctx, PlatformAnthropic, groupIDs, reason, seen); err != nil && firstErr == nil {
firstErr = err
@@ -481,7 +486,7 @@ func (s *SchedulerSnapshotService) rebuildByGroupIDs(ctx context.Context, groupI
if len(groupIDs) == 0 {
return nil
}
- platforms := []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity}
+ platforms := []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformKiro, PlatformCursor}
var firstErr error
for _, platform := range platforms {
if err := s.rebuildBucketsForPlatform(ctx, platform, groupIDs, reason, seen); err != nil && firstErr == nil {
@@ -643,7 +648,7 @@ func (s *SchedulerSnapshotService) loadAccountsFromDB(ctx context.Context, bucke
}
if useMixed {
- platforms := []string{bucket.Platform, PlatformAntigravity}
+ platforms := mixedSchedulingPlatforms(bucket.Platform)
var accounts []Account
var err error
if groupID > 0 {
@@ -657,11 +662,11 @@ func (s *SchedulerSnapshotService) loadAccountsFromDB(ctx context.Context, bucke
return nil, err
}
filtered := make([]Account, 0, len(accounts))
- for _, acc := range accounts {
- if acc.Platform == PlatformAntigravity && !acc.IsMixedSchedulingEnabled() {
+ for i := range accounts {
+ if !isAccountAllowedInMixedScheduling(&accounts[i], bucket.Platform) {
continue
}
- filtered = append(filtered, acc)
+ filtered = append(filtered, accounts[i])
}
return filtered, nil
}
@@ -722,7 +727,7 @@ func (s *SchedulerSnapshotService) resolveMode(platform string, hasForcePlatform
if hasForcePlatform {
return SchedulerModeForced
}
- if platform == PlatformAnthropic || platform == PlatformGemini {
+ if useMixedSchedulingForPlatform(platform, false) {
return SchedulerModeMixed
}
return SchedulerModeSingle
@@ -783,7 +788,7 @@ func (s *SchedulerSnapshotService) fullRebuildInterval() time.Duration {
func (s *SchedulerSnapshotService) defaultBuckets(ctx context.Context) ([]SchedulerBucket, error) {
buckets := make([]SchedulerBucket, 0)
- platforms := []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity}
+ platforms := []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformCursor}
for _, platform := range platforms {
buckets = append(buckets, SchedulerBucket{GroupID: 0, Platform: platform, Mode: SchedulerModeSingle})
buckets = append(buckets, SchedulerBucket{GroupID: 0, Platform: platform, Mode: SchedulerModeForced})
diff --git a/backend/internal/service/secret_domains.go b/backend/internal/service/secret_domains.go
new file mode 100644
index 00000000000..8e1cbf7a632
--- /dev/null
+++ b/backend/internal/service/secret_domains.go
@@ -0,0 +1,38 @@
+package service
+
+import "fmt"
+
+const (
+ SecretDomainTOTP = "totp-secret"
+ SecretDomainTOTPCache = "totp-cache"
+ SecretDomainAccountCredential = "account-credential"
+ SecretDomainBackupS3 = "backup-s3"
+ SecretDomainContentModeration = "content-moderation"
+ SecretDomainChannelMonitor = "channel-monitor"
+ SecretDomainPaymentProvider = "payment-provider"
+ SecretDomainProxyCredential = "proxy-credential"
+ SecretDomainSchedulerCache = "scheduler-cache"
+ SecretDomainOAuthTokenCache = "oauth-token-cache"
+ SecretDomainJWTHMAC = "jwt-hmac"
+ SecretDomainSettingSecret = "setting-secret"
+)
+
+// EncryptForSecretDomain refuses generic encryptors so runtime writes cannot
+// silently fall back to relocatable legacy ciphertext.
+func EncryptForSecretDomain(encryptor SecretEncryptor, domain, plaintext string) (string, error) {
+ domainEncryptor, ok := encryptor.(DomainSecretEncryptor)
+ if !ok || domainEncryptor == nil {
+ return "", fmt.Errorf("domain secret encryptor is required for %s", domain)
+ }
+ return domainEncryptor.EncryptForDomain(domain, plaintext)
+}
+
+// DecryptForSecretDomain refuses legacy ciphertext. The startup security
+// migration is the only code allowed to use generic Decrypt.
+func DecryptForSecretDomain(encryptor SecretEncryptor, domain, ciphertext string) (string, error) {
+ domainEncryptor, ok := encryptor.(DomainSecretEncryptor)
+ if !ok || domainEncryptor == nil {
+ return "", fmt.Errorf("domain secret encryptor is required for %s", domain)
+ }
+ return domainEncryptor.DecryptForDomain(domain, ciphertext)
+}
diff --git a/backend/internal/service/sensitive_logging_residual_security_test.go b/backend/internal/service/sensitive_logging_residual_security_test.go
new file mode 100644
index 00000000000..ac70bbd42a5
--- /dev/null
+++ b/backend/internal/service/sensitive_logging_residual_security_test.go
@@ -0,0 +1,34 @@
+//go:build unit
+
+package service
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestAntigravityTransformErrorLogOmitsResponseBody(t *testing.T) {
+ secret := "tenant-secret-in-gemini-response"
+ sink, restore := captureStructuredLog(t)
+ defer restore()
+
+ logAntigravityTransformError(errors.New("synthetic transform failure"), []byte(`{"text":"`+secret+`"}`))
+
+ require.False(t, sink.ContainsMessage(secret))
+ require.True(t, sink.ContainsMessage("transform_error"))
+ require.True(t, sink.ContainsMessage("body_bytes="))
+}
+
+func TestWebSearchExecutionLogOmitsTenantQuery(t *testing.T) {
+ secret := "private medical search query"
+ sink, restore := captureStructuredLog(t)
+ defer restore()
+
+ logWebSearchExecution(&Account{ID: 7, Name: "search-account"}, secret)
+
+ require.False(t, sink.ContainsMessage(secret))
+ require.False(t, sink.ContainsFieldValue("query", secret))
+ require.True(t, sink.ContainsFieldValue("query_bytes", "28"))
+}
diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go
index 2bae686ae54..92f9b5c9583 100644
--- a/backend/internal/service/setting_service.go
+++ b/backend/internal/service/setting_service.go
@@ -3,12 +3,15 @@ package service
import (
"context"
"crypto/rand"
+ "crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
+ "io"
"log/slog"
"math"
+ "net/http"
"net/url"
"sort"
"strconv"
@@ -18,7 +21,8 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/imroc/req/v3"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
+ "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
"golang.org/x/sync/singleflight"
)
@@ -129,6 +133,8 @@ type AuthSourceDefaultSettings struct {
LinuxDo ProviderDefaultGrantSettings
OIDC ProviderDefaultGrantSettings
WeChat ProviderDefaultGrantSettings
+ GitHub ProviderDefaultGrantSettings
+ Google ProviderDefaultGrantSettings
ForceEmailOnThirdPartySignup bool
}
@@ -169,6 +175,20 @@ var (
grantOnSignup: SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
grantOnFirstBind: SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
}
+ gitHubAuthSourceDefaultKeys = authSourceDefaultKeySet{
+ balance: SettingKeyAuthSourceDefaultGitHubBalance,
+ concurrency: SettingKeyAuthSourceDefaultGitHubConcurrency,
+ subscriptions: SettingKeyAuthSourceDefaultGitHubSubscriptions,
+ grantOnSignup: SettingKeyAuthSourceDefaultGitHubGrantOnSignup,
+ grantOnFirstBind: SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind,
+ }
+ googleAuthSourceDefaultKeys = authSourceDefaultKeySet{
+ balance: SettingKeyAuthSourceDefaultGoogleBalance,
+ concurrency: SettingKeyAuthSourceDefaultGoogleConcurrency,
+ subscriptions: SettingKeyAuthSourceDefaultGoogleSubscriptions,
+ grantOnSignup: SettingKeyAuthSourceDefaultGoogleGrantOnSignup,
+ grantOnFirstBind: SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind,
+ }
)
const (
@@ -177,8 +197,151 @@ const (
defaultWeChatConnectMode = "open"
defaultWeChatConnectScopes = "snsapi_login"
defaultWeChatConnectFrontend = "/auth/wechat/callback"
+ defaultGitHubOAuthAuthorize = "https://github.com/login/oauth/authorize"
+ defaultGitHubOAuthToken = "https://github.com/login/oauth/access_token"
+ defaultGitHubOAuthUserInfo = "https://api.github.com/user"
+ defaultGitHubOAuthEmails = "https://api.github.com/user/emails"
+ defaultGitHubOAuthScopes = "read:user user:email"
+ defaultGitHubOAuthFrontend = "/auth/oauth/callback"
+ defaultGoogleOAuthAuthorize = "https://accounts.google.com/o/oauth2/v2/auth"
+ defaultGoogleOAuthToken = "https://oauth2.googleapis.com/token"
+ defaultGoogleOAuthUserInfo = "https://openidconnect.googleapis.com/v1/userinfo"
+ defaultGoogleOAuthScopes = "openid email profile"
+ defaultGoogleOAuthFrontend = "/auth/oauth/callback"
+ defaultLoginAgreementMode = "modal"
+ defaultLoginAgreementDate = "2026-03-31"
)
+func normalizeLoginAgreementMode(raw string) string {
+ switch strings.ToLower(strings.TrimSpace(raw)) {
+ case "checkbox":
+ return "checkbox"
+ default:
+ return defaultLoginAgreementMode
+ }
+}
+
+func defaultLoginAgreementDocuments() []LoginAgreementDocument {
+ return []LoginAgreementDocument{
+ {
+ ID: "terms",
+ Title: "服务条款",
+ ContentMD: "",
+ },
+ {
+ ID: "usage-policy",
+ Title: "使用政策",
+ ContentMD: "",
+ },
+ {
+ ID: "supported-regions",
+ Title: "支持的国家和地区",
+ ContentMD: "",
+ },
+ {
+ ID: "service-specific-terms",
+ Title: "服务特定条款",
+ ContentMD: "",
+ },
+ }
+}
+
+func normalizeLoginAgreementDocumentID(raw string) string {
+ raw = strings.ToLower(strings.TrimSpace(raw))
+ var b strings.Builder
+ lastSeparator := false
+ for _, r := range raw {
+ if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
+ _, _ = b.WriteRune(r)
+ lastSeparator = false
+ continue
+ }
+ if r == '-' || r == '_' || r == ' ' || r == '.' || r == '/' {
+ if !lastSeparator && b.Len() > 0 {
+ if r == '_' {
+ _, _ = b.WriteRune('_')
+ } else {
+ _, _ = b.WriteRune('-')
+ }
+ lastSeparator = true
+ }
+ }
+ }
+ return strings.Trim(b.String(), "-_")
+}
+
+func normalizeLoginAgreementDocuments(docs []LoginAgreementDocument) []LoginAgreementDocument {
+ normalized := make([]LoginAgreementDocument, 0, len(docs))
+ seen := make(map[string]int, len(docs))
+ for i, doc := range docs {
+ title := strings.TrimSpace(doc.Title)
+ content := strings.TrimSpace(doc.ContentMD)
+ if title == "" && content == "" {
+ continue
+ }
+ id := normalizeLoginAgreementDocumentID(doc.ID)
+ if id == "" {
+ sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%s:%s", i, title, content)))
+ id = hex.EncodeToString(sum[:])[:12]
+ }
+ baseID := id
+ for suffix := 2; seen[id] > 0; suffix++ {
+ id = fmt.Sprintf("%s-%d", baseID, suffix)
+ }
+ seen[id]++
+ normalized = append(normalized, LoginAgreementDocument{
+ ID: id,
+ Title: title,
+ ContentMD: content,
+ })
+ }
+ return normalized
+}
+
+func parseLoginAgreementDocuments(raw string) []LoginAgreementDocument {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return defaultLoginAgreementDocuments()
+ }
+ var docs []LoginAgreementDocument
+ if err := json.Unmarshal([]byte(raw), &docs); err != nil {
+ return defaultLoginAgreementDocuments()
+ }
+ docs = normalizeLoginAgreementDocuments(docs)
+ if len(docs) == 0 {
+ return defaultLoginAgreementDocuments()
+ }
+ return docs
+}
+
+func marshalLoginAgreementDocuments(docs []LoginAgreementDocument) (string, error) {
+ normalized := normalizeLoginAgreementDocuments(docs)
+ if len(normalized) == 0 {
+ normalized = defaultLoginAgreementDocuments()
+ }
+ b, err := json.Marshal(normalized)
+ if err != nil {
+ return "", fmt.Errorf("marshal login agreement documents: %w", err)
+ }
+ return string(b), nil
+}
+
+func buildLoginAgreementRevision(updatedAt string, docs []LoginAgreementDocument) string {
+ normalized := normalizeLoginAgreementDocuments(docs)
+ payload, err := json.Marshal(struct {
+ UpdatedAt string `json:"updated_at"`
+ Documents []LoginAgreementDocument `json:"documents"`
+ }{
+ UpdatedAt: strings.TrimSpace(updatedAt),
+ Documents: normalized,
+ })
+ if err != nil {
+ payload = []byte(strings.TrimSpace(updatedAt))
+ }
+ sum := sha256.Sum256(payload)
+ return hex.EncodeToString(sum[:])[:16]
+}
+
func normalizeWeChatConnectModeSetting(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "mp":
@@ -411,6 +574,10 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyPasswordResetEnabled,
SettingKeyInvitationCodeEnabled,
SettingKeyTotpEnabled,
+ SettingKeyLoginAgreementEnabled,
+ SettingKeyLoginAgreementMode,
+ SettingKeyLoginAgreementUpdatedAt,
+ SettingKeyLoginAgreementDocuments,
SettingKeyTurnstileEnabled,
SettingKeyTurnstileSiteKey,
SettingKeySiteName,
@@ -448,6 +615,12 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingPaymentEnabled,
SettingKeyOIDCConnectEnabled,
SettingKeyOIDCConnectProviderName,
+ SettingKeyGitHubOAuthEnabled,
+ SettingKeyGitHubOAuthClientID,
+ SettingKeyGitHubOAuthClientSecret,
+ SettingKeyGoogleOAuthEnabled,
+ SettingKeyGoogleOAuthClientID,
+ SettingKeyGoogleOAuthClientSecret,
SettingKeyBalanceLowNotifyEnabled,
SettingKeyBalanceLowNotifyThreshold,
SettingKeyBalanceLowNotifyRechargeURL,
@@ -456,6 +629,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyChannelMonitorDefaultIntervalSeconds,
SettingKeyAvailableChannelsEnabled,
SettingKeyAffiliateEnabled,
+ SettingKeyRiskControlEnabled,
}
settings, err := s.settingRepo.GetMultiple(ctx, keys)
@@ -482,6 +656,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
if oidcProviderName == "" {
oidcProviderName = "OIDC"
}
+ gitHubEnabled := s.emailOAuthPublicEnabled(settings, "github")
+ googleEnabled := s.emailOAuthPublicEnabled(settings, "google")
weChatEnabled, weChatOpenEnabled, weChatMPEnabled, weChatMobileEnabled := s.weChatOAuthCapabilitiesFromSettings(settings)
// Password reset requires email verification to be enabled
@@ -494,6 +670,11 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
settings[SettingKeyTableDefaultPageSize],
settings[SettingKeyTablePageSizeOptions],
)
+ loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments])
+ loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt])
+ if loginAgreementUpdatedAt == "" {
+ loginAgreementUpdatedAt = defaultLoginAgreementDate
+ }
var balanceLowNotifyThreshold float64
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
@@ -509,11 +690,16 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
PasswordResetEnabled: passwordResetEnabled,
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
+ LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0,
+ LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]),
+ LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
+ LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments),
+ LoginAgreementDocuments: loginAgreementDocuments,
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
SiteLogo: settings[SettingKeySiteLogo],
- SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
+ SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "按需充值的 AI API 中转服务"),
APIBaseURL: settings[SettingKeyAPIBaseURL],
ContactInfo: settings[SettingKeyContactInfo],
DocURL: settings[SettingKeyDocURL],
@@ -534,6 +720,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
PaymentEnabled: settings[SettingPaymentEnabled] == "true",
OIDCOAuthEnabled: oidcEnabled,
OIDCOAuthProviderName: oidcProviderName,
+ GitHubOAuthEnabled: gitHubEnabled,
+ GoogleOAuthEnabled: googleEnabled,
BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
@@ -545,9 +733,24 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
+
+ RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true",
}, nil
}
+const PublicSiteLogoAssetPath = "/api/v1/settings/site-logo"
+
+// PublicSiteLogoReference returns a lightweight public logo reference. Stored
+// data-image logos can be hundreds of KB and must not be inlined into every
+// HTML response or public settings JSON.
+func PublicSiteLogoReference(raw string) string {
+ trimmed := strings.TrimSpace(raw)
+ if strings.HasPrefix(strings.ToLower(trimmed), "data:image/") {
+ return PublicSiteLogoAssetPath
+ }
+ return trimmed
+}
+
// channelMonitorIntervalMin / channelMonitorIntervalMax bound the default interval
// (mirrors the monitor-level constraint but lives here so setting_service stays decoupled).
const (
@@ -647,43 +850,50 @@ func (s *SettingService) SetVersion(version string) {
// A unit test diffs this struct's JSON keys against dto.PublicSettings to catch
// drift automatically (see setting_service_injection_test.go).
type PublicSettingsInjectionPayload struct {
- RegistrationEnabled bool `json:"registration_enabled"`
- EmailVerifyEnabled bool `json:"email_verify_enabled"`
- RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
- PromoCodeEnabled bool `json:"promo_code_enabled"`
- PasswordResetEnabled bool `json:"password_reset_enabled"`
- InvitationCodeEnabled bool `json:"invitation_code_enabled"`
- TotpEnabled bool `json:"totp_enabled"`
- TurnstileEnabled bool `json:"turnstile_enabled"`
- TurnstileSiteKey string `json:"turnstile_site_key"`
- SiteName string `json:"site_name"`
- SiteLogo string `json:"site_logo"`
- SiteSubtitle string `json:"site_subtitle"`
- APIBaseURL string `json:"api_base_url"`
- ContactInfo string `json:"contact_info"`
- DocURL string `json:"doc_url"`
- HomeContent string `json:"home_content"`
- HideCcsImportButton bool `json:"hide_ccs_import_button"`
- PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
- PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
- TableDefaultPageSize int `json:"table_default_page_size"`
- TablePageSizeOptions []int `json:"table_page_size_options"`
- CustomMenuItems json.RawMessage `json:"custom_menu_items"`
- CustomEndpoints json.RawMessage `json:"custom_endpoints"`
- LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
- WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
- WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
- WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
- WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
- OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
- OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
- BackendModeEnabled bool `json:"backend_mode_enabled"`
- PaymentEnabled bool `json:"payment_enabled"`
- Version string `json:"version"`
- BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
- AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
- BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
- BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
+ RegistrationEnabled bool `json:"registration_enabled"`
+ EmailVerifyEnabled bool `json:"email_verify_enabled"`
+ RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
+ PromoCodeEnabled bool `json:"promo_code_enabled"`
+ PasswordResetEnabled bool `json:"password_reset_enabled"`
+ InvitationCodeEnabled bool `json:"invitation_code_enabled"`
+ TotpEnabled bool `json:"totp_enabled"`
+ LoginAgreementEnabled bool `json:"login_agreement_enabled"`
+ LoginAgreementMode string `json:"login_agreement_mode"`
+ LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
+ LoginAgreementRevision string `json:"login_agreement_revision"`
+ LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
+ TurnstileEnabled bool `json:"turnstile_enabled"`
+ TurnstileSiteKey string `json:"turnstile_site_key"`
+ SiteName string `json:"site_name"`
+ SiteLogo string `json:"site_logo"`
+ SiteSubtitle string `json:"site_subtitle"`
+ APIBaseURL string `json:"api_base_url"`
+ ContactInfo string `json:"contact_info"`
+ DocURL string `json:"doc_url"`
+ HomeContent string `json:"home_content"`
+ HideCcsImportButton bool `json:"hide_ccs_import_button"`
+ PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
+ PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
+ TableDefaultPageSize int `json:"table_default_page_size"`
+ TablePageSizeOptions []int `json:"table_page_size_options"`
+ CustomMenuItems json.RawMessage `json:"custom_menu_items"`
+ CustomEndpoints json.RawMessage `json:"custom_endpoints"`
+ LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
+ WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
+ WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
+ WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
+ WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
+ OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
+ OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
+ GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
+ GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
+ BackendModeEnabled bool `json:"backend_mode_enabled"`
+ PaymentEnabled bool `json:"payment_enabled"`
+ Version string `json:"version"`
+ BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
+ AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
+ BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
+ BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
// Feature flags — MUST match the opt-in/opt-out registry in
// frontend/src/utils/featureFlags.ts. Missing a field here is the bug
@@ -692,6 +902,7 @@ type PublicSettingsInjectionPayload struct {
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
AffiliateEnabled bool `json:"affiliate_enabled"`
+ RiskControlEnabled bool `json:"risk_control_enabled"`
}
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection.
@@ -710,10 +921,15 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
+ LoginAgreementEnabled: settings.LoginAgreementEnabled,
+ LoginAgreementMode: settings.LoginAgreementMode,
+ LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
+ LoginAgreementRevision: settings.LoginAgreementRevision,
+ LoginAgreementDocuments: settings.LoginAgreementDocuments,
TurnstileEnabled: settings.TurnstileEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey,
SiteName: settings.SiteName,
- SiteLogo: settings.SiteLogo,
+ SiteLogo: PublicSiteLogoReference(settings.SiteLogo),
SiteSubtitle: settings.SiteSubtitle,
APIBaseURL: settings.APIBaseURL,
ContactInfo: settings.ContactInfo,
@@ -733,6 +949,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
+ GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
+ GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
BackendModeEnabled: settings.BackendModeEnabled,
PaymentEnabled: settings.PaymentEnabled,
Version: s.version,
@@ -745,6 +963,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
AffiliateEnabled: settings.AffiliateEnabled,
+ RiskControlEnabled: settings.RiskControlEnabled,
}, nil
}
@@ -806,6 +1025,98 @@ func (s *SettingService) weChatOAuthCapabilitiesFromSettings(settings map[string
return openReady || mpReady, openReady, mpReady, mobileReady
}
+func (s *SettingService) emailOAuthBaseConfig(provider string) config.EmailOAuthProviderConfig {
+ switch strings.ToLower(strings.TrimSpace(provider)) {
+ case "github":
+ cfg := config.EmailOAuthProviderConfig{
+ AuthorizeURL: defaultGitHubOAuthAuthorize,
+ TokenURL: defaultGitHubOAuthToken,
+ UserInfoURL: defaultGitHubOAuthUserInfo,
+ EmailsURL: defaultGitHubOAuthEmails,
+ Scopes: defaultGitHubOAuthScopes,
+ FrontendRedirectURL: defaultGitHubOAuthFrontend,
+ }
+ if s != nil && s.cfg != nil {
+ cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GitHubOAuth)
+ }
+ return cfg
+ case "google":
+ cfg := config.EmailOAuthProviderConfig{
+ AuthorizeURL: defaultGoogleOAuthAuthorize,
+ TokenURL: defaultGoogleOAuthToken,
+ UserInfoURL: defaultGoogleOAuthUserInfo,
+ Scopes: defaultGoogleOAuthScopes,
+ FrontendRedirectURL: defaultGoogleOAuthFrontend,
+ }
+ if s != nil && s.cfg != nil {
+ cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GoogleOAuth)
+ }
+ return cfg
+ default:
+ return config.EmailOAuthProviderConfig{}
+ }
+}
+
+func mergeEmailOAuthBaseConfig(base, override config.EmailOAuthProviderConfig) config.EmailOAuthProviderConfig {
+ base.Enabled = override.Enabled
+ if strings.TrimSpace(override.ClientID) != "" {
+ base.ClientID = strings.TrimSpace(override.ClientID)
+ }
+ if strings.TrimSpace(override.ClientSecret) != "" {
+ base.ClientSecret = strings.TrimSpace(override.ClientSecret)
+ }
+ if strings.TrimSpace(override.AuthorizeURL) != "" {
+ base.AuthorizeURL = strings.TrimSpace(override.AuthorizeURL)
+ }
+ if strings.TrimSpace(override.TokenURL) != "" {
+ base.TokenURL = strings.TrimSpace(override.TokenURL)
+ }
+ if strings.TrimSpace(override.UserInfoURL) != "" {
+ base.UserInfoURL = strings.TrimSpace(override.UserInfoURL)
+ }
+ if strings.TrimSpace(override.EmailsURL) != "" {
+ base.EmailsURL = strings.TrimSpace(override.EmailsURL)
+ }
+ if strings.TrimSpace(override.Scopes) != "" {
+ base.Scopes = strings.TrimSpace(override.Scopes)
+ }
+ if strings.TrimSpace(override.RedirectURL) != "" {
+ base.RedirectURL = strings.TrimSpace(override.RedirectURL)
+ }
+ if strings.TrimSpace(override.FrontendRedirectURL) != "" {
+ base.FrontendRedirectURL = strings.TrimSpace(override.FrontendRedirectURL)
+ }
+ return base
+}
+
+func (s *SettingService) emailOAuthPublicEnabled(settings map[string]string, provider string) bool {
+ cfg := s.effectiveEmailOAuthConfig(settings, provider)
+ return cfg.Enabled && strings.TrimSpace(cfg.ClientID) != "" && strings.TrimSpace(cfg.ClientSecret) != ""
+}
+
+func (s *SettingService) effectiveEmailOAuthConfig(settings map[string]string, provider string) config.EmailOAuthProviderConfig {
+ cfg := s.emailOAuthBaseConfig(provider)
+ switch strings.ToLower(strings.TrimSpace(provider)) {
+ case "github":
+ if raw, ok := settings[SettingKeyGitHubOAuthEnabled]; ok {
+ cfg.Enabled = raw == "true"
+ }
+ cfg.ClientID = firstNonEmpty(settings[SettingKeyGitHubOAuthClientID], cfg.ClientID)
+ cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGitHubOAuthClientSecret], cfg.ClientSecret)
+ cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthRedirectURL], cfg.RedirectURL)
+ cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGitHubOAuthFrontend)
+ case "google":
+ if raw, ok := settings[SettingKeyGoogleOAuthEnabled]; ok {
+ cfg.Enabled = raw == "true"
+ }
+ cfg.ClientID = firstNonEmpty(settings[SettingKeyGoogleOAuthClientID], cfg.ClientID)
+ cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGoogleOAuthClientSecret], cfg.ClientSecret)
+ cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthRedirectURL], cfg.RedirectURL)
+ cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGoogleOAuthFrontend)
+ }
+ return cfg
+}
+
// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON
// array string, returning only items with visibility != "admin".
func filterUserVisibleMenuItems(raw string) json.RawMessage {
@@ -990,6 +1301,12 @@ func (s *SettingService) OIDCSecurityWriteDefaults(ctx context.Context) (bool, b
// UpdateSettingsWithAuthSourceDefaults persists system settings and auth-source defaults in a single write.
func (s *SettingService) UpdateSettingsWithAuthSourceDefaults(ctx context.Context, settings *SystemSettings, authDefaults *AuthSourceDefaultSettings) error {
+ return s.UpdateSettingsWithAuthSourceDefaultsAndAdditional(ctx, settings, authDefaults, nil)
+}
+
+// UpdateSettingsWithAuthSourceDefaultsAndAdditional persists every settings
+// domain through one repository bulk write. additional must already be validated.
+func (s *SettingService) UpdateSettingsWithAuthSourceDefaultsAndAdditional(ctx context.Context, settings *SystemSettings, authDefaults *AuthSourceDefaultSettings, additional map[string]string) error {
updates, err := s.buildSystemSettingsUpdates(ctx, settings)
if err != nil {
return err
@@ -1002,6 +1319,9 @@ func (s *SettingService) UpdateSettingsWithAuthSourceDefaults(ctx context.Contex
for key, value := range authSourceUpdates {
updates[key] = value
}
+ for key, value := range additional {
+ updates[key] = value
+ }
err = s.settingRepo.SetMultiple(ctx, updates)
if err == nil {
@@ -1052,6 +1372,16 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
if settings.WeChatConnectFrontendRedirectURL == "" {
settings.WeChatConnectFrontendRedirectURL = defaultWeChatConnectFrontend
}
+ settings.GitHubOAuthRedirectURL = strings.TrimSpace(settings.GitHubOAuthRedirectURL)
+ settings.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(settings.GitHubOAuthFrontendRedirectURL)
+ if settings.GitHubOAuthFrontendRedirectURL == "" {
+ settings.GitHubOAuthFrontendRedirectURL = defaultGitHubOAuthFrontend
+ }
+ settings.GoogleOAuthRedirectURL = strings.TrimSpace(settings.GoogleOAuthRedirectURL)
+ settings.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(settings.GoogleOAuthFrontendRedirectURL)
+ if settings.GoogleOAuthFrontendRedirectURL == "" {
+ settings.GoogleOAuthFrontendRedirectURL = defaultGoogleOAuthFrontend
+ }
updates := make(map[string]string)
@@ -1068,6 +1398,19 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyFrontendURL] = settings.FrontendURL
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled)
+ settings.LoginAgreementMode = normalizeLoginAgreementMode(settings.LoginAgreementMode)
+ settings.LoginAgreementUpdatedAt = strings.TrimSpace(settings.LoginAgreementUpdatedAt)
+ if settings.LoginAgreementUpdatedAt == "" {
+ settings.LoginAgreementUpdatedAt = defaultLoginAgreementDate
+ }
+ loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(settings.LoginAgreementDocuments)
+ if err != nil {
+ return nil, err
+ }
+ updates[SettingKeyLoginAgreementEnabled] = strconv.FormatBool(settings.LoginAgreementEnabled)
+ updates[SettingKeyLoginAgreementMode] = settings.LoginAgreementMode
+ updates[SettingKeyLoginAgreementUpdatedAt] = settings.LoginAgreementUpdatedAt
+ updates[SettingKeyLoginAgreementDocuments] = loginAgreementDocumentsJSON
// 邮件服务设置(只有非空才更新密码)
updates[SettingKeySMTPHost] = settings.SMTPHost
@@ -1121,6 +1464,22 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret
}
+ // GitHub / Google 邮箱快捷登录
+ updates[SettingKeyGitHubOAuthEnabled] = strconv.FormatBool(settings.GitHubOAuthEnabled)
+ updates[SettingKeyGitHubOAuthClientID] = strings.TrimSpace(settings.GitHubOAuthClientID)
+ updates[SettingKeyGitHubOAuthRedirectURL] = settings.GitHubOAuthRedirectURL
+ updates[SettingKeyGitHubOAuthFrontendRedirectURL] = settings.GitHubOAuthFrontendRedirectURL
+ if settings.GitHubOAuthClientSecret != "" {
+ updates[SettingKeyGitHubOAuthClientSecret] = strings.TrimSpace(settings.GitHubOAuthClientSecret)
+ }
+ updates[SettingKeyGoogleOAuthEnabled] = strconv.FormatBool(settings.GoogleOAuthEnabled)
+ updates[SettingKeyGoogleOAuthClientID] = strings.TrimSpace(settings.GoogleOAuthClientID)
+ updates[SettingKeyGoogleOAuthRedirectURL] = settings.GoogleOAuthRedirectURL
+ updates[SettingKeyGoogleOAuthFrontendRedirectURL] = settings.GoogleOAuthFrontendRedirectURL
+ if settings.GoogleOAuthClientSecret != "" {
+ updates[SettingKeyGoogleOAuthClientSecret] = strings.TrimSpace(settings.GoogleOAuthClientSecret)
+ }
+
// WeChat Connect OAuth 登录
updates[SettingKeyWeChatConnectEnabled] = strconv.FormatBool(settings.WeChatConnectEnabled)
updates[SettingKeyWeChatConnectAppID] = settings.WeChatConnectAppID
@@ -1232,6 +1591,9 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
// Affiliate (邀请返利) feature switch
updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled)
+ // 风控中心功能开关
+ updates[SettingKeyRiskControlEnabled] = strconv.FormatBool(settings.RiskControlEnabled)
+
// Claude Code version check
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion
@@ -1273,17 +1635,21 @@ func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, sett
settings.LinuxDo.Subscriptions,
settings.OIDC.Subscriptions,
settings.WeChat.Subscriptions,
+ settings.GitHub.Subscriptions,
+ settings.Google.Subscriptions,
} {
if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil {
return nil, err
}
}
- updates := make(map[string]string, 21)
+ updates := make(map[string]string, 31)
writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email)
writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo)
writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC)
writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat)
+ writeProviderDefaultGrantUpdates(updates, gitHubAuthSourceDefaultKeys, settings.GitHub)
+ writeProviderDefaultGrantUpdates(updates, googleAuthSourceDefaultKeys, settings.Google)
updates[SettingKeyForceEmailOnThirdPartySignup] = strconv.FormatBool(settings.ForceEmailOnThirdPartySignup)
return updates, nil
}
@@ -1362,6 +1728,61 @@ func (s *SettingService) validateDefaultSubscriptionGroups(ctx context.Context,
return nil
}
+func (s *SettingService) GetEmailOAuthProviderConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) {
+ provider = strings.ToLower(strings.TrimSpace(provider))
+ if provider != "github" && provider != "google" {
+ return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_PROVIDER_NOT_FOUND", "oauth provider not found")
+ }
+ keys := []string{
+ SettingKeyGitHubOAuthEnabled,
+ SettingKeyGitHubOAuthClientID,
+ SettingKeyGitHubOAuthClientSecret,
+ SettingKeyGitHubOAuthRedirectURL,
+ SettingKeyGitHubOAuthFrontendRedirectURL,
+ SettingKeyGoogleOAuthEnabled,
+ SettingKeyGoogleOAuthClientID,
+ SettingKeyGoogleOAuthClientSecret,
+ SettingKeyGoogleOAuthRedirectURL,
+ SettingKeyGoogleOAuthFrontendRedirectURL,
+ }
+ settings, err := s.settingRepo.GetMultiple(ctx, keys)
+ if err != nil {
+ return config.EmailOAuthProviderConfig{}, fmt.Errorf("get email oauth settings: %w", err)
+ }
+ cfg := s.effectiveEmailOAuthConfig(settings, provider)
+ if !cfg.Enabled {
+ return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
+ }
+ if strings.TrimSpace(cfg.ClientID) == "" {
+ return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
+ }
+ if strings.TrimSpace(cfg.ClientSecret) == "" {
+ return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
+ }
+ for label, rawURL := range map[string]string{
+ "authorize": cfg.AuthorizeURL,
+ "token": cfg.TokenURL,
+ "userinfo": cfg.UserInfoURL,
+ "redirect": cfg.RedirectURL,
+ } {
+ if strings.TrimSpace(rawURL) == "" {
+ return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url not configured")
+ }
+ if err := config.ValidateAbsoluteHTTPURL(rawURL); err != nil {
+ return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url invalid")
+ }
+ }
+ if strings.TrimSpace(cfg.EmailsURL) != "" {
+ if err := config.ValidateAbsoluteHTTPURL(cfg.EmailsURL); err != nil {
+ return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth emails url invalid")
+ }
+ }
+ if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil {
+ return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
+ }
+ return cfg, nil
+}
+
// IsRegistrationEnabled 检查是否开放注册
func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
@@ -1400,10 +1821,10 @@ func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool {
}
slog.Warn("failed to get backend_mode_enabled setting", "error", err)
backendModeCache.Store(&cachedBackendMode{
- value: false,
+ value: true,
expiresAt: time.Now().Add(backendModeErrorTTL).UnixNano(),
})
- return false, nil
+ return true, nil
}
enabled := value == "true"
backendModeCache.Store(&cachedBackendMode{
@@ -1415,7 +1836,7 @@ func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool {
if val, ok := result.(bool); ok {
return val
}
- return false
+ return true
}
type gatewayForwardingSettingsResult struct {
@@ -1534,6 +1955,15 @@ func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool {
return value == "true"
}
+// GetCustomMenuItemsRaw returns the raw JSON string of custom_menu_items setting.
+func (s *SettingService) GetCustomMenuItemsRaw(ctx context.Context) string {
+ value, err := s.settingRepo.GetValue(ctx, SettingKeyCustomMenuItems)
+ if err != nil {
+ return "[]"
+ }
+ return value
+}
+
// IsAffiliateEnabled 检查是否启用邀请返利功能(总开关)
func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled)
@@ -1629,10 +2059,10 @@ func (s *SettingService) IsTotpEnabled(ctx context.Context) bool {
return value == "true"
}
-// IsTotpEncryptionKeyConfigured 检查 TOTP 加密密钥是否已手动配置
-// 只有手动配置了密钥才允许在管理后台启用 TOTP 功能
+// IsTotpEncryptionKeyConfigured checks the dedicated TOTP secret-domain root.
+// The legacy TOTP_ENCRYPTION_KEY is migration-only and must not enable 2FA.
func (s *SettingService) IsTotpEncryptionKeyConfigured() bool {
- return s.cfg.Totp.EncryptionKeyConfigured
+ return strings.TrimSpace(s.cfg.SecretEncryption.TOTPSecretKey) != ""
}
// GetSiteName 获取网站名称
@@ -1711,6 +2141,16 @@ func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*Aut
SettingKeyAuthSourceDefaultWeChatSubscriptions,
SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
+ SettingKeyAuthSourceDefaultGitHubBalance,
+ SettingKeyAuthSourceDefaultGitHubConcurrency,
+ SettingKeyAuthSourceDefaultGitHubSubscriptions,
+ SettingKeyAuthSourceDefaultGitHubGrantOnSignup,
+ SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind,
+ SettingKeyAuthSourceDefaultGoogleBalance,
+ SettingKeyAuthSourceDefaultGoogleConcurrency,
+ SettingKeyAuthSourceDefaultGoogleSubscriptions,
+ SettingKeyAuthSourceDefaultGoogleGrantOnSignup,
+ SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind,
SettingKeyForceEmailOnThirdPartySignup,
}
@@ -1724,6 +2164,8 @@ func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*Aut
LinuxDo: parseProviderDefaultGrantSettings(settings, linuxDoAuthSourceDefaultKeys),
OIDC: parseProviderDefaultGrantSettings(settings, oidcAuthSourceDefaultKeys),
WeChat: parseProviderDefaultGrantSettings(settings, weChatAuthSourceDefaultKeys),
+ GitHub: parseProviderDefaultGrantSettings(settings, gitHubAuthSourceDefaultKeys),
+ Google: parseProviderDefaultGrantSettings(settings, googleAuthSourceDefaultKeys),
ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
}, nil
}
@@ -1793,6 +2235,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
oidcValidateIDTokenDefault = s.cfg.OIDC.ValidateIDToken
}
}
+ loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(defaultLoginAgreementDocuments())
+ if err != nil {
+ return err
+ }
// 初始化默认设置
defaults := map[string]string{
@@ -1800,6 +2246,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyEmailVerifyEnabled: "false",
SettingKeyRegistrationEmailSuffixWhitelist: "[]",
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
+ SettingKeyLoginAgreementEnabled: "false",
+ SettingKeyLoginAgreementMode: defaultLoginAgreementMode,
+ SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate,
+ SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON,
SettingKeySiteName: "Sub2API",
SettingKeySiteLogo: "",
SettingKeyPurchaseSubscriptionEnabled: "false",
@@ -1824,6 +2274,16 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyWeChatConnectScopes: "snsapi_login",
SettingKeyWeChatConnectRedirectURL: "",
SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend,
+ SettingKeyGitHubOAuthEnabled: "false",
+ SettingKeyGitHubOAuthClientID: "",
+ SettingKeyGitHubOAuthClientSecret: "",
+ SettingKeyGitHubOAuthRedirectURL: "",
+ SettingKeyGitHubOAuthFrontendRedirectURL: defaultGitHubOAuthFrontend,
+ SettingKeyGoogleOAuthEnabled: "false",
+ SettingKeyGoogleOAuthClientID: "",
+ SettingKeyGoogleOAuthClientSecret: "",
+ SettingKeyGoogleOAuthRedirectURL: "",
+ SettingKeyGoogleOAuthFrontendRedirectURL: defaultGoogleOAuthFrontend,
SettingKeyOIDCConnectEnabled: "false",
SettingKeyOIDCConnectProviderName: "OIDC",
SettingKeyOIDCConnectClientID: "",
@@ -1874,6 +2334,16 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]",
SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "false",
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false",
+ SettingKeyAuthSourceDefaultGitHubBalance: "0",
+ SettingKeyAuthSourceDefaultGitHubConcurrency: "5",
+ SettingKeyAuthSourceDefaultGitHubSubscriptions: "[]",
+ SettingKeyAuthSourceDefaultGitHubGrantOnSignup: "false",
+ SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind: "false",
+ SettingKeyAuthSourceDefaultGoogleBalance: "0",
+ SettingKeyAuthSourceDefaultGoogleConcurrency: "5",
+ SettingKeyAuthSourceDefaultGoogleSubscriptions: "[]",
+ SettingKeyAuthSourceDefaultGoogleGrantOnSignup: "false",
+ SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind: "false",
SettingKeyForceEmailOnThirdPartySignup: "false",
SettingKeySMTPPort: "587",
SettingKeySMTPUseTLS: "false",
@@ -1903,6 +2373,15 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
// Affiliate (邀请返利) feature (default disabled; opt-in)
SettingKeyAffiliateEnabled: "false",
+ // 风控中心功能(默认关闭,显式启用)
+ SettingKeyRiskControlEnabled: "false",
+
+ // HFC Telegram 风控告警(默认关闭,显式启用)
+ SettingKeyHFCTelegramRiskAlertEnabled: "false",
+ SettingKeyHFCTelegramBotToken: "",
+ SettingKeyHFCTelegramChatID: "",
+ SettingKeyHFCTelegramMinSeverity: "high",
+
// Claude Code version check (default: empty = disabled)
SettingKeyMinClaudeCodeVersion: "",
SettingKeyMaxClaudeCodeVersion: "",
@@ -1923,6 +2402,11 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
// parseSettings 解析设置到结构体
func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings {
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
+ loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments])
+ loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt])
+ if loginAgreementUpdatedAt == "" {
+ loginAgreementUpdatedAt = defaultLoginAgreementDate
+ }
result := &SystemSettings{
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
EmailVerifyEnabled: emailVerifyEnabled,
@@ -1932,6 +2416,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
FrontendURL: settings[SettingKeyFrontendURL],
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
+ LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true",
+ LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]),
+ LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
+ LoginAgreementDocuments: loginAgreementDocuments,
SMTPHost: settings[SettingKeySMTPHost],
SMTPUsername: settings[SettingKeySMTPUsername],
SMTPFrom: settings[SettingKeySMTPFrom],
@@ -1943,7 +2431,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "",
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
SiteLogo: settings[SettingKeySiteLogo],
- SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
+ SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "按需充值的 AI API 中转服务"),
APIBaseURL: settings[SettingKeyAPIBaseURL],
ContactInfo: settings[SettingKeyContactInfo],
DocURL: settings[SettingKeyDocURL],
@@ -2173,6 +2661,22 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
}
result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != ""
+ gitHubEffective := s.effectiveEmailOAuthConfig(settings, "github")
+ result.GitHubOAuthEnabled = gitHubEffective.Enabled
+ result.GitHubOAuthClientID = strings.TrimSpace(gitHubEffective.ClientID)
+ result.GitHubOAuthClientSecret = strings.TrimSpace(gitHubEffective.ClientSecret)
+ result.GitHubOAuthClientSecretConfigured = result.GitHubOAuthClientSecret != ""
+ result.GitHubOAuthRedirectURL = strings.TrimSpace(gitHubEffective.RedirectURL)
+ result.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(gitHubEffective.FrontendRedirectURL)
+
+ googleEffective := s.effectiveEmailOAuthConfig(settings, "google")
+ result.GoogleOAuthEnabled = googleEffective.Enabled
+ result.GoogleOAuthClientID = strings.TrimSpace(googleEffective.ClientID)
+ result.GoogleOAuthClientSecret = strings.TrimSpace(googleEffective.ClientSecret)
+ result.GoogleOAuthClientSecretConfigured = result.GoogleOAuthClientSecret != ""
+ result.GoogleOAuthRedirectURL = strings.TrimSpace(googleEffective.RedirectURL)
+ result.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(googleEffective.FrontendRedirectURL)
+
// WeChat Connect 设置:
// - 优先读取 DB 系统设置
// - 缺失时回退到 config/env,保持升级兼容
@@ -2242,6 +2746,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
// Affiliate (邀请返利) feature (default: disabled; strict true)
result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true"
+ // 风控中心功能(默认关闭,严格 true 才启用)
+ result.RiskControlEnabled = settings[SettingKeyRiskControlEnabled] == "true"
+
// Claude Code version check
result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion]
result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion]
@@ -2778,6 +3285,55 @@ func (s *SettingService) SetOverloadCooldownSettings(ctx context.Context, settin
return s.settingRepo.Set(ctx, SettingKeyOverloadCooldownSettings, string(data))
}
+// GetRateLimit429CooldownSettings 获取429默认回避配置
+func (s *SettingService) GetRateLimit429CooldownSettings(ctx context.Context) (*RateLimit429CooldownSettings, error) {
+ value, err := s.settingRepo.GetValue(ctx, SettingKeyRateLimit429CooldownSettings)
+ if err != nil {
+ if errors.Is(err, ErrSettingNotFound) {
+ return DefaultRateLimit429CooldownSettings(), nil
+ }
+ return nil, fmt.Errorf("get 429 cooldown settings: %w", err)
+ }
+ if value == "" {
+ return DefaultRateLimit429CooldownSettings(), nil
+ }
+
+ var settings RateLimit429CooldownSettings
+ if err := json.Unmarshal([]byte(value), &settings); err != nil {
+ return DefaultRateLimit429CooldownSettings(), nil
+ }
+
+ if settings.CooldownSeconds < 1 {
+ settings.CooldownSeconds = 1
+ }
+ if settings.CooldownSeconds > 7200 {
+ settings.CooldownSeconds = 7200
+ }
+
+ return &settings, nil
+}
+
+// SetRateLimit429CooldownSettings 设置429默认回避配置
+func (s *SettingService) SetRateLimit429CooldownSettings(ctx context.Context, settings *RateLimit429CooldownSettings) error {
+ if settings == nil {
+ return fmt.Errorf("settings cannot be nil")
+ }
+
+ if settings.CooldownSeconds < 1 || settings.CooldownSeconds > 7200 {
+ if settings.Enabled {
+ return fmt.Errorf("cooldown_seconds must be between 1-7200")
+ }
+ settings.CooldownSeconds = 5
+ }
+
+ data, err := json.Marshal(settings)
+ if err != nil {
+ return fmt.Errorf("marshal 429 cooldown settings: %w", err)
+ }
+
+ return s.settingRepo.Set(ctx, SettingKeyRateLimit429CooldownSettings, string(data))
+}
+
// GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。
//
// 优先级:
@@ -2927,16 +3483,40 @@ func (s *SettingService) GetOIDCConnectOAuthConfig(ctx context.Context) (config.
effective.DiscoveryURL = discoveryURL
}
if discoveryURL != "" {
- if err := config.ValidateAbsoluteHTTPURL(discoveryURL); err != nil {
+ allowInsecureHTTP := false
+ allowPrivateHosts := false
+ if s.cfg != nil {
+ allowInsecureHTTP = s.cfg.Security.URLAllowlist.AllowInsecureHTTP
+ allowPrivateHosts = s.cfg.Security.URLAllowlist.AllowPrivateHosts
+ }
+ normalizedDiscoveryURL, err := urlvalidator.ValidateHTTPURL(
+ discoveryURL,
+ allowInsecureHTTP,
+ urlvalidator.ValidationOptions{AllowPrivate: allowPrivateHosts},
+ )
+ if err != nil {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery url invalid")
}
+ discoveryURL = normalizedDiscoveryURL
+ effective.DiscoveryURL = normalizedDiscoveryURL
}
needsDiscovery := strings.TrimSpace(effective.AuthorizeURL) == "" ||
strings.TrimSpace(effective.TokenURL) == "" ||
(effective.ValidateIDToken && strings.TrimSpace(effective.JWKSURL) == "")
if needsDiscovery && discoveryURL != "" {
- metadata, resolveErr := oidcResolveProviderMetadata(ctx, discoveryURL)
+ allowInsecureHTTP := false
+ allowPrivateHosts := false
+ if s.cfg != nil {
+ allowInsecureHTTP = s.cfg.Security.URLAllowlist.AllowInsecureHTTP
+ allowPrivateHosts = s.cfg.Security.URLAllowlist.AllowPrivateHosts
+ }
+ metadata, resolveErr := oidcResolveProviderMetadataWithPolicy(
+ ctx,
+ discoveryURL,
+ allowInsecureHTTP,
+ allowPrivateHosts,
+ )
if resolveErr != nil {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery resolve failed").WithCause(resolveErr)
}
@@ -3021,6 +3601,11 @@ type oidcProviderMetadata struct {
JWKSURI string `json:"jwks_uri"`
}
+const (
+ oidcDiscoveryRequestTimeout = 15 * time.Second
+ oidcDiscoveryMaxResponseBytes = 1 << 20
+)
+
func oidcDefaultDiscoveryURL(issuerURL string) string {
issuerURL = strings.TrimSpace(issuerURL)
if issuerURL == "" {
@@ -3029,27 +3614,62 @@ func oidcDefaultDiscoveryURL(issuerURL string) string {
return strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration"
}
-func oidcResolveProviderMetadata(ctx context.Context, discoveryURL string) (*oidcProviderMetadata, error) {
+func oidcResolveProviderMetadataWithPolicy(
+ ctx context.Context,
+ discoveryURL string,
+ allowInsecureHTTP bool,
+ allowPrivateHosts bool,
+) (*oidcProviderMetadata, error) {
discoveryURL = strings.TrimSpace(discoveryURL)
if discoveryURL == "" {
return nil, fmt.Errorf("discovery url is empty")
}
-
- resp, err := req.C().
- SetTimeout(15*time.Second).
- R().
- SetContext(ctx).
- SetHeader("Accept", "application/json").
- Get(discoveryURL)
+ normalizedURL, err := urlvalidator.ValidateHTTPURL(
+ discoveryURL,
+ allowInsecureHTTP,
+ urlvalidator.ValidationOptions{AllowPrivate: allowPrivateHosts},
+ )
+ if err != nil {
+ return nil, fmt.Errorf("validate discovery url: %w", err)
+ }
+ baseClient, err := httpclient.GetClient(httpclient.Options{
+ Timeout: oidcDiscoveryRequestTimeout,
+ ResponseHeaderTimeout: oidcDiscoveryRequestTimeout,
+ ValidateResolvedIP: true,
+ AllowPrivateHosts: allowPrivateHosts,
+ MaxIdleConnsPerHost: 2,
+ MaxConnsPerHost: 4,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("create discovery client: %w", err)
+ }
+ client := *baseClient
+ client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
+ return errors.New("oidc discovery redirect is not allowed")
+ }
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, normalizedURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("create discovery request: %w", err)
+ }
+ request.Header.Set("Accept", "application/json")
+ resp, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("request discovery document: %w", err)
}
- if !resp.IsSuccessState() {
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("discovery request failed: status=%d", resp.StatusCode)
}
+ body, err := io.ReadAll(io.LimitReader(resp.Body, oidcDiscoveryMaxResponseBytes+1))
+ if err != nil {
+ return nil, fmt.Errorf("read discovery document: %w", err)
+ }
+ if len(body) > oidcDiscoveryMaxResponseBytes {
+ return nil, fmt.Errorf("discovery document too large")
+ }
metadata := &oidcProviderMetadata{}
- if err := json.Unmarshal(resp.Bytes(), metadata); err != nil {
+ if err := json.Unmarshal(body, metadata); err != nil {
return nil, fmt.Errorf("parse discovery document: %w", err)
}
return metadata, nil
@@ -3318,8 +3938,18 @@ func (s *SettingService) GetOpenAIFastPolicySettings(ctx context.Context) (*Open
// SetOpenAIFastPolicySettings 设置 OpenAI fast 策略配置
func (s *SettingService) SetOpenAIFastPolicySettings(ctx context.Context, settings *OpenAIFastPolicySettings) error {
+ updates, err := BuildOpenAIFastPolicySettingsUpdates(settings)
+ if err != nil {
+ return err
+ }
+ return s.settingRepo.Set(ctx, SettingKeyOpenAIFastPolicySettings, updates[SettingKeyOpenAIFastPolicySettings])
+}
+
+// BuildOpenAIFastPolicySettingsUpdates validates and serializes the policy
+// without persisting it, allowing an enclosing atomic settings update.
+func BuildOpenAIFastPolicySettingsUpdates(settings *OpenAIFastPolicySettings) (map[string]string, error) {
if settings == nil {
- return fmt.Errorf("settings cannot be nil")
+ return nil, fmt.Errorf("settings cannot be nil")
}
validActions := map[string]bool{
@@ -3338,33 +3968,32 @@ func (s *SettingService) SetOpenAIFastPolicySettings(ctx context.Context, settin
tier = OpenAIFastTierAny
}
if !validTiers[tier] {
- return fmt.Errorf("rule[%d]: invalid service_tier %q", i, rule.ServiceTier)
+ return nil, fmt.Errorf("rule[%d]: invalid service_tier %q", i, rule.ServiceTier)
}
settings.Rules[i].ServiceTier = tier
if !validActions[rule.Action] {
- return fmt.Errorf("rule[%d]: invalid action %q", i, rule.Action)
+ return nil, fmt.Errorf("rule[%d]: invalid action %q", i, rule.Action)
}
if !validScopes[rule.Scope] {
- return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope)
+ return nil, fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope)
}
for j, pattern := range rule.ModelWhitelist {
trimmed := strings.TrimSpace(pattern)
if trimmed == "" {
- return fmt.Errorf("rule[%d]: model_whitelist[%d] cannot be empty", i, j)
+ return nil, fmt.Errorf("rule[%d]: model_whitelist[%d] cannot be empty", i, j)
}
settings.Rules[i].ModelWhitelist[j] = trimmed
}
if rule.FallbackAction != "" && !validActions[rule.FallbackAction] {
- return fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction)
+ return nil, fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction)
}
}
data, err := json.Marshal(settings)
if err != nil {
- return fmt.Errorf("marshal openai fast policy settings: %w", err)
+ return nil, fmt.Errorf("marshal openai fast policy settings: %w", err)
}
-
- return s.settingRepo.Set(ctx, SettingKeyOpenAIFastPolicySettings, string(data))
+ return map[string]string{SettingKeyOpenAIFastPolicySettings: string(data)}, nil
}
// SetStreamTimeoutSettings 设置流超时处理配置
diff --git a/backend/internal/service/setting_service_backend_mode_test.go b/backend/internal/service/setting_service_backend_mode_test.go
index 39922ec873a..2134c85de99 100644
--- a/backend/internal/service/setting_service_backend_mode_test.go
+++ b/backend/internal/service/setting_service_backend_mode_test.go
@@ -143,7 +143,7 @@ func TestIsBackendModeEnabled_ReturnsFalseOnNotFound(t *testing.T) {
require.Equal(t, 1, repo.calls)
}
-func TestIsBackendModeEnabled_ReturnsFalseOnDBError(t *testing.T) {
+func TestIsBackendModeEnabled_FailsClosedOnDBError(t *testing.T) {
resetBackendModeTestCache(t)
repo := &bmRepoStub{
@@ -154,7 +154,7 @@ func TestIsBackendModeEnabled_ReturnsFalseOnDBError(t *testing.T) {
}
svc := NewSettingService(repo, &config.Config{})
- require.False(t, svc.IsBackendModeEnabled(context.Background()))
+ require.True(t, svc.IsBackendModeEnabled(context.Background()))
require.Equal(t, 1, repo.calls)
}
diff --git a/backend/internal/service/setting_service_oidc_config_test.go b/backend/internal/service/setting_service_oidc_config_test.go
index 61324204312..861fe18f00c 100644
--- a/backend/internal/service/setting_service_oidc_config_test.go
+++ b/backend/internal/service/setting_service_oidc_config_test.go
@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
+ "strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
@@ -73,6 +74,12 @@ func TestGetOIDCConnectOAuthConfig_ResolvesEndpointsFromIssuerDiscovery(t *testi
baseURL = srv.URL
cfg := &config.Config{
+ Security: config.SecurityConfig{
+ URLAllowlist: config.URLAllowlistConfig{
+ AllowInsecureHTTP: true,
+ AllowPrivateHosts: true,
+ },
+ },
OIDC: config.OIDCConnectConfig{
Enabled: true,
ProviderName: "OIDC",
@@ -102,6 +109,47 @@ func TestGetOIDCConnectOAuthConfig_ResolvesEndpointsFromIssuerDiscovery(t *testi
require.Equal(t, srv.URL+"/issuer/protocol/openid-connect/certs", got.JWKSURL)
}
+func TestOIDCResolveProviderMetadataRejectsPrivateDestinationByDefault(t *testing.T) {
+ var hits int
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ hits++
+ _, _ = w.Write([]byte(`{"authorization_endpoint":"https://issuer.example.com/auth"}`))
+ }))
+ defer srv.Close()
+
+ _, err := oidcResolveProviderMetadataWithPolicy(context.Background(), srv.URL, true, false)
+ require.Error(t, err)
+ require.Zero(t, hits, "private destination must be rejected before the HTTP request")
+}
+
+func TestOIDCResolveProviderMetadataRejectsRedirect(t *testing.T) {
+ target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = w.Write([]byte(`{"authorization_endpoint":"https://issuer.example.com/auth"}`))
+ }))
+ defer target.Close()
+
+ source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, target.URL, http.StatusFound)
+ }))
+ defer source.Close()
+
+ _, err := oidcResolveProviderMetadataWithPolicy(context.Background(), source.URL, true, true)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "redirect")
+}
+
+func TestOIDCResolveProviderMetadataRejectsOversizedDocument(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"authorization_endpoint":"` + strings.Repeat("a", oidcDiscoveryMaxResponseBytes) + `"}`))
+ }))
+ defer srv.Close()
+
+ _, err := oidcResolveProviderMetadataWithPolicy(context.Background(), srv.URL, true, true)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "too large")
+}
+
func TestSettingService_ParseSettings_PreservesOptionalOIDCCompatibilityFlags(t *testing.T) {
svc := NewSettingService(&settingOIDCRepoStub{values: map[string]string{}}, &config.Config{})
diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go
index 1ecd4e6f416..f085c19e0aa 100644
--- a/backend/internal/service/setting_service_public_test.go
+++ b/backend/internal/service/setting_service_public_test.go
@@ -78,6 +78,21 @@ func TestSettingService_GetPublicSettings_ExposesTablePreferences(t *testing.T)
require.Equal(t, []int{20, 50, 100}, settings.TablePageSizeOptions)
}
+func TestSettingService_GetPublicSettings_DefaultsToCreditsOnlySiteSubtitle(t *testing.T) {
+ svc := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{})
+
+ settings, err := svc.GetPublicSettings(context.Background())
+ require.NoError(t, err)
+ require.Equal(t, "按需充值的 AI API 中转服务", settings.SiteSubtitle)
+ require.NotContains(t, settings.SiteSubtitle, "Subscription")
+}
+
+func TestPublicSiteLogoReference_RewritesDataImageToAssetURL(t *testing.T) {
+ require.Equal(t, PublicSiteLogoAssetPath, PublicSiteLogoReference(" data:image/png;base64,aGVsbG8= "))
+ require.Equal(t, "/logo.png", PublicSiteLogoReference(" /logo.png "))
+ require.Equal(t, "https://example.com/logo.png", PublicSiteLogoReference("https://example.com/logo.png"))
+}
+
func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t *testing.T) {
repo := &settingPublicRepoStub{
values: map[string]string{
diff --git a/backend/internal/service/setting_service_totp_key_test.go b/backend/internal/service/setting_service_totp_key_test.go
new file mode 100644
index 00000000000..d1ece41e3eb
--- /dev/null
+++ b/backend/internal/service/setting_service_totp_key_test.go
@@ -0,0 +1,21 @@
+package service
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/stretchr/testify/require"
+)
+
+func TestIsTotpEncryptionKeyConfiguredUsesDedicatedDomainRoot(t *testing.T) {
+ cfg := &config.Config{Totp: config.TotpConfig{
+ EncryptionKey: strings.Repeat("a", 64),
+ EncryptionKeyConfigured: true,
+ }}
+ svc := NewSettingService(nil, cfg)
+ require.False(t, svc.IsTotpEncryptionKeyConfigured())
+
+ cfg.SecretEncryption.TOTPSecretKey = strings.Repeat("1", 64)
+ require.True(t, svc.IsTotpEncryptionKeyConfigured())
+}
diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go
index 41c01ccac0e..80b8b32a901 100644
--- a/backend/internal/service/settings_view.go
+++ b/backend/internal/service/settings_view.go
@@ -20,6 +20,10 @@ type SystemSettings struct {
FrontendURL string
InvitationCodeEnabled bool
TotpEnabled bool // TOTP 双因素认证
+ LoginAgreementEnabled bool
+ LoginAgreementMode string
+ LoginAgreementUpdatedAt string
+ LoginAgreementDocuments []LoginAgreementDocument
SMTPHost string
SMTPPort int
@@ -89,6 +93,20 @@ type SystemSettings struct {
OIDCConnectUserInfoIDPath string
OIDCConnectUserInfoUsernamePath string
+ // GitHub / Google 邮箱快捷登录
+ GitHubOAuthEnabled bool
+ GitHubOAuthClientID string
+ GitHubOAuthClientSecret string
+ GitHubOAuthClientSecretConfigured bool
+ GitHubOAuthRedirectURL string
+ GitHubOAuthFrontendRedirectURL string
+ GoogleOAuthEnabled bool
+ GoogleOAuthClientID string
+ GoogleOAuthClientSecret string
+ GoogleOAuthClientSecretConfigured bool
+ GoogleOAuthRedirectURL string
+ GoogleOAuthFrontendRedirectURL string
+
SiteName string
SiteLogo string
SiteSubtitle string
@@ -106,6 +124,7 @@ type SystemSettings struct {
DefaultConcurrency int
DefaultBalance float64
+ RiskControlEnabled bool
AffiliateEnabled bool
AffiliateRebateRate float64
AffiliateRebateFreezeHours int
@@ -190,6 +209,11 @@ type PublicSettings struct {
PasswordResetEnabled bool
InvitationCodeEnabled bool
TotpEnabled bool // TOTP 双因素认证
+ LoginAgreementEnabled bool
+ LoginAgreementMode string
+ LoginAgreementUpdatedAt string
+ LoginAgreementRevision string
+ LoginAgreementDocuments []LoginAgreementDocument
TurnstileEnabled bool
TurnstileSiteKey string
SiteName string
@@ -217,6 +241,8 @@ type PublicSettings struct {
PaymentEnabled bool
OIDCOAuthEnabled bool
OIDCOAuthProviderName string
+ GitHubOAuthEnabled bool
+ GoogleOAuthEnabled bool
Version string
BalanceLowNotifyEnabled bool
@@ -233,6 +259,15 @@ type PublicSettings struct {
// Affiliate (邀请返利) feature toggle
AffiliateEnabled bool `json:"affiliate_enabled"`
+
+ // 风控中心功能开关
+ RiskControlEnabled bool `json:"risk_control_enabled"`
+}
+
+type LoginAgreementDocument struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ ContentMD string `json:"content_md"`
}
type WeChatConnectOAuthConfig struct {
@@ -381,6 +416,14 @@ type OverloadCooldownSettings struct {
CooldownMinutes int `json:"cooldown_minutes"`
}
+// RateLimit429CooldownSettings 429默认回避配置
+type RateLimit429CooldownSettings struct {
+ // Enabled 是否在无法解析上游重置时间时应用默认429回避
+ Enabled bool `json:"enabled"`
+ // CooldownSeconds 默认回避时长(秒)
+ CooldownSeconds int `json:"cooldown_seconds"`
+}
+
// DefaultOverloadCooldownSettings 返回默认的过载冷却配置(启用,10分钟)
func DefaultOverloadCooldownSettings() *OverloadCooldownSettings {
return &OverloadCooldownSettings{
@@ -389,6 +432,14 @@ func DefaultOverloadCooldownSettings() *OverloadCooldownSettings {
}
}
+// DefaultRateLimit429CooldownSettings 返回默认的429回避配置(启用,5秒)
+func DefaultRateLimit429CooldownSettings() *RateLimit429CooldownSettings {
+ return &RateLimit429CooldownSettings{
+ Enabled: true,
+ CooldownSeconds: 5,
+ }
+}
+
// DefaultBetaPolicySettings 返回默认的 Beta 策略配置
func DefaultBetaPolicySettings() *BetaPolicySettings {
return &BetaPolicySettings{
diff --git a/backend/internal/service/sticky_session_test.go b/backend/internal/service/sticky_session_test.go
index 11ace7bd8e0..02369b19ef5 100644
--- a/backend/internal/service/sticky_session_test.go
+++ b/backend/internal/service/sticky_session_test.go
@@ -122,8 +122,8 @@ func TestShouldClearStickySession(t *testing.T) {
{
name: "overloaded account",
account: &Account{
- Status: StatusActive,
- Schedulable: true,
+ Status: StatusActive,
+ Schedulable: true,
OverloadUntil: &future,
},
requestedModel: "",
diff --git a/backend/internal/service/subscription_admin_assign.go b/backend/internal/service/subscription_admin_assign.go
new file mode 100644
index 00000000000..22efd72c8e6
--- /dev/null
+++ b/backend/internal/service/subscription_admin_assign.go
@@ -0,0 +1,103 @@
+package service
+
+import (
+ "context"
+ "math"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+)
+
+const MaxAdminWalletInitialUSD = 10_000_000
+
+// NormalizeAdminSubscriptionAssignInput validates the public admin assignment
+// contract and returns an immutable canonical copy. Plan, group and manual
+// wallet modes are strict alternatives; internal payment/redeem callers keep
+// using AssignSubscription because they may legitimately carry plan metadata
+// together with an already-resolved wallet delta.
+func NormalizeAdminSubscriptionAssignInput(input *AssignSubscriptionInput) (*AssignSubscriptionInput, error) {
+ if input == nil {
+ return nil, ErrSubscriptionNilInput
+ }
+ if input.UserID <= 0 {
+ return nil, infraerrors.BadRequest("ADMIN_ASSIGN_USER_INVALID", "user_id must be > 0")
+ }
+ if input.GroupID < 0 {
+ return nil, infraerrors.BadRequest("ADMIN_ASSIGN_GROUP_INVALID", "group_id must be > 0 when provided")
+ }
+ if input.ValidityDays < 0 || input.ValidityDays > MaxValidityDays {
+ return nil, infraerrors.BadRequest("ADMIN_ASSIGN_VALIDITY_INVALID", "validity_days is outside the allowed range")
+ }
+
+ hasPlan := input.PlanID != nil
+ if hasPlan && *input.PlanID <= 0 {
+ return nil, infraerrors.BadRequest("ADMIN_ASSIGN_PLAN_INVALID", "plan_id must be > 0")
+ }
+ hasWallet := input.WalletInitialUSD != nil
+ if hasWallet && !isValidAdminWalletDelta(*input.WalletInitialUSD) {
+ return nil, infraerrors.BadRequest("ADMIN_ASSIGN_WALLET_INVALID", "wallet_initial_usd is outside the allowed range")
+ }
+ hasGroup := input.GroupID > 0
+
+ modeCount := 0
+ for _, selected := range []bool{hasPlan, hasGroup, hasWallet} {
+ if selected {
+ modeCount++
+ }
+ }
+ if modeCount != 1 {
+ return nil, infraerrors.BadRequest("ADMIN_ASSIGN_MODE_INVALID", "exactly one of plan_id, group_id, or wallet_initial_usd is required")
+ }
+
+ if (hasPlan || hasWallet) && input.ValidityDays != 0 {
+ return nil, infraerrors.BadRequest("ADMIN_ASSIGN_VALIDITY_INVALID", "validity_days is only allowed for group assignments")
+ }
+ if hasPlan && input.PlanType != "" {
+ return nil, infraerrors.BadRequest("ADMIN_ASSIGN_PLAN_TYPE_INVALID", "plan type is resolved from plan_id")
+ }
+ if hasGroup && input.PlanType != "" {
+ return nil, infraerrors.BadRequest("ADMIN_ASSIGN_PLAN_TYPE_INVALID", "plan type is not allowed for group assignments")
+ }
+ if hasWallet && input.PlanType != "" && input.PlanType != PlanTypeCredits {
+ return nil, infraerrors.BadRequest("ADMIN_ASSIGN_PLAN_TYPE_INVALID", "manual wallet assignments must use credits plan type")
+ }
+
+ normalized := &AssignSubscriptionInput{
+ UserID: input.UserID,
+ GroupID: input.GroupID,
+ ValidityDays: input.ValidityDays,
+ AssignedBy: input.AssignedBy,
+ Notes: input.Notes,
+ }
+ if hasPlan {
+ planID := *input.PlanID
+ normalized.PlanID = &planID
+ }
+ if hasWallet {
+ walletDelta := *input.WalletInitialUSD
+ normalized.WalletInitialUSD = &walletDelta
+ normalized.PlanType = PlanTypeCredits
+ }
+ return normalized, nil
+}
+
+func isValidAdminWalletDelta(value float64) bool {
+ return value > 0 && value <= MaxAdminWalletInitialUSD && !math.IsNaN(value) && !math.IsInf(value, 0)
+}
+
+// AssignAdminSubscription is the double-validation boundary for the public
+// admin endpoint. Callers must not use AssignSubscription directly for raw
+// admin request data.
+func (s *SubscriptionService) AssignAdminSubscription(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, error) {
+ normalized, err := NormalizeAdminSubscriptionAssignInput(input)
+ if err != nil {
+ return nil, err
+ }
+ normalized, err = s.normalizePlanWalletAssignInput(ctx, normalized)
+ if err != nil {
+ return nil, err
+ }
+ if normalized.PlanType == PlanTypeSubscription {
+ return nil, infraerrors.BadRequest("MONTHLY_PLANS_RETIRED", "monthly plans can no longer be assigned; add wallet credits instead")
+ }
+ return s.AssignSubscription(ctx, normalized)
+}
diff --git a/backend/internal/service/subscription_admin_assign_test.go b/backend/internal/service/subscription_admin_assign_test.go
new file mode 100644
index 00000000000..50cc34d23e3
--- /dev/null
+++ b/backend/internal/service/subscription_admin_assign_test.go
@@ -0,0 +1,172 @@
+package service
+
+import (
+ "context"
+ "math"
+ "testing"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/stretchr/testify/require"
+)
+
+func TestNormalizeAdminSubscriptionAssignInputCanonicalModes(t *testing.T) {
+ planID := int64(987654)
+ walletDelta := 50.0
+
+ tests := []struct {
+ name string
+ input *AssignSubscriptionInput
+ check func(*testing.T, *AssignSubscriptionInput)
+ }{
+ {
+ name: "plan",
+ input: &AssignSubscriptionInput{UserID: 1, PlanID: &planID, AssignedBy: 9, Notes: "plan"},
+ check: func(t *testing.T, got *AssignSubscriptionInput) {
+ require.NotNil(t, got.PlanID)
+ require.Equal(t, planID, *got.PlanID)
+ require.NotSame(t, &planID, got.PlanID)
+ require.Nil(t, got.WalletInitialUSD)
+ require.Zero(t, got.GroupID)
+ require.Empty(t, got.PlanType)
+ },
+ },
+ {
+ name: "manual wallet",
+ input: &AssignSubscriptionInput{UserID: 1, WalletInitialUSD: &walletDelta, AssignedBy: 9, Notes: "wallet"},
+ check: func(t *testing.T, got *AssignSubscriptionInput) {
+ require.NotNil(t, got.WalletInitialUSD)
+ require.Equal(t, walletDelta, *got.WalletInitialUSD)
+ require.NotSame(t, &walletDelta, got.WalletInitialUSD)
+ require.Nil(t, got.PlanID)
+ require.Zero(t, got.GroupID)
+ require.Equal(t, PlanTypeCredits, got.PlanType)
+ },
+ },
+ {
+ name: "group",
+ input: &AssignSubscriptionInput{UserID: 1, GroupID: 22, ValidityDays: 30, AssignedBy: 9, Notes: "group"},
+ check: func(t *testing.T, got *AssignSubscriptionInput) {
+ require.Equal(t, int64(22), got.GroupID)
+ require.Equal(t, 30, got.ValidityDays)
+ require.Nil(t, got.PlanID)
+ require.Nil(t, got.WalletInitialUSD)
+ require.Empty(t, got.PlanType)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := NormalizeAdminSubscriptionAssignInput(tt.input)
+ require.NoError(t, err)
+ require.NotSame(t, tt.input, got)
+ require.Equal(t, tt.input.UserID, got.UserID)
+ require.Equal(t, tt.input.AssignedBy, got.AssignedBy)
+ require.Equal(t, tt.input.Notes, got.Notes)
+ tt.check(t, got)
+ })
+ }
+}
+
+func TestNormalizeAdminSubscriptionAssignInputRejectsInvalidOrAmbiguousInput(t *testing.T) {
+ planID := int64(11)
+ zeroPlanID := int64(0)
+ walletDelta := 50.0
+ zeroWallet := 0.0
+ overWallet := float64(MaxAdminWalletInitialUSD + 1)
+ nanWallet := math.NaN()
+
+ tests := []struct {
+ name string
+ input *AssignSubscriptionInput
+ wantReason string
+ }{
+ {name: "nil", input: nil, wantReason: infraerrors.Reason(ErrSubscriptionNilInput)},
+ {name: "invalid user", input: &AssignSubscriptionInput{UserID: 0, PlanID: &planID}, wantReason: "ADMIN_ASSIGN_USER_INVALID"},
+ {name: "negative group", input: &AssignSubscriptionInput{UserID: 1, GroupID: -1}, wantReason: "ADMIN_ASSIGN_GROUP_INVALID"},
+ {name: "zero plan id", input: &AssignSubscriptionInput{UserID: 1, PlanID: &zeroPlanID}, wantReason: "ADMIN_ASSIGN_PLAN_INVALID"},
+ {name: "negative validity", input: &AssignSubscriptionInput{UserID: 1, GroupID: 22, ValidityDays: -1}, wantReason: "ADMIN_ASSIGN_VALIDITY_INVALID"},
+ {name: "excess validity", input: &AssignSubscriptionInput{UserID: 1, GroupID: 22, ValidityDays: MaxValidityDays + 1}, wantReason: "ADMIN_ASSIGN_VALIDITY_INVALID"},
+ {name: "no mode", input: &AssignSubscriptionInput{UserID: 1}, wantReason: "ADMIN_ASSIGN_MODE_INVALID"},
+ {name: "plan and wallet", input: &AssignSubscriptionInput{UserID: 1, PlanID: &planID, WalletInitialUSD: &walletDelta}, wantReason: "ADMIN_ASSIGN_MODE_INVALID"},
+ {name: "plan and group", input: &AssignSubscriptionInput{UserID: 1, PlanID: &planID, GroupID: 22}, wantReason: "ADMIN_ASSIGN_MODE_INVALID"},
+ {name: "wallet and group", input: &AssignSubscriptionInput{UserID: 1, GroupID: 22, WalletInitialUSD: &walletDelta}, wantReason: "ADMIN_ASSIGN_MODE_INVALID"},
+ {name: "plan validity", input: &AssignSubscriptionInput{UserID: 1, PlanID: &planID, ValidityDays: 30}, wantReason: "ADMIN_ASSIGN_VALIDITY_INVALID"},
+ {name: "wallet validity", input: &AssignSubscriptionInput{UserID: 1, WalletInitialUSD: &walletDelta, ValidityDays: 30}, wantReason: "ADMIN_ASSIGN_VALIDITY_INVALID"},
+ {name: "zero wallet", input: &AssignSubscriptionInput{UserID: 1, WalletInitialUSD: &zeroWallet}, wantReason: "ADMIN_ASSIGN_WALLET_INVALID"},
+ {name: "excess wallet", input: &AssignSubscriptionInput{UserID: 1, WalletInitialUSD: &overWallet}, wantReason: "ADMIN_ASSIGN_WALLET_INVALID"},
+ {name: "nan wallet", input: &AssignSubscriptionInput{UserID: 1, WalletInitialUSD: &nanWallet}, wantReason: "ADMIN_ASSIGN_WALLET_INVALID"},
+ {name: "wallet wrong type", input: &AssignSubscriptionInput{UserID: 1, WalletInitialUSD: &walletDelta, PlanType: PlanTypeSubscription}, wantReason: "ADMIN_ASSIGN_PLAN_TYPE_INVALID"},
+ {name: "group with plan type", input: &AssignSubscriptionInput{UserID: 1, GroupID: 22, PlanType: PlanTypeCredits}, wantReason: "ADMIN_ASSIGN_PLAN_TYPE_INVALID"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, err := NormalizeAdminSubscriptionAssignInput(tt.input)
+ require.Error(t, err)
+ require.Equal(t, tt.wantReason, infraerrors.Reason(err))
+ })
+ }
+}
+
+func TestAssignAdminSubscriptionValidatesBeforeRepositoryAccess(t *testing.T) {
+ planID := int64(11)
+ svc := &SubscriptionService{}
+ _, err := svc.AssignAdminSubscription(context.Background(), &AssignSubscriptionInput{
+ UserID: 1,
+ PlanID: &planID,
+ ValidityDays: 30,
+ })
+ require.Error(t, err)
+ require.Equal(t, "ADMIN_ASSIGN_VALIDITY_INVALID", infraerrors.Reason(err))
+}
+
+func TestAssignAdminSubscriptionRejectsRetiredMonthlyPlan(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ group := createMonthlyPlanGroup(t, ctx, client, "retired-admin-monthly", StatusActive)
+ plan, err := client.SubscriptionPlan.Create().
+ SetName("retired admin monthly").
+ SetGroupID(group.ID).
+ SetPlanType(PlanTypeSubscription).
+ SetPrice(20).
+ SetValidityDays(30).
+ SetForSale(false).
+ Save(ctx)
+ require.NoError(t, err)
+
+ svc := &SubscriptionService{entClient: client}
+ _, err = svc.AssignAdminSubscription(ctx, &AssignSubscriptionInput{
+ UserID: 1,
+ PlanID: &plan.ID,
+ AssignedBy: 9,
+ Notes: "must not revive monthly",
+ })
+ require.Error(t, err)
+ require.Equal(t, "MONTHLY_PLANS_RETIRED", infraerrors.Reason(err))
+}
+
+func TestAffiliateRebateOverrideForAdminAssignUsesResolvedPlanTypeNotDatabaseID(t *testing.T) {
+ dynamicPlanID := int64(987654)
+ formerlyCreditsID := int64(11)
+
+ tests := []struct {
+ name string
+ planID *int64
+ planType string
+ want float64
+ }{
+ {name: "dynamic credits plan", planID: &dynamicPlanID, planType: PlanTypeCredits, want: AffiliateRebateCreditsCardRate},
+ {name: "reused legacy id is monthly", planID: &formerlyCreditsID, planType: PlanTypeSubscription, want: AffiliateRebateSubscriptionRate},
+ {name: "manual wallet remains zero", planID: nil, planType: PlanTypeCredits, want: AffiliateRebateSubscriptionRate},
+ {name: "missing resolved type fails closed", planID: &formerlyCreditsID, planType: "", want: AffiliateRebateSubscriptionRate},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ rate := AffiliateRebateOverrideForAdminAssign(tt.planID, tt.planType)
+ require.NotNil(t, rate)
+ require.Equal(t, tt.want, *rate)
+ })
+ }
+}
diff --git a/backend/internal/service/subscription_admin_idempotency_test.go b/backend/internal/service/subscription_admin_idempotency_test.go
new file mode 100644
index 00000000000..b523a269ddd
--- /dev/null
+++ b/backend/internal/service/subscription_admin_idempotency_test.go
@@ -0,0 +1,119 @@
+package service
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/stretchr/testify/require"
+)
+
+func adminAssignIdempotencyOptions(key string, payload any) IdempotencyExecuteOptions {
+ return IdempotencyExecuteOptions{
+ Scope: "admin.subscriptions.assign",
+ ActorScope: "admin:99",
+ Method: "POST",
+ Route: "/api/v1/admin/subscriptions/assign",
+ IdempotencyKey: key,
+ Payload: payload,
+ TTL: time.Hour,
+ RequireKey: true,
+ Persistent: true,
+ }
+}
+
+func TestAdminAssignIdempotencyCoordinatorProtectsWalletTopupService(t *testing.T) {
+ ctx := context.Background()
+ subRepo := newSubscriptionUserSubRepoStub()
+ existingBalance := 100.0
+ existingInitial := 100.0
+ subRepo.seed(&UserSubscription{
+ ID: 7,
+ UserID: 173,
+ Status: SubscriptionStatusActive,
+ ExpiresAt: MaxExpiresAt,
+ WalletBalanceUSD: &existingBalance,
+ WalletInitialUSD: &existingInitial,
+ })
+
+ topup := &walletTopupServiceStub{
+ returnEntry: WalletLedgerEntry{ID: 555, SubscriptionID: 7, DeltaUSD: 50, BalanceAfter: 150},
+ }
+ subscriptionService := NewSubscriptionService(groupRepoNoop{}, subRepo, nil, nil, nil)
+ subscriptionService.SetWalletTopupService(topup)
+
+ cfg := DefaultIdempotencyConfig()
+ cfg.ObserveOnly = false
+ coordinator := NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), cfg)
+ payload := map[string]any{"user_id": int64(173), "wallet_initial_usd": 50.0, "notes": "manual topup"}
+ inputAmount := 50.0
+ executeCalls := 0
+ execute := func(ctx context.Context) (any, error) {
+ executeCalls++
+ return subscriptionService.AssignSubscription(ctx, &AssignSubscriptionInput{
+ UserID: 173,
+ AssignedBy: 99,
+ Notes: "manual topup",
+ WalletInitialUSD: &inputAmount,
+ PlanType: PlanTypeCredits,
+ })
+ }
+
+ first, err := coordinator.Execute(ctx, adminAssignIdempotencyOptions("wallet-ack-lost", payload), execute)
+ require.NoError(t, err)
+ require.False(t, first.Replayed)
+ second, err := coordinator.Execute(ctx, adminAssignIdempotencyOptions("wallet-ack-lost", payload), execute)
+ require.NoError(t, err)
+ require.True(t, second.Replayed)
+ require.Equal(t, 1, executeCalls)
+ require.Equal(t, 1, topup.calls, "the wallet Topup service must run exactly once")
+
+ _, err = coordinator.Execute(ctx, adminAssignIdempotencyOptions("wallet-ack-lost", map[string]any{
+ "user_id": int64(173),
+ "wallet_initial_usd": 75.0,
+ }), execute)
+ require.Error(t, err)
+ require.Equal(t, infraerrors.Code(ErrIdempotencyKeyConflict), infraerrors.Code(err))
+ require.Equal(t, 1, executeCalls)
+ require.Equal(t, 1, topup.calls)
+}
+
+func TestAdminAssignIdempotencyCoordinatorProtectsMonthlyService(t *testing.T) {
+ ctx := context.Background()
+ groupRepo := &subscriptionGroupRepoStub{
+ group: &Group{ID: 22, SubscriptionType: SubscriptionTypeSubscription},
+ }
+ subRepo := newSubscriptionUserSubRepoStub()
+ subscriptionService := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
+
+ cfg := DefaultIdempotencyConfig()
+ cfg.ObserveOnly = false
+ coordinator := NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), cfg)
+ payload := map[string]any{
+ "user_id": int64(208),
+ "group_id": int64(22),
+ "validity_days": 30,
+ "notes": "monthly vip",
+ }
+ executeCalls := 0
+ execute := func(ctx context.Context) (any, error) {
+ executeCalls++
+ return subscriptionService.AssignSubscription(ctx, &AssignSubscriptionInput{
+ UserID: 208,
+ GroupID: 22,
+ ValidityDays: 30,
+ AssignedBy: 99,
+ Notes: "monthly vip",
+ })
+ }
+
+ first, err := coordinator.Execute(ctx, adminAssignIdempotencyOptions("monthly-ack-lost", payload), execute)
+ require.NoError(t, err)
+ require.False(t, first.Replayed)
+ second, err := coordinator.Execute(ctx, adminAssignIdempotencyOptions("monthly-ack-lost", payload), execute)
+ require.NoError(t, err)
+ require.True(t, second.Replayed)
+ require.Equal(t, 1, executeCalls)
+ require.Equal(t, 1, subRepo.createCalls, "monthly assignment must create at most one subscription")
+}
diff --git a/backend/internal/service/subscription_assign_idempotency_test.go b/backend/internal/service/subscription_assign_idempotency_test.go
index 40bab206063..e2aa47cf894 100644
--- a/backend/internal/service/subscription_assign_idempotency_test.go
+++ b/backend/internal/service/subscription_assign_idempotency_test.go
@@ -11,6 +11,8 @@ import (
"github.com/stretchr/testify/require"
)
+func ptrInt64(v int64) *int64 { return &v }
+
type groupRepoNoop struct{}
func (groupRepoNoop) Create(context.Context, *Group) error { panic("unexpected Create call") }
@@ -79,6 +81,18 @@ func (userSubRepoNoop) GetByUserIDAndGroupID(context.Context, int64, int64) (*Us
func (userSubRepoNoop) GetActiveByUserIDAndGroupID(context.Context, int64, int64) (*UserSubscription, error) {
panic("unexpected GetActiveByUserIDAndGroupID call")
}
+func (userSubRepoNoop) GetActiveWalletByUserID(context.Context, int64) (*UserSubscription, error) {
+ panic("unexpected GetActiveWalletByUserID call")
+}
+func (u userSubRepoNoop) GetActiveCreditsWalletByUserID(ctx context.Context, userID int64) (*UserSubscription, error) {
+ return u.GetActiveWalletByUserID(ctx, userID)
+}
+func (userSubRepoNoop) GetActiveByPlanCoveringGroup(context.Context, int64, int64) (*UserSubscription, error) {
+ return nil, ErrSubscriptionNotFound
+}
+func (userSubRepoNoop) HasAnyActiveSubscription(context.Context, int64) (bool, error) {
+ return false, nil
+}
func (userSubRepoNoop) Update(context.Context, *UserSubscription) error {
panic("unexpected Update call")
}
@@ -110,6 +124,9 @@ func (userSubRepoNoop) UpdateNotes(context.Context, int64, string) error {
func (userSubRepoNoop) ActivateWindows(context.Context, int64, time.Time) error {
panic("unexpected ActivateWindows call")
}
+func (userSubRepoNoop) AdvanceUsageWindow(context.Context, int64, SubscriptionUsageWindowAdvance) (bool, error) {
+ panic("unexpected AdvanceUsageWindow call")
+}
func (userSubRepoNoop) ResetDailyUsage(context.Context, int64, time.Time) error {
panic("unexpected ResetDailyUsage call")
}
@@ -129,17 +146,19 @@ func (userSubRepoNoop) BatchUpdateExpiredStatus(context.Context) (int64, error)
type subscriptionUserSubRepoStub struct {
userSubRepoNoop
- nextID int64
- byID map[int64]*UserSubscription
- byUserGroup map[string]*UserSubscription
- createCalls int
+ nextID int64
+ byID map[int64]*UserSubscription
+ byUserGroup map[string]*UserSubscription
+ activeWallets map[int64]*UserSubscription // userID → wallet sub(同时间一个用户最多一条 active)
+ createCalls int
}
func newSubscriptionUserSubRepoStub() *subscriptionUserSubRepoStub {
return &subscriptionUserSubRepoStub{
- nextID: 1,
- byID: make(map[int64]*UserSubscription),
- byUserGroup: make(map[string]*UserSubscription),
+ nextID: 1,
+ byID: make(map[int64]*UserSubscription),
+ byUserGroup: make(map[string]*UserSubscription),
+ activeWallets: make(map[int64]*UserSubscription),
}
}
@@ -157,7 +176,12 @@ func (s *subscriptionUserSubRepoStub) seed(sub *UserSubscription) {
s.nextID++
}
s.byID[cp.ID] = &cp
- s.byUserGroup[s.key(cp.UserID, cp.GroupID)] = &cp
+ if cp.GroupID != nil {
+ s.byUserGroup[s.key(cp.UserID, *cp.GroupID)] = &cp
+ }
+ if cp.WalletBalanceUSD != nil && cp.Status == SubscriptionStatusActive {
+ s.activeWallets[cp.UserID] = &cp
+ }
}
func (s *subscriptionUserSubRepoStub) ExistsByUserIDAndGroupID(_ context.Context, userID, groupID int64) (bool, error) {
@@ -186,10 +210,35 @@ func (s *subscriptionUserSubRepoStub) Create(_ context.Context, sub *UserSubscri
}
sub.ID = cp.ID
s.byID[cp.ID] = &cp
- s.byUserGroup[s.key(cp.UserID, cp.GroupID)] = &cp
+ if cp.GroupID != nil {
+ s.byUserGroup[s.key(cp.UserID, *cp.GroupID)] = &cp
+ }
+ if cp.WalletBalanceUSD != nil && cp.Status == SubscriptionStatusActive {
+ s.activeWallets[cp.UserID] = &cp
+ }
return nil
}
+func (s *subscriptionUserSubRepoStub) GetActiveWalletByUserID(_ context.Context, userID int64) (*UserSubscription, error) {
+ sub := s.activeWallets[userID]
+ if sub == nil {
+ return nil, ErrSubscriptionNotFound
+ }
+ cp := *sub
+ return &cp, nil
+}
+func (s *subscriptionUserSubRepoStub) GetActiveCreditsWalletByUserID(ctx context.Context, userID int64) (*UserSubscription, error) {
+ return s.GetActiveWalletByUserID(ctx, userID)
+}
+
+func (s *subscriptionUserSubRepoStub) GetActiveByPlanCoveringGroup(_ context.Context, _, _ int64) (*UserSubscription, error) {
+ return nil, ErrSubscriptionNotFound
+}
+
+func (s *subscriptionUserSubRepoStub) HasAnyActiveSubscription(_ context.Context, _ int64) (bool, error) {
+ return false, nil
+}
+
func (s *subscriptionUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserSubscription, error) {
sub := s.byID[id]
if sub == nil {
@@ -208,7 +257,7 @@ func TestAssignSubscriptionReuseWhenSemanticsMatch(t *testing.T) {
subRepo.seed(&UserSubscription{
ID: 10,
UserID: 1001,
- GroupID: 1,
+ GroupID: ptrInt64(1),
StartsAt: start,
ExpiresAt: start.AddDate(0, 0, 30),
Notes: "init",
@@ -235,7 +284,7 @@ func TestAssignSubscriptionConflictWhenSemanticsMismatch(t *testing.T) {
subRepo.seed(&UserSubscription{
ID: 11,
UserID: 2001,
- GroupID: 1,
+ GroupID: ptrInt64(1),
StartsAt: start,
ExpiresAt: start.AddDate(0, 0, 30),
Notes: "old-note",
@@ -263,7 +312,7 @@ func TestBulkAssignSubscriptionCreatedReusedAndConflict(t *testing.T) {
subRepo.seed(&UserSubscription{
ID: 21,
UserID: 1,
- GroupID: 1,
+ GroupID: ptrInt64(1),
StartsAt: start,
ExpiresAt: start.AddDate(0, 0, 30),
Notes: "same-note",
@@ -272,7 +321,7 @@ func TestBulkAssignSubscriptionCreatedReusedAndConflict(t *testing.T) {
subRepo.seed(&UserSubscription{
ID: 23,
UserID: 3,
- GroupID: 1,
+ GroupID: ptrInt64(1),
StartsAt: start,
ExpiresAt: start.AddDate(0, 0, 60),
Notes: "same-note",
@@ -330,7 +379,7 @@ func TestDetectAssignSemanticConflictCases(t *testing.T) {
start := time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC)
base := &UserSubscription{
UserID: 1,
- GroupID: 1,
+ GroupID: ptrInt64(1),
StartsAt: start,
ExpiresAt: start.AddDate(0, 0, 30),
Notes: "same",
diff --git a/backend/internal/service/subscription_assign_wallet_test.go b/backend/internal/service/subscription_assign_wallet_test.go
new file mode 100644
index 00000000000..aac31cfbae1
--- /dev/null
+++ b/backend/internal/service/subscription_assign_wallet_test.go
@@ -0,0 +1,437 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/stretchr/testify/require"
+)
+
+// TestAssignWalletSubscriptionCreatesNewWallet 验证 A8:当 input.WalletInitialUSD
+// 非 nil 时,AssignSubscription 跳过 group 路径,创建一条 group_id=NULL 的钱包
+// 订阅,wallet_initial_usd 和 wallet_balance_usd 都等于初始值。
+func TestAssignWalletSubscriptionCreatesNewWallet(t *testing.T) {
+ subRepo := newSubscriptionUserSubRepoStub()
+ // groupRepo 不应被调用:钱包路径不查 group
+ groupRepo := groupRepoNoop{}
+
+ svc := NewSubscriptionService(groupRepo, subRepo, nil, nil, nil)
+ lifecycle := &walletTopupServiceStub{}
+ svc.SetWalletTopupService(lifecycle)
+
+ initial := 1500.0
+ sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
+ UserID: 1001,
+ ValidityDays: 30,
+ AssignedBy: 9, // admin id
+ Notes: "credits-1500",
+ WalletInitialUSD: &initial,
+ PlanType: PlanTypeCredits,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, sub)
+ require.Nil(t, sub.GroupID, "钱包订阅 group_id 必须为 NULL")
+ require.NotNil(t, sub.WalletInitialUSD)
+ require.NotNil(t, sub.WalletBalanceUSD)
+ require.Equal(t, 1500.0, *sub.WalletInitialUSD)
+ require.Equal(t, 1500.0, *sub.WalletBalanceUSD, "新建时余额=初始值")
+ require.NotNil(t, sub.WalletCreditDeltaUSD)
+ require.Equal(t, 1500.0, *sub.WalletCreditDeltaUSD, "运行时返利元数据必须是本次激活额度")
+ require.True(t, sub.IsWalletMode())
+ require.Equal(t, 1, subRepo.createCalls)
+ require.Equal(t, 1, lifecycle.activationCalls)
+}
+
+func TestAssignMonthlyPlanCreationIsRejectedBeforeAdminAssignment(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ planSvc := &PaymentConfigService{entClient: client}
+ groupEntity, err := client.Group.Create().
+ SetName("paid-lite-v3").
+ SetSubscriptionType(SubscriptionTypeSubscription).
+ SetStatus(StatusActive).
+ Save(ctx)
+ require.NoError(t, err)
+ groupID := groupEntity.ID
+ plan, err := planSvc.CreatePlan(ctx, CreatePlanRequest{
+ Name: "paid-lite-v3-30d",
+ Price: 99,
+ ValidityDays: 30,
+ ValidityUnit: "days",
+ ForSale: true,
+ PlanType: PlanTypeSubscription,
+ GroupID: &groupID,
+ PlanGroupIDs: []int64{groupID},
+ })
+ require.Error(t, err)
+ require.Nil(t, plan)
+ require.Equal(t, "MONTHLY_PLANS_RETIRED", infraerrors.Reason(err))
+}
+
+// walletGroupKeyEnsurerStub mock 出 WalletGroupKeyService 的两条路径。
+//
+// 5/14 反转决策后激活流程走 EnsureWalletUniversalKey 单 key 路径;
+// EnsureWalletGroupKeys 多 key 路径保留作底层能力,激活不再调。
+type walletGroupKeyEnsurerStub struct {
+ // 多 key 路径调用计数(5/14 后激活不再触发,仅供 group key 直接测试用)
+ calls int
+ userIDs []int64
+ groupIDs [][]int64
+ keys []APIKey
+ created int
+ err error
+
+ // 单 key 路径调用计数(激活默认走这条)
+ universalCalls int
+ universalUserIDs []int64
+ universalKey *APIKey
+ universalCreated bool
+ universalErr error
+}
+
+func (s *walletGroupKeyEnsurerStub) EnsureWalletGroupKeys(_ context.Context, userID int64, groupIDs []int64) ([]APIKey, int, error) {
+ s.calls++
+ s.userIDs = append(s.userIDs, userID)
+ s.groupIDs = append(s.groupIDs, groupIDs)
+ return s.keys, s.created, s.err
+}
+
+func (s *walletGroupKeyEnsurerStub) EnsureWalletUniversalKey(_ context.Context, userID int64) (*APIKey, bool, error) {
+ s.universalCalls++
+ s.universalUserIDs = append(s.universalUserIDs, userID)
+ return s.universalKey, s.universalCreated, s.universalErr
+}
+
+// TestAssignWalletSubscriptionCreatesUniversalKeyOnActivation 验证 5/14 反转决策:
+// 钱包激活走单 key 路径 — 调 EnsureWalletUniversalKey 建 1 把 group_id=NULL 通用 key,
+// 不调多 key 路径 EnsureWalletGroupKeys。PlanID 不再影响建 key 行为。
+func TestAssignWalletSubscriptionCreatesUniversalKeyOnActivation(t *testing.T) {
+ subRepo := newSubscriptionUserSubRepoStub()
+ svc := NewSubscriptionService(groupRepoNoop{}, subRepo, nil, nil, nil)
+ svc.SetWalletTopupService(&walletTopupServiceStub{})
+
+ mockKey := &APIKey{ID: 999, UserID: 1001, Name: WalletUniversalAPIKeyName, GroupID: nil}
+ keyEnsurer := &walletGroupKeyEnsurerStub{
+ universalKey: mockKey,
+ universalCreated: true,
+ }
+ svc.SetWalletGroupKeyService(keyEnsurer)
+
+ initial := 1500.0
+ sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
+ UserID: 1001,
+ ValidityDays: 30,
+ WalletInitialUSD: &initial,
+ PlanType: PlanTypeCredits,
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, sub)
+ require.Equal(t, 0, keyEnsurer.calls, "5/14 反转后不再走多 key 路径")
+ require.Equal(t, 1, keyEnsurer.universalCalls, "应触发单 key 路径")
+ require.Equal(t, []int64{1001}, keyEnsurer.universalUserIDs)
+ require.NotNil(t, sub.WalletUniversalKey)
+ require.Equal(t, mockKey.ID, sub.WalletUniversalKey.ID)
+ require.True(t, sub.WalletUniversalKeyCreated)
+}
+
+func TestAssignWalletSubscriptionRejectsMonthlyWalletWhenActiveExists(t *testing.T) {
+ subRepo := newSubscriptionUserSubRepoStub()
+ existing := 100.0
+ subRepo.seed(&UserSubscription{
+ ID: 7,
+ UserID: 1001,
+ Status: SubscriptionStatusActive,
+ WalletBalanceUSD: &existing,
+ WalletInitialUSD: &existing,
+ })
+
+ svc := NewSubscriptionService(groupRepoNoop{}, subRepo, nil, nil, nil)
+ svc.SetWalletTopupService(&walletTopupServiceStub{})
+
+ initial := 1500.0
+ _, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
+ UserID: 1001,
+ ValidityDays: 30,
+ WalletInitialUSD: &initial,
+ })
+ require.Error(t, err)
+ require.Equal(t, "MONTHLY_PLAN_WALLET_DISABLED", infraerrors.Reason(err))
+ require.Equal(t, 0, subRepo.createCalls)
+}
+
+// TestAssignWalletSubscriptionRejectsNonPositiveBalance 验证:初始余额 <= 0
+// 直接 service 层报错(handler 层 binding 也会拦截,service 是兜底)。
+func TestAssignWalletSubscriptionRejectsNonPositiveBalance(t *testing.T) {
+ subRepo := newSubscriptionUserSubRepoStub()
+ svc := NewSubscriptionService(groupRepoNoop{}, subRepo, nil, nil, nil)
+ svc.SetWalletTopupService(&walletTopupServiceStub{})
+
+ for _, bad := range []float64{0, -1, -100} {
+ v := bad
+ _, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
+ UserID: 1001,
+ ValidityDays: 30,
+ WalletInitialUSD: &v,
+ })
+ require.Error(t, err, "balance=%v 应被拒绝", bad)
+ }
+ require.Equal(t, 0, subRepo.createCalls)
+}
+
+// TestAssignWalletSubscriptionCreditsPlanForcesMaxExpiresAt 验证 B2.3:
+// plan_type='credits' 走永久 expires_at(截断到 MaxExpiresAt 2099-12-31),
+// validity_days 被忽略;plan_type='subscription' 或空串不能走 wallet。
+//
+// 见 docs/plans/2026-05-13-wallet-multikey-credits-design.md §2.2。
+func TestAssignWalletSubscriptionCreditsPlanForcesMaxExpiresAt(t *testing.T) {
+ t.Run("credits 永久有效", func(t *testing.T) {
+ subRepo := newSubscriptionUserSubRepoStub()
+ svc := NewSubscriptionService(groupRepoNoop{}, subRepo, nil, nil, nil)
+ svc.SetWalletTopupService(&walletTopupServiceStub{})
+
+ initial := 500.0
+ sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
+ UserID: 2001,
+ ValidityDays: 7, // 应该被忽略
+ WalletInitialUSD: &initial,
+ PlanType: PlanTypeCredits,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, sub)
+ require.Equal(t, MaxExpiresAt, sub.ExpiresAt, "额度卡 expires_at 必须 == MaxExpiresAt (2099)")
+ })
+
+ t.Run("subscription 被拒绝", func(t *testing.T) {
+ subRepo := newSubscriptionUserSubRepoStub()
+ svc := NewSubscriptionService(groupRepoNoop{}, subRepo, nil, nil, nil)
+ svc.SetWalletTopupService(&walletTopupServiceStub{})
+
+ initial := 1500.0
+ _, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
+ UserID: 2002,
+ ValidityDays: 30,
+ WalletInitialUSD: &initial,
+ PlanType: PlanTypeSubscription,
+ })
+ require.Error(t, err)
+ require.Equal(t, "MONTHLY_PLAN_WALLET_DISABLED", infraerrors.Reason(err))
+ })
+
+ t.Run("空串等价 subscription 被拒绝", func(t *testing.T) {
+ subRepo := newSubscriptionUserSubRepoStub()
+ svc := NewSubscriptionService(groupRepoNoop{}, subRepo, nil, nil, nil)
+ svc.SetWalletTopupService(&walletTopupServiceStub{})
+
+ initial := 100.0
+ _, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
+ UserID: 2003,
+ ValidityDays: 30,
+ WalletInitialUSD: &initial,
+ // PlanType 留空
+ })
+ require.Error(t, err)
+ require.Equal(t, "MONTHLY_PLAN_WALLET_DISABLED", infraerrors.Reason(err))
+ })
+}
+
+// walletTopupServiceStub 记录 Topup 调用,模拟 repo 返回叠加后的 balance。
+type walletTopupServiceStub struct {
+ activationCalls int
+ lastActivationSubID int64
+ lastActivationInitial float64
+ calls int
+ lastSubID int64
+ lastDelta float64
+ lastOperator *int64
+ lastNotes string
+ returnEntry WalletLedgerEntry
+ err error
+}
+
+func (s *walletTopupServiceStub) Activate(_ context.Context, subscriptionID int64, initialUSD float64, operatorID *int64, notes string) (WalletLedgerEntry, error) {
+ s.activationCalls++
+ s.lastActivationSubID = subscriptionID
+ s.lastActivationInitial = initialUSD
+ if s.err != nil {
+ return WalletLedgerEntry{}, s.err
+ }
+ return WalletLedgerEntry{SubscriptionID: subscriptionID, DeltaUSD: initialUSD, BalanceAfter: initialUSD, Reason: WalletLedgerReasonActivation, OperatorID: operatorID, Notes: notes}, nil
+}
+
+func (s *walletTopupServiceStub) Topup(_ context.Context, subscriptionID int64, deltaUSD float64, operatorID *int64, notes string) (WalletLedgerEntry, error) {
+ s.calls++
+ s.lastSubID = subscriptionID
+ s.lastDelta = deltaUSD
+ s.lastOperator = operatorID
+ s.lastNotes = notes
+ return s.returnEntry, s.err
+}
+
+func (s *walletTopupServiceStub) ActivateFromPayment(_ context.Context, subscriptionID int64, initialUSD float64, _ int64, notes string) (WalletLedgerEntry, error) {
+ return s.Activate(context.Background(), subscriptionID, initialUSD, nil, notes)
+}
+
+func (s *walletTopupServiceStub) TopupFromPayment(_ context.Context, subscriptionID int64, deltaUSD float64, _ int64, notes string) (WalletLedgerEntry, error) {
+ return s.Topup(context.Background(), subscriptionID, deltaUSD, nil, notes)
+}
+
+// TestAssignWalletSubscriptionToppedUpWhenCreditsAndExistingActive 验证 B2.4:
+// 用户已有 active 钱包 + 再来一张额度卡 (plan_type='credits') →
+// 走 Topup 路径而不是 conflict。
+//
+// 见 docs/plans/2026-05-13-wallet-multikey-credits-design.md §2.3。
+func TestAssignWalletSubscriptionToppedUpWhenCreditsAndExistingActive(t *testing.T) {
+ subRepo := newSubscriptionUserSubRepoStub()
+ existingBalance := 100.0
+ existingInitial := 100.0
+ subRepo.seed(&UserSubscription{
+ ID: 7,
+ UserID: 1001,
+ Status: SubscriptionStatusActive,
+ WalletBalanceUSD: &existingBalance,
+ WalletInitialUSD: &existingInitial,
+ })
+
+ svc := NewSubscriptionService(groupRepoNoop{}, subRepo, nil, nil, nil)
+ topupStub := &walletTopupServiceStub{
+ returnEntry: WalletLedgerEntry{ID: 555, BalanceAfter: 600.0},
+ }
+ svc.SetWalletTopupService(topupStub)
+
+ delta := 500.0
+ sub, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
+ UserID: 1001,
+ ValidityDays: 7, // 额度卡忽略
+ AssignedBy: 9,
+ Notes: "credits-500 ¥149",
+ WalletInitialUSD: &delta,
+ PlanType: PlanTypeCredits,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, sub)
+ require.Equal(t, int64(7), sub.ID, "叠加必须复用现有 subscription,不新建")
+ require.Equal(t, 0, subRepo.createCalls, "topup 路径不应触发 Create")
+
+ require.Equal(t, 1, topupStub.calls)
+ require.Equal(t, int64(7), topupStub.lastSubID)
+ require.Equal(t, 500.0, topupStub.lastDelta)
+ require.NotNil(t, topupStub.lastOperator)
+ require.Equal(t, int64(9), *topupStub.lastOperator)
+ require.Contains(t, topupStub.lastNotes, "credits topup:")
+ require.Contains(t, topupStub.lastNotes, "credits-500")
+
+ require.NotNil(t, sub.WalletBalanceUSD)
+ require.Equal(t, 600.0, *sub.WalletBalanceUSD, "余额必须 == ledger.balance_after")
+ require.NotNil(t, sub.WalletInitialUSD)
+ require.Equal(t, 600.0, *sub.WalletInitialUSD, "initial 必须 == 旧 initial + delta")
+ require.NotNil(t, sub.WalletCreditDeltaUSD)
+ require.Equal(t, 500.0, *sub.WalletCreditDeltaUSD,
+ "返利基数元数据必须只记录本次 topup delta,不得记录累计 initial")
+}
+
+func TestAssignCreditsPlanTopupReturnsPlanQuotaAsCurrentCreditDelta(t *testing.T) {
+ ctx := context.Background()
+ client := newPaymentConfigServiceTestClient(t)
+ planQuota := 50.0
+ plan, err := client.SubscriptionPlan.Create().
+ SetName("credits-50").
+ SetPrice(50).
+ SetPlanType(PlanTypeCredits).
+ SetWalletQuotaUsd(planQuota).
+ Save(ctx)
+ require.NoError(t, err)
+
+ subRepo := newSubscriptionUserSubRepoStub()
+ existingBalance := 95.0
+ existingInitial := 100.0
+ subRepo.seed(&UserSubscription{
+ ID: 17,
+ UserID: 1001,
+ Status: SubscriptionStatusActive,
+ WalletBalanceUSD: &existingBalance,
+ WalletInitialUSD: &existingInitial,
+ })
+
+ svc := NewSubscriptionService(groupRepoNoop{}, subRepo, nil, client, nil)
+ topupStub := &walletTopupServiceStub{
+ returnEntry: WalletLedgerEntry{ID: 556, BalanceAfter: 145.0},
+ }
+ svc.SetWalletTopupService(topupStub)
+
+ sub, err := svc.AssignAdminSubscription(ctx, &AssignSubscriptionInput{
+ UserID: 1001,
+ AssignedBy: 9,
+ Notes: "admin credits plan topup",
+ PlanID: &plan.ID,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, sub.WalletInitialUSD)
+ require.Equal(t, 150.0, *sub.WalletInitialUSD, "wallet_initial_usd remains the cumulative display total")
+ require.NotNil(t, sub.WalletCreditDeltaUSD)
+ require.Equal(t, planQuota, *sub.WalletCreditDeltaUSD,
+ "current-operation metadata must come from this credits plan's wallet_quota_usd")
+ require.Equal(t, PlanTypeCredits, sub.AssignmentPlanType,
+ "rebate classification must use the plan type resolved in the assignment transaction")
+ require.Equal(t, planQuota, topupStub.lastDelta)
+}
+
+func TestAssignWalletSubscriptionRejectsMonthlyWalletWhenSubscriptionPlanAndExistingActive(t *testing.T) {
+ subRepo := newSubscriptionUserSubRepoStub()
+ existing := 100.0
+ subRepo.seed(&UserSubscription{
+ ID: 8,
+ UserID: 1002,
+ Status: SubscriptionStatusActive,
+ WalletBalanceUSD: &existing,
+ WalletInitialUSD: &existing,
+ })
+
+ svc := NewSubscriptionService(groupRepoNoop{}, subRepo, nil, nil, nil)
+ topupStub := &walletTopupServiceStub{}
+ svc.SetWalletTopupService(topupStub)
+
+ initial := 1500.0
+ _, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
+ UserID: 1002,
+ ValidityDays: 30,
+ WalletInitialUSD: &initial,
+ PlanType: PlanTypeSubscription,
+ })
+ require.Error(t, err)
+ require.Equal(t, "MONTHLY_PLAN_WALLET_DISABLED", infraerrors.Reason(err))
+ require.Equal(t, 0, subRepo.createCalls)
+ require.Equal(t, 0, topupStub.calls, "月卡分支不应触发 topup")
+}
+
+// TestAssignWalletSubscriptionConflictWhenCreditsButTopupServiceMissing 验证 B2.4
+// 兜底:plan_type='credits' 但没注入 WalletTopupService(旧 wire 路径)→
+// conflict_reason=wallet_topup_unsupported,提示运维注入服务。
+func TestAssignWalletSubscriptionConflictWhenCreditsButTopupServiceMissing(t *testing.T) {
+ subRepo := newSubscriptionUserSubRepoStub()
+ existing := 100.0
+ subRepo.seed(&UserSubscription{
+ ID: 9,
+ UserID: 1003,
+ Status: SubscriptionStatusActive,
+ WalletBalanceUSD: &existing,
+ WalletInitialUSD: &existing,
+ })
+
+ svc := NewSubscriptionService(groupRepoNoop{}, subRepo, nil, nil, nil)
+ // 不调 SetWalletTopupService
+
+ delta := 500.0
+ _, err := svc.AssignSubscription(context.Background(), &AssignSubscriptionInput{
+ UserID: 1003,
+ WalletInitialUSD: &delta,
+ PlanType: PlanTypeCredits,
+ })
+ require.Error(t, err)
+ var coded *infraerrors.Error
+ require.True(t, errors.As(err, &coded))
+ require.Equal(t, ErrSubscriptionAssignConflict.Code, coded.Code)
+ require.Equal(t, "wallet_topup_unsupported", coded.Metadata["conflict_reason"])
+}
diff --git a/backend/internal/service/subscription_extend_cache_security_test.go b/backend/internal/service/subscription_extend_cache_security_test.go
new file mode 100644
index 00000000000..dc3c51772ed
--- /dev/null
+++ b/backend/internal/service/subscription_extend_cache_security_test.go
@@ -0,0 +1,53 @@
+package service
+
+import (
+ "context"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/stretchr/testify/require"
+)
+
+type extendCacheUserSubRepoStub struct {
+ userSubRepoNoop
+ sub UserSubscription
+ extendCalls atomic.Int32
+}
+
+func (s *extendCacheUserSubRepoStub) GetByID(context.Context, int64) (*UserSubscription, error) {
+ copy := s.sub
+ return ©, nil
+}
+
+func (s *extendCacheUserSubRepoStub) ExtendExpiry(_ context.Context, _ int64, expiresAt time.Time) error {
+ s.sub.ExpiresAt = expiresAt
+ s.extendCalls.Add(1)
+ return nil
+}
+
+func TestExtendSubscriptionDefersCacheInvalidationUntilOuterCommit(t *testing.T) {
+ groupID := int64(22)
+ repo := &extendCacheUserSubRepoStub{sub: UserSubscription{
+ ID: 91,
+ UserID: 173,
+ GroupID: &groupID,
+ Status: SubscriptionStatusActive,
+ ExpiresAt: time.Now().Add(24 * time.Hour),
+ }}
+ cache := &billingCacheWorkerStub{}
+ svc := &SubscriptionService{
+ userSubRepo: repo,
+ billingCacheService: &BillingCacheService{cache: cache},
+ }
+ txCtx := dbent.NewTxContext(context.Background(), &dbent.Tx{})
+
+ _, err := svc.ExtendSubscription(txCtx, repo.sub.ID, 1)
+ require.NoError(t, err)
+ require.Equal(t, int32(1), repo.extendCalls.Load())
+ require.Zero(t, atomic.LoadInt64(&cache.subscriptionInvalidations), "an uncommitted write must not evict the shared cache")
+
+ require.NoError(t, svc.InvalidateSubscriptionCachesAfterCommit(context.Background(), repo.sub.ID))
+ require.Equal(t, int64(1), atomic.LoadInt64(&cache.subscriptionInvalidations))
+}
diff --git a/backend/internal/service/subscription_maintenance_queue.go b/backend/internal/service/subscription_maintenance_queue.go
index 35bf18f3c37..748d1110ddb 100644
--- a/backend/internal/service/subscription_maintenance_queue.go
+++ b/backend/internal/service/subscription_maintenance_queue.go
@@ -6,8 +6,9 @@ import (
"sync"
)
-// SubscriptionMaintenanceQueue 提供"有界队列 + 固定 worker"的后台执行器。
-// 用于从请求热路径触发维护动作时,避免无限 goroutine 膨胀。
+// SubscriptionMaintenanceQueue provides a bounded worker pool for window
+// transitions. Request-path callers still wait for their task result; the queue
+// only caps concurrent database work and pending fan-out.
type SubscriptionMaintenanceQueue struct {
queue chan func()
wg sync.WaitGroup
@@ -48,9 +49,9 @@ func NewSubscriptionMaintenanceQueue(workerCount, queueSize int) *SubscriptionMa
return q
}
-// TryEnqueue 尝试将任务入队。
-// 当队列已满时返回 error(调用方应该选择跳过并记录告警/限频日志)。
-// 当队列已关闭时返回 error,不会 panic。
+// TryEnqueue attempts to enqueue a task. A full or stopped queue returns an
+// error so request-path callers can fail closed instead of skipping a required
+// window transition.
func (q *SubscriptionMaintenanceQueue) TryEnqueue(task func()) error {
if q == nil {
return fmt.Errorf("maintenance queue is nil")
diff --git a/backend/internal/service/subscription_reset_quota_test.go b/backend/internal/service/subscription_reset_quota_test.go
index 3bbc2170732..56104f68d3a 100644
--- a/backend/internal/service/subscription_reset_quota_test.go
+++ b/backend/internal/service/subscription_reset_quota_test.go
@@ -11,8 +11,8 @@ import (
"github.com/stretchr/testify/require"
)
-// resetQuotaUserSubRepoStub 支持 GetByID、ResetDailyUsage、ResetWeeklyUsage、ResetMonthlyUsage,
-// 其余方法继承 userSubRepoNoop(panic)。
+// resetQuotaUserSubRepoStub supports both explicit admin resets and the
+// compare-and-swap transitions used by request-path window maintenance.
type resetQuotaUserSubRepoStub struct {
userSubRepoNoop
@@ -43,23 +43,89 @@ func (r *resetQuotaUserSubRepoStub) ResetDailyUsage(_ context.Context, _ int64,
return r.resetDailyErr
}
-func (r *resetQuotaUserSubRepoStub) ResetWeeklyUsage(_ context.Context, _ int64, _ time.Time) error {
+func (r *resetQuotaUserSubRepoStub) ResetWeeklyUsage(_ context.Context, _ int64, windowStart time.Time) error {
r.resetWeeklyCalled = true
+ if r.resetWeeklyErr == nil && r.sub != nil {
+ r.sub.WeeklyUsageUSD = 0
+ r.sub.WeeklyWindowStart = &windowStart
+ }
return r.resetWeeklyErr
}
-func (r *resetQuotaUserSubRepoStub) ResetMonthlyUsage(_ context.Context, _ int64, _ time.Time) error {
+func (r *resetQuotaUserSubRepoStub) ResetMonthlyUsage(_ context.Context, _ int64, windowStart time.Time) error {
r.resetMonthlyCalled = true
+ if r.resetMonthlyErr == nil && r.sub != nil {
+ r.sub.MonthlyUsageUSD = 0
+ r.sub.MonthlyWindowStart = &windowStart
+ }
return r.resetMonthlyErr
}
+func (r *resetQuotaUserSubRepoStub) AdvanceUsageWindow(_ context.Context, id int64, advance SubscriptionUsageWindowAdvance) (bool, error) {
+ if r.sub == nil || r.sub.ID != id {
+ return false, ErrSubscriptionNotFound
+ }
+ var current *time.Time
+ var transitionErr error
+ switch advance.Window {
+ case SubscriptionUsageWindowDaily:
+ r.resetDailyCalled = true
+ current = r.sub.DailyWindowStart
+ transitionErr = r.resetDailyErr
+ case SubscriptionUsageWindowWeekly:
+ r.resetWeeklyCalled = true
+ current = r.sub.WeeklyWindowStart
+ transitionErr = r.resetWeeklyErr
+ case SubscriptionUsageWindowMonthly:
+ r.resetMonthlyCalled = true
+ current = r.sub.MonthlyWindowStart
+ transitionErr = r.resetMonthlyErr
+ default:
+ return false, ErrInvalidInput
+ }
+ if transitionErr != nil {
+ return false, transitionErr
+ }
+ if !sameOptionalTime(current, advance.ExpectedStart) {
+ return false, nil
+ }
+
+ start := advance.NewStart
+ switch advance.Window {
+ case SubscriptionUsageWindowDaily:
+ r.sub.DailyWindowStart = &start
+ if advance.ResetUsage {
+ r.sub.DailyUsageUSD = 0
+ }
+ case SubscriptionUsageWindowWeekly:
+ r.sub.WeeklyWindowStart = &start
+ if advance.ResetUsage {
+ r.sub.WeeklyUsageUSD = 0
+ }
+ case SubscriptionUsageWindowMonthly:
+ r.sub.MonthlyWindowStart = &start
+ if advance.ResetUsage {
+ r.sub.MonthlyUsageUSD = 0
+ }
+ }
+ return true, nil
+}
+
+func (r *resetQuotaUserSubRepoStub) GetActiveByPlanCoveringGroup(_ context.Context, _, _ int64) (*UserSubscription, error) {
+ return nil, ErrSubscriptionNotFound
+}
+
+func (r *resetQuotaUserSubRepoStub) HasAnyActiveSubscription(_ context.Context, _ int64) (bool, error) {
+ return false, nil
+}
+
func newResetQuotaSvc(stub *resetQuotaUserSubRepoStub) *SubscriptionService {
return NewSubscriptionService(groupRepoNoop{}, stub, nil, nil, nil)
}
func TestAdminResetQuota_ResetBoth(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{
- sub: &UserSubscription{ID: 1, UserID: 10, GroupID: 20},
+ sub: &UserSubscription{ID: 1, UserID: 10, GroupID: ptrInt64(20)},
}
svc := newResetQuotaSvc(stub)
@@ -74,7 +140,7 @@ func TestAdminResetQuota_ResetBoth(t *testing.T) {
func TestAdminResetQuota_ResetDailyOnly(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{
- sub: &UserSubscription{ID: 2, UserID: 10, GroupID: 20},
+ sub: &UserSubscription{ID: 2, UserID: 10, GroupID: ptrInt64(20)},
}
svc := newResetQuotaSvc(stub)
@@ -89,7 +155,7 @@ func TestAdminResetQuota_ResetDailyOnly(t *testing.T) {
func TestAdminResetQuota_ResetWeeklyOnly(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{
- sub: &UserSubscription{ID: 3, UserID: 10, GroupID: 20},
+ sub: &UserSubscription{ID: 3, UserID: 10, GroupID: ptrInt64(20)},
}
svc := newResetQuotaSvc(stub)
@@ -104,7 +170,7 @@ func TestAdminResetQuota_ResetWeeklyOnly(t *testing.T) {
func TestAdminResetQuota_BothFalseReturnsError(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{
- sub: &UserSubscription{ID: 7, UserID: 10, GroupID: 20},
+ sub: &UserSubscription{ID: 7, UserID: 10, GroupID: ptrInt64(20)},
}
svc := newResetQuotaSvc(stub)
@@ -131,7 +197,7 @@ func TestAdminResetQuota_SubscriptionNotFound(t *testing.T) {
func TestAdminResetQuota_ResetDailyUsageError(t *testing.T) {
dbErr := errors.New("db error")
stub := &resetQuotaUserSubRepoStub{
- sub: &UserSubscription{ID: 4, UserID: 10, GroupID: 20},
+ sub: &UserSubscription{ID: 4, UserID: 10, GroupID: ptrInt64(20)},
resetDailyErr: dbErr,
}
svc := newResetQuotaSvc(stub)
@@ -146,7 +212,7 @@ func TestAdminResetQuota_ResetDailyUsageError(t *testing.T) {
func TestAdminResetQuota_ResetWeeklyUsageError(t *testing.T) {
dbErr := errors.New("db error")
stub := &resetQuotaUserSubRepoStub{
- sub: &UserSubscription{ID: 5, UserID: 10, GroupID: 20},
+ sub: &UserSubscription{ID: 5, UserID: 10, GroupID: ptrInt64(20)},
resetWeeklyErr: dbErr,
}
svc := newResetQuotaSvc(stub)
@@ -159,7 +225,7 @@ func TestAdminResetQuota_ResetWeeklyUsageError(t *testing.T) {
func TestAdminResetQuota_ResetMonthlyOnly(t *testing.T) {
stub := &resetQuotaUserSubRepoStub{
- sub: &UserSubscription{ID: 8, UserID: 10, GroupID: 20},
+ sub: &UserSubscription{ID: 8, UserID: 10, GroupID: ptrInt64(20)},
}
svc := newResetQuotaSvc(stub)
@@ -175,7 +241,7 @@ func TestAdminResetQuota_ResetMonthlyOnly(t *testing.T) {
func TestAdminResetQuota_ResetMonthlyUsageError(t *testing.T) {
dbErr := errors.New("db error")
stub := &resetQuotaUserSubRepoStub{
- sub: &UserSubscription{ID: 9, UserID: 10, GroupID: 20},
+ sub: &UserSubscription{ID: 9, UserID: 10, GroupID: ptrInt64(20)},
resetMonthlyErr: dbErr,
}
svc := newResetQuotaSvc(stub)
@@ -191,7 +257,7 @@ func TestAdminResetQuota_ReturnsRefreshedSub(t *testing.T) {
sub: &UserSubscription{
ID: 6,
UserID: 10,
- GroupID: 20,
+ GroupID: ptrInt64(20),
DailyUsageUSD: 99.9,
},
}
@@ -205,3 +271,47 @@ func TestAdminResetQuota_ReturnsRefreshedSub(t *testing.T) {
require.Equal(t, float64(0), result.DailyUsageUSD, "返回的订阅应反映已归零的用量")
require.True(t, stub.resetDailyCalled)
}
+
+func TestCheckAndActivateWindow_UsesSubscriptionStartForMonthlyWindow(t *testing.T) {
+ startsAt := time.Date(2026, 6, 8, 16, 38, 8, 612528000, time.Local)
+ stub := &resetQuotaUserSubRepoStub{
+ sub: &UserSubscription{ID: 10, UserID: 211, GroupID: ptrInt64(13), StartsAt: startsAt},
+ }
+ svc := newResetQuotaSvc(stub)
+
+ err := svc.CheckAndActivateWindow(context.Background(), stub.sub)
+
+ require.NoError(t, err)
+ require.True(t, stub.resetDailyCalled)
+ require.True(t, stub.resetWeeklyCalled)
+ require.True(t, stub.resetMonthlyCalled)
+ require.NotNil(t, stub.sub.MonthlyWindowStart)
+ require.Equal(t, startsAt, *stub.sub.MonthlyWindowStart)
+ require.NotEqual(t, startOfDay(startsAt), *stub.sub.MonthlyWindowStart)
+}
+
+func TestCheckAndResetWindows_UsesCurrentInstantForMonthlyWindow(t *testing.T) {
+ oldMonthlyStart := time.Now().Add(-30*24*time.Hour - time.Second)
+ stub := &resetQuotaUserSubRepoStub{
+ sub: &UserSubscription{
+ ID: 11,
+ UserID: 211,
+ GroupID: ptrInt64(13),
+ MonthlyWindowStart: &oldMonthlyStart,
+ MonthlyUsageUSD: 100,
+ },
+ }
+ svc := newResetQuotaSvc(stub)
+ before := time.Now()
+
+ err := svc.CheckAndResetWindows(context.Background(), stub.sub)
+ after := time.Now()
+
+ require.NoError(t, err)
+ require.True(t, stub.resetMonthlyCalled)
+ require.NotNil(t, stub.sub.MonthlyWindowStart)
+ require.False(t, stub.sub.MonthlyWindowStart.Before(before))
+ require.False(t, stub.sub.MonthlyWindowStart.After(after))
+ require.NotEqual(t, startOfDay(after), *stub.sub.MonthlyWindowStart)
+ require.Zero(t, stub.sub.MonthlyUsageUSD)
+}
diff --git a/backend/internal/service/subscription_service.go b/backend/internal/service/subscription_service.go
index f0a5540e0aa..1c152d9e9fe 100644
--- a/backend/internal/service/subscription_service.go
+++ b/backend/internal/service/subscription_service.go
@@ -2,6 +2,7 @@ package service
import (
"context"
+ "errors"
"fmt"
"log"
"math/rand/v2"
@@ -10,6 +11,10 @@ import (
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
+ "github.com/Wei-Shaw/sub2api/ent/group"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplan"
+ "github.com/Wei-Shaw/sub2api/ent/subscriptionplangroup"
+ "github.com/Wei-Shaw/sub2api/ent/userallowedgroup"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
@@ -25,18 +30,22 @@ var MaxExpiresAt = time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC)
const MaxValidityDays = 36500
var (
- ErrSubscriptionNotFound = infraerrors.NotFound("SUBSCRIPTION_NOT_FOUND", "subscription not found")
- ErrSubscriptionExpired = infraerrors.Forbidden("SUBSCRIPTION_EXPIRED", "subscription has expired")
- ErrSubscriptionSuspended = infraerrors.Forbidden("SUBSCRIPTION_SUSPENDED", "subscription is suspended")
- ErrSubscriptionAlreadyExists = infraerrors.Conflict("SUBSCRIPTION_ALREADY_EXISTS", "subscription already exists for this user and group")
- ErrSubscriptionAssignConflict = infraerrors.Conflict("SUBSCRIPTION_ASSIGN_CONFLICT", "subscription exists but request conflicts with existing assignment semantics")
- ErrGroupNotSubscriptionType = infraerrors.BadRequest("GROUP_NOT_SUBSCRIPTION_TYPE", "group is not a subscription type")
- ErrInvalidInput = infraerrors.BadRequest("INVALID_INPUT", "at least one of resetDaily, resetWeekly, or resetMonthly must be true")
- ErrDailyLimitExceeded = infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", "daily usage limit exceeded")
- ErrWeeklyLimitExceeded = infraerrors.TooManyRequests("WEEKLY_LIMIT_EXCEEDED", "weekly usage limit exceeded")
- ErrMonthlyLimitExceeded = infraerrors.TooManyRequests("MONTHLY_LIMIT_EXCEEDED", "monthly usage limit exceeded")
- ErrSubscriptionNilInput = infraerrors.BadRequest("SUBSCRIPTION_NIL_INPUT", "subscription input cannot be nil")
- ErrAdjustWouldExpire = infraerrors.BadRequest("ADJUST_WOULD_EXPIRE", "adjustment would result in expired subscription (remaining days must be > 0)")
+ ErrSubscriptionNotFound = infraerrors.NotFound("SUBSCRIPTION_NOT_FOUND", "subscription not found")
+ ErrSubscriptionExpired = infraerrors.Forbidden("SUBSCRIPTION_EXPIRED", "subscription has expired")
+ ErrSubscriptionSuspended = infraerrors.Forbidden("SUBSCRIPTION_SUSPENDED", "subscription is suspended")
+ ErrSubscriptionAlreadyExists = infraerrors.Conflict("SUBSCRIPTION_ALREADY_EXISTS", "subscription already exists for this user and group")
+ ErrSubscriptionAssignConflict = infraerrors.Conflict("SUBSCRIPTION_ASSIGN_CONFLICT", "subscription exists but request conflicts with existing assignment semantics")
+ ErrSubscriptionUsageBillingInFlight = infraerrors.Conflict(
+ "SUBSCRIPTION_USAGE_BILLING_IN_FLIGHT",
+ "wallet has in-flight usage billing; reconcile it before revoking the subscription",
+ )
+ ErrGroupNotSubscriptionType = infraerrors.BadRequest("GROUP_NOT_SUBSCRIPTION_TYPE", "group is not a subscription type")
+ ErrInvalidInput = infraerrors.BadRequest("INVALID_INPUT", "at least one of resetDaily, resetWeekly, or resetMonthly must be true")
+ ErrDailyLimitExceeded = infraerrors.TooManyRequests("DAILY_LIMIT_EXCEEDED", "daily usage limit exceeded")
+ ErrWeeklyLimitExceeded = infraerrors.TooManyRequests("WEEKLY_LIMIT_EXCEEDED", "weekly usage limit exceeded")
+ ErrMonthlyLimitExceeded = infraerrors.TooManyRequests("MONTHLY_LIMIT_EXCEEDED", "monthly usage limit exceeded")
+ ErrSubscriptionNilInput = infraerrors.BadRequest("SUBSCRIPTION_NIL_INPUT", "subscription input cannot be nil")
+ ErrAdjustWouldExpire = infraerrors.BadRequest("ADJUST_WOULD_EXPIRE", "adjustment would result in expired subscription (remaining days must be > 0)")
)
// SubscriptionService 订阅服务
@@ -45,6 +54,8 @@ type SubscriptionService struct {
userSubRepo UserSubscriptionRepository
billingCacheService *BillingCacheService
entClient *dbent.Client
+ walletKeyService WalletGroupKeyService
+ walletTopupService WalletTopupService
// L1 缓存:加速中间件热路径的订阅查询
subCacheL1 *ristretto.Cache
@@ -55,6 +66,44 @@ type SubscriptionService struct {
maintenanceQueue *SubscriptionMaintenanceQueue
}
+// subscriptionAssignmentLocker is implemented by the PostgreSQL repository.
+// It serializes entitlement mutations for one user inside the caller's
+// transaction, so concurrent paid orders cannot both extend from the same
+// stale expiration timestamp. In-memory test repositories need not implement
+// it because they do not provide database transaction semantics.
+type subscriptionAssignmentLocker interface {
+ LockUserForSubscriptionAssignment(ctx context.Context, userID int64) error
+}
+
+// WalletGroupKeyService 钱包 key 服务接口。
+//
+// 5/14 反转决策(参见 docs/plans/2026-05-14-wallet-single-key-reversal.md):
+// 激活流程走 EnsureWalletUniversalKey 单 key 路径,建 1 把 group_id=NULL 通用 key,
+// 靠 model_router (B1.1/B1.2) 按调用模型自动路由到对应 group。
+// EnsureWalletGroupKeys 多 key 路径保留作底层能力,激活不再调用(不删,将来要切回快速)。
+type WalletGroupKeyService interface {
+ EnsureWalletGroupKeys(ctx context.Context, userID int64, groupIDs []int64) ([]APIKey, int, error)
+ EnsureWalletUniversalKey(ctx context.Context, userID int64) (*APIKey, bool, error)
+}
+
+// WalletTopupService 钱包叠加充值接口(B2.4):把 deltaUSD 同时累加到现有钱包订阅的
+// wallet_balance_usd 和 wallet_initial_usd,并写一条 reason='topup' 流水。
+//
+// SubscriptionService 在「用户已有 active 钱包 + 本次是额度卡 (plan_type='credits')」
+// 场景下调用,避免为额度卡新建独立 wallet 行。WalletService 实现此接口。
+type WalletTopupService interface {
+ Activate(ctx context.Context, subscriptionID int64, initialUSD float64, operatorID *int64, notes string) (WalletLedgerEntry, error)
+ Topup(ctx context.Context, subscriptionID int64, deltaUSD float64, operatorID *int64, notes string) (WalletLedgerEntry, error)
+}
+
+// WalletPaymentSourceTopupService records the immutable payment order that
+// created a credits activation/top-up. Payment fulfillment must use these
+// methods so a later refund can reverse exactly that purchase delta.
+type WalletPaymentSourceTopupService interface {
+ ActivateFromPayment(ctx context.Context, subscriptionID int64, initialUSD float64, paymentOrderID int64, notes string) (WalletLedgerEntry, error)
+ TopupFromPayment(ctx context.Context, subscriptionID int64, deltaUSD float64, paymentOrderID int64, notes string) (WalletLedgerEntry, error)
+}
+
// NewSubscriptionService 创建订阅服务
func NewSubscriptionService(groupRepo GroupRepository, userSubRepo UserSubscriptionRepository, billingCacheService *BillingCacheService, entClient *dbent.Client, cfg *config.Config) *SubscriptionService {
svc := &SubscriptionService{
@@ -68,6 +117,16 @@ func NewSubscriptionService(groupRepo GroupRepository, userSubRepo UserSubscript
return svc
}
+func (s *SubscriptionService) SetWalletGroupKeyService(keyService WalletGroupKeyService) {
+ s.walletKeyService = keyService
+}
+
+// SetWalletTopupService 注入钱包叠加服务。未注入时,已有 active 钱包 + 再来一张额度卡
+// 直接返回 ErrSubscriptionAssignConflict(B2.4 之前的旧行为)。
+func (s *SubscriptionService) SetWalletTopupService(topup WalletTopupService) {
+ s.walletTopupService = topup
+}
+
func (s *SubscriptionService) initMaintenanceQueue(cfg *config.Config) {
if cfg == nil {
return
@@ -140,26 +199,253 @@ func (s *SubscriptionService) InvalidateSubCache(userID, groupID int64) {
return
}
s.subCacheL1.Del(subCacheKey(userID, groupID))
+ s.subCacheL1.Wait()
}
// AssignSubscriptionInput 分配订阅输入
+//
+// 三种模式:
+// - Plan 模式:PlanID > 0, WalletInitialUSD == nil,由 plan 读取钱包额度/有效期
+// - Group 模式(v3):GroupID > 0, WalletInitialUSD == nil
+// - 钱包模式 (v4):WalletInitialUSD != nil, GroupID 忽略;用户级,与 group 解耦
type AssignSubscriptionInput struct {
UserID int64
GroupID int64
ValidityDays int
AssignedBy int64
Notes string
+
+ // WalletInitialUSD 非 nil → 走钱包路径:创建一条 group_id=NULL 的钱包订阅,
+ // 初始余额=该值(同时写入 wallet_initial_usd 和 wallet_balance_usd)。
+ // 月卡允许一个用户多条 active wallet 订阅并存,按 expires_at 先到期先消费。
+ WalletInitialUSD *float64
+
+ // PlanID 钱包模式下用于查 subscription_plan_groups 决定建哪些 group 的 key。
+ // 为 nil 时跳过自动建 key(仅创建钱包订阅,admin 手动开通场景)。
+ PlanID *int64
+
+ // PlanType 钱包模式 plan 形态:subscription (月卡) / credits (额度卡)。
+ // 空串视为 subscription。credits 走永久 expires_at(截断到 MaxExpiresAt 2099)。
+ // 来源:payment_fulfillment.doWalletSub 读 SubscriptionPlan.PlanType 透传。
+ PlanType string
+
+ // PaymentOrderID is set only by payment fulfillment. It is persisted on the
+ // activation/topup ledger row as the immutable source for refund reversal.
+ PaymentOrderID *int64
+}
+
+// IsCreditsAssign 当前 input 是否为额度卡(永久有效)分配。
+func (i *AssignSubscriptionInput) IsCreditsAssign() bool {
+ return i != nil && i.PlanType == PlanTypeCredits
+}
+
+// IsWalletAssign 判断当前 input 是否为钱包模式分配。
+func (i *AssignSubscriptionInput) IsWalletAssign() bool {
+ return i != nil && i.WalletInitialUSD != nil
+}
+
+// RunAssignmentTransaction executes an admin assignment workflow in one Ent
+// transaction. The caller may include the idempotency claim/result and
+// auxiliary writes (for example affiliate ledger entries) in execute so that
+// a commit is all-or-nothing across every financial side effect.
+func (s *SubscriptionService) RunAssignmentTransaction(ctx context.Context, execute func(context.Context) error) error {
+ if execute == nil {
+ return infraerrors.InternalServer("ASSIGNMENT_TRANSACTION_EXECUTOR_NIL", "assignment transaction executor is nil")
+ }
+ if dbent.TxFromContext(ctx) != nil {
+ return execute(ctx)
+ }
+ if s == nil || s.entClient == nil {
+ return infraerrors.ServiceUnavailable("ASSIGNMENT_TRANSACTION_UNAVAILABLE", "assignment transaction is unavailable")
+ }
+
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return fmt.Errorf("begin assignment transaction: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ txCtx := dbent.NewTxContext(ctx, tx)
+ if err := execute(txCtx); err != nil {
+ return err
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit assignment transaction: %w", err)
+ }
+ return nil
}
// AssignSubscription 分配订阅给用户(不允许重复分配)
func (s *SubscriptionService) AssignSubscription(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, error) {
- sub, _, err := s.assignSubscriptionWithReuse(ctx, input)
+ normalized, err := s.normalizePlanWalletAssignInput(ctx, input)
+ if err != nil {
+ return nil, err
+ }
+ if normalized.IsWalletAssign() {
+ sub, assignErr := s.assignWalletSubscriptionAtomic(ctx, normalized)
+ return assignmentResultWithPlanType(sub, normalized), assignErr
+ }
+ if s.entClient != nil && dbent.TxFromContext(ctx) == nil {
+ sub, assignErr := s.assignGroupSubscriptionAtomic(ctx, normalized)
+ return assignmentResultWithPlanType(sub, normalized), assignErr
+ }
+ sub, _, err := s.assignSubscriptionWithReuse(ctx, normalized)
+ if err != nil {
+ return nil, err
+ }
+ return assignmentResultWithPlanType(sub, normalized), nil
+}
+
+func assignmentResultWithPlanType(sub *UserSubscription, input *AssignSubscriptionInput) *UserSubscription {
+ if sub == nil || input == nil || input.PlanID == nil {
+ return sub
+ }
+ result := *sub
+ result.AssignmentPlanType = input.PlanType
+ return &result
+}
+
+func (s *SubscriptionService) assignGroupSubscriptionAtomic(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, error) {
+ sub, _, err := s.assignGroupSubscriptionWithReuseAtomic(ctx, input)
+ return sub, err
+}
+
+func (s *SubscriptionService) assignGroupSubscriptionWithReuseAtomic(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, bool, error) {
+ if s.entClient == nil || dbent.TxFromContext(ctx) != nil {
+ return s.assignSubscriptionWithReuse(ctx, input)
+ }
+
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return nil, false, fmt.Errorf("begin subscription assignment transaction: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ txCtx := dbent.NewTxContext(ctx, tx)
+ sub, reused, err := s.assignSubscriptionWithReuse(txCtx, input)
+ if err != nil {
+ return nil, false, err
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, false, fmt.Errorf("commit subscription assignment transaction: %w", err)
+ }
+ return sub, reused, nil
+}
+
+func (s *SubscriptionService) assignWalletSubscriptionAtomic(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, error) {
+ if s.entClient == nil || dbent.TxFromContext(ctx) != nil {
+ sub, _, err := s.assignWalletSubscriptionWithReuse(ctx, input)
+ return sub, err
+ }
+
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("begin wallet assignment transaction: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ txCtx := dbent.NewTxContext(ctx, tx)
+ sub, _, err := s.assignWalletSubscriptionWithReuse(txCtx, input)
if err != nil {
return nil, err
}
+ if err := tx.Commit(); err != nil {
+ return nil, fmt.Errorf("commit wallet assignment transaction: %w", err)
+ }
return sub, nil
}
+func (s *SubscriptionService) normalizePlanWalletAssignInput(ctx context.Context, input *AssignSubscriptionInput) (*AssignSubscriptionInput, error) {
+ if input == nil {
+ return nil, ErrSubscriptionNilInput
+ }
+ if input.PlanID == nil || input.WalletInitialUSD != nil {
+ return input, nil
+ }
+
+ client := s.entClient
+ if tx := dbent.TxFromContext(ctx); tx != nil {
+ client = tx.Client()
+ }
+ if client == nil {
+ return nil, infraerrors.BadRequest("SUBSCRIPTION_PLAN_UNAVAILABLE", "subscription plan lookup is unavailable")
+ }
+
+ plan, err := client.SubscriptionPlan.Query().
+ Where(subscriptionplan.IDEQ(*input.PlanID)).
+ Only(ctx)
+ if dbent.IsNotFound(err) {
+ return nil, infraerrors.BadRequest("SUBSCRIPTION_PLAN_NOT_FOUND", "subscription plan not found")
+ }
+ if err != nil {
+ return nil, err
+ }
+ planType, err := validatePlanType(plan.PlanType)
+ if err != nil {
+ return nil, err
+ }
+ if planType != PlanTypeCredits {
+ primaryGroupID, err := s.resolveSubscriptionPlanPrimaryGroupID(ctx, client, plan)
+ if err != nil {
+ return nil, err
+ }
+ return &AssignSubscriptionInput{
+ UserID: input.UserID,
+ GroupID: primaryGroupID,
+ ValidityDays: plan.ValidityDays,
+ AssignedBy: input.AssignedBy,
+ Notes: input.Notes,
+ PlanID: input.PlanID,
+ PlanType: planType,
+ }, nil
+ }
+ if plan.WalletQuotaUsd == nil || *plan.WalletQuotaUsd <= 0 {
+ return nil, infraerrors.BadRequest("SUBSCRIPTION_PLAN_NOT_WALLET", "credits plan is not a wallet plan")
+ }
+
+ walletInitial := *plan.WalletQuotaUsd
+ return &AssignSubscriptionInput{
+ UserID: input.UserID,
+ GroupID: input.GroupID,
+ ValidityDays: plan.ValidityDays,
+ AssignedBy: input.AssignedBy,
+ Notes: input.Notes,
+ WalletInitialUSD: &walletInitial,
+ PlanID: input.PlanID,
+ PlanType: planType,
+ }, nil
+}
+
+func (s *SubscriptionService) resolveSubscriptionPlanPrimaryGroupID(ctx context.Context, client *dbent.Client, plan *dbent.SubscriptionPlan) (int64, error) {
+ if plan == nil {
+ return 0, infraerrors.BadRequest("SUBSCRIPTION_PLAN_NOT_FOUND", "subscription plan not found")
+ }
+ if plan.GroupID != nil && *plan.GroupID > 0 {
+ return *plan.GroupID, nil
+ }
+ if client == nil {
+ return 0, infraerrors.BadRequest("SUBSCRIPTION_PLAN_GROUP_REQUIRED", "subscription plan group lookup is unavailable")
+ }
+ groupIDs, err := client.SubscriptionPlanGroup.Query().
+ Where(
+ subscriptionplangroup.PlanIDEQ(plan.ID),
+ subscriptionplangroup.HasGroupWith(
+ group.SubscriptionTypeEQ(SubscriptionTypeSubscription),
+ group.StatusEQ(StatusActive),
+ group.DeletedAtIsNil(),
+ ),
+ ).
+ Select(subscriptionplangroup.FieldGroupID).
+ Ints(ctx)
+ if err != nil {
+ return 0, err
+ }
+ if len(groupIDs) != 1 {
+ return 0, infraerrors.BadRequest("SUBSCRIPTION_PLAN_GROUP_REQUIRED", "monthly subscription plans must have exactly one active subscription group")
+ }
+ return int64(groupIDs[0]), nil
+}
+
// AssignOrExtendSubscription 分配或续期订阅(用于兑换码等场景)
// 如果用户已有同分组的订阅:
// - 未过期:从当前过期时间累加天数
@@ -167,7 +453,32 @@ func (s *SubscriptionService) AssignSubscription(ctx context.Context, input *Ass
//
// 如果没有订阅:创建新订阅
func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, bool, error) {
- // 检查分组是否存在且为订阅类型
+ if input == nil {
+ return nil, false, ErrSubscriptionNilInput
+ }
+ if s.entClient == nil || dbent.TxFromContext(ctx) != nil {
+ return s.assignOrExtendSubscriptionInTransaction(ctx, input)
+ }
+
+ tx, err := s.entClient.Tx(ctx)
+ if err != nil {
+ return nil, false, fmt.Errorf("begin subscription assignment transaction: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ txCtx := dbent.NewTxContext(ctx, tx)
+ sub, reused, err := s.assignOrExtendSubscriptionInTransaction(txCtx, input)
+ if err != nil {
+ return nil, false, err
+ }
+ if err := tx.Commit(); err != nil {
+ return nil, false, fmt.Errorf("commit subscription assignment transaction: %w", err)
+ }
+ s.invalidateSubscriptionCaches(ctx, input.UserID, input.GroupID)
+ return sub, reused, nil
+}
+
+func (s *SubscriptionService) assignOrExtendSubscriptionInTransaction(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, bool, error) {
group, err := s.groupRepo.GetByID(ctx, input.GroupID)
if err != nil {
return nil, false, fmt.Errorf("group not found: %w", err)
@@ -175,113 +486,85 @@ func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, in
if !group.IsSubscriptionType() {
return nil, false, ErrGroupNotSubscriptionType
}
+ if dbent.TxFromContext(ctx) != nil {
+ if locker, ok := s.userSubRepo.(subscriptionAssignmentLocker); ok {
+ if err := locker.LockUserForSubscriptionAssignment(ctx, input.UserID); err != nil {
+ return nil, false, fmt.Errorf("lock subscription assignment: %w", err)
+ }
+ }
+ }
- // 查询是否已有订阅
existingSub, err := s.userSubRepo.GetByUserIDAndGroupID(ctx, input.UserID, input.GroupID)
- if err != nil {
- // 不存在记录是正常情况,其他错误需要返回
+ if errors.Is(err, ErrSubscriptionNotFound) {
existingSub = nil
+ } else if err != nil {
+ return nil, false, err
}
- validityDays := input.ValidityDays
- if validityDays <= 0 {
- validityDays = 30
- }
- if validityDays > MaxValidityDays {
- validityDays = MaxValidityDays
+ validityDays := normalizeAssignValidityDays(input.ValidityDays)
+ if existingSub == nil {
+ sub, err := s.createSubscription(ctx, input)
+ return sub, false, err
}
- // 已有订阅,执行续期(在事务中完成所有更新)
- if existingSub != nil {
- now := time.Now()
- var newExpiresAt time.Time
-
- if existingSub.ExpiresAt.After(now) {
- // 未过期:从当前过期时间累加
- newExpiresAt = existingSub.ExpiresAt.AddDate(0, 0, validityDays)
- } else {
- // 已过期:从当前时间开始计算
- newExpiresAt = now.AddDate(0, 0, validityDays)
- }
-
- // 确保不超过最大过期时间
- if newExpiresAt.After(MaxExpiresAt) {
- newExpiresAt = MaxExpiresAt
- }
-
- // 开启事务:ExtendExpiry + UpdateStatus + UpdateNotes 在同一事务中完成
- tx, err := s.entClient.Tx(ctx)
- if err != nil {
- return nil, false, fmt.Errorf("begin transaction: %w", err)
- }
- txCtx := dbent.NewTxContext(ctx, tx)
-
- // 更新过期时间
- if err := s.userSubRepo.ExtendExpiry(txCtx, existingSub.ID, newExpiresAt); err != nil {
- _ = tx.Rollback()
- return nil, false, fmt.Errorf("extend subscription: %w", err)
- }
-
- // 如果订阅已过期或被暂停,恢复为active状态
- if existingSub.Status != SubscriptionStatusActive {
- if err := s.userSubRepo.UpdateStatus(txCtx, existingSub.ID, SubscriptionStatusActive); err != nil {
- _ = tx.Rollback()
- return nil, false, fmt.Errorf("update subscription status: %w", err)
- }
- }
-
- // 追加备注
- if input.Notes != "" {
- newNotes := existingSub.Notes
- if newNotes != "" {
- newNotes += "\n"
- }
- newNotes += input.Notes
- if err := s.userSubRepo.UpdateNotes(txCtx, existingSub.ID, newNotes); err != nil {
- _ = tx.Rollback()
- return nil, false, fmt.Errorf("update subscription notes: %w", err)
- }
+ now := time.Now()
+ newExpiresAt := now.AddDate(0, 0, validityDays)
+ if existingSub.ExpiresAt.After(now) {
+ newExpiresAt = existingSub.ExpiresAt.AddDate(0, 0, validityDays)
+ }
+ if newExpiresAt.After(MaxExpiresAt) {
+ newExpiresAt = MaxExpiresAt
+ }
+ if err := s.userSubRepo.ExtendExpiry(ctx, existingSub.ID, newExpiresAt); err != nil {
+ return nil, false, fmt.Errorf("extend subscription: %w", err)
+ }
+ if existingSub.Status != SubscriptionStatusActive {
+ if err := s.userSubRepo.UpdateStatus(ctx, existingSub.ID, SubscriptionStatusActive); err != nil {
+ return nil, false, fmt.Errorf("update subscription status: %w", err)
}
-
- // 提交事务
- if err := tx.Commit(); err != nil {
- return nil, false, fmt.Errorf("commit transaction: %w", err)
+ }
+ if input.Notes != "" {
+ newNotes := existingSub.Notes
+ if newNotes != "" {
+ newNotes += "\n"
}
-
- // 失效订阅缓存
- s.InvalidateSubCache(input.UserID, input.GroupID)
- if s.billingCacheService != nil {
- userID, groupID := input.UserID, input.GroupID
- go func() {
- cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- _ = s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID)
- }()
+ newNotes += input.Notes
+ if err := s.userSubRepo.UpdateNotes(ctx, existingSub.ID, newNotes); err != nil {
+ return nil, false, fmt.Errorf("update subscription notes: %w", err)
}
-
- // 返回更新后的订阅
- sub, err := s.userSubRepo.GetByID(ctx, existingSub.ID)
- return sub, true, err // true 表示是续期
}
-
- // 没有订阅,创建新订阅
- sub, err := s.createSubscription(ctx, input)
- if err != nil {
+ if err := s.ensureSubscriptionGroupAccess(ctx, input.UserID, input.GroupID); err != nil {
return nil, false, err
}
+ sub, err := s.userSubRepo.GetByID(ctx, existingSub.ID)
+ return sub, true, err
+}
- // 失效订阅缓存
- s.InvalidateSubCache(input.UserID, input.GroupID)
- if s.billingCacheService != nil {
- userID, groupID := input.UserID, input.GroupID
- go func() {
- cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- _ = s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID)
- }()
+func (s *SubscriptionService) invalidateSubscriptionCaches(ctx context.Context, userID, groupID int64) {
+ s.InvalidateSubCache(userID, groupID)
+ if s.billingCacheService == nil {
+ return
}
+ invalidateBillingCacheAfterCommit(ctx, "subscription mutation", func(cacheCtx context.Context) error {
+ return s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID)
+ })
+}
- return sub, false, nil // false 表示是新建
+// InvalidateSubscriptionCachesAfterCommit reloads the authoritative
+// subscription after an outer transaction has committed, then evicts both the
+// process-local and shared billing caches. Re-reading by ID also makes an
+// idempotent replay repair a cache invalidation that may have been interrupted
+// after the original commit.
+func (s *SubscriptionService) InvalidateSubscriptionCachesAfterCommit(ctx context.Context, subscriptionID int64) error {
+ sub, err := s.userSubRepo.GetByID(ctx, subscriptionID)
+ if err != nil {
+ return fmt.Errorf("reload subscription for post-commit cache invalidation: %w", err)
+ }
+ if sub.GroupID == nil {
+ return nil
+ }
+ s.invalidateSubscriptionCaches(ctx, sub.UserID, *sub.GroupID)
+ return nil
}
// createSubscription 创建新订阅(内部方法)
@@ -300,9 +583,10 @@ func (s *SubscriptionService) createSubscription(ctx context.Context, input *Ass
expiresAt = MaxExpiresAt
}
+ groupID := input.GroupID
sub := &UserSubscription{
UserID: input.UserID,
- GroupID: input.GroupID,
+ GroupID: &groupID,
StartsAt: now,
ExpiresAt: expiresAt,
Status: SubscriptionStatusActive,
@@ -319,11 +603,259 @@ func (s *SubscriptionService) createSubscription(ctx context.Context, input *Ass
if err := s.userSubRepo.Create(ctx, sub); err != nil {
return nil, err
}
+ if err := s.ensureSubscriptionGroupAccess(ctx, input.UserID, input.GroupID); err != nil {
+ return nil, err
+ }
// 重新获取完整订阅信息(包含关联)
return s.userSubRepo.GetByID(ctx, sub.ID)
}
+func (s *SubscriptionService) ensureSubscriptionGroupAccess(ctx context.Context, userID, groupID int64) error {
+ if userID <= 0 || groupID <= 0 || s.entClient == nil {
+ return nil
+ }
+ client := s.entClient
+ if tx := dbent.TxFromContext(ctx); tx != nil {
+ client = tx.Client()
+ }
+ if err := client.UserAllowedGroup.Create().
+ SetUserID(userID).
+ SetGroupID(groupID).
+ OnConflictColumns(userallowedgroup.FieldUserID, userallowedgroup.FieldGroupID).
+ DoNothing().
+ Exec(ctx); err != nil {
+ return fmt.Errorf("sync subscription allowed group: %w", err)
+ }
+ return nil
+}
+
+// assignWalletSubscriptionWithReuse 钱包模式分配(v4)。
+//
+// 三条分支:
+// 1. 用户没有 active 钱包,或本次是月卡(PlanType='subscription')→ 新建独立行。
+// 月卡叠月卡:允许多条 active wallet 并存,各自独立到期时间和余额,
+// 计费时按 expires_at 升序选行(先到期先消费)。
+// 2. 已有 active 钱包 + 本次是额度卡 (PlanType='credits') →
+// 调用 walletTopupService.Topup,把 quota 合入现有钱包(balance+initial 双 +delta,
+// ledger 写 reason='topup');不新建 user_subscriptions 行。设计 §2.3。
+// 3. 已有 active 钱包 + 本次是额度卡 + 未注入 topup 服务 →
+// 返回 ErrSubscriptionAssignConflict(conflict_reason=wallet_topup_unsupported)。
+//
+// 不复用 group 路径的 ExistsByUserIDAndGroupID 是因为钱包订阅 group_id=NULL,
+// 复合查询用 group_id=0 拿不到;改用 GetActiveWalletByUserID。
+func (s *SubscriptionService) assignWalletSubscriptionWithReuse(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, bool, error) {
+ if input.WalletInitialUSD == nil || *input.WalletInitialUSD <= 0 {
+ return nil, false, fmt.Errorf("wallet_initial_usd must be > 0")
+ }
+
+ if !input.IsCreditsAssign() {
+ return nil, false, infraerrors.BadRequest("MONTHLY_PLAN_WALLET_DISABLED", "monthly subscription plans must use group subscriptions")
+ }
+
+ // 额度卡:查找现有 active 钱包并叠加。
+ existing, err := s.userSubRepo.GetActiveCreditsWalletByUserID(ctx, input.UserID)
+ if err != nil && !errors.Is(err, ErrSubscriptionNotFound) {
+ return nil, false, err
+ }
+ if existing != nil {
+ return s.topupExistingWallet(ctx, input, existing)
+ }
+
+ // 额度卡但还没有任何 active 钱包 → 新建。
+ sub, err := s.createWalletSubscription(ctx, input)
+ if err != nil {
+ return nil, false, err
+ }
+ if err := s.activateWalletSubscription(ctx, input, sub); err != nil {
+ return nil, false, err
+ }
+ if err := s.ensureWalletGroupKeys(ctx, input, sub); err != nil {
+ return nil, false, err
+ }
+ creditDelta := *input.WalletInitialUSD
+ sub.WalletCreditDeltaUSD = &creditDelta
+
+ // 钱包订阅与 group 解耦,不需要按 (user, group) 失效订阅缓存;
+ // 中间件下次请求会直接 GetActiveWalletByUserID 命中。
+ return sub, false, nil
+}
+
+func (s *SubscriptionService) activateWalletSubscription(ctx context.Context, input *AssignSubscriptionInput, sub *UserSubscription) error {
+ if s.walletTopupService == nil {
+ return infraerrors.InternalServer("WALLET_LIFECYCLE_UNAVAILABLE", "wallet lifecycle service is not configured")
+ }
+ if input == nil || input.WalletInitialUSD == nil || sub == nil {
+ return ErrSubscriptionNilInput
+ }
+ var operatorID *int64
+ if input.AssignedBy > 0 {
+ op := input.AssignedBy
+ operatorID = &op
+ }
+ var err error
+ if input.PaymentOrderID != nil {
+ sourced, ok := s.walletTopupService.(WalletPaymentSourceTopupService)
+ if !ok {
+ return infraerrors.InternalServer("WALLET_PAYMENT_SOURCE_UNAVAILABLE", "wallet lifecycle does not support immutable payment sources")
+ }
+ _, err = sourced.ActivateFromPayment(ctx, sub.ID, *input.WalletInitialUSD, *input.PaymentOrderID, strings.TrimSpace(input.Notes))
+ } else {
+ _, err = s.walletTopupService.Activate(ctx, sub.ID, *input.WalletInitialUSD, operatorID, strings.TrimSpace(input.Notes))
+ }
+ if err != nil {
+ return fmt.Errorf("activate wallet ledger: %w", err)
+ }
+ return nil
+}
+
+// topupExistingWallet 用户已有 active 钱包 + credits → 走 B2.4 叠加路径或拒绝。
+//
+// 常规入口只会让 credits 调到这里;非 credits 若误入则返回 conflict 作为兜底。
+// 未注入 walletTopupService 时返回 wallet_topup_unsupported,避免额度卡静默丢失。
+//
+// 返回 reused=true,sub.ID 不变(叠加在 existing 行上),但 WalletBalanceUSD /
+// WalletInitialUSD 字段已更新到叠加后的值;同时 ensureWalletGroupKeys 也会跑一遍
+// (幂等:缺哪把补哪把),保证额度卡新关联的 group 也能拿到 key。
+func (s *SubscriptionService) topupExistingWallet(ctx context.Context, input *AssignSubscriptionInput, existing *UserSubscription) (*UserSubscription, bool, error) {
+ if !input.IsCreditsAssign() {
+ return nil, false, ErrSubscriptionAssignConflict.WithMetadata(map[string]string{
+ "conflict_reason": "wallet_already_active",
+ })
+ }
+ if s.walletTopupService == nil {
+ return nil, false, ErrSubscriptionAssignConflict.WithMetadata(map[string]string{
+ "conflict_reason": "wallet_topup_unsupported",
+ })
+ }
+
+ delta := *input.WalletInitialUSD
+ notes := fmt.Sprintf("credits topup: %s", strings.TrimSpace(input.Notes))
+ var operator *int64
+ if input.AssignedBy > 0 {
+ op := input.AssignedBy
+ operator = &op
+ }
+ var entry WalletLedgerEntry
+ var err error
+ if input.PaymentOrderID != nil {
+ sourced, ok := s.walletTopupService.(WalletPaymentSourceTopupService)
+ if !ok {
+ return nil, false, infraerrors.InternalServer("WALLET_PAYMENT_SOURCE_UNAVAILABLE", "wallet lifecycle does not support immutable payment sources")
+ }
+ entry, err = sourced.TopupFromPayment(ctx, existing.ID, delta, *input.PaymentOrderID, notes)
+ } else {
+ entry, err = s.walletTopupService.Topup(ctx, existing.ID, delta, operator, notes)
+ }
+ if err != nil {
+ return nil, false, fmt.Errorf("topup wallet: %w", err)
+ }
+
+ // 同步内存字段(避免再读一次 DB)。balance_after 来自 ledger 返回,权威。
+ newBalance := entry.BalanceAfter
+ newInitial := *existing.WalletInitialUSD + delta
+ existing.WalletBalanceUSD = &newBalance
+ existing.WalletInitialUSD = &newInitial
+ existing.WalletCreditDeltaUSD = &delta
+
+ // 额度卡新关联的 group 可能没建 key —— 跑一遍 ensureWalletGroupKeys 补缺。
+ // 已有 key 复用,不重复建。
+ if err := s.ensureWalletGroupKeys(ctx, input, existing); err != nil {
+ return nil, false, err
+ }
+
+ return existing, true, nil
+}
+
+// ensureWalletGroupKeys 钱包激活/topup 时为用户建 1 把通用 key(group_id=NULL),
+// 靠 model_router (B1.1/B1.2) 按调用模型自动路由。
+//
+// 5/14 反转决策(参见 docs/plans/2026-05-14-wallet-single-key-reversal.md):
+// 撤销 B2.2 多 key 改造,回到 B1.4 单 key 形态。多 key 路径 (EnsureWalletGroupKeys)
+// 保留作底层能力,激活不再调用,函数名保留 ensureWalletGroupKeys 减少调用方改动。
+//
+// 失败策略:建 key 失败返回错误,钱包订阅在外层事务中应一并回滚。
+func (s *SubscriptionService) ensureWalletGroupKeys(ctx context.Context, input *AssignSubscriptionInput, sub *UserSubscription) error {
+ if s.walletKeyService == nil || sub == nil || input == nil {
+ return nil
+ }
+ key, created, err := s.walletKeyService.EnsureWalletUniversalKey(ctx, input.UserID)
+ if err != nil {
+ return fmt.Errorf("ensure wallet universal api key: %w", err)
+ }
+ sub.WalletUniversalKey = key
+ sub.WalletUniversalKeyCreated = created
+ return nil
+}
+
+// lookupPlanGroupIDs 查询 plan 关联的 group ID 列表(subscription_plan_groups 表)。
+//
+// 5/14 反转决策后单 key 路径不再调用本方法;保留作多 key 路径(B2.2 EnsureWalletGroupKeys)
+// 的底层能力,将来若切回多 key 形态直接复用,避免重写。
+//
+//nolint:unused // 反转决策保留底层能力,见 docs/plans/2026-05-14-wallet-single-key-reversal.md
+func (s *SubscriptionService) lookupPlanGroupIDs(ctx context.Context, planID int64) ([]int64, error) {
+ if s.entClient == nil {
+ return nil, nil
+ }
+ rows, err := s.entClient.SubscriptionPlanGroup.Query().
+ Where(subscriptionplangroup.PlanIDEQ(planID)).
+ Select(subscriptionplangroup.FieldGroupID).
+ Ints(ctx)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]int64, 0, len(rows))
+ for _, v := range rows {
+ out = append(out, int64(v))
+ }
+ return out, nil
+}
+
+// createWalletSubscription 创建新钱包订阅(内部方法)。group_id=NULL,
+// wallet_initial_usd 和 wallet_balance_usd 都设为 input 给定的初始值。
+//
+// 月卡 (plan_type='subscription',含空串默认):expires_at = now + validity_days。
+// 额度卡 (plan_type='credits'):expires_at = MaxExpiresAt (2099-12-31),validity_days 忽略。
+// 见 docs/plans/2026-05-13-wallet-multikey-credits-design.md §2.2。
+func (s *SubscriptionService) createWalletSubscription(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, error) {
+ if !input.IsCreditsAssign() {
+ return nil, infraerrors.BadRequest("MONTHLY_PLAN_WALLET_DISABLED", "monthly subscription plans must use group subscriptions")
+ }
+
+ now := time.Now()
+ expiresAt := MaxExpiresAt
+
+ initial := *input.WalletInitialUSD
+ balance := initial
+ lockedRates, err := s.buildMonthlyLockedRatesForPlan(ctx, input)
+ if err != nil {
+ return nil, fmt.Errorf("build monthly locked rates: %w", err)
+ }
+ sub := &UserSubscription{
+ UserID: input.UserID,
+ GroupID: nil,
+ StartsAt: now,
+ ExpiresAt: expiresAt,
+ Status: SubscriptionStatusActive,
+ WalletInitialUSD: &initial,
+ WalletBalanceUSD: &balance,
+ LockedRates: lockedRates,
+ AssignedAt: now,
+ Notes: input.Notes,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if input.AssignedBy > 0 {
+ sub.AssignedBy = &input.AssignedBy
+ }
+
+ if err := s.userSubRepo.Create(ctx, sub); err != nil {
+ return nil, err
+ }
+ return s.userSubRepo.GetByID(ctx, sub.ID)
+}
+
// BulkAssignSubscriptionInput 批量分配订阅输入
type BulkAssignSubscriptionInput struct {
UserIDs []int64
@@ -353,7 +885,7 @@ func (s *SubscriptionService) BulkAssignSubscription(ctx context.Context, input
}
for _, userID := range input.UserIDs {
- sub, reused, err := s.assignSubscriptionWithReuse(ctx, &AssignSubscriptionInput{
+ sub, reused, err := s.assignGroupSubscriptionWithReuseAtomic(ctx, &AssignSubscriptionInput{
UserID: userID,
GroupID: input.GroupID,
ValidityDays: input.ValidityDays,
@@ -381,6 +913,11 @@ func (s *SubscriptionService) BulkAssignSubscription(ctx context.Context, input
}
func (s *SubscriptionService) assignSubscriptionWithReuse(ctx context.Context, input *AssignSubscriptionInput) (*UserSubscription, bool, error) {
+ // 钱包模式 (v4):跳过 group 校验,按 user 维度幂等
+ if input.IsWalletAssign() {
+ return s.assignWalletSubscriptionWithReuse(ctx, input)
+ }
+
// 检查分组是否存在且为订阅类型
group, err := s.groupRepo.GetByID(ctx, input.GroupID)
if err != nil {
@@ -405,6 +942,9 @@ func (s *SubscriptionService) assignSubscriptionWithReuse(ctx context.Context, i
"conflict_reason": conflictReason,
})
}
+ if err := s.ensureSubscriptionGroupAccess(ctx, input.UserID, input.GroupID); err != nil {
+ return nil, false, err
+ }
return sub, true, nil
}
@@ -413,16 +953,8 @@ func (s *SubscriptionService) assignSubscriptionWithReuse(ctx context.Context, i
return nil, false, err
}
- // 失效订阅缓存
- s.InvalidateSubCache(input.UserID, input.GroupID)
- if s.billingCacheService != nil {
- userID, groupID := input.UserID, input.GroupID
- go func() {
- cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- _ = s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID)
- }()
- }
+ // 完成缓存失效后再返回,避免刚分配成功却立即读到旧订阅状态。
+ s.invalidateSubscriptionCaches(ctx, input.UserID, input.GroupID)
return sub, false, nil
}
@@ -474,15 +1006,9 @@ func (s *SubscriptionService) RevokeSubscription(ctx context.Context, subscripti
return err
}
- // 失效订阅缓存
- s.InvalidateSubCache(sub.UserID, sub.GroupID)
- if s.billingCacheService != nil {
- userID, groupID := sub.UserID, sub.GroupID
- go func() {
- cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- _ = s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID)
- }()
+ // 失效订阅缓存(钱包模式 sub.GroupID == nil,跳过 group 维度缓存)
+ if sub.GroupID != nil {
+ s.invalidateSubscriptionCaches(ctx, sub.UserID, *sub.GroupID)
}
return nil
@@ -541,15 +1067,12 @@ func (s *SubscriptionService) ExtendSubscription(ctx context.Context, subscripti
}
}
- // 失效订阅缓存
- s.InvalidateSubCache(sub.UserID, sub.GroupID)
- if s.billingCacheService != nil {
- userID, groupID := sub.UserID, sub.GroupID
- go func() {
- cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- _ = s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID)
- }()
+ // An outer Ent transaction has not committed yet, so invalidating here can
+ // race with a concurrent reader that refills the old positive cache. The
+ // transactional admin path performs a second authoritative read and
+ // invalidates after commit. Direct/autocommit callers can evict immediately.
+ if sub.GroupID != nil && dbent.TxFromContext(ctx) == nil {
+ s.invalidateSubscriptionCaches(ctx, sub.UserID, *sub.GroupID)
}
return s.userSubRepo.GetByID(ctx, subscriptionID)
@@ -600,6 +1123,39 @@ func (s *SubscriptionService) GetActiveSubscription(ctx context.Context, userID,
return &cp, nil
}
+// GetActiveWalletSubscription 查找用户当前 active 钱包订阅 (v4)。
+// 钱包订阅独立于 api_key.group_id:用户只要持有一条 wallet_balance_usd != NULL
+// 的 active 订阅,所有 group 请求都走钱包扣费。schema 上有 partial unique
+// index 保证最多一条,repo 直接 Only() 即可。
+//
+// 不命中 L1 缓存:钱包订阅频率低(一用户最多一条)且扣款会在事务里 FOR UPDATE
+// 重新查,缓存收益有限。返回 ErrSubscriptionNotFound 表示用户没钱包订阅,
+// 调用方应回退到 (user, group) 老路径或主余额检查。
+func (s *SubscriptionService) GetActiveWalletSubscription(ctx context.Context, userID int64) (*UserSubscription, error) {
+ return s.userSubRepo.GetActiveWalletByUserID(ctx, userID)
+}
+
+func (s *SubscriptionService) GetActiveCreditsWalletSubscription(ctx context.Context, userID int64) (*UserSubscription, error) {
+ return s.userSubRepo.GetActiveCreditsWalletByUserID(ctx, userID)
+}
+
+// GetActiveSubscriptionCoveringGroup 查用户 active 月卡订阅,其 plan 通过
+// subscription_plan_groups 间接覆盖了 groupID。返回 ErrSubscriptionNotFound
+// 表示不覆盖,middleware 应继续 fallback。
+//
+// 用例:用户在 admin 把 api_key.group_id 切到非订阅主 group 后,middleware
+// 不能再静默扣 user.balance,得先看 plan_groups 是否覆盖(2026-05-16 方案 C)。
+func (s *SubscriptionService) GetActiveSubscriptionCoveringGroup(ctx context.Context, userID, groupID int64) (*UserSubscription, error) {
+ return s.userSubRepo.GetActiveByPlanCoveringGroup(ctx, userID, groupID)
+}
+
+// UserHasAnyActiveSubscription 用户是否有任何 active 订阅(含钱包 / 月卡)。
+// middleware fallback 决策用:有订阅但当前 group 不覆盖 → 403;无订阅 → 允许
+// 走老 user.balance 兼容路径(纯余额用户)。
+func (s *SubscriptionService) UserHasAnyActiveSubscription(ctx context.Context, userID int64) (bool, error) {
+ return s.userSubRepo.HasAnyActiveSubscription(ctx, userID)
+}
+
// ListUserSubscriptions 获取用户的所有订阅
func (s *SubscriptionService) ListUserSubscriptions(ctx context.Context, userID int64) ([]UserSubscription, error) {
subs, err := s.userSubRepo.ListByUserID(ctx, userID)
@@ -687,17 +1243,59 @@ func startOfDay(t time.Time) time.Time {
// CheckAndActivateWindow 检查并激活窗口(首次使用时)
func (s *SubscriptionService) CheckAndActivateWindow(ctx context.Context, sub *UserSubscription) error {
- if sub.IsWindowActivated() {
+ if sub.DailyWindowStart != nil && sub.WeeklyWindowStart != nil && sub.MonthlyWindowStart != nil {
return nil
}
- // 使用当天零点作为窗口起始时间
- windowStart := startOfDay(time.Now())
- return s.userSubRepo.ActivateWindows(ctx, sub.ID, windowStart)
+ now := time.Now()
+ windowStart := startOfDay(now)
+ monthlyWindowStart := sub.StartsAt
+ if monthlyWindowStart.IsZero() {
+ monthlyWindowStart = now
+ }
+ if sub.DailyWindowStart == nil {
+ advanced, err := s.userSubRepo.AdvanceUsageWindow(ctx, sub.ID, SubscriptionUsageWindowAdvance{
+ Window: SubscriptionUsageWindowDaily,
+ NewStart: windowStart,
+ ResetUsage: false,
+ })
+ if err != nil {
+ return err
+ }
+ if advanced {
+ sub.DailyWindowStart = &windowStart
+ }
+ }
+ if sub.WeeklyWindowStart == nil {
+ advanced, err := s.userSubRepo.AdvanceUsageWindow(ctx, sub.ID, SubscriptionUsageWindowAdvance{
+ Window: SubscriptionUsageWindowWeekly,
+ NewStart: windowStart,
+ ResetUsage: false,
+ })
+ if err != nil {
+ return err
+ }
+ if advanced {
+ sub.WeeklyWindowStart = &windowStart
+ }
+ }
+ if sub.MonthlyWindowStart == nil {
+ advanced, err := s.userSubRepo.AdvanceUsageWindow(ctx, sub.ID, SubscriptionUsageWindowAdvance{
+ Window: SubscriptionUsageWindowMonthly,
+ NewStart: monthlyWindowStart,
+ ResetUsage: false,
+ })
+ if err != nil {
+ return err
+ }
+ if advanced {
+ sub.MonthlyWindowStart = &monthlyWindowStart
+ }
+ }
+ return nil
}
// AdminResetQuota manually resets the daily, weekly, and/or monthly usage windows.
-// Uses startOfDay(now) as the new window start, matching automatic resets.
func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionID int64, resetDaily, resetWeekly, resetMonthly bool) (*UserSubscription, error) {
if !resetDaily && !resetWeekly && !resetMonthly {
return nil, ErrInvalidInput
@@ -706,7 +1304,8 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI
if err != nil {
return nil, err
}
- windowStart := startOfDay(time.Now())
+ now := time.Now()
+ windowStart := startOfDay(now)
if resetDaily {
if err := s.userSubRepo.ResetDailyUsage(ctx, sub.ID, windowStart); err != nil {
return nil, err
@@ -718,19 +1317,22 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI
}
}
if resetMonthly {
- if err := s.userSubRepo.ResetMonthlyUsage(ctx, sub.ID, windowStart); err != nil {
+ if err := s.userSubRepo.ResetMonthlyUsage(ctx, sub.ID, now); err != nil {
return nil, err
}
}
// Invalidate L1 ristretto cache. Ristretto's Del() is asynchronous by design,
// so call Wait() immediately after to flush pending operations and guarantee
// the deleted key is not returned on the very next Get() call.
- s.InvalidateSubCache(sub.UserID, sub.GroupID)
- if s.subCacheL1 != nil {
- s.subCacheL1.Wait()
- }
- if s.billingCacheService != nil {
- _ = s.billingCacheService.InvalidateSubscription(ctx, sub.UserID, sub.GroupID)
+ // 钱包模式 sub.GroupID == nil,跳过 group 维度缓存
+ if sub.GroupID != nil {
+ s.InvalidateSubCache(sub.UserID, *sub.GroupID)
+ if s.subCacheL1 != nil {
+ s.subCacheL1.Wait()
+ }
+ if s.billingCacheService != nil {
+ _ = s.billingCacheService.InvalidateSubscription(ctx, sub.UserID, *sub.GroupID)
+ }
}
// Return the refreshed subscription from DB
return s.userSubRepo.GetByID(ctx, subscriptionID)
@@ -738,45 +1340,69 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI
// CheckAndResetWindows 检查并重置过期的窗口
func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *UserSubscription) error {
- // 使用当天零点作为新窗口起始时间
- windowStart := startOfDay(time.Now())
+ now := time.Now()
+ windowStart := startOfDay(now)
needsInvalidateCache := false
// 日窗口重置(24小时)
if sub.NeedsDailyReset() {
- if err := s.userSubRepo.ResetDailyUsage(ctx, sub.ID, windowStart); err != nil {
+ advanced, err := s.userSubRepo.AdvanceUsageWindow(ctx, sub.ID, SubscriptionUsageWindowAdvance{
+ Window: SubscriptionUsageWindowDaily,
+ ExpectedStart: sub.DailyWindowStart,
+ NewStart: windowStart,
+ ResetUsage: true,
+ })
+ if err != nil {
return err
}
- sub.DailyWindowStart = &windowStart
- sub.DailyUsageUSD = 0
- needsInvalidateCache = true
+ if advanced {
+ sub.DailyWindowStart = &windowStart
+ sub.DailyUsageUSD = 0
+ needsInvalidateCache = true
+ }
}
// 周窗口重置(7天)
if sub.NeedsWeeklyReset() {
- if err := s.userSubRepo.ResetWeeklyUsage(ctx, sub.ID, windowStart); err != nil {
+ advanced, err := s.userSubRepo.AdvanceUsageWindow(ctx, sub.ID, SubscriptionUsageWindowAdvance{
+ Window: SubscriptionUsageWindowWeekly,
+ ExpectedStart: sub.WeeklyWindowStart,
+ NewStart: windowStart,
+ ResetUsage: true,
+ })
+ if err != nil {
return err
}
- sub.WeeklyWindowStart = &windowStart
- sub.WeeklyUsageUSD = 0
- needsInvalidateCache = true
+ if advanced {
+ sub.WeeklyWindowStart = &windowStart
+ sub.WeeklyUsageUSD = 0
+ needsInvalidateCache = true
+ }
}
// 月窗口重置(30天)
if sub.NeedsMonthlyReset() {
- if err := s.userSubRepo.ResetMonthlyUsage(ctx, sub.ID, windowStart); err != nil {
+ advanced, err := s.userSubRepo.AdvanceUsageWindow(ctx, sub.ID, SubscriptionUsageWindowAdvance{
+ Window: SubscriptionUsageWindowMonthly,
+ ExpectedStart: sub.MonthlyWindowStart,
+ NewStart: now,
+ ResetUsage: true,
+ })
+ if err != nil {
return err
}
- sub.MonthlyWindowStart = &windowStart
- sub.MonthlyUsageUSD = 0
- needsInvalidateCache = true
+ if advanced {
+ sub.MonthlyWindowStart = &now
+ sub.MonthlyUsageUSD = 0
+ needsInvalidateCache = true
+ }
}
- // 如果有窗口被重置,失效缓存以保持一致性
- if needsInvalidateCache {
- s.InvalidateSubCache(sub.UserID, sub.GroupID)
+ // 如果有窗口被重置,失效缓存以保持一致性(钱包模式 sub.GroupID == nil,跳过 group 维度缓存)
+ if needsInvalidateCache && sub.GroupID != nil {
+ s.InvalidateSubCache(sub.UserID, *sub.GroupID)
if s.billingCacheService != nil {
- _ = s.billingCacheService.InvalidateSubscription(ctx, sub.UserID, sub.GroupID)
+ _ = s.billingCacheService.InvalidateSubscription(ctx, sub.UserID, *sub.GroupID)
}
}
@@ -799,8 +1425,9 @@ func (s *SubscriptionService) CheckUsageLimits(ctx context.Context, sub *UserSub
}
// ValidateAndCheckLimits 合并验证+限额检查(中间件热路径专用)
-// 仅做内存检查,不触发 DB 写入。窗口重置的 DB 写入由 DoWindowMaintenance 异步完成。
-// 返回 needsMaintenance 表示是否需要异步执行窗口维护。
+// 仅做内存检查,不触发 DB 写入。窗口重置的 DB 写入由 DoWindowMaintenance
+// 在请求继续前同步完成。
+// 返回 needsMaintenance 表示是否需要执行窗口维护。
func (s *SubscriptionService) ValidateAndCheckLimits(sub *UserSubscription, group *Group) (needsMaintenance bool, err error) {
// 1. 验证订阅状态
if sub.Status == SubscriptionStatusExpired {
@@ -813,78 +1440,106 @@ func (s *SubscriptionService) ValidateAndCheckLimits(sub *UserSubscription, grou
return false, ErrSubscriptionExpired
}
- // 2. 内存中修正过期窗口的用量,确保 CheckUsageLimits 不会误拒绝用户
- // 实际的 DB 窗口重置由 DoWindowMaintenance 异步完成
- if sub.NeedsDailyReset() {
- sub.DailyUsageUSD = 0
+ // 2. 使用副本计算新窗口额度,不修改可能来自 L1 cache 的订阅对象。
+ // 请求只有在同步、原子的窗口维护完成后才会继续。
+ effective := *sub
+ if effective.NeedsDailyReset() {
+ effective.DailyUsageUSD = 0
needsMaintenance = true
}
- if sub.NeedsWeeklyReset() {
- sub.WeeklyUsageUSD = 0
+ if effective.NeedsWeeklyReset() {
+ effective.WeeklyUsageUSD = 0
needsMaintenance = true
}
- if sub.NeedsMonthlyReset() {
- sub.MonthlyUsageUSD = 0
+ if effective.NeedsMonthlyReset() {
+ effective.MonthlyUsageUSD = 0
needsMaintenance = true
}
- if !sub.IsWindowActivated() {
+ if !effective.IsWindowActivated() {
needsMaintenance = true
}
// 3. 检查用量限额
- if !sub.CheckDailyLimit(group, 0) {
+ if !effective.CheckDailyLimit(group, 0) {
return needsMaintenance, ErrDailyLimitExceeded
}
- if !sub.CheckWeeklyLimit(group, 0) {
+ if !effective.CheckWeeklyLimit(group, 0) {
return needsMaintenance, ErrWeeklyLimitExceeded
}
- if !sub.CheckMonthlyLimit(group, 0) {
+ if !effective.CheckMonthlyLimit(group, 0) {
return needsMaintenance, ErrMonthlyLimitExceeded
}
return needsMaintenance, nil
}
-// DoWindowMaintenance 异步执行窗口维护(激活+重置)
-// 使用独立 context,不受请求取消影响。
-// 注意:此方法仅在 ValidateAndCheckLimits 返回 needsMaintenance=true 时调用,
-// 而 IsExpired()=true 的订阅在 ValidateAndCheckLimits 中已被拦截返回错误,
-// 因此进入此方法的订阅一定未过期,无需处理过期状态同步。
-func (s *SubscriptionService) DoWindowMaintenance(sub *UserSubscription) {
- if s == nil {
- return
+// DoWindowMaintenance completes authoritative, CAS-protected window
+// maintenance before the request proceeds. A configured bounded queue still
+// limits DB fan-out, but callers wait for the result so usage settlement cannot
+// overtake a delayed reset.
+func (s *SubscriptionService) DoWindowMaintenance(ctx context.Context, subscriptionID int64) (*UserSubscription, error) {
+ if s == nil || s.userSubRepo == nil {
+ return nil, infraerrors.ServiceUnavailable("SUBSCRIPTION_MAINTENANCE_UNAVAILABLE", "subscription window maintenance is unavailable")
+ }
+ type result struct {
+ sub *UserSubscription
+ err error
+ }
+ run := func() result {
+ maintenanceCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ sub, err := s.doWindowMaintenance(maintenanceCtx, subscriptionID)
+ return result{sub: sub, err: err}
+ }
+ if s.maintenanceQueue == nil {
+ res := run()
+ return res.sub, res.err
+ }
+
+ results := make(chan result, 1)
+ queuedRun := func() {
+ deferredResult := result{}
+ defer func() {
+ if recovered := recover(); recovered != nil {
+ deferredResult.err = fmt.Errorf("subscription window maintenance panic: %v", recovered)
+ }
+ results <- deferredResult
+ }()
+ deferredResult = run()
}
- if s.maintenanceQueue != nil {
- err := s.maintenanceQueue.TryEnqueue(func() {
- s.doWindowMaintenance(sub)
- })
- if err != nil {
- log.Printf("Subscription maintenance enqueue failed: %v", err)
- }
- return
+ if err := s.maintenanceQueue.TryEnqueue(queuedRun); err != nil {
+ return nil, fmt.Errorf("enqueue subscription window maintenance: %w", err)
+ }
+ select {
+ case res := <-results:
+ return res.sub, res.err
+ case <-ctx.Done():
+ return nil, ctx.Err()
}
-
- s.doWindowMaintenance(sub)
}
-func (s *SubscriptionService) doWindowMaintenance(sub *UserSubscription) {
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
-
- // 激活窗口(首次使用时)
- if !sub.IsWindowActivated() {
- if err := s.CheckAndActivateWindow(ctx, sub); err != nil {
- log.Printf("Failed to activate subscription windows: %v", err)
+func (s *SubscriptionService) doWindowMaintenance(ctx context.Context, subscriptionID int64) (*UserSubscription, error) {
+ current, err := s.userSubRepo.GetByID(ctx, subscriptionID)
+ if err != nil {
+ return nil, err
+ }
+ if !current.IsWindowActivated() {
+ if err := s.CheckAndActivateWindow(ctx, current); err != nil {
+ return nil, err
}
}
-
- // 重置过期窗口
- if err := s.CheckAndResetWindows(ctx, sub); err != nil {
- log.Printf("Failed to reset subscription windows: %v", err)
+ if err := s.CheckAndResetWindows(ctx, current); err != nil {
+ return nil, err
}
- // 失效 L1 缓存,确保后续请求拿到更新后的数据
- s.InvalidateSubCache(sub.UserID, sub.GroupID)
+ latest, err := s.userSubRepo.GetByID(ctx, subscriptionID)
+ if err != nil {
+ return nil, err
+ }
+ if latest.GroupID != nil {
+ s.InvalidateSubCache(latest.UserID, *latest.GroupID)
+ }
+ return latest, nil
}
// RecordUsage 记录使用量到订阅
@@ -921,9 +1576,14 @@ func (s *SubscriptionService) GetSubscriptionProgress(ctx context.Context, subsc
return nil, ErrSubscriptionNotFound
}
+ // 钱包模式 (v4) 没有单 group 概念,进度需另算(暂不支持,A2 阶段补全)
+ if sub.GroupID == nil {
+ return nil, infraerrors.BadRequest("WALLET_MODE_NOT_SUPPORTED", "wallet-mode subscription progress not yet implemented")
+ }
+
group := sub.Group
if group == nil {
- group, err = s.groupRepo.GetByID(ctx, sub.GroupID)
+ group, err = s.groupRepo.GetByID(ctx, *sub.GroupID)
if err != nil {
return nil, err
}
diff --git a/backend/internal/service/subscription_window_maintenance_security_test.go b/backend/internal/service/subscription_window_maintenance_security_test.go
new file mode 100644
index 00000000000..78a3f9d9b6c
--- /dev/null
+++ b/backend/internal/service/subscription_window_maintenance_security_test.go
@@ -0,0 +1,167 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/stretchr/testify/require"
+)
+
+type windowMaintenanceRepoStub struct {
+ userSubRepoNoop
+ mu sync.Mutex
+ sub *UserSubscription
+}
+
+func (r *windowMaintenanceRepoStub) GetByID(_ context.Context, id int64) (*UserSubscription, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.sub == nil || r.sub.ID != id {
+ return nil, ErrSubscriptionNotFound
+ }
+ cloned := *r.sub
+ return &cloned, nil
+}
+
+func (r *windowMaintenanceRepoStub) AdvanceUsageWindow(_ context.Context, id int64, advance SubscriptionUsageWindowAdvance) (bool, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.sub == nil || r.sub.ID != id {
+ return false, ErrSubscriptionNotFound
+ }
+ current := r.windowStart(advance.Window)
+ if !sameOptionalTime(current, advance.ExpectedStart) {
+ return false, nil
+ }
+ r.setWindow(advance)
+ return true, nil
+}
+
+func (r *windowMaintenanceRepoStub) IncrementUsage(_ context.Context, id int64, cost float64) error {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.sub == nil || r.sub.ID != id {
+ return ErrSubscriptionNotFound
+ }
+ r.sub.DailyUsageUSD += cost
+ r.sub.WeeklyUsageUSD += cost
+ r.sub.MonthlyUsageUSD += cost
+ return nil
+}
+
+func (r *windowMaintenanceRepoStub) windowStart(window SubscriptionUsageWindow) *time.Time {
+ switch window {
+ case SubscriptionUsageWindowDaily:
+ return r.sub.DailyWindowStart
+ case SubscriptionUsageWindowWeekly:
+ return r.sub.WeeklyWindowStart
+ default:
+ return r.sub.MonthlyWindowStart
+ }
+}
+
+func (r *windowMaintenanceRepoStub) setWindow(advance SubscriptionUsageWindowAdvance) {
+ start := advance.NewStart
+ switch advance.Window {
+ case SubscriptionUsageWindowDaily:
+ r.sub.DailyWindowStart = &start
+ if advance.ResetUsage {
+ r.sub.DailyUsageUSD = 0
+ }
+ case SubscriptionUsageWindowWeekly:
+ r.sub.WeeklyWindowStart = &start
+ if advance.ResetUsage {
+ r.sub.WeeklyUsageUSD = 0
+ }
+ case SubscriptionUsageWindowMonthly:
+ r.sub.MonthlyWindowStart = &start
+ if advance.ResetUsage {
+ r.sub.MonthlyUsageUSD = 0
+ }
+ }
+}
+
+func sameOptionalTime(left, right *time.Time) bool {
+ if left == nil || right == nil {
+ return left == nil && right == nil
+ }
+ return left.Equal(*right)
+}
+
+func TestWindowMaintenanceStaleSecondPassCannotEraseNewUsage(t *testing.T) {
+ oldDaily := time.Now().Add(-25 * time.Hour)
+ recentWeekly := time.Now().Add(-time.Hour)
+ recentMonthly := time.Now().Add(-time.Hour)
+ repo := &windowMaintenanceRepoStub{sub: &UserSubscription{
+ ID: 71, UserID: 8, GroupID: ptrInt64(9), Status: SubscriptionStatusActive,
+ ExpiresAt: time.Now().Add(24 * time.Hour), DailyWindowStart: &oldDaily,
+ WeeklyWindowStart: &recentWeekly, MonthlyWindowStart: &recentMonthly,
+ DailyUsageUSD: 12,
+ }}
+ svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
+
+ first, err := svc.DoWindowMaintenance(context.Background(), 71)
+ require.NoError(t, err)
+ require.Zero(t, first.DailyUsageUSD)
+ require.NoError(t, repo.IncrementUsage(context.Background(), 71, 5))
+
+ second, err := svc.DoWindowMaintenance(context.Background(), 71)
+ require.NoError(t, err)
+ require.InDelta(t, 5, second.DailyUsageUSD, 0.000001)
+ require.WithinDuration(t, *first.DailyWindowStart, *second.DailyWindowStart, time.Microsecond)
+}
+
+func TestWindowMaintenanceFirstActivationPreservesAlreadyRecordedUsage(t *testing.T) {
+ repo := &windowMaintenanceRepoStub{sub: &UserSubscription{
+ ID: 72, UserID: 8, GroupID: ptrInt64(9), Status: SubscriptionStatusActive,
+ StartsAt: time.Now(), ExpiresAt: time.Now().Add(24 * time.Hour),
+ DailyUsageUSD: 3, WeeklyUsageUSD: 3, MonthlyUsageUSD: 3,
+ }}
+ svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
+
+ maintained, err := svc.DoWindowMaintenance(context.Background(), 72)
+ require.NoError(t, err)
+ require.InDelta(t, 3, maintained.DailyUsageUSD, 0.000001)
+ require.InDelta(t, 3, maintained.WeeklyUsageUSD, 0.000001)
+ require.InDelta(t, 3, maintained.MonthlyUsageUSD, 0.000001)
+ require.NotNil(t, maintained.DailyWindowStart)
+ require.NotNil(t, maintained.WeeklyWindowStart)
+ require.NotNil(t, maintained.MonthlyWindowStart)
+}
+
+func TestValidateAndCheckLimitsDoesNotMutateCachedSubscription(t *testing.T) {
+ oldDaily := time.Now().Add(-25 * time.Hour)
+ limit := 1.0
+ sub := &UserSubscription{
+ DailyWindowStart: &oldDaily,
+ DailyUsageUSD: 99,
+ Status: SubscriptionStatusActive,
+ ExpiresAt: time.Now().Add(time.Hour),
+ }
+ group := &Group{DailyLimitUSD: &limit}
+ svc := &SubscriptionService{}
+
+ needsMaintenance, err := svc.ValidateAndCheckLimits(sub, group)
+ require.NoError(t, err)
+ require.True(t, needsMaintenance)
+ require.InDelta(t, 99, sub.DailyUsageUSD, 0.000001)
+}
+
+func TestWindowMaintenanceWorkerPanicFailsClosedWithoutHangingCaller(t *testing.T) {
+ cfg := &config.Config{}
+ cfg.SubscriptionMaintenance.WorkerCount = 1
+ cfg.SubscriptionMaintenance.QueueSize = 1
+ svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, cfg)
+ t.Cleanup(svc.Stop)
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+
+ _, err := svc.DoWindowMaintenance(ctx, 73)
+ require.Error(t, err)
+ require.ErrorContains(t, err, "subscription window maintenance panic")
+}
diff --git a/backend/internal/service/telegram_risk_alert.go b/backend/internal/service/telegram_risk_alert.go
new file mode 100644
index 00000000000..e79f27fb541
--- /dev/null
+++ b/backend/internal/service/telegram_risk_alert.go
@@ -0,0 +1,356 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ telegramRiskAlertDefaultBaseURL = "https://api.telegram.org"
+ telegramRiskAlertTimeout = 5 * time.Second
+ telegramRiskAlertMaxEvidence = 6
+ telegramRiskAlertMaxLineRunes = 180
+
+ HFCAbuseRiskSeverityLow = "low"
+ HFCAbuseRiskSeverityMedium = "medium"
+ HFCAbuseRiskSeverityHigh = "high"
+ HFCAbuseRiskSeverityCritical = "critical"
+)
+
+var errTelegramRiskAlertSkipped = errors.New("telegram risk alert skipped")
+
+type TelegramRiskNotifier struct {
+ settingRepo SettingRepository
+ client *http.Client
+ apiBaseURL string
+ timeout time.Duration
+}
+
+type hfcTelegramRiskAlertConfig struct {
+ Enabled bool
+ BotToken string
+ ChatID string
+ MinSeverity string
+}
+
+type HFCAbuseRiskTelegramAlert struct {
+ EventID int64
+ Source string
+ Severity string
+ Summary string
+ UserID int64
+ UserEmail string
+ APIKeyID int64
+ APIKeyName string
+ GroupID int64
+ GroupName string
+ RiskScore int
+ SignupIPPrefix string
+ DeviceFingerprintHash string
+ Evidence []string
+ Action string
+ OccurredAt time.Time
+}
+
+func NewTelegramRiskNotifier(settingRepo SettingRepository) *TelegramRiskNotifier {
+ return &TelegramRiskNotifier{
+ settingRepo: settingRepo,
+ client: &http.Client{
+ Timeout: telegramRiskAlertTimeout,
+ },
+ apiBaseURL: telegramRiskAlertDefaultBaseURL,
+ timeout: telegramRiskAlertTimeout,
+ }
+}
+
+func DispatchHFCAbuseRiskTelegramAlert(settingRepo SettingRepository, alert HFCAbuseRiskTelegramAlert) {
+ if settingRepo == nil {
+ return
+ }
+ go func() {
+ ctx, cancel := context.WithTimeout(context.Background(), telegramRiskAlertTimeout)
+ defer cancel()
+
+ if err := NewTelegramRiskNotifier(settingRepo).SendHFCAbuseRisk(ctx, alert); err != nil && !errors.Is(err, errTelegramRiskAlertSkipped) {
+ slog.Warn("hfc_abuse_risk.telegram_alert_failed",
+ "source", sanitizeTelegramLogField(alert.Source),
+ "severity", normalizeHFCAbuseRiskSeverity(alert.Severity),
+ "user_id", alert.UserID,
+ "api_key_id", alert.APIKeyID,
+ "error", err)
+ }
+ }()
+}
+
+func (n *TelegramRiskNotifier) SendHFCAbuseRisk(ctx context.Context, alert HFCAbuseRiskTelegramAlert) error {
+ if n == nil || n.settingRepo == nil {
+ return errTelegramRiskAlertSkipped
+ }
+ cfg, err := n.loadConfig(ctx)
+ if err != nil {
+ return fmt.Errorf("load telegram risk alert config: %w", err)
+ }
+ if !cfg.Enabled {
+ return errTelegramRiskAlertSkipped
+ }
+ if cfg.BotToken == "" || cfg.ChatID == "" {
+ return errors.New("telegram risk alert missing bot token or chat id")
+ }
+ if !hfcAbuseRiskSeverityAllowed(alert.Severity, cfg.MinSeverity) {
+ return errTelegramRiskAlertSkipped
+ }
+
+ token := strings.TrimSpace(cfg.BotToken)
+ if strings.Contains(token, "/") {
+ return errors.New("telegram bot token contains invalid path separator")
+ }
+
+ payload := map[string]any{
+ "chat_id": cfg.ChatID,
+ "text": buildHFCAbuseRiskTelegramMessage(alert),
+ "disable_web_page_preview": true,
+ }
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("marshal telegram payload: %w", err)
+ }
+
+ reqCtx := ctx
+ if _, ok := ctx.Deadline(); !ok && n.timeout > 0 {
+ var cancel context.CancelFunc
+ reqCtx, cancel = context.WithTimeout(ctx, n.timeout)
+ defer cancel()
+ }
+ url := strings.TrimRight(n.apiBaseURL, "/") + "/bot" + token + "/sendMessage"
+ req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, url, bytes.NewReader(raw))
+ if err != nil {
+ return errors.New("build telegram request failed")
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ client := n.client
+ if client == nil {
+ client = &http.Client{Timeout: telegramRiskAlertTimeout}
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ return errors.New("send telegram request failed")
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ var apiResp struct {
+ OK bool `json:"ok"`
+ Description string `json:"description"`
+ }
+ _ = json.Unmarshal(body, &apiResp)
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 || !apiResp.OK {
+ desc := strings.TrimSpace(apiResp.Description)
+ if desc == "" {
+ desc = http.StatusText(resp.StatusCode)
+ }
+ return fmt.Errorf("telegram send failed: status=%d description=%s", resp.StatusCode, sanitizeTelegramLine(desc))
+ }
+ return nil
+}
+
+func (n *TelegramRiskNotifier) loadConfig(ctx context.Context) (hfcTelegramRiskAlertConfig, error) {
+ keys := []string{
+ SettingKeyHFCTelegramRiskAlertEnabled,
+ SettingKeyHFCTelegramBotToken,
+ SettingKeyHFCTelegramChatID,
+ SettingKeyHFCTelegramMinSeverity,
+ }
+ values, err := n.settingRepo.GetMultiple(ctx, keys)
+ if err != nil {
+ return hfcTelegramRiskAlertConfig{}, err
+ }
+
+ enabled := parseBoolSetting(firstNonEmpty(
+ os.Getenv("HFC_TELEGRAM_RISK_ALERT_ENABLED"),
+ os.Getenv("HFC_TELEGRAM_ALERTS_ENABLED"),
+ values[SettingKeyHFCTelegramRiskAlertEnabled],
+ ))
+ return hfcTelegramRiskAlertConfig{
+ Enabled: enabled,
+ BotToken: firstNonEmpty(
+ os.Getenv("HFC_TELEGRAM_BOT_TOKEN"),
+ os.Getenv("TELEGRAM_BOT_TOKEN"),
+ values[SettingKeyHFCTelegramBotToken],
+ ),
+ ChatID: firstNonEmpty(
+ os.Getenv("HFC_TELEGRAM_CHAT_ID"),
+ os.Getenv("TELEGRAM_CHAT_ID"),
+ values[SettingKeyHFCTelegramChatID],
+ ),
+ MinSeverity: normalizeHFCAbuseRiskSeverity(firstNonEmpty(
+ os.Getenv("HFC_TELEGRAM_MIN_SEVERITY"),
+ values[SettingKeyHFCTelegramMinSeverity],
+ HFCAbuseRiskSeverityHigh,
+ )),
+ }, nil
+}
+
+func buildHFCAbuseRiskTelegramMessage(alert HFCAbuseRiskTelegramAlert) string {
+ severity := strings.ToUpper(normalizeHFCAbuseRiskSeverity(alert.Severity))
+ occurredAt := alert.OccurredAt
+ if occurredAt.IsZero() {
+ occurredAt = time.Now()
+ }
+
+ lines := []string{
+ fmt.Sprintf("[HFC 风控告警] %s", severity),
+ "来源: " + sanitizeTelegramLine(firstNonEmpty(alert.Source, "hfc_abuse_risk")),
+ "摘要: " + sanitizeTelegramLine(alert.Summary),
+ }
+ if alert.EventID > 0 {
+ lines = append(lines, "事件: #"+strconv.FormatInt(alert.EventID, 10))
+ }
+ if alert.UserID > 0 || strings.TrimSpace(alert.UserEmail) != "" {
+ user := ""
+ if alert.UserID > 0 {
+ user = "#" + strconv.FormatInt(alert.UserID, 10)
+ }
+ if email := strings.TrimSpace(alert.UserEmail); email != "" {
+ user = strings.TrimSpace(user + " " + MaskEmail(email))
+ }
+ lines = append(lines, "用户: "+sanitizeTelegramLine(user))
+ }
+ if alert.APIKeyID > 0 || strings.TrimSpace(alert.APIKeyName) != "" {
+ apiKey := ""
+ if alert.APIKeyID > 0 {
+ apiKey = "#" + strconv.FormatInt(alert.APIKeyID, 10)
+ }
+ if name := strings.TrimSpace(alert.APIKeyName); name != "" {
+ apiKey = strings.TrimSpace(apiKey + " " + name)
+ }
+ lines = append(lines, "API key: "+sanitizeTelegramLine(apiKey))
+ }
+ if alert.GroupID > 0 || strings.TrimSpace(alert.GroupName) != "" {
+ group := ""
+ if alert.GroupID > 0 {
+ group = "#" + strconv.FormatInt(alert.GroupID, 10)
+ }
+ if name := strings.TrimSpace(alert.GroupName); name != "" {
+ group = strings.TrimSpace(group + " " + name)
+ }
+ lines = append(lines, "分组: "+sanitizeTelegramLine(group))
+ }
+ if alert.RiskScore > 0 {
+ lines = append(lines, "风险分: "+strconv.Itoa(alert.RiskScore))
+ }
+ if alert.SignupIPPrefix != "" {
+ lines = append(lines, "注册 IP 段: "+sanitizeTelegramLine(alert.SignupIPPrefix))
+ }
+ if alert.DeviceFingerprintHash != "" {
+ lines = append(lines, "设备指纹 hash: "+truncateMiddle(alert.DeviceFingerprintHash, 8, 8))
+ }
+
+ if evidence := normalizeTelegramEvidence(alert.Evidence); len(evidence) > 0 {
+ lines = append(lines, "证据:")
+ for _, item := range evidence {
+ lines = append(lines, "- "+item)
+ }
+ }
+ if alert.Action != "" {
+ lines = append(lines, "建议/动作: "+sanitizeTelegramLine(alert.Action))
+ }
+ lines = append(lines, "时间: "+occurredAt.Format(time.RFC3339))
+
+ return strings.Join(lines, "\n")
+}
+
+func normalizeTelegramEvidence(items []string) []string {
+ out := make([]string, 0, minInt(len(items), telegramRiskAlertMaxEvidence))
+ for _, item := range items {
+ clean := sanitizeTelegramLine(item)
+ if clean == "" {
+ continue
+ }
+ out = append(out, clean)
+ if len(out) >= telegramRiskAlertMaxEvidence {
+ break
+ }
+ }
+ return out
+}
+
+func sanitizeTelegramLine(value string) string {
+ value = strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(value, "\r", " "), "\n", " "))
+ fields := strings.Fields(value)
+ if len(fields) > 0 {
+ value = strings.Join(fields, " ")
+ }
+ runes := []rune(value)
+ if len(runes) > telegramRiskAlertMaxLineRunes {
+ return string(runes[:telegramRiskAlertMaxLineRunes]) + "..."
+ }
+ return value
+}
+
+func sanitizeTelegramLogField(value string) string {
+ return sanitizeTelegramLine(value)
+}
+
+func truncateMiddle(value string, head, tail int) string {
+ value = strings.TrimSpace(value)
+ runes := []rune(value)
+ if len(runes) <= head+tail+3 {
+ return value
+ }
+ return string(runes[:head]) + "..." + string(runes[len(runes)-tail:])
+}
+
+func hfcAbuseRiskSeverityAllowed(actual, minimum string) bool {
+ return hfcAbuseRiskSeverityRank(normalizeHFCAbuseRiskSeverity(actual)) >= hfcAbuseRiskSeverityRank(normalizeHFCAbuseRiskSeverity(minimum))
+}
+
+func normalizeHFCAbuseRiskSeverity(value string) string {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case HFCAbuseRiskSeverityLow, HFCAbuseRiskSeverityMedium, HFCAbuseRiskSeverityHigh, HFCAbuseRiskSeverityCritical:
+ return strings.ToLower(strings.TrimSpace(value))
+ default:
+ return HFCAbuseRiskSeverityHigh
+ }
+}
+
+func hfcAbuseRiskSeverityRank(value string) int {
+ switch normalizeHFCAbuseRiskSeverity(value) {
+ case HFCAbuseRiskSeverityLow:
+ return 1
+ case HFCAbuseRiskSeverityMedium:
+ return 2
+ case HFCAbuseRiskSeverityHigh:
+ return 3
+ case HFCAbuseRiskSeverityCritical:
+ return 4
+ default:
+ return 3
+ }
+}
+
+func parseBoolSetting(value string) bool {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "true", "1", "yes", "y", "on", "enabled":
+ return true
+ default:
+ return false
+ }
+}
+
+func minInt(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/backend/internal/service/telegram_risk_alert_test.go b/backend/internal/service/telegram_risk_alert_test.go
new file mode 100644
index 00000000000..8be07ad02ba
--- /dev/null
+++ b/backend/internal/service/telegram_risk_alert_test.go
@@ -0,0 +1,118 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestTelegramRiskNotifier_SendHFCAbuseRisk_PostsRedactedMessage(t *testing.T) {
+ var got struct {
+ Path string
+ Body map[string]any
+ }
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ got.Path = r.URL.Path
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&got.Body))
+ _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
+ }))
+ defer server.Close()
+
+ repo := &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyHFCTelegramRiskAlertEnabled: "true",
+ SettingKeyHFCTelegramBotToken: "123456:test-token",
+ SettingKeyHFCTelegramChatID: "-100123",
+ SettingKeyHFCTelegramMinSeverity: "high",
+ }}
+ notifier := NewTelegramRiskNotifier(repo)
+ notifier.apiBaseURL = server.URL
+
+ err := notifier.SendHFCAbuseRisk(context.Background(), HFCAbuseRiskTelegramAlert{
+ Source: "signup_risk",
+ Severity: HFCAbuseRiskSeverityHigh,
+ Summary: "duplicate signup signals",
+ UserID: 42,
+ UserEmail: "user@example.com",
+ RiskScore: 80,
+ SignupIPPrefix: "203.0.113.0/24",
+ DeviceFingerprintHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ Evidence: []string{
+ "hold_reason=duplicate_device_fingerprint",
+ "raw secret should not be here",
+ },
+ Action: "trial bonus held",
+ OccurredAt: time.Date(2026, 5, 28, 1, 30, 0, 0, time.UTC),
+ })
+
+ require.NoError(t, err)
+ require.Equal(t, "/bot123456:test-token/sendMessage", got.Path)
+ require.Equal(t, "-100123", got.Body["chat_id"])
+ text, _ := got.Body["text"].(string)
+ require.Contains(t, text, "[HFC 风控告警] HIGH")
+ require.Contains(t, text, "用户: #42 u***r@example.com")
+ require.Contains(t, text, "注册 IP 段: 203.0.113.0/24")
+ require.Contains(t, text, "设备指纹 hash: 01234567...89abcdef")
+ require.NotContains(t, text, "user@example.com")
+ require.NotContains(t, text, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
+}
+
+func TestTelegramRiskNotifier_SendHFCAbuseRisk_SkipsBelowMinSeverity(t *testing.T) {
+ repo := &contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyHFCTelegramRiskAlertEnabled: "true",
+ SettingKeyHFCTelegramBotToken: "123456:test-token",
+ SettingKeyHFCTelegramChatID: "-100123",
+ SettingKeyHFCTelegramMinSeverity: "critical",
+ }}
+ notifier := NewTelegramRiskNotifier(repo)
+
+ err := notifier.SendHFCAbuseRisk(context.Background(), HFCAbuseRiskTelegramAlert{
+ Severity: HFCAbuseRiskSeverityHigh,
+ Summary: "high only",
+ })
+
+ require.ErrorIs(t, err, errTelegramRiskAlertSkipped)
+}
+
+func TestTelegramRiskNotifier_SendHFCAbuseRisk_UsesEnvFallback(t *testing.T) {
+ t.Setenv("HFC_TELEGRAM_RISK_ALERT_ENABLED", "true")
+ t.Setenv("HFC_TELEGRAM_BOT_TOKEN", "123456:env-token")
+ t.Setenv("HFC_TELEGRAM_CHAT_ID", "-100env")
+
+ var called bool
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ called = true
+ _ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
+ }))
+ defer server.Close()
+
+ notifier := NewTelegramRiskNotifier(&contentModerationTestSettingRepo{values: map[string]string{
+ SettingKeyHFCTelegramRiskAlertEnabled: "false",
+ SettingKeyHFCTelegramMinSeverity: "high",
+ }})
+ notifier.apiBaseURL = server.URL
+
+ err := notifier.SendHFCAbuseRisk(context.Background(), HFCAbuseRiskTelegramAlert{
+ Severity: HFCAbuseRiskSeverityCritical,
+ Summary: "env configured",
+ })
+
+ require.NoError(t, err)
+ require.True(t, called)
+}
+
+func TestTelegramRiskNotifier_SendHFCAbuseRisk_DisabledByDefault(t *testing.T) {
+ notifier := NewTelegramRiskNotifier(&contentModerationTestSettingRepo{values: map[string]string{}})
+
+ err := notifier.SendHFCAbuseRisk(context.Background(), HFCAbuseRiskTelegramAlert{
+ Severity: HFCAbuseRiskSeverityCritical,
+ Summary: "disabled",
+ })
+
+ require.True(t, errors.Is(err, errTelegramRiskAlertSkipped))
+}
diff --git a/backend/internal/service/token_cache_invalidator.go b/backend/internal/service/token_cache_invalidator.go
index 74c9edc3995..7c063c4e327 100644
--- a/backend/internal/service/token_cache_invalidator.go
+++ b/backend/internal/service/token_cache_invalidator.go
@@ -2,6 +2,8 @@ package service
import (
"context"
+ "errors"
+ "fmt"
"log/slog"
"strconv"
)
@@ -36,22 +38,37 @@ func (c *CompositeTokenCacheInvalidator) InvalidateToken(ctx context.Context, ac
// Gemini 可能有两种缓存键:project_id 或 account_id
// 首次获取 token 时可能没有 project_id,之后自动检测到 project_id 后会使用新 key
// 刷新时需要同时删除两种可能的 key,确保不会遗留旧缓存
- keysToDelete = append(keysToDelete, GeminiTokenCacheKey(account))
- keysToDelete = append(keysToDelete, "gemini:"+accountIDKey)
+ primaryBaseKey := geminiTokenCacheBaseKey(account)
+ accountBaseKey := "gemini:" + accountIDKey
+ keysToDelete = append(keysToDelete,
+ credentialBoundOAuthTokenCacheKey(primaryBaseKey, account),
+ credentialBoundOAuthTokenCacheKey(accountBaseKey, account),
+ primaryBaseKey,
+ accountBaseKey,
+ )
case PlatformAntigravity:
// Antigravity 同样可能有两种缓存键
- keysToDelete = append(keysToDelete, AntigravityTokenCacheKey(account))
- keysToDelete = append(keysToDelete, "ag:"+accountIDKey)
+ primaryBaseKey := antigravityTokenCacheBaseKey(account)
+ accountBaseKey := "ag:" + accountIDKey
+ keysToDelete = append(keysToDelete,
+ credentialBoundOAuthTokenCacheKey(primaryBaseKey, account),
+ credentialBoundOAuthTokenCacheKey(accountBaseKey, account),
+ primaryBaseKey,
+ accountBaseKey,
+ )
case PlatformOpenAI:
- keysToDelete = append(keysToDelete, OpenAITokenCacheKey(account))
+ baseKey := openAITokenCacheBaseKey(account)
+ keysToDelete = append(keysToDelete, credentialBoundOAuthTokenCacheKey(baseKey, account), baseKey)
case PlatformAnthropic:
- keysToDelete = append(keysToDelete, ClaudeTokenCacheKey(account))
+ baseKey := claudeTokenCacheBaseKey(account)
+ keysToDelete = append(keysToDelete, credentialBoundOAuthTokenCacheKey(baseKey, account), baseKey)
default:
return nil
}
// 删除所有可能的缓存键(去重后)
seen := make(map[string]bool)
+ var deleteErrors []error
for _, key := range keysToDelete {
if seen[key] {
continue
@@ -59,10 +76,38 @@ func (c *CompositeTokenCacheInvalidator) InvalidateToken(ctx context.Context, ac
seen[key] = true
if err := c.cache.DeleteAccessToken(ctx, key); err != nil {
slog.Warn("token_cache_delete_failed", "key", key, "account_id", account.ID, "error", err)
+ deleteErrors = append(deleteErrors, fmt.Errorf("delete token cache key %q: %w", key, err))
}
}
- return nil
+ return errors.Join(deleteErrors...)
+}
+
+// validateOAuthTokenCacheHit checks the authoritative account after a shared
+// cache hit and before the cached token can be returned. A failed authority
+// read fails closed: callers must not use a token whose credential generation
+// can no longer be proven current.
+func validateOAuthTokenCacheHit(ctx context.Context, account *Account, repo AccountRepository) (*Account, bool, error) {
+ latestAccount, isStale, err := CheckTokenVersion(ctx, account, repo)
+ if err != nil {
+ return nil, false, fmt.Errorf("validate oauth token cache hit: %w", err)
+ }
+ if latestAccount.Type != account.Type || latestAccount.Platform != account.Platform {
+ return nil, false, fmt.Errorf(
+ "validate oauth token cache hit for account %d: authoritative account kind changed",
+ account.ID,
+ )
+ }
+ if isStale {
+ return latestAccount, false, nil
+ }
+ if oauthTokenCacheGeneration(account) != oauthTokenCacheGeneration(latestAccount) {
+ return nil, false, fmt.Errorf(
+ "validate oauth token cache hit for account %d: request version is ahead of authority with a different credential generation",
+ account.ID,
+ )
+ }
+ return latestAccount, true, nil
}
// CheckTokenVersion 检查 account 的 token 版本是否已过时,并返回最新的 account
@@ -70,45 +115,53 @@ func (c *CompositeTokenCacheInvalidator) InvalidateToken(ctx context.Context, ac
// 如果刷新任务已更新 token 并删除缓存,此时请求线程的旧 account 对象不应写入缓存
//
// 返回值:
-// - latestAccount: 从 DB 获取的最新 account(如果查询失败则返回 nil)
+// - latestAccount: 从 DB 获取的最新 account
// - isStale: true 表示 token 已过时(应使用 latestAccount),false 表示可以使用当前 account
-func CheckTokenVersion(ctx context.Context, account *Account, repo AccountRepository) (latestAccount *Account, isStale bool) {
- if account == nil || repo == nil {
- return nil, false
+// - err: 权威读取不可用;调用方必须跳过共享缓存写入
+func CheckTokenVersion(ctx context.Context, account *Account, repo AccountRepository) (latestAccount *Account, isStale bool, err error) {
+ if account == nil {
+ return nil, false, fmt.Errorf("check token version: account is nil")
+ }
+ if repo == nil {
+ return nil, false, fmt.Errorf("check token version: account repository is nil")
}
currentVersion := account.GetCredentialAsInt64("_token_version")
- latestAccount, err := repo.GetByID(ctx, account.ID)
- if err != nil || latestAccount == nil {
- // 查询失败,默认允许缓存,不返回 latestAccount
- return nil, false
+ latestAccount, err = repo.GetByID(ctx, account.ID)
+ if err != nil {
+ return nil, false, fmt.Errorf("check token version for account %d: %w", account.ID, err)
+ }
+ if latestAccount == nil {
+ return nil, false, fmt.Errorf("check token version for account %d: authoritative account not found", account.ID)
}
latestVersion := latestAccount.GetCredentialAsInt64("_token_version")
- // 情况1: 当前 account 没有版本号,但 DB 中已有版本号
- // 说明异步刷新任务已更新 token,当前 account 已过时
- if currentVersion == 0 && latestVersion > 0 {
- slog.Debug("token_version_stale_no_current_version",
+ // A larger authoritative version always supersedes the request snapshot.
+ if latestVersion > currentVersion {
+ slog.Debug("token_version_stale",
"account_id", account.ID,
+ "current_version", currentVersion,
"latest_version", latestVersion)
- return latestAccount, true
+ return latestAccount, true, nil
}
-
- // 情况2: 两边都没有版本号,说明从未被异步刷新过,允许缓存
- if currentVersion == 0 && latestVersion == 0 {
- return latestAccount, false
+ // A request-local account may be the freshly persisted refresh result while
+ // a lagging read still exposes the previous version. Do not roll it back.
+ if currentVersion > latestVersion {
+ return latestAccount, false, nil
}
- // 情况3: 比较版本号,如果 DB 中的版本更新,当前 account 已过时
- if latestVersion > currentVersion {
- slog.Debug("token_version_stale",
+ // Equal versions (including legacy zero/zero rows) still require the token
+ // material to match. This closes generic/bulk credential replacement paths
+ // that predate _token_version stamping.
+ if oauthTokenCacheGeneration(account) != oauthTokenCacheGeneration(latestAccount) {
+ slog.Debug("token_credential_generation_stale",
"account_id", account.ID,
"current_version", currentVersion,
"latest_version", latestVersion)
- return latestAccount, true
+ return latestAccount, true, nil
}
- return latestAccount, false
+ return latestAccount, false, nil
}
diff --git a/backend/internal/service/token_cache_invalidator_test.go b/backend/internal/service/token_cache_invalidator_test.go
index 8342cf39eb8..16a480e8128 100644
--- a/backend/internal/service/token_cache_invalidator_test.go
+++ b/backend/internal/service/token_cache_invalidator_test.go
@@ -29,11 +29,11 @@ func (s *geminiTokenCacheStub) DeleteAccessToken(ctx context.Context, cacheKey s
return s.deleteErr
}
-func (s *geminiTokenCacheStub) AcquireRefreshLock(ctx context.Context, cacheKey string, ttl time.Duration) (bool, error) {
- return true, nil
+func (s *geminiTokenCacheStub) AcquireRefreshLock(ctx context.Context, cacheKey string, ttl time.Duration) (bool, string, error) {
+ return true, "invalidator-owner", nil
}
-func (s *geminiTokenCacheStub) ReleaseRefreshLock(ctx context.Context, cacheKey string) error {
+func (s *geminiTokenCacheStub) ReleaseRefreshLock(ctx context.Context, cacheKey string, ownershipToken string) error {
return nil
}
@@ -70,8 +70,8 @@ func TestCompositeTokenCacheInvalidator_GeminiWithoutProjectID(t *testing.T) {
err := invalidator.InvalidateToken(context.Background(), account)
require.NoError(t, err)
- // 没有 project_id 时,两个 key 相同,去重后只删除一个
- require.Equal(t, []string{"gemini:account:10"}, cache.deletedKeys)
+ // 删除当前凭据代际键,并兼容清理升级前的无代际键。
+ require.Equal(t, []string{GeminiTokenCacheKey(account), "gemini:account:10"}, cache.deletedKeys)
}
func TestCompositeTokenCacheInvalidator_Antigravity(t *testing.T) {
@@ -106,8 +106,8 @@ func TestCompositeTokenCacheInvalidator_AntigravityWithoutProjectID(t *testing.T
err := invalidator.InvalidateToken(context.Background(), account)
require.NoError(t, err)
- // 没有 project_id 时,两个 key 相同,去重后只删除一个
- require.Equal(t, []string{"ag:account:99"}, cache.deletedKeys)
+ // 删除当前凭据代际键,并兼容清理升级前的无代际键。
+ require.Equal(t, []string{AntigravityTokenCacheKey(account), "ag:account:99"}, cache.deletedKeys)
}
func TestCompositeTokenCacheInvalidator_OpenAI(t *testing.T) {
@@ -124,7 +124,7 @@ func TestCompositeTokenCacheInvalidator_OpenAI(t *testing.T) {
err := invalidator.InvalidateToken(context.Background(), account)
require.NoError(t, err)
- require.Equal(t, []string{"openai:account:500"}, cache.deletedKeys)
+ require.Equal(t, []string{OpenAITokenCacheKey(account), "openai:account:500"}, cache.deletedKeys)
}
func TestCompositeTokenCacheInvalidator_Claude(t *testing.T) {
@@ -141,7 +141,7 @@ func TestCompositeTokenCacheInvalidator_Claude(t *testing.T) {
err := invalidator.InvalidateToken(context.Background(), account)
require.NoError(t, err)
- require.Equal(t, []string{"claude:account:600"}, cache.deletedKeys)
+ require.Equal(t, []string{ClaudeTokenCacheKey(account), "claude:account:600"}, cache.deletedKeys)
}
func TestCompositeTokenCacheInvalidator_SkipNonOAuth(t *testing.T) {
@@ -272,10 +272,72 @@ func TestCompositeTokenCacheInvalidator_DeleteError(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- // 新行为:删除失败只记录日志,不返回错误
- // 这是因为缓存失效失败不应影响主业务流程
err := invalidator.InvalidateToken(context.Background(), tt.account)
- require.NoError(t, err)
+ require.ErrorIs(t, err, expectedErr)
+ })
+ }
+}
+
+func TestCompositeTokenCacheInvalidator_AttemptsEveryKeyWhenDeleteFails(t *testing.T) {
+ expectedErr := errors.New("redis connection failed")
+ tests := []struct {
+ name string
+ account *Account
+ expected func(*Account) []string
+ }{
+ {
+ name: "gemini",
+ account: &Account{
+ ID: 42,
+ Platform: PlatformGemini,
+ Type: AccountTypeOAuth,
+ Credentials: map[string]any{
+ "project_id": "project-42",
+ "access_token": "new-access-token",
+ "refresh_token": "new-refresh-token",
+ },
+ },
+ expected: func(account *Account) []string {
+ return []string{
+ GeminiTokenCacheKey(account),
+ credentialBoundOAuthTokenCacheKey("gemini:account:42", account),
+ "gemini:project-42",
+ "gemini:account:42",
+ }
+ },
+ },
+ {
+ name: "antigravity",
+ account: &Account{
+ ID: 43,
+ Platform: PlatformAntigravity,
+ Type: AccountTypeOAuth,
+ Credentials: map[string]any{
+ "project_id": "project-43",
+ "access_token": "new-ag-access-token",
+ "refresh_token": "new-ag-refresh-token",
+ },
+ },
+ expected: func(account *Account) []string {
+ return []string{
+ AntigravityTokenCacheKey(account),
+ credentialBoundOAuthTokenCacheKey("ag:account:43", account),
+ "ag:project-43",
+ "ag:account:43",
+ }
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cache := &geminiTokenCacheStub{deleteErr: expectedErr}
+ invalidator := NewCompositeTokenCacheInvalidator(cache)
+
+ err := invalidator.InvalidateToken(context.Background(), tt.account)
+
+ require.ErrorIs(t, err, expectedErr)
+ require.Equal(t, tt.expected(tt.account), cache.deletedKeys)
})
}
}
@@ -398,6 +460,16 @@ func TestAccount_GetCredentialAsInt64_NilAccount(t *testing.T) {
// ========== CheckTokenVersion 测试 ==========
+type tokenVersionAccountRepoStub struct {
+ mockAccountRepoForGemini
+ latest *Account
+ err error
+}
+
+func (r *tokenVersionAccountRepoStub) GetByID(context.Context, int64) (*Account, error) {
+ return r.latest, r.err
+}
+
func TestCheckTokenVersion(t *testing.T) {
tests := []struct {
name string
@@ -405,12 +477,14 @@ func TestCheckTokenVersion(t *testing.T) {
latestAccount *Account
repoErr error
expectedStale bool
+ wantErr bool
}{
{
name: "nil_account",
account: nil,
latestAccount: nil,
expectedStale: false,
+ wantErr: true,
},
{
name: "no_version_in_account_but_db_has_version",
@@ -448,6 +522,36 @@ func TestCheckTokenVersion(t *testing.T) {
},
expectedStale: false,
},
+ {
+ name: "same_version_but_token_material_changed",
+ account: &Account{
+ ID: 1,
+ Credentials: map[string]any{
+ "_token_version": int64(100),
+ "access_token": "old-token",
+ },
+ },
+ latestAccount: &Account{
+ ID: 1,
+ Credentials: map[string]any{
+ "_token_version": int64(100),
+ "access_token": "new-token",
+ },
+ },
+ expectedStale: true,
+ },
+ {
+ name: "legacy_zero_versions_but_token_material_changed",
+ account: &Account{
+ ID: 1,
+ Credentials: map[string]any{"access_token": "legacy-old-token"},
+ },
+ latestAccount: &Account{
+ ID: 1,
+ Credentials: map[string]any{"access_token": "legacy-new-token"},
+ },
+ expectedStale: true,
+ },
{
name: "current_version_newer",
account: &Account{
@@ -480,7 +584,8 @@ func TestCheckTokenVersion(t *testing.T) {
},
latestAccount: nil,
repoErr: errors.New("db error"),
- expectedStale: false, // 查询失败,默认允许缓存
+ expectedStale: false,
+ wantErr: true,
},
{
name: "repo_returns_nil",
@@ -490,49 +595,23 @@ func TestCheckTokenVersion(t *testing.T) {
},
latestAccount: nil,
repoErr: nil,
- expectedStale: false, // 查询返回 nil,默认允许缓存
+ expectedStale: false,
+ wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- // 由于 CheckTokenVersion 接受 AccountRepository 接口,而创建完整的 mock 很繁琐
- // 这里我们直接测试函数的核心逻辑来验证行为
-
- if tt.name == "nil_account" {
- _, isStale := CheckTokenVersion(context.Background(), nil, nil)
- require.Equal(t, tt.expectedStale, isStale)
- return
- }
-
- // 模拟 CheckTokenVersion 的核心逻辑
- account := tt.account
- currentVersion := account.GetCredentialAsInt64("_token_version")
-
- // 模拟 repo 查询
- latestAccount := tt.latestAccount
- if tt.repoErr != nil || latestAccount == nil {
- require.Equal(t, tt.expectedStale, false)
- return
- }
-
- latestVersion := latestAccount.GetCredentialAsInt64("_token_version")
-
- // 情况1: 当前 account 没有版本号,但 DB 中已有版本号
- if currentVersion == 0 && latestVersion > 0 {
- require.Equal(t, tt.expectedStale, true)
- return
- }
-
- // 情况2: 两边都没有版本号
- if currentVersion == 0 && latestVersion == 0 {
- require.Equal(t, tt.expectedStale, false)
+ repo := &tokenVersionAccountRepoStub{latest: tt.latestAccount, err: tt.repoErr}
+ latest, isStale, err := CheckTokenVersion(context.Background(), tt.account, repo)
+ require.Equal(t, tt.expectedStale, isStale)
+ if tt.wantErr {
+ require.Error(t, err)
+ require.Nil(t, latest)
return
}
-
- // 情况3: 比较版本号
- isStale := latestVersion > currentVersion
- require.Equal(t, tt.expectedStale, isStale)
+ require.NoError(t, err)
+ require.Same(t, tt.latestAccount, latest)
})
}
}
@@ -542,6 +621,8 @@ func TestCheckTokenVersion_NilRepo(t *testing.T) {
ID: 1,
Credentials: map[string]any{"_token_version": int64(100)},
}
- _, isStale := CheckTokenVersion(context.Background(), account, nil)
- require.False(t, isStale) // nil repo,默认允许缓存
+ latest, isStale, err := CheckTokenVersion(context.Background(), account, nil)
+ require.Error(t, err)
+ require.Nil(t, latest)
+ require.False(t, isStale)
}
diff --git a/backend/internal/service/token_cache_key.go b/backend/internal/service/token_cache_key.go
index df0c025ee51..d4042d96f5c 100644
--- a/backend/internal/service/token_cache_key.go
+++ b/backend/internal/service/token_cache_key.go
@@ -1,15 +1,71 @@
package service
-import "strconv"
+import (
+ "crypto/sha256"
+ "encoding/binary"
+ "encoding/hex"
+ "strconv"
+)
+
+const oauthTokenCacheGenerationDomain = "sub2api/oauth-token-cache-generation/v1\x00"
// OpenAITokenCacheKey 生成 OpenAI OAuth 账号的缓存键
-// 格式: "openai:account:{account_id}"
+// 格式: "openai:account:{account_id}:cred:{generation}"
func OpenAITokenCacheKey(account *Account) string {
- return "openai:account:" + strconv.FormatInt(account.ID, 10)
+ return credentialBoundOAuthTokenCacheKey(openAITokenCacheBaseKey(account), account)
}
// ClaudeTokenCacheKey 生成 Claude (Anthropic) OAuth 账号的缓存键
-// 格式: "claude:account:{account_id}"
+// 格式: "claude:account:{account_id}:cred:{generation}"
func ClaudeTokenCacheKey(account *Account) string {
+ return credentialBoundOAuthTokenCacheKey(claudeTokenCacheBaseKey(account), account)
+}
+
+func openAITokenCacheBaseKey(account *Account) string {
+ return "openai:account:" + strconv.FormatInt(account.ID, 10)
+}
+
+func claudeTokenCacheBaseKey(account *Account) string {
return "claude:account:" + strconv.FormatInt(account.ID, 10)
}
+
+func credentialBoundOAuthTokenCacheKey(base string, account *Account) string {
+ generation := oauthTokenCacheGeneration(account)
+ if generation == "" {
+ return base
+ }
+ return base + ":cred:" + generation
+}
+
+// oauthTokenCacheGeneration binds a shared access-token cache entry to the
+// credential generation that created it. Only a one-way digest is placed in
+// the Redis key; access and refresh tokens are never embedded in key names.
+func oauthTokenCacheGeneration(account *Account) string {
+ if account == nil {
+ return ""
+ }
+ accessToken := account.GetCredential("access_token")
+ refreshToken := account.GetCredential("refresh_token")
+ version := account.GetCredentialAsInt64("_token_version")
+ if accessToken == "" && refreshToken == "" && version == 0 {
+ return ""
+ }
+
+ hash := sha256.New()
+ _, _ = hash.Write([]byte(oauthTokenCacheGenerationDomain))
+ writeOAuthTokenCacheGenerationField(hash, accessToken)
+ writeOAuthTokenCacheGenerationField(hash, refreshToken)
+ writeOAuthTokenCacheGenerationField(hash, strconv.FormatInt(version, 10))
+ return hex.EncodeToString(hash.Sum(nil))
+}
+
+type oauthTokenCacheGenerationWriter interface {
+ Write([]byte) (int, error)
+}
+
+func writeOAuthTokenCacheGenerationField(writer oauthTokenCacheGenerationWriter, value string) {
+ var length [8]byte
+ binary.BigEndian.PutUint64(length[:], uint64(len(value)))
+ _, _ = writer.Write(length[:])
+ _, _ = writer.Write([]byte(value))
+}
diff --git a/backend/internal/service/token_cache_key_test.go b/backend/internal/service/token_cache_key_test.go
index 6215eeaf4e3..4c8e79bbf57 100644
--- a/backend/internal/service/token_cache_key_test.go
+++ b/backend/internal/service/token_cache_key_test.go
@@ -154,9 +154,10 @@ func TestAntigravityTokenCacheKey(t *testing.T) {
func TestOpenAITokenCacheKey(t *testing.T) {
tests := []struct {
- name string
- account *Account
- expected string
+ name string
+ account *Account
+ expected string
+ credentialBound bool
}{
{
name: "basic_account",
@@ -173,7 +174,8 @@ func TestOpenAITokenCacheKey(t *testing.T) {
"access_token": "test-token",
},
},
- expected: "openai:account:301",
+ expected: "openai:account:301",
+ credentialBound: true,
},
{
name: "account_id_zero",
@@ -194,6 +196,11 @@ func TestOpenAITokenCacheKey(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := OpenAITokenCacheKey(tt.account)
+ if tt.credentialBound {
+ require.Regexp(t, `^`+tt.expected+`:cred:[0-9a-f]{64}$`, result)
+ require.NotContains(t, result, tt.account.GetCredential("access_token"))
+ return
+ }
require.Equal(t, tt.expected, result)
})
}
@@ -201,9 +208,10 @@ func TestOpenAITokenCacheKey(t *testing.T) {
func TestClaudeTokenCacheKey(t *testing.T) {
tests := []struct {
- name string
- account *Account
- expected string
+ name string
+ account *Account
+ expected string
+ credentialBound bool
}{
{
name: "basic_account",
@@ -220,7 +228,8 @@ func TestClaudeTokenCacheKey(t *testing.T) {
"access_token": "claude-token",
},
},
- expected: "claude:account:401",
+ expected: "claude:account:401",
+ credentialBound: true,
},
{
name: "account_id_zero",
@@ -241,6 +250,11 @@ func TestClaudeTokenCacheKey(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ClaudeTokenCacheKey(tt.account)
+ if tt.credentialBound {
+ require.Regexp(t, `^`+tt.expected+`:cred:[0-9a-f]{64}$`, result)
+ require.NotContains(t, result, tt.account.GetCredential("access_token"))
+ return
+ }
require.Equal(t, tt.expected, result)
})
}
diff --git a/backend/internal/service/token_refresh_service_test.go b/backend/internal/service/token_refresh_service_test.go
index 2179a85e923..9ba8fea3138 100644
--- a/backend/internal/service/token_refresh_service_test.go
+++ b/backend/internal/service/token_refresh_service_test.go
@@ -568,11 +568,14 @@ func (m *mockTokenCacheForRefreshAPI) DeleteAccessToken(_ context.Context, _ str
return nil
}
-func (m *mockTokenCacheForRefreshAPI) AcquireRefreshLock(_ context.Context, _ string, _ time.Duration) (bool, error) {
- return m.lockResult, m.lockErr
+func (m *mockTokenCacheForRefreshAPI) AcquireRefreshLock(_ context.Context, _ string, _ time.Duration) (bool, string, error) {
+ if !m.lockResult || m.lockErr != nil {
+ return m.lockResult, "", m.lockErr
+ }
+ return true, "token-refresh-owner", nil
}
-func (m *mockTokenCacheForRefreshAPI) ReleaseRefreshLock(_ context.Context, _ string) error {
+func (m *mockTokenCacheForRefreshAPI) ReleaseRefreshLock(_ context.Context, _ string, _ string) error {
m.releaseCalls++
return nil
}
diff --git a/backend/internal/service/totp_service.go b/backend/internal/service/totp_service.go
index 052739ed19c..b5d1c02c7a0 100644
--- a/backend/internal/service/totp_service.go
+++ b/backend/internal/service/totp_service.go
@@ -2,7 +2,9 @@ package service
import (
"context"
+ "crypto/hmac"
"crypto/rand"
+ "crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
@@ -15,14 +17,15 @@ import (
)
var (
- ErrTotpNotEnabled = infraerrors.BadRequest("TOTP_NOT_ENABLED", "totp feature is not enabled")
- ErrTotpAlreadyEnabled = infraerrors.BadRequest("TOTP_ALREADY_ENABLED", "totp is already enabled for this account")
- ErrTotpNotSetup = infraerrors.BadRequest("TOTP_NOT_SETUP", "totp is not set up for this account")
- ErrTotpInvalidCode = infraerrors.BadRequest("TOTP_INVALID_CODE", "invalid totp code")
- ErrTotpSetupExpired = infraerrors.BadRequest("TOTP_SETUP_EXPIRED", "totp setup session expired")
- ErrTotpTooManyAttempts = infraerrors.TooManyRequests("TOTP_TOO_MANY_ATTEMPTS", "too many verification attempts, please try again later")
- ErrVerifyCodeRequired = infraerrors.BadRequest("VERIFY_CODE_REQUIRED", "email verification code is required")
- ErrPasswordRequired = infraerrors.BadRequest("PASSWORD_REQUIRED", "password is required")
+ ErrTotpNotEnabled = infraerrors.BadRequest("TOTP_NOT_ENABLED", "totp feature is not enabled")
+ ErrTotpAlreadyEnabled = infraerrors.BadRequest("TOTP_ALREADY_ENABLED", "totp is already enabled for this account")
+ ErrTotpNotSetup = infraerrors.BadRequest("TOTP_NOT_SETUP", "totp is not set up for this account")
+ ErrTotpInvalidCode = infraerrors.BadRequest("TOTP_INVALID_CODE", "invalid totp code")
+ ErrTotpSetupExpired = infraerrors.BadRequest("TOTP_SETUP_EXPIRED", "totp setup session expired")
+ ErrTotpTooManyAttempts = infraerrors.TooManyRequests("TOTP_TOO_MANY_ATTEMPTS", "too many verification attempts, please try again later")
+ ErrTotpVerifyUnavailable = infraerrors.ServiceUnavailable("TOTP_VERIFY_UNAVAILABLE", "totp verification is temporarily unavailable")
+ ErrVerifyCodeRequired = infraerrors.BadRequest("VERIFY_CODE_REQUIRED", "email verification code is required")
+ ErrPasswordRequired = infraerrors.BadRequest("PASSWORD_REQUIRED", "password is required")
)
// TotpCache defines cache operations for TOTP service
@@ -43,12 +46,33 @@ type TotpCache interface {
ClearVerifyAttempts(ctx context.Context, userID int64) error
}
+// TotpScopedVerifyCache isolates high-sensitivity re-authentication attempts
+// from the login limiter so one flow cannot lock out the other.
+type TotpScopedVerifyCache interface {
+ IncrementScopedVerifyAttempts(ctx context.Context, purpose string, userID int64) (int, error)
+ ClearScopedVerifyAttempts(ctx context.Context, purpose string, userID int64) error
+ ConsumeScopedVerification(ctx context.Context, purpose string, userID int64, fingerprint string, ttl time.Duration) (bool, error)
+}
+
+type TotpLoginSessionConsumer interface {
+ ConsumeLoginSession(ctx context.Context, tempToken string) (*TotpLoginSession, error)
+}
+
// SecretEncryptor defines encryption operations for TOTP secrets
type SecretEncryptor interface {
Encrypt(plaintext string) (string, error)
Decrypt(ciphertext string) (string, error)
}
+// DomainSecretEncryptor binds new ciphertext to an explicit secret domain.
+// Generic Encrypt/Decrypt remain available only for controlled legacy migration;
+// runtime secret consumers must use the domain-aware methods.
+type DomainSecretEncryptor interface {
+ SecretEncryptor
+ EncryptForDomain(domain, plaintext string) (string, error)
+ DecryptForDomain(domain, ciphertext string) (string, error)
+}
+
// TotpSetupSession represents a TOTP setup session
type TotpSetupSession struct {
Secret string // Plain text TOTP secret (not encrypted yet)
@@ -85,11 +109,15 @@ type TotpSetupResponse struct {
}
const (
- totpSetupTTL = 5 * time.Minute
- totpLoginTTL = 5 * time.Minute
- totpAttemptsTTL = 15 * time.Minute
- maxTotpAttempts = 5
- totpIssuer = "Sub2API"
+ totpSetupTTL = 5 * time.Minute
+ totpLoginTTL = 5 * time.Minute
+ totpAttemptsTTL = 15 * time.Minute
+ maxTotpAttempts = 5
+ totpIssuer = "Sub2API"
+ TotpVerifyPurposeAPIKeyReveal = "api_key_reveal"
+ TotpVerifyPurposeAPIKeyCreate = "api_key_create"
+ TotpVerifyPurposeAPIKeyUpdate = "api_key_update"
+ totpVerifyPurposeManagement = "totp_management_password"
)
// TotpService handles TOTP operations
@@ -166,11 +194,8 @@ func (s *TotpService) InitiateSetup(ctx context.Context, userID int64, emailCode
}
} else {
// Email verification disabled - verify password
- if password == "" {
- return nil, ErrPasswordRequired
- }
- if !user.CheckPassword(password) {
- return nil, ErrPasswordIncorrect
+ if err := s.verifyManagementPassword(ctx, user, password); err != nil {
+ return nil, err
}
}
@@ -235,43 +260,38 @@ func (s *TotpService) CompleteSetup(ctx context.Context, userID int64, totpCode,
return ErrTotpInvalidCode
}
- setupSecretPrefix := "N/A"
- if len(session.Secret) >= 4 {
- setupSecretPrefix = session.Secret[:4]
- }
slog.Debug("totp_complete_setup_before_encrypt",
"user_id", userID,
- "secret_len", len(session.Secret),
- "secret_prefix", setupSecretPrefix)
+ "secret_len", len(session.Secret))
// Encrypt the secret
- encryptedSecret, err := s.encryptor.Encrypt(session.Secret)
+ if s.encryptor == nil {
+ return fmt.Errorf("encrypt totp secret: encryptor is not configured")
+ }
+ encryptedSecret, err := EncryptForSecretDomain(s.encryptor, SecretDomainTOTP, session.Secret)
if err != nil {
return fmt.Errorf("encrypt totp secret: %w", err)
}
+ if encryptedSecret == "" {
+ return fmt.Errorf("encrypt totp secret: encryptor returned empty ciphertext")
+ }
slog.Debug("totp_complete_setup_encrypted",
"user_id", userID,
"encrypted_len", len(encryptedSecret))
// Verify encryption by decrypting
- decrypted, decErr := s.encryptor.Decrypt(encryptedSecret)
+ decrypted, decErr := DecryptForSecretDomain(s.encryptor, SecretDomainTOTP, encryptedSecret)
if decErr != nil {
- slog.Debug("totp_complete_setup_verify_failed",
- "user_id", userID,
- "error", decErr)
- } else {
- decryptedPrefix := "N/A"
- if len(decrypted) >= 4 {
- decryptedPrefix = decrypted[:4]
- }
- slog.Debug("totp_complete_setup_verified",
- "user_id", userID,
- "original_len", len(session.Secret),
- "decrypted_len", len(decrypted),
- "match", session.Secret == decrypted,
- "decrypted_prefix", decryptedPrefix)
+ return fmt.Errorf("verify encrypted totp secret: %w", decErr)
+ }
+ if subtle.ConstantTimeCompare([]byte(session.Secret), []byte(decrypted)) != 1 {
+ return fmt.Errorf("verify encrypted totp secret: decrypted secret mismatch")
}
+ slog.Debug("totp_complete_setup_verified",
+ "user_id", userID,
+ "original_len", len(session.Secret),
+ "decrypted_len", len(decrypted))
// Update user with encrypted TOTP secret
if err := s.userRepo.UpdateTotpSecret(ctx, userID, &encryptedSecret); err != nil {
@@ -313,11 +333,8 @@ func (s *TotpService) Disable(ctx context.Context, userID int64, emailCode, pass
}
} else {
// Email verification disabled - verify password
- if password == "" {
- return ErrPasswordRequired
- }
- if !user.CheckPassword(password) {
- return ErrPasswordIncorrect
+ if err := s.verifyManagementPassword(ctx, user, password); err != nil {
+ return err
}
}
@@ -329,15 +346,91 @@ func (s *TotpService) Disable(ctx context.Context, userID int64, emailCode, pass
return nil
}
+func (s *TotpService) verifyManagementPassword(ctx context.Context, user *User, password string) error {
+ if password == "" {
+ return ErrPasswordRequired
+ }
+ if s == nil || s.cache == nil || user == nil {
+ return ErrTotpVerifyUnavailable
+ }
+ scoped, ok := s.cache.(TotpScopedVerifyCache)
+ if !ok {
+ return ErrTotpVerifyUnavailable
+ }
+ attempts, err := scoped.IncrementScopedVerifyAttempts(ctx, totpVerifyPurposeManagement, user.ID)
+ if err != nil {
+ return ErrTotpVerifyUnavailable
+ }
+ if attempts > maxTotpAttempts {
+ return ErrTotpTooManyAttempts
+ }
+ if !user.CheckPassword(password) {
+ return ErrPasswordIncorrect
+ }
+ if err := scoped.ClearScopedVerifyAttempts(ctx, totpVerifyPurposeManagement, user.ID); err != nil {
+ return ErrTotpVerifyUnavailable
+ }
+ return nil
+}
+
// VerifyCode verifies a TOTP code for a user
func (s *TotpService) VerifyCode(ctx context.Context, userID int64, code string) error {
+ if s == nil || s.cache == nil {
+ return ErrTotpVerifyUnavailable
+ }
+ return s.verifyCodeWithLimiter(
+ ctx,
+ userID,
+ code,
+ s.cache.IncrementVerifyAttempts,
+ s.cache.ClearVerifyAttempts,
+ nil,
+ )
+}
+
+// VerifyCodeForPurpose keeps API-key reveal failures out of the login limiter.
+func (s *TotpService) VerifyCodeForPurpose(ctx context.Context, userID int64, code, purpose string) error {
+ if (purpose != TotpVerifyPurposeAPIKeyReveal && purpose != TotpVerifyPurposeAPIKeyCreate && purpose != TotpVerifyPurposeAPIKeyUpdate) || s == nil || s.cache == nil {
+ return ErrTotpVerifyUnavailable
+ }
+ scoped, ok := s.cache.(TotpScopedVerifyCache)
+ if !ok {
+ return ErrTotpVerifyUnavailable
+ }
+ return s.verifyCodeWithLimiter(
+ ctx,
+ userID,
+ code,
+ func(ctx context.Context, userID int64) (int, error) {
+ return scoped.IncrementScopedVerifyAttempts(ctx, purpose, userID)
+ },
+ func(ctx context.Context, userID int64) error {
+ return scoped.ClearScopedVerifyAttempts(ctx, purpose, userID)
+ },
+ func(ctx context.Context, userID int64, fingerprint string) (bool, error) {
+ return scoped.ConsumeScopedVerification(ctx, purpose, userID, fingerprint, 2*time.Minute)
+ },
+ )
+}
+
+func (s *TotpService) verifyCodeWithLimiter(
+ ctx context.Context,
+ userID int64,
+ code string,
+ increment func(context.Context, int64) (int, error),
+ clear func(context.Context, int64) error,
+ consume func(context.Context, int64, string) (bool, error),
+) error {
slog.Debug("totp_verify_code_called",
"user_id", userID,
"code_len", len(code))
// Check rate limiting
- attempts, err := s.cache.GetVerifyAttempts(ctx, userID)
- if err == nil && attempts >= maxTotpAttempts {
+ attempts, err := increment(ctx, userID)
+ if err != nil {
+ return ErrTotpVerifyUnavailable
+ }
+ if attempts > maxTotpAttempts {
return ErrTotpTooManyAttempts
}
@@ -363,7 +456,7 @@ func (s *TotpService) VerifyCode(ctx context.Context, userID int64, code string)
"encrypted_len", len(*user.TotpSecretEncrypted))
// Decrypt the secret
- secret, err := s.encryptor.Decrypt(*user.TotpSecretEncrypted)
+ secret, err := DecryptForSecretDomain(s.encryptor, SecretDomainTOTP, *user.TotpSecretEncrypted)
if err != nil {
slog.Debug("totp_verify_decrypt_failed",
"user_id", userID,
@@ -371,14 +464,9 @@ func (s *TotpService) VerifyCode(ctx context.Context, userID int64, code string)
return infraerrors.InternalServer("TOTP_VERIFY_ERROR", "failed to verify totp code")
}
- secretPrefix := "N/A"
- if len(secret) >= 4 {
- secretPrefix = secret[:4]
- }
slog.Debug("totp_verify_decrypted",
"user_id", userID,
- "secret_len", len(secret),
- "secret_prefix", secretPrefix)
+ "secret_len", len(secret))
// Verify the code
valid := totp.Validate(code, secret)
@@ -386,21 +474,36 @@ func (s *TotpService) VerifyCode(ctx context.Context, userID int64, code string)
"user_id", userID,
"valid", valid,
"secret_len", len(secret),
- "secret_prefix", secretPrefix,
"server_time", time.Now().UTC().Format(time.RFC3339))
if !valid {
- // Increment failed attempts
- _, _ = s.cache.IncrementVerifyAttempts(ctx, userID)
return ErrTotpInvalidCode
}
+ if consume != nil {
+ fingerprint := totpScopedVerificationFingerprint(secret, code, userID)
+ consumed, err := consume(ctx, userID, fingerprint)
+ if err != nil {
+ return ErrTotpVerifyUnavailable
+ }
+ if !consumed {
+ return ErrTotpInvalidCode
+ }
+ }
// Clear attempt counter on success
- _ = s.cache.ClearVerifyAttempts(ctx, userID)
+ if err := clear(ctx, userID); err != nil {
+ return ErrTotpVerifyUnavailable
+ }
return nil
}
+func totpScopedVerificationFingerprint(secret, code string, userID int64) string {
+ mac := hmac.New(sha256.New, []byte(secret))
+ _, _ = fmt.Fprintf(mac, "%d\n%s", userID, code)
+ return hex.EncodeToString(mac.Sum(nil))
+}
+
// CreateLoginSession creates a temporary login session for 2FA
func (s *TotpService) CreateLoginSession(ctx context.Context, userID int64, email string) (string, error) {
return s.createLoginSession(ctx, userID, email, nil)
@@ -452,6 +555,16 @@ func (s *TotpService) GetLoginSession(ctx context.Context, tempToken string) (*T
return s.cache.GetLoginSession(ctx, tempToken)
}
+// ConsumeLoginSession atomically claims a verified 2FA session. Only the
+// winning request may continue to token issuance or pending OAuth binding.
+func (s *TotpService) ConsumeLoginSession(ctx context.Context, tempToken string) (*TotpLoginSession, error) {
+ consumer, ok := s.cache.(TotpLoginSessionConsumer)
+ if !ok {
+ return nil, ErrTotpVerifyUnavailable
+ }
+ return consumer.ConsumeLoginSession(ctx, tempToken)
+}
+
// DeleteLoginSession deletes a login session
func (s *TotpService) DeleteLoginSession(ctx context.Context, tempToken string) error {
return s.cache.DeleteLoginSession(ctx, tempToken)
diff --git a/backend/internal/service/totp_service_security_test.go b/backend/internal/service/totp_service_security_test.go
new file mode 100644
index 00000000000..8dc5c495f97
--- /dev/null
+++ b/backend/internal/service/totp_service_security_test.go
@@ -0,0 +1,261 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/pquerna/otp/totp"
+ "github.com/stretchr/testify/require"
+)
+
+type totpSecurityUserRepo struct {
+ *userRepoStub
+ updateSecretCalls int
+ enableCalls int
+}
+
+type totpVerifyUserRepo struct {
+ UserRepository
+ user *User
+}
+
+func (r *totpVerifyUserRepo) GetByID(context.Context, int64) (*User, error) {
+ copy := *r.user
+ return ©, nil
+}
+
+func (r *totpSecurityUserRepo) UpdateTotpSecret(context.Context, int64, *string) error {
+ r.updateSecretCalls++
+ return nil
+}
+
+func (r *totpSecurityUserRepo) EnableTotp(context.Context, int64) error {
+ r.enableCalls++
+ return nil
+}
+
+type totpSecurityCache struct {
+ session *TotpSetupSession
+ deleteCalls int
+ attempts int
+ scopedAttempts int
+ incrementErr error
+ consumeResult bool
+ consumeErr error
+}
+
+func (c *totpSecurityCache) GetSetupSession(context.Context, int64) (*TotpSetupSession, error) {
+ return c.session, nil
+}
+func (*totpSecurityCache) SetSetupSession(context.Context, int64, *TotpSetupSession, time.Duration) error {
+ return nil
+}
+func (c *totpSecurityCache) DeleteSetupSession(context.Context, int64) error {
+ c.deleteCalls++
+ return nil
+}
+func (*totpSecurityCache) GetLoginSession(context.Context, string) (*TotpLoginSession, error) {
+ return nil, nil
+}
+func (*totpSecurityCache) SetLoginSession(context.Context, string, *TotpLoginSession, time.Duration) error {
+ return nil
+}
+func (*totpSecurityCache) DeleteLoginSession(context.Context, string) error { return nil }
+func (c *totpSecurityCache) IncrementVerifyAttempts(context.Context, int64) (int, error) {
+ if c.incrementErr != nil {
+ return 0, c.incrementErr
+ }
+ c.attempts++
+ return c.attempts, nil
+}
+func (c *totpSecurityCache) GetVerifyAttempts(context.Context, int64) (int, error) {
+ return c.attempts, nil
+}
+func (*totpSecurityCache) ClearVerifyAttempts(context.Context, int64) error { return nil }
+func (c *totpSecurityCache) IncrementScopedVerifyAttempts(context.Context, string, int64) (int, error) {
+ c.scopedAttempts++
+ return c.scopedAttempts, c.incrementErr
+}
+func (c *totpSecurityCache) ClearScopedVerifyAttempts(context.Context, string, int64) error {
+ c.scopedAttempts = 0
+ return nil
+}
+func (c *totpSecurityCache) ConsumeScopedVerification(context.Context, string, int64, string, time.Duration) (bool, error) {
+ return c.consumeResult, c.consumeErr
+}
+
+type totpSecuritySettingRepo struct{}
+
+func (totpSecuritySettingRepo) Get(context.Context, string) (*Setting, error) {
+ return nil, ErrSettingNotFound
+}
+
+func TestTotpVerifyFailsClosedWhenRateLimitStoreIsUnavailable(t *testing.T) {
+ svc := &TotpService{cache: &totpSecurityCache{incrementErr: errors.New("redis unavailable")}}
+ err := svc.VerifyCode(context.Background(), 7, "123456")
+ require.ErrorIs(t, err, ErrTotpVerifyUnavailable)
+}
+
+func TestTotpVerifyClaimsAttemptBeforeCredentialLookup(t *testing.T) {
+ cache := &totpSecurityCache{attempts: maxTotpAttempts}
+ svc := &TotpService{cache: cache}
+ err := svc.VerifyCode(context.Background(), 7, "123456")
+ require.ErrorIs(t, err, ErrTotpTooManyAttempts)
+ require.Equal(t, maxTotpAttempts+1, cache.attempts)
+}
+
+func TestTotpRevealLimiterIsIsolatedFromLoginAttempts(t *testing.T) {
+ cache := &totpSecurityCache{attempts: maxTotpAttempts, scopedAttempts: maxTotpAttempts}
+ svc := &TotpService{cache: cache}
+ err := svc.VerifyCodeForPurpose(context.Background(), 7, "123456", TotpVerifyPurposeAPIKeyReveal)
+ require.ErrorIs(t, err, ErrTotpTooManyAttempts)
+ require.Equal(t, maxTotpAttempts, cache.attempts)
+ require.Equal(t, maxTotpAttempts+1, cache.scopedAttempts)
+}
+
+func TestTotpScopedVerificationConsumesCodeOnce(t *testing.T) {
+ const secret = "JBSWY3DPEHPK3PXP"
+ encrypted := "ciphertext"
+ code, err := totp.GenerateCode(secret, time.Now())
+ require.NoError(t, err)
+ userRepo := &totpVerifyUserRepo{user: &User{ID: 7, TotpEnabled: true, TotpSecretEncrypted: &encrypted}}
+
+ cache := &totpSecurityCache{consumeResult: true}
+ svc := &TotpService{userRepo: userRepo, cache: cache, encryptor: totpRoundTripEncryptor{plaintext: secret}}
+ require.NoError(t, svc.VerifyCodeForPurpose(context.Background(), 7, code, TotpVerifyPurposeAPIKeyReveal))
+ require.Zero(t, cache.attempts)
+ require.Zero(t, cache.scopedAttempts)
+
+ cache.consumeResult = false
+ require.ErrorIs(t, svc.VerifyCodeForPurpose(context.Background(), 7, code, TotpVerifyPurposeAPIKeyReveal), ErrTotpInvalidCode)
+}
+
+func TestTotpUpdatePurposeUsesScopedVerification(t *testing.T) {
+ const secret = "JBSWY3DPEHPK3PXP"
+ encrypted := "ciphertext"
+ code, err := totp.GenerateCode(secret, time.Now())
+ require.NoError(t, err)
+ userRepo := &totpVerifyUserRepo{user: &User{ID: 7, TotpEnabled: true, TotpSecretEncrypted: &encrypted}}
+ cache := &totpSecurityCache{consumeResult: true}
+ svc := &TotpService{userRepo: userRepo, cache: cache, encryptor: totpRoundTripEncryptor{plaintext: secret}}
+
+ require.NoError(t, svc.VerifyCodeForPurpose(context.Background(), 7, code, TotpVerifyPurposeAPIKeyUpdate))
+}
+
+func TestTotpManagementPasswordUsesDedicatedAttemptLimiter(t *testing.T) {
+ user := &User{ID: 7, Email: "oauth@example.com"}
+ require.NoError(t, user.SetPassword("known-password"))
+ settings := NewSettingService(totpSecuritySettingRepo{}, &config.Config{})
+
+ tests := []struct {
+ name string
+ run func(*TotpService) error
+ }{
+ {
+ name: "setup",
+ run: func(svc *TotpService) error {
+ _, err := svc.InitiateSetup(context.Background(), user.ID, "", "wrong-password")
+ return err
+ },
+ },
+ {
+ name: "disable",
+ run: func(svc *TotpService) error {
+ return svc.Disable(context.Background(), user.ID, "", "wrong-password")
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ userCopy := *user
+ userCopy.TotpEnabled = tt.name == "disable"
+ cache := &totpSecurityCache{scopedAttempts: maxTotpAttempts}
+ svc := NewTotpService(&totpVerifyUserRepo{user: &userCopy}, totpRoundTripEncryptor{}, cache, settings, nil, nil)
+
+ err := tt.run(svc)
+ require.ErrorIs(t, err, ErrTotpTooManyAttempts)
+ require.Equal(t, maxTotpAttempts+1, cache.scopedAttempts)
+ })
+ }
+}
+
+func (totpSecuritySettingRepo) GetValue(_ context.Context, key string) (string, error) {
+ if key == SettingKeyTotpEnabled {
+ return "true", nil
+ }
+ return "", ErrSettingNotFound
+}
+func (totpSecuritySettingRepo) Set(context.Context, string, string) error { return nil }
+func (totpSecuritySettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) {
+ return map[string]string{}, nil
+}
+func (totpSecuritySettingRepo) SetMultiple(context.Context, map[string]string) error { return nil }
+func (totpSecuritySettingRepo) GetAll(context.Context) (map[string]string, error) {
+ return map[string]string{}, nil
+}
+func (totpSecuritySettingRepo) Delete(context.Context, string) error { return nil }
+
+type totpRoundTripEncryptor struct {
+ ciphertext string
+ plaintext string
+ decryptErr error
+}
+
+func (e totpRoundTripEncryptor) Encrypt(string) (string, error) { return e.ciphertext, nil }
+func (e totpRoundTripEncryptor) Decrypt(string) (string, error) {
+ return e.plaintext, e.decryptErr
+}
+func (e totpRoundTripEncryptor) EncryptForDomain(string, string) (string, error) {
+ return e.ciphertext, nil
+}
+func (e totpRoundTripEncryptor) DecryptForDomain(string, string) (string, error) {
+ return e.plaintext, e.decryptErr
+}
+
+func TestTotpCompleteSetupFailsClosedWhenCiphertextCannotRoundTrip(t *testing.T) {
+ const secret = "JBSWY3DPEHPK3PXP"
+ code, err := totp.GenerateCode(secret, time.Now())
+ require.NoError(t, err)
+
+ tests := []struct {
+ name string
+ encryptor SecretEncryptor
+ }{
+ {
+ name: "decrypt error",
+ encryptor: totpRoundTripEncryptor{ciphertext: "ciphertext", decryptErr: errors.New("cannot decrypt")},
+ },
+ {
+ name: "plaintext mismatch",
+ encryptor: totpRoundTripEncryptor{ciphertext: "ciphertext", plaintext: "DIFFERENTSECRET"},
+ },
+ {
+ name: "empty ciphertext",
+ encryptor: totpRoundTripEncryptor{plaintext: secret},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ userRepo := &totpSecurityUserRepo{userRepoStub: &userRepoStub{}}
+ cache := &totpSecurityCache{session: &TotpSetupSession{
+ Secret: secret,
+ SetupToken: "setup-token",
+ CreatedAt: time.Now(),
+ }}
+ settings := NewSettingService(totpSecuritySettingRepo{}, &config.Config{})
+ svc := NewTotpService(userRepo, tt.encryptor, cache, settings, nil, nil)
+
+ err := svc.CompleteSetup(context.Background(), 173, code, "setup-token")
+ require.Error(t, err)
+ require.Zero(t, userRepo.updateSecretCalls, "unreadable ciphertext must never be persisted")
+ require.Zero(t, userRepo.enableCalls, "2FA must remain disabled when its secret cannot round-trip")
+ require.Zero(t, cache.deleteCalls, "the user must be able to retry the still-valid setup session")
+ })
+ }
+}
diff --git a/backend/internal/service/update_service.go b/backend/internal/service/update_service.go
index 34ad461023a..6c3ffdd32fa 100644
--- a/backend/internal/service/update_service.go
+++ b/backend/internal/service/update_service.go
@@ -8,6 +8,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/url"
@@ -30,8 +31,19 @@ const (
// Security: max download size (500MB)
maxDownloadSize = 500 * 1024 * 1024
+
+ officialUpdateApplyEnabledEnv = "SUB2API_OFFICIAL_UPDATE_APPLY_ENABLED"
+
+ // This HFC build carries fork-specific schema and security controls. Replacing it
+ // in-process with a generic upstream binary is never a compatible operation.
+ // Re-enabling official updates requires a reviewed build pipeline change, not an
+ // environment-only bypass.
+ hfcOfficialUpdateApplySupported = false
)
+var ErrOfficialUpdateApplyDisabled = errors.New("official binary update apply is disabled for this build")
+var ErrOfficialUpdateChecksumRequired = errors.New("official release checksums.txt is required")
+
// UpdateCache defines cache operations for update service
type UpdateCache interface {
GetUpdateInfo(ctx context.Context) (string, error)
@@ -47,31 +59,34 @@ type GitHubReleaseClient interface {
// UpdateService handles software updates
type UpdateService struct {
- cache UpdateCache
- githubClient GitHubReleaseClient
- currentVersion string
- buildType string // "source" for manual builds, "release" for CI builds
+ cache UpdateCache
+ githubClient GitHubReleaseClient
+ currentVersion string
+ buildType string // "source" for manual builds, "release" for CI builds
+ updateApplyAllowed bool
}
// NewUpdateService creates a new UpdateService
func NewUpdateService(cache UpdateCache, githubClient GitHubReleaseClient, version, buildType string) *UpdateService {
return &UpdateService{
- cache: cache,
- githubClient: githubClient,
- currentVersion: version,
- buildType: buildType,
+ cache: cache,
+ githubClient: githubClient,
+ currentVersion: version,
+ buildType: buildType,
+ updateApplyAllowed: hfcOfficialUpdateApplySupported && strings.EqualFold(strings.TrimSpace(os.Getenv(officialUpdateApplyEnabledEnv)), "true"),
}
}
// UpdateInfo contains update information
type UpdateInfo struct {
- CurrentVersion string `json:"current_version"`
- LatestVersion string `json:"latest_version"`
- HasUpdate bool `json:"has_update"`
- ReleaseInfo *ReleaseInfo `json:"release_info,omitempty"`
- Cached bool `json:"cached"`
- Warning string `json:"warning,omitempty"`
- BuildType string `json:"build_type"` // "source" or "release"
+ CurrentVersion string `json:"current_version"`
+ LatestVersion string `json:"latest_version"`
+ HasUpdate bool `json:"has_update"`
+ ReleaseInfo *ReleaseInfo `json:"release_info,omitempty"`
+ Cached bool `json:"cached"`
+ Warning string `json:"warning,omitempty"`
+ BuildType string `json:"build_type"` // "source" or "release"
+ UpdateApplyAllowed bool `json:"update_apply_allowed"`
}
// ReleaseInfo contains GitHub release details
@@ -111,7 +126,7 @@ func (s *UpdateService) CheckUpdate(ctx context.Context, force bool) (*UpdateInf
// Try cache first
if !force {
if cached, err := s.getFromCache(ctx); err == nil && cached != nil {
- return cached, nil
+ return s.applyUpdateMutationPolicy(cached), nil
}
}
@@ -121,17 +136,18 @@ func (s *UpdateService) CheckUpdate(ctx context.Context, force bool) (*UpdateInf
// Return cached on error
if cached, cacheErr := s.getFromCache(ctx); cacheErr == nil && cached != nil {
cached.Warning = "Using cached data: " + err.Error()
- return cached, nil
+ return s.applyUpdateMutationPolicy(cached), nil
}
- return &UpdateInfo{
+ return s.applyUpdateMutationPolicy(&UpdateInfo{
CurrentVersion: s.currentVersion,
LatestVersion: s.currentVersion,
HasUpdate: false,
Warning: err.Error(),
BuildType: s.buildType,
- }, nil
+ }), nil
}
+ info = s.applyUpdateMutationPolicy(info)
// Cache result
s.saveToCache(ctx, info)
return info, nil
@@ -140,6 +156,9 @@ func (s *UpdateService) CheckUpdate(ctx context.Context, force bool) (*UpdateInf
// PerformUpdate downloads and applies the update
// Uses atomic file replacement pattern for safe in-place updates
func (s *UpdateService) PerformUpdate(ctx context.Context) error {
+ if s == nil || !s.updateApplyAllowed {
+ return ErrOfficialUpdateApplyDisabled
+ }
info, err := s.CheckUpdate(ctx, true)
if err != nil {
return err
@@ -148,6 +167,9 @@ func (s *UpdateService) PerformUpdate(ctx context.Context) error {
if !info.HasUpdate {
return fmt.Errorf("no update available")
}
+ if info.ReleaseInfo == nil {
+ return errors.New("latest release metadata is incomplete")
+ }
// Find matching archive and checksum for current platform
archiveName := s.getArchiveName()
@@ -166,15 +188,16 @@ func (s *UpdateService) PerformUpdate(ctx context.Context) error {
if downloadURL == "" {
return fmt.Errorf("no compatible release found for %s/%s", runtime.GOOS, runtime.GOARCH)
}
+ if checksumURL == "" {
+ return ErrOfficialUpdateChecksumRequired
+ }
// SECURITY: Validate download URL is from trusted domain
if err := validateDownloadURL(downloadURL); err != nil {
return fmt.Errorf("invalid download URL: %w", err)
}
- if checksumURL != "" {
- if err := validateDownloadURL(checksumURL); err != nil {
- return fmt.Errorf("invalid checksum URL: %w", err)
- }
+ if err := validateDownloadURL(checksumURL); err != nil {
+ return fmt.Errorf("invalid checksum URL: %w", err)
}
// Get current executable path
@@ -203,11 +226,10 @@ func (s *UpdateService) PerformUpdate(ctx context.Context) error {
return fmt.Errorf("download failed: %w", err)
}
- // Verify checksum if available
- if checksumURL != "" {
- if err := s.verifyChecksum(ctx, archivePath, checksumURL); err != nil {
- return fmt.Errorf("checksum verification failed: %w", err)
- }
+ // Checksums are mandatory: never install an artifact whose digest was not
+ // explicitly published for the selected archive.
+ if err := s.verifyChecksum(ctx, archivePath, checksumURL); err != nil {
+ return fmt.Errorf("checksum verification failed: %w", err)
}
// Extract binary from archive
@@ -251,6 +273,9 @@ func (s *UpdateService) PerformUpdate(ctx context.Context) error {
// Rollback restores the previous version
func (s *UpdateService) Rollback() error {
+ if s == nil || !s.updateApplyAllowed {
+ return ErrOfficialUpdateApplyDisabled
+ }
exePath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable path: %w", err)
@@ -273,6 +298,22 @@ func (s *UpdateService) Rollback() error {
return nil
}
+func (s *UpdateService) applyUpdateMutationPolicy(info *UpdateInfo) *UpdateInfo {
+ if info == nil {
+ return nil
+ }
+ info.UpdateApplyAllowed = s != nil && s.updateApplyAllowed
+ if !info.UpdateApplyAllowed {
+ const warning = "Official binary apply is disabled for this custom build; review compatibility and deploy through the controlled release process."
+ if strings.TrimSpace(info.Warning) == "" {
+ info.Warning = warning
+ } else if !strings.Contains(info.Warning, warning) {
+ info.Warning += " " + warning
+ }
+ }
+ return info
+}
+
func (s *UpdateService) fetchLatestRelease(ctx context.Context) (*UpdateInfo, error) {
release, err := s.githubClient.FetchLatestRelease(ctx, githubRepo)
if err != nil {
diff --git a/backend/internal/service/update_service_security_test.go b/backend/internal/service/update_service_security_test.go
new file mode 100644
index 00000000000..44054c9854a
--- /dev/null
+++ b/backend/internal/service/update_service_security_test.go
@@ -0,0 +1,99 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "runtime"
+ "testing"
+ "time"
+)
+
+type updateSecurityCache struct{}
+
+func (updateSecurityCache) GetUpdateInfo(context.Context) (string, error) {
+ return "", errors.New("cache miss")
+}
+
+func (updateSecurityCache) SetUpdateInfo(context.Context, string, time.Duration) error {
+ return nil
+}
+
+type updateSecurityReleaseClient struct {
+ release *GitHubRelease
+ downloadCalled bool
+}
+
+func (c *updateSecurityReleaseClient) FetchLatestRelease(context.Context, string) (*GitHubRelease, error) {
+ return c.release, nil
+}
+
+func (c *updateSecurityReleaseClient) DownloadFile(context.Context, string, string, int64) error {
+ c.downloadCalled = true
+ return nil
+}
+
+func (c *updateSecurityReleaseClient) FetchChecksumFile(context.Context, string) ([]byte, error) {
+ return nil, errors.New("unexpected checksum fetch")
+}
+
+func TestOfficialUpdateMutationDisabledByDefault(t *testing.T) {
+ t.Setenv(officialUpdateApplyEnabledEnv, "")
+ svc := NewUpdateService(nil, nil, "0.1.151-hfc", "release")
+
+ if err := svc.PerformUpdate(context.Background()); !errors.Is(err, ErrOfficialUpdateApplyDisabled) {
+ t.Fatalf("PerformUpdate() error=%v, want disabled guard", err)
+ }
+ if err := svc.Rollback(); !errors.Is(err, ErrOfficialUpdateApplyDisabled) {
+ t.Fatalf("Rollback() error=%v, want disabled guard", err)
+ }
+}
+
+func TestOfficialUpdateMutationRequiresExplicitTrue(t *testing.T) {
+ for _, value := range []string{"1", "yes", "TRUE-ish", "false"} {
+ t.Run(value, func(t *testing.T) {
+ t.Setenv(officialUpdateApplyEnabledEnv, value)
+ svc := NewUpdateService(nil, nil, "0.1.151-hfc", "release")
+ if svc.updateApplyAllowed {
+ t.Fatalf("update apply unexpectedly enabled for %q", value)
+ }
+ })
+ }
+}
+
+func TestOfficialUpdateMutationCannotBeEnabledByEnvironmentForHFCBuild(t *testing.T) {
+ t.Setenv(officialUpdateApplyEnabledEnv, "true")
+ svc := NewUpdateService(nil, nil, "0.1.151-hfc", "release")
+
+ if svc.updateApplyAllowed {
+ t.Fatal("HFC build must not permit an environment-only upstream binary replacement")
+ }
+ if err := svc.PerformUpdate(context.Background()); !errors.Is(err, ErrOfficialUpdateApplyDisabled) {
+ t.Fatalf("PerformUpdate() error=%v, want disabled compatibility guard", err)
+ }
+}
+
+func TestOfficialUpdateRequiresChecksumAssetBeforeDownload(t *testing.T) {
+ client := &updateSecurityReleaseClient{}
+ svc := &UpdateService{
+ cache: updateSecurityCache{},
+ githubClient: client,
+ currentVersion: "1.0.0",
+ buildType: "release",
+ updateApplyAllowed: true, // bypass the HFC constructor gate to test defense in depth
+ }
+ client.release = &GitHubRelease{
+ TagName: "v1.0.1",
+ Assets: []GitHubAsset{{
+ Name: "sub2api_" + runtime.GOOS + "_" + runtime.GOARCH + ".tar.gz",
+ BrowserDownloadURL: "https://github.com/Wei-Shaw/sub2api/releases/download/v1.0.1/sub2api.tar.gz",
+ }},
+ }
+
+ err := svc.PerformUpdate(context.Background())
+ if !errors.Is(err, ErrOfficialUpdateChecksumRequired) {
+ t.Fatalf("PerformUpdate() error=%v, want mandatory checksum error", err)
+ }
+ if client.downloadCalled {
+ t.Fatal("archive download must not start when checksums.txt is absent")
+ }
+}
diff --git a/backend/internal/service/upstream_url_validation.go b/backend/internal/service/upstream_url_validation.go
new file mode 100644
index 00000000000..cf29342005f
--- /dev/null
+++ b/backend/internal/service/upstream_url_validation.go
@@ -0,0 +1,24 @@
+package service
+
+import (
+ "fmt"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
+)
+
+func validateUpstreamBaseURLFormat(raw string, cfg *config.Config) (string, error) {
+ allowInsecureHTTP := false
+ allowPrivateHosts := false
+ if cfg != nil {
+ allowInsecureHTTP = cfg.Security.URLAllowlist.AllowInsecureHTTP
+ allowPrivateHosts = cfg.Security.URLAllowlist.AllowPrivateHosts
+ }
+ normalized, err := urlvalidator.ValidateHTTPURL(raw, allowInsecureHTTP, urlvalidator.ValidationOptions{
+ AllowPrivate: allowPrivateHosts,
+ })
+ if err != nil {
+ return "", fmt.Errorf("invalid base_url: %w", err)
+ }
+ return normalized, nil
+}
diff --git a/backend/internal/service/upstream_url_validation_test.go b/backend/internal/service/upstream_url_validation_test.go
new file mode 100644
index 00000000000..df6bd9e6508
--- /dev/null
+++ b/backend/internal/service/upstream_url_validation_test.go
@@ -0,0 +1,73 @@
+package service
+
+import (
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+)
+
+func TestValidateUpstreamBaseURLFormatIgnoresHostAllowlist(t *testing.T) {
+ cfg := &config.Config{
+ Security: config.SecurityConfig{
+ URLAllowlist: config.URLAllowlistConfig{
+ Enabled: true,
+ UpstreamHosts: []string{"api.openai.com"},
+ },
+ },
+ }
+
+ normalized, err := validateUpstreamBaseURLFormat("https://custom-upstream.example.com/v1/", cfg)
+ if err != nil {
+ t.Fatalf("expected custom upstream host to pass without allowlist, got %v", err)
+ }
+ if normalized != "https://custom-upstream.example.com/v1" {
+ t.Fatalf("expected normalized URL, got %q", normalized)
+ }
+}
+
+func TestValidateUpstreamBaseURLFormatStillRejectsInvalidURL(t *testing.T) {
+ if _, err := validateUpstreamBaseURLFormat("://bad", &config.Config{}); err == nil {
+ t.Fatalf("expected invalid URL to fail")
+ }
+}
+
+func TestValidateUpstreamBaseURLFormatHonorsHTTPPolicy(t *testing.T) {
+ strictCfg := &config.Config{
+ Security: config.SecurityConfig{
+ URLAllowlist: config.URLAllowlistConfig{AllowInsecureHTTP: false},
+ },
+ }
+ if _, err := validateUpstreamBaseURLFormat("http://custom-upstream.example.com", strictCfg); err == nil {
+ t.Fatalf("expected http URL to fail when allow_insecure_http is false")
+ }
+
+ relaxedCfg := &config.Config{
+ Security: config.SecurityConfig{
+ URLAllowlist: config.URLAllowlistConfig{AllowInsecureHTTP: true},
+ },
+ }
+ if _, err := validateUpstreamBaseURLFormat("http://custom-upstream.example.com", relaxedCfg); err != nil {
+ t.Fatalf("expected http URL to pass when allow_insecure_http is true, got %v", err)
+ }
+}
+
+func TestValidateUpstreamBaseURLFormatRejectsPrivateHostsWithoutAllowlistMode(t *testing.T) {
+ cfg := &config.Config{
+ Security: config.SecurityConfig{
+ URLAllowlist: config.URLAllowlistConfig{
+ Enabled: false,
+ AllowPrivateHosts: false,
+ },
+ },
+ }
+ for _, raw := range []string{"https://localhost", "https://127.0.0.1", "https://169.254.169.254"} {
+ if _, err := validateUpstreamBaseURLFormat(raw, cfg); err == nil {
+ t.Fatalf("private upstream %q was accepted", raw)
+ }
+ }
+
+ cfg.Security.URLAllowlist.AllowPrivateHosts = true
+ if _, err := validateUpstreamBaseURLFormat("https://127.0.0.1", cfg); err != nil {
+ t.Fatalf("explicit private-host opt-in was not honored: %v", err)
+ }
+}
diff --git a/backend/internal/service/usage_billing.go b/backend/internal/service/usage_billing.go
index 30495624b5e..c5463c24b41 100644
--- a/backend/internal/service/usage_billing.go
+++ b/backend/internal/service/usage_billing.go
@@ -16,17 +16,27 @@ var ErrUsageBillingRequestConflict = errors.New("usage billing request fingerpri
type UsageBillingCommand struct {
RequestID string
APIKeyID int64
+ AuthCacheLocator string
RequestFingerprint string
RequestPayloadHash string
- UserID int64
- AccountID int64
- SubscriptionID *int64
- AccountType string
- Model string
- ServiceTier string
- ReasoningEffort string
- BillingType int8
+ UserID int64
+ AccountID int64
+ SubscriptionID *int64
+ // EffectiveBillingGroupID freezes the group whose subscription cache and
+ // quota windows are authoritative. It may differ from the routed API-key
+ // group when a monthly plan covers multiple groups.
+ EffectiveBillingGroupID *int64
+ AccountType string
+ Model string
+ ServiceTier string
+ ReasoningEffort string
+ BillingType int8
+ // BindingsFrozen means the command came from an outbox envelope whose
+ // tenant, group, account and billing-mode bindings were locked and verified
+ // atomically at enqueue time. Replay may therefore finish charging rows that
+ // were soft-deleted after the upstream request succeeded.
+ BindingsFrozen bool
InputTokens int
OutputTokens int
CacheCreationTokens int
@@ -39,6 +49,12 @@ type UsageBillingCommand struct {
APIKeyQuotaCost float64
APIKeyRateLimitCost float64
AccountQuotaCost float64
+
+ // WalletCost 钱包模式 (v4) 实际扣款金额。设置时表示要在事务内 FOR UPDATE
+ // 锁住 user_subscriptions 行扣减 wallet_balance_usd 并落 ledger 流水。
+ // 与 SubscriptionCost 互斥:v3 老订阅走 SubscriptionCost (group 维度限额),
+ // v4 钱包订阅走 WalletCost。详见 design §2.2。
+ WalletCost float64
}
func (c *UsageBillingCommand) Normalize() {
@@ -56,7 +72,7 @@ func buildUsageBillingFingerprint(c *UsageBillingCommand) string {
return ""
}
raw := fmt.Sprintf(
- "%d|%d|%d|%s|%s|%s|%s|%d|%d|%d|%d|%d|%d|%s|%d|%0.10f|%0.10f|%0.10f|%0.10f|%0.10f",
+ "%d|%d|%d|%s|%s|%s|%s|%d|%d|%d|%d|%d|%d|%s|%d|%d|%0.10f|%0.10f|%0.10f|%0.10f|%0.10f|%0.10f",
c.UserID,
c.AccountID,
c.APIKeyID,
@@ -72,11 +88,13 @@ func buildUsageBillingFingerprint(c *UsageBillingCommand) string {
c.ImageCount,
strings.TrimSpace(c.MediaType),
valueOrZero(c.SubscriptionID),
+ valueOrZero(c.EffectiveBillingGroupID),
c.BalanceCost,
c.SubscriptionCost,
c.APIKeyQuotaCost,
c.APIKeyRateLimitCost,
c.AccountQuotaCost,
+ c.WalletCost,
)
if payloadHash := strings.TrimSpace(c.RequestPayloadHash); payloadHash != "" {
raw += "|" + payloadHash
@@ -114,7 +132,9 @@ type AccountQuotaState struct {
type UsageBillingApplyResult struct {
Applied bool
APIKeyQuotaExhausted bool
+ WalletInsufficient bool // v4 钱包模式扣款时余额不足(事务回滚,未落账)
NewBalance *float64 // post-deduction balance (nil = no balance deduction)
+ NewWalletBalance *float64 // post-deduction wallet balance (nil = not wallet mode)
QuotaState *AccountQuotaState // post-increment quota state (nil = no quota increment)
}
diff --git a/backend/internal/service/usage_billing_admission.go b/backend/internal/service/usage_billing_admission.go
new file mode 100644
index 00000000000..86469cf8bbf
--- /dev/null
+++ b/backend/internal/service/usage_billing_admission.go
@@ -0,0 +1,371 @@
+package service
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "strings"
+ "time"
+)
+
+var (
+ ErrUsageBillingAdmissionInvalid = errors.New("usage billing admission is invalid")
+ ErrUsageBillingAdmissionMissing = errors.New("usage billing admission is missing")
+ ErrUsageBillingAdmissionFinalized = errors.New("usage billing admission is already finalized")
+ ErrUsageBillingAdmissionLeaseLost = errors.New("usage billing admission fencing lease is lost")
+ ErrUsageBillingAdmissionOrphaned = errors.New("usage billing admission requires reconciliation")
+)
+
+const (
+ UsageBillingAdmissionStatePrepared = "prepared"
+ UsageBillingAdmissionStateDispatched = "dispatched"
+ UsageBillingAdmissionStateOutboxPending = "outbox_pending"
+ UsageBillingAdmissionStateSettled = "settled"
+ UsageBillingAdmissionStateOrphaned = "orphaned"
+ UsageBillingAdmissionStateReconcile = "reconcile"
+ UsageBillingAdmissionStateAbandoned = "abandoned"
+
+ UsageBillingAttemptStatePrepared = "prepared"
+ UsageBillingAttemptStateDispatched = "dispatched"
+ UsageBillingAttemptStateFailed = "failed"
+ UsageBillingAttemptStateFinalized = "finalized"
+
+ // Compatibility aliases retained while repository settlement tests move to
+ // the explicit lifecycle names.
+ UsageBillingAdmissionStatusAdmitted = UsageBillingAdmissionStatePrepared
+ UsageBillingAdmissionStatusFinalized = UsageBillingAdmissionStateOutboxPending
+ UsageBillingAdmissionStatusAbandoned = UsageBillingAdmissionStateAbandoned
+)
+
+type UsageBillingAdmissionInput struct {
+ RequestID string
+ APIKeyID int64
+ AuthCacheLocator string
+ UserID int64
+ SubscriptionID *int64
+ GroupID *int64
+ EffectiveBillingGroupID *int64
+ BillingType int8
+
+ OwnerToken string
+ AttemptID string
+ AccountID int64
+ AccountType string
+
+ BillingModel string
+ RequestPayloadHash string
+ PricingSource string
+ PricingRevision string
+ PricingHash string
+ RateMultiplier float64
+ WorstCaseCostUSD float64
+
+ AlternateBillingModel string
+ AlternatePricingSource string
+ AlternatePricingRevision string
+ AlternatePricingHash string
+ AlternateRateMultiplier float64
+}
+
+type UsageBillingAdmission struct {
+ payload usageBillingAdmissionPayload
+}
+
+type usageBillingAdmissionPayload struct {
+ RequestID string `json:"request_id"`
+ APIKeyID int64 `json:"api_key_id"`
+ AuthCacheLocator string `json:"auth_cache_locator,omitempty"`
+ UserID int64 `json:"user_id"`
+ SubscriptionID *int64 `json:"subscription_id,omitempty"`
+ GroupID *int64 `json:"group_id"`
+ EffectiveBillingGroupID *int64 `json:"effective_billing_group_id"`
+ BillingType int8 `json:"billing_type"`
+
+ OwnerToken string `json:"-"`
+ AttemptID string `json:"-"`
+ AccountID int64 `json:"-"`
+ AccountType string `json:"-"`
+
+ BillingModel string `json:"billing_model,omitempty"`
+ RequestPayloadHash string `json:"request_payload_hash,omitempty"`
+ PricingSource string `json:"pricing_source,omitempty"`
+ PricingRevision string `json:"pricing_revision,omitempty"`
+ PricingHash string `json:"pricing_hash,omitempty"`
+ RateMultiplier float64 `json:"rate_multiplier"`
+ WorstCaseCostUSD float64 `json:"worst_case_cost_usd"`
+
+ AlternateBillingModel string `json:"alternate_billing_model,omitempty"`
+ AlternatePricingSource string `json:"alternate_pricing_source,omitempty"`
+ AlternatePricingRevision string `json:"alternate_pricing_revision,omitempty"`
+ AlternatePricingHash string `json:"alternate_pricing_hash,omitempty"`
+ AlternateRateMultiplier float64 `json:"alternate_rate_multiplier,omitempty"`
+}
+
+type usageBillingAdmissionBaseFingerprint struct {
+ RequestID string `json:"request_id"`
+ APIKeyID int64 `json:"api_key_id"`
+ AuthCacheLocator string `json:"auth_cache_locator,omitempty"`
+ UserID int64 `json:"user_id"`
+ SubscriptionID *int64 `json:"subscription_id,omitempty"`
+ GroupID *int64 `json:"group_id"`
+ EffectiveBillingGroupID *int64 `json:"effective_billing_group_id"`
+ BillingType int8 `json:"billing_type"`
+ BillingModel string `json:"billing_model,omitempty"`
+ RequestPayloadHash string `json:"request_payload_hash,omitempty"`
+ PricingSource string `json:"pricing_source,omitempty"`
+ PricingRevision string `json:"pricing_revision,omitempty"`
+ PricingHash string `json:"pricing_hash,omitempty"`
+ RateMultiplier float64 `json:"rate_multiplier"`
+ WorstCaseCostUSD float64 `json:"worst_case_cost_usd"`
+ AlternateBillingModel string `json:"alternate_billing_model,omitempty"`
+ AlternatePricingSource string `json:"alternate_pricing_source,omitempty"`
+ AlternatePricingRevision string `json:"alternate_pricing_revision,omitempty"`
+ AlternatePricingHash string `json:"alternate_pricing_hash,omitempty"`
+ AlternateRateMultiplier float64 `json:"alternate_rate_multiplier,omitempty"`
+}
+
+func NewUsageBillingAdmission(input UsageBillingAdmissionInput) (UsageBillingAdmission, error) {
+ effectiveGroupID := copyInt64(input.EffectiveBillingGroupID)
+ if effectiveGroupID == nil {
+ effectiveGroupID = copyInt64(input.GroupID)
+ }
+ admission := UsageBillingAdmission{payload: usageBillingAdmissionPayload{
+ RequestID: strings.TrimSpace(input.RequestID), APIKeyID: input.APIKeyID,
+ AuthCacheLocator: strings.ToLower(strings.TrimSpace(input.AuthCacheLocator)), UserID: input.UserID,
+ SubscriptionID: copyInt64(input.SubscriptionID), GroupID: copyInt64(input.GroupID),
+ EffectiveBillingGroupID: effectiveGroupID, BillingType: input.BillingType,
+ OwnerToken: strings.ToLower(strings.TrimSpace(input.OwnerToken)), AttemptID: strings.ToLower(strings.TrimSpace(input.AttemptID)),
+ AccountID: input.AccountID, AccountType: strings.TrimSpace(input.AccountType),
+ BillingModel: strings.TrimSpace(input.BillingModel), RequestPayloadHash: strings.ToLower(strings.TrimSpace(input.RequestPayloadHash)),
+ PricingSource: strings.TrimSpace(input.PricingSource),
+ PricingRevision: strings.TrimSpace(input.PricingRevision), PricingHash: strings.ToLower(strings.TrimSpace(input.PricingHash)),
+ RateMultiplier: canonicalUsageBillingRate(input.RateMultiplier),
+ WorstCaseCostUSD: canonicalUsageBillingReservation(input.WorstCaseCostUSD),
+ AlternateBillingModel: strings.TrimSpace(input.AlternateBillingModel),
+ AlternatePricingSource: strings.TrimSpace(input.AlternatePricingSource),
+ AlternatePricingRevision: strings.TrimSpace(input.AlternatePricingRevision),
+ AlternatePricingHash: strings.ToLower(strings.TrimSpace(input.AlternatePricingHash)),
+ AlternateRateMultiplier: canonicalUsageBillingOptionalRate(input.AlternateRateMultiplier),
+ }}
+ if err := admission.Validate(); err != nil {
+ return UsageBillingAdmission{}, err
+ }
+ return admission, nil
+}
+
+func (a UsageBillingAdmission) Validate() error {
+ p := a.payload
+ if p.RequestID == "" || len(p.RequestID) > 255 || p.APIKeyID <= 0 || p.UserID <= 0 || p.AccountID <= 0 {
+ return ErrUsageBillingAdmissionInvalid
+ }
+ if p.AuthCacheLocator != "" && !validSHA256(p.AuthCacheLocator) {
+ return ErrUsageBillingAdmissionInvalid
+ }
+ if p.GroupID == nil || *p.GroupID <= 0 || p.EffectiveBillingGroupID == nil || *p.EffectiveBillingGroupID <= 0 {
+ return ErrUsageBillingAdmissionInvalid
+ }
+ if p.SubscriptionID != nil && *p.SubscriptionID <= 0 {
+ return ErrUsageBillingAdmissionInvalid
+ }
+ if p.AccountType == "" || len(p.AccountType) > 32 || !validFenceToken(p.OwnerToken) || !validFenceToken(p.AttemptID) {
+ return ErrUsageBillingAdmissionInvalid
+ }
+ if p.BillingType != BillingTypeBalance && p.BillingType != BillingTypeSubscription {
+ return ErrUsageBillingAdmissionInvalid
+ }
+ if (p.BillingType == BillingTypeBalance) == (p.SubscriptionID != nil) {
+ return ErrUsageBillingAdmissionInvalid
+ }
+ if (UsageBillingReservationQuote{
+ RequestPayloadHash: p.RequestPayloadHash,
+ BillingModel: p.BillingModel,
+ PricingSource: p.PricingSource,
+ PricingRevision: p.PricingRevision,
+ PricingHash: p.PricingHash,
+ RateMultiplier: p.RateMultiplier,
+ WorstCaseCostUSD: p.WorstCaseCostUSD,
+ AlternateBillingModel: p.AlternateBillingModel,
+ AlternatePricingSource: p.AlternatePricingSource,
+ AlternatePricingRevision: p.AlternatePricingRevision,
+ AlternatePricingHash: p.AlternatePricingHash,
+ AlternateRateMultiplier: p.AlternateRateMultiplier,
+ }).Validate() != nil {
+ return ErrUsageBillingAdmissionInvalid
+ }
+ return nil
+}
+
+func validFenceToken(value string) bool {
+ if len(value) < 16 || len(value) > 64 {
+ return false
+ }
+ for _, r := range value {
+ if (r < 'a' || r > 'f') && (r < '0' || r > '9') {
+ return false
+ }
+ }
+ return true
+}
+
+func (a UsageBillingAdmission) Fingerprint() string {
+ p := a.payload
+ canonical := usageBillingAdmissionBaseFingerprint{
+ RequestID: p.RequestID, APIKeyID: p.APIKeyID, AuthCacheLocator: p.AuthCacheLocator,
+ UserID: p.UserID, SubscriptionID: copyInt64(p.SubscriptionID), GroupID: copyInt64(p.GroupID),
+ EffectiveBillingGroupID: copyInt64(p.EffectiveBillingGroupID), BillingType: p.BillingType,
+ BillingModel: p.BillingModel, RequestPayloadHash: p.RequestPayloadHash,
+ PricingSource: p.PricingSource, PricingRevision: p.PricingRevision,
+ PricingHash: p.PricingHash, RateMultiplier: p.RateMultiplier, WorstCaseCostUSD: p.WorstCaseCostUSD,
+ AlternateBillingModel: p.AlternateBillingModel, AlternatePricingSource: p.AlternatePricingSource,
+ AlternatePricingRevision: p.AlternatePricingRevision, AlternatePricingHash: p.AlternatePricingHash,
+ AlternateRateMultiplier: p.AlternateRateMultiplier,
+ }
+ raw, err := json.Marshal(canonical)
+ if err != nil {
+ return ""
+ }
+ sum := sha256.Sum256(raw)
+ return hex.EncodeToString(sum[:])
+}
+
+func (a UsageBillingAdmission) AttemptFingerprint() string {
+ raw, err := json.Marshal(struct {
+ RequestID string `json:"request_id"`
+ APIKeyID int64 `json:"api_key_id"`
+ AttemptID string `json:"attempt_id"`
+ AccountID int64 `json:"account_id"`
+ AccountType string `json:"account_type"`
+ }{a.RequestID(), a.APIKeyID(), a.AttemptID(), a.AccountID(), a.AccountType()})
+ if err != nil {
+ return ""
+ }
+ sum := sha256.Sum256(raw)
+ return hex.EncodeToString(sum[:])
+}
+
+func (a UsageBillingAdmission) MatchesEnvelopeBase(envelope UsageBillingEnvelope) bool {
+ if a.Validate() != nil || envelope.Validate() != nil {
+ return false
+ }
+ pricingMatches := a.matchesEnvelopePricing(envelope)
+ return a.RequestID() == envelope.RequestID() && a.APIKeyID() == envelope.APIKeyID() &&
+ a.RequestPayloadHash() == envelope.RequestPayloadHash() &&
+ a.AuthCacheLocator() == envelope.AuthCacheLocator() && a.UserID() == envelope.UserID() &&
+ equalOptionalInt64(a.SubscriptionID(), envelope.SubscriptionID()) &&
+ equalOptionalInt64(a.GroupID(), envelope.GroupID()) &&
+ equalOptionalInt64(a.EffectiveBillingGroupID(), envelope.EffectiveBillingGroupID()) &&
+ a.BillingType() == envelope.BillingType() && pricingMatches
+}
+
+func (a UsageBillingAdmission) matchesEnvelopePricing(envelope UsageBillingEnvelope) bool {
+ primary := a.BillingModel() == envelope.BillingModel() &&
+ a.PricingSource() == envelope.PricingSource() && a.PricingRevision() == envelope.PricingRevision() &&
+ a.PricingHash() == envelope.PricingHash() && a.RateMultiplier() == envelope.RateMultiplier()
+ if primary {
+ return true
+ }
+ return a.AlternateBillingModel() != "" &&
+ a.AlternateBillingModel() == envelope.BillingModel() &&
+ a.AlternatePricingSource() == envelope.PricingSource() &&
+ a.AlternatePricingRevision() == envelope.PricingRevision() &&
+ a.AlternatePricingHash() == envelope.PricingHash() &&
+ a.AlternateRateMultiplier() == envelope.RateMultiplier()
+}
+
+func (a UsageBillingAdmission) AttemptMatchesEnvelope(envelope UsageBillingEnvelope) bool {
+ return a.AttemptID() == envelope.AdmissionAttemptID() &&
+ a.AccountID() == envelope.AccountID() && a.AccountType() == envelope.AccountType()
+}
+
+// MatchesEnvelope is retained for callers that need the complete base+attempt check.
+func (a UsageBillingAdmission) MatchesEnvelope(envelope UsageBillingEnvelope) bool {
+ return a.MatchesEnvelopeBase(envelope) && a.AttemptMatchesEnvelope(envelope)
+}
+
+func equalOptionalInt64(left, right *int64) bool {
+ if left == nil || right == nil {
+ return left == nil && right == nil
+ }
+ return *left == *right
+}
+
+func (a UsageBillingAdmission) RequestID() string { return a.payload.RequestID }
+func (a UsageBillingAdmission) APIKeyID() int64 { return a.payload.APIKeyID }
+func (a UsageBillingAdmission) AuthCacheLocator() string { return a.payload.AuthCacheLocator }
+func (a UsageBillingAdmission) UserID() int64 { return a.payload.UserID }
+func (a UsageBillingAdmission) SubscriptionID() *int64 { return copyInt64(a.payload.SubscriptionID) }
+func (a UsageBillingAdmission) GroupID() *int64 { return copyInt64(a.payload.GroupID) }
+func (a UsageBillingAdmission) EffectiveBillingGroupID() *int64 {
+ return copyInt64(a.payload.EffectiveBillingGroupID)
+}
+func (a UsageBillingAdmission) BillingType() int8 { return a.payload.BillingType }
+func (a UsageBillingAdmission) OwnerToken() string { return a.payload.OwnerToken }
+func (a UsageBillingAdmission) AttemptID() string { return a.payload.AttemptID }
+func (a UsageBillingAdmission) AccountID() int64 { return a.payload.AccountID }
+func (a UsageBillingAdmission) AccountType() string { return a.payload.AccountType }
+func (a UsageBillingAdmission) BillingModel() string { return a.payload.BillingModel }
+func (a UsageBillingAdmission) RequestPayloadHash() string { return a.payload.RequestPayloadHash }
+func (a UsageBillingAdmission) PricingSource() string { return a.payload.PricingSource }
+func (a UsageBillingAdmission) PricingRevision() string { return a.payload.PricingRevision }
+func (a UsageBillingAdmission) PricingHash() string { return a.payload.PricingHash }
+func (a UsageBillingAdmission) RateMultiplier() float64 { return a.payload.RateMultiplier }
+func (a UsageBillingAdmission) WorstCaseCostUSD() float64 { return a.payload.WorstCaseCostUSD }
+func (a UsageBillingAdmission) AlternateBillingModel() string {
+ return a.payload.AlternateBillingModel
+}
+func (a UsageBillingAdmission) AlternatePricingSource() string {
+ return a.payload.AlternatePricingSource
+}
+func (a UsageBillingAdmission) AlternatePricingRevision() string {
+ return a.payload.AlternatePricingRevision
+}
+func (a UsageBillingAdmission) AlternatePricingHash() string {
+ return a.payload.AlternatePricingHash
+}
+func (a UsageBillingAdmission) AlternateRateMultiplier() float64 {
+ return a.payload.AlternateRateMultiplier
+}
+
+func canonicalUsageBillingOptionalRate(value float64) float64 {
+ if value == 0 {
+ return 0
+ }
+ return canonicalUsageBillingRate(value)
+}
+
+type UsageBillingAdmissionRepository interface {
+ Admit(ctx context.Context, admission UsageBillingAdmission) error
+ MarkDispatched(ctx context.Context, ref UsageBillingAdmissionAttemptRef) error
+ MarkAttemptFailed(ctx context.Context, ref UsageBillingAdmissionAttemptRef) error
+ MarkOrphaned(ctx context.Context, ref UsageBillingAdmissionAttemptRef) error
+ Heartbeat(ctx context.Context, ref UsageBillingAdmissionAttemptRef, lease time.Duration) error
+ Abandon(ctx context.Context, ref UsageBillingAdmissionAttemptRef) error
+ WaitSettled(ctx context.Context, ref UsageBillingAdmissionAttemptRef) error
+}
+
+type UsageBillingAdmissionReconcileResult struct {
+ AbandonedPrepared int
+ AbandonedFailedDispatched int
+ OrphanedDispatched int
+ FlaggedDeadLetter int
+}
+
+func (r UsageBillingAdmissionReconcileResult) Total() int {
+ return r.AbandonedPrepared + r.AbandonedFailedDispatched + r.OrphanedDispatched + r.FlaggedDeadLetter
+}
+
+// UsageBillingAdmissionReconciler repairs only lifecycle states whose
+// financial outcome is provable. Prepared attempts are safe to release after
+// the pre-transport grace period. Dispatched holds are released only when every
+// attempt is durably failed; unknown delivery is retained for evidence-based
+// reconciliation, and dead-lettered settlements are explicitly flagged.
+type UsageBillingAdmissionReconciler interface {
+ ReconcileStaleAdmissions(
+ ctx context.Context,
+ preparedGrace time.Duration,
+ dispatchedGrace time.Duration,
+ limit int,
+ ) (UsageBillingAdmissionReconcileResult, error)
+}
diff --git a/backend/internal/service/usage_billing_admission_quote_test.go b/backend/internal/service/usage_billing_admission_quote_test.go
new file mode 100644
index 00000000000..f868acfc6d0
--- /dev/null
+++ b/backend/internal/service/usage_billing_admission_quote_test.go
@@ -0,0 +1,167 @@
+package service
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPrepareUsageBillingRequestContextFreezesIdentityAcrossFailover(t *testing.T) {
+ base := context.WithValue(context.Background(), ctxkey.RequestID, "request-123")
+ first, err := PrepareUsageBillingRequestContext(base)
+ require.NoError(t, err)
+ second, err := PrepareUsageBillingRequestContext(first)
+ require.NoError(t, err)
+
+ requestID, _ := first.Value(ctxkey.UsageBillingRequestID).(string)
+ ownerToken, _ := first.Value(ctxkey.UsageBillingOwnerToken).(string)
+ require.Equal(t, "local:request-123", requestID)
+ require.Len(t, ownerToken, 32)
+ require.Equal(t, requestID, second.Value(ctxkey.UsageBillingRequestID))
+ require.Equal(t, ownerToken, second.Value(ctxkey.UsageBillingOwnerToken))
+}
+
+func validUsageBillingAdmissionInput() UsageBillingAdmissionInput {
+ groupID := int64(31)
+ subscriptionID := int64(71)
+ return UsageBillingAdmissionInput{
+ RequestID: "usage-admission-quote",
+ APIKeyID: 11,
+ AuthCacheLocator: strings.Repeat("a", 64),
+ UserID: 22,
+ SubscriptionID: &subscriptionID,
+ GroupID: &groupID,
+ EffectiveBillingGroupID: &groupID,
+ BillingType: BillingTypeSubscription,
+ OwnerToken: strings.Repeat("b", 32),
+ AttemptID: strings.Repeat("c", 32),
+ AccountID: 33,
+ AccountType: AccountTypeOAuth,
+ BillingModel: "gpt-5.6-terra",
+ RequestPayloadHash: strings.Repeat("d", 64),
+ PricingSource: PricingSourceBuiltinGPT56,
+ PricingRevision: GPT56PricingRevision,
+ PricingHash: strings.Repeat("e", 64),
+ RateMultiplier: 1,
+ WorstCaseCostUSD: 0.25,
+ }
+}
+
+func TestUsageBillingAdmissionRequiresImmutableRequestAndPricingQuote(t *testing.T) {
+ valid := validUsageBillingAdmissionInput()
+ admission, err := NewUsageBillingAdmission(valid)
+ require.NoError(t, err)
+ require.Equal(t, valid.RequestPayloadHash, admission.RequestPayloadHash())
+ require.Equal(t, valid.PricingHash, admission.PricingHash())
+ require.Equal(t, valid.WorstCaseCostUSD, admission.WorstCaseCostUSD())
+
+ for _, mutate := range []func(*UsageBillingAdmissionInput){
+ func(input *UsageBillingAdmissionInput) { input.RequestPayloadHash = "" },
+ func(input *UsageBillingAdmissionInput) { input.BillingModel = "" },
+ func(input *UsageBillingAdmissionInput) { input.PricingSource = "" },
+ func(input *UsageBillingAdmissionInput) { input.PricingRevision = "" },
+ func(input *UsageBillingAdmissionInput) { input.PricingHash = "" },
+ func(input *UsageBillingAdmissionInput) { input.RateMultiplier = 0 },
+ func(input *UsageBillingAdmissionInput) { input.WorstCaseCostUSD = 0 },
+ func(input *UsageBillingAdmissionInput) { input.AlternateBillingModel = "gpt-image-2" },
+ } {
+ input := validUsageBillingAdmissionInput()
+ mutate(&input)
+ _, err := NewUsageBillingAdmission(input)
+ require.ErrorIs(t, err, ErrUsageBillingAdmissionInvalid)
+ }
+}
+
+func TestUsageBillingAdmissionAllowsOnlyFrozenAlternatePricing(t *testing.T) {
+ input := validUsageBillingAdmissionInput()
+ input.AlternateBillingModel = "gpt-image-2"
+ input.AlternatePricingSource = PricingSourceBuiltinFallback
+ input.AlternatePricingRevision = builtinFallbackRevision
+ input.AlternatePricingHash = strings.Repeat("f", 64)
+ input.AlternateRateMultiplier = 1.5
+ admission, err := NewUsageBillingAdmission(input)
+ require.NoError(t, err)
+
+ envelopeInput := UsageBillingEnvelopeInput{
+ RequestID: input.RequestID, RequestPayloadHash: input.RequestPayloadHash,
+ AdmissionAttemptID: input.AttemptID,
+ APIKeyID: input.APIKeyID, AuthCacheLocator: input.AuthCacheLocator,
+ UserID: input.UserID, AccountID: input.AccountID, SubscriptionID: input.SubscriptionID,
+ GroupID: input.GroupID, EffectiveBillingGroupID: input.EffectiveBillingGroupID,
+ AccountType: input.AccountType, BillingModel: input.AlternateBillingModel, BillingType: input.BillingType,
+ PricingSource: input.AlternatePricingSource, PricingRevision: input.AlternatePricingRevision,
+ PricingHash: input.AlternatePricingHash, RateMultiplier: input.AlternateRateMultiplier,
+ AccountRateMultiplier: 1,
+ }
+ envelope, err := NewUsageBillingEnvelope(envelopeInput)
+ require.NoError(t, err)
+ require.True(t, admission.MatchesEnvelope(envelope))
+
+ envelopeInput.AdmissionAttemptID = strings.Repeat("8", 32)
+ wrongAttempt, err := NewUsageBillingEnvelope(envelopeInput)
+ require.NoError(t, err)
+ require.False(t, admission.MatchesEnvelope(wrongAttempt))
+ envelopeInput.AdmissionAttemptID = input.AttemptID
+
+ envelopeInput.PricingHash = strings.Repeat("9", 64)
+ tampered, err := NewUsageBillingEnvelope(envelopeInput)
+ require.NoError(t, err)
+ require.False(t, admission.MatchesEnvelope(tampered))
+}
+
+func TestUsageBillingAdmissionCanonicalizesDatabaseNumericPrecision(t *testing.T) {
+ input := validUsageBillingAdmissionInput()
+ input.RateMultiplier = 1.1234567890123
+ input.WorstCaseCostUSD = 0.1234567890123
+ admission, err := NewUsageBillingAdmission(input)
+ require.NoError(t, err)
+ require.Equal(t, 1.123456789, admission.RateMultiplier())
+ require.Equal(t, 0.1234567891, admission.WorstCaseCostUSD())
+
+ envelope, err := NewUsageBillingEnvelope(UsageBillingEnvelopeInput{
+ RequestID: input.RequestID, RequestPayloadHash: input.RequestPayloadHash,
+ APIKeyID: input.APIKeyID, AuthCacheLocator: input.AuthCacheLocator,
+ UserID: input.UserID, AccountID: input.AccountID, SubscriptionID: input.SubscriptionID,
+ GroupID: input.GroupID, EffectiveBillingGroupID: input.EffectiveBillingGroupID,
+ AccountType: input.AccountType, BillingModel: input.BillingModel, BillingType: input.BillingType,
+ PricingSource: input.PricingSource, PricingRevision: input.PricingRevision, PricingHash: input.PricingHash,
+ RateMultiplier: input.RateMultiplier, AccountRateMultiplier: 1,
+ })
+ require.NoError(t, err)
+ require.True(t, admission.MatchesEnvelopeBase(envelope))
+}
+
+func TestBuildUsageBillingAdmissionCopiesReservationQuote(t *testing.T) {
+ groupID := int64(31)
+ subscriptionID := int64(71)
+ quote := UsageBillingReservationQuote{
+ RequestPayloadHash: strings.Repeat("d", 64),
+ BillingModel: "gpt-5.6-terra",
+ PricingSource: PricingSourceBuiltinGPT56,
+ PricingRevision: GPT56PricingRevision,
+ PricingHash: strings.Repeat("e", 64),
+ RateMultiplier: 1.25,
+ WorstCaseCostUSD: 0.5,
+ }
+ walletBalance := 10.0
+ ctx, admission, err := buildUsageBillingAdmission(
+ context.Background(),
+ &APIKey{ID: 11, Key: "sk-admission", GroupID: &groupID, Group: &Group{ID: groupID}},
+ &User{ID: 22},
+ &Account{ID: 33, Type: AccountTypeOAuth},
+ &UserSubscription{ID: subscriptionID, UserID: 22, WalletBalanceUSD: &walletBalance},
+ quote,
+ )
+ require.NoError(t, err)
+ require.NotNil(t, ctx)
+ require.Equal(t, quote.RequestPayloadHash, admission.RequestPayloadHash())
+ require.Equal(t, quote.BillingModel, admission.BillingModel())
+ require.Equal(t, quote.PricingSource, admission.PricingSource())
+ require.Equal(t, quote.PricingRevision, admission.PricingRevision())
+ require.Equal(t, quote.PricingHash, admission.PricingHash())
+ require.Equal(t, quote.RateMultiplier, admission.RateMultiplier())
+ require.Equal(t, quote.WorstCaseCostUSD, admission.WorstCaseCostUSD())
+}
diff --git a/backend/internal/service/usage_billing_admission_service.go b/backend/internal/service/usage_billing_admission_service.go
new file mode 100644
index 00000000000..7bcab756390
--- /dev/null
+++ b/backend/internal/service/usage_billing_admission_service.go
@@ -0,0 +1,214 @@
+package service
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/config"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
+)
+
+const (
+ usageBillingAdmissionWalletLease = 2 * time.Minute
+ usageBillingAdmissionHeartbeatPeriod = 30 * time.Second
+)
+
+type UsageBillingAdmissionSession struct {
+ RequestID string
+ APIKeyID int64
+ OwnerToken string
+ AttemptID string
+ Wallet bool
+}
+
+func (s *UsageBillingAdmissionSession) Ref() UsageBillingAdmissionAttemptRef {
+ if s == nil {
+ return UsageBillingAdmissionAttemptRef{}
+ }
+ return UsageBillingAdmissionAttemptRef{
+ RequestID: s.RequestID, APIKeyID: s.APIKeyID,
+ OwnerToken: s.OwnerToken, AttemptID: s.AttemptID,
+ }
+}
+
+func buildUsageBillingAdmission(
+ ctx context.Context,
+ apiKey *APIKey,
+ user *User,
+ account *Account,
+ subscription *UserSubscription,
+ quote UsageBillingReservationQuote,
+) (context.Context, UsageBillingAdmission, error) {
+ if apiKey == nil || user == nil || account == nil || apiKey.GroupID == nil || apiKey.Group == nil {
+ return ctx, UsageBillingAdmission{}, ErrUsageBillingAdmissionInvalid
+ }
+ if err := quote.Validate(); err != nil {
+ return ctx, UsageBillingAdmission{}, ErrUsageBillingAdmissionInvalid
+ }
+ var err error
+ ctx, err = PrepareUsageBillingRequestContext(ctx)
+ if err != nil {
+ return ctx, UsageBillingAdmission{}, err
+ }
+ requestID, _ := ctx.Value(ctxkey.UsageBillingRequestID).(string)
+ ownerToken, _ := ctx.Value(ctxkey.UsageBillingOwnerToken).(string)
+ attemptID, err := newUsageBillingFenceToken()
+ if err != nil {
+ return ctx, UsageBillingAdmission{}, err
+ }
+ isSubscriptionBilling, effectiveGroup := EffectiveBillingContext(apiKey.Group, subscription)
+ effectiveGroupID := resolveEffectiveBillingGroupID(apiKey.GroupID, effectiveGroup, subscription)
+ billingType := BillingTypeBalance
+ var subscriptionID *int64
+ if isSubscriptionBilling {
+ if subscription == nil {
+ return ctx, UsageBillingAdmission{}, ErrUsageBillingAdmissionInvalid
+ }
+ billingType = BillingTypeSubscription
+ subscriptionID = &subscription.ID
+ }
+ admission, err := NewUsageBillingAdmission(UsageBillingAdmissionInput{
+ RequestID: requestID, APIKeyID: apiKey.ID, AuthCacheLocator: APIKeyStoredAuthCacheLocator(apiKey),
+ UserID: user.ID, AccountID: account.ID, SubscriptionID: subscriptionID,
+ GroupID: apiKey.GroupID, EffectiveBillingGroupID: effectiveGroupID,
+ AccountType: account.Type, BillingType: billingType,
+ OwnerToken: ownerToken, AttemptID: attemptID,
+ BillingModel: quote.BillingModel, RequestPayloadHash: quote.RequestPayloadHash,
+ PricingSource: quote.PricingSource, PricingRevision: quote.PricingRevision,
+ PricingHash: quote.PricingHash, RateMultiplier: quote.RateMultiplier,
+ WorstCaseCostUSD: quote.WorstCaseCostUSD,
+ AlternateBillingModel: quote.AlternateBillingModel,
+ AlternatePricingSource: quote.AlternatePricingSource,
+ AlternatePricingRevision: quote.AlternatePricingRevision,
+ AlternatePricingHash: quote.AlternatePricingHash,
+ AlternateRateMultiplier: quote.AlternateRateMultiplier,
+ })
+ return ctx, admission, err
+}
+
+// PrepareUsageBillingRequestContext freezes the admission base identity once
+// for all account attempts and for the later detached RecordUsage call.
+func PrepareUsageBillingRequestContext(ctx context.Context) (context.Context, error) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ requestID, _ := ctx.Value(ctxkey.UsageBillingRequestID).(string)
+ if strings.TrimSpace(requestID) == "" {
+ requestID = resolveUsageBillingRequestID(ctx, "")
+ ctx = context.WithValue(ctx, ctxkey.UsageBillingRequestID, requestID)
+ }
+ ownerToken, _ := ctx.Value(ctxkey.UsageBillingOwnerToken).(string)
+ if !validFenceToken(strings.TrimSpace(ownerToken)) {
+ var err error
+ ownerToken, err = newUsageBillingFenceToken()
+ if err != nil {
+ return ctx, err
+ }
+ ctx = context.WithValue(ctx, ctxkey.UsageBillingOwnerToken, ownerToken)
+ }
+ return ctx, nil
+}
+
+func newUsageBillingFenceToken() (string, error) {
+ var value [16]byte
+ if _, err := rand.Read(value[:]); err != nil {
+ return "", fmt.Errorf("generate usage billing fencing token: %w", err)
+ }
+ return hex.EncodeToString(value[:]), nil
+}
+
+func admitUsageBillingRequest(
+ ctx context.Context,
+ cfg *config.Config,
+ require bool,
+ repo UsageBillingAdmissionRepository,
+ apiKey *APIKey,
+ user *User,
+ account *Account,
+ subscription *UserSubscription,
+ quote UsageBillingReservationQuote,
+) (context.Context, *UsageBillingAdmissionSession, error) {
+ if cfg != nil && cfg.RunMode == config.RunModeSimple {
+ return ctx, nil, nil
+ }
+ if !require {
+ return ctx, nil, nil
+ }
+ if repo == nil {
+ return ctx, nil, ErrUsageBillingOutboxUnavailable
+ }
+ ctx, admission, err := buildUsageBillingAdmission(ctx, apiKey, user, account, subscription, quote)
+ if err != nil {
+ return ctx, nil, err
+ }
+ if err := repo.Admit(ctx, admission); err != nil {
+ return ctx, nil, fmt.Errorf("usage billing pre-admission: %w", err)
+ }
+ return ctx, &UsageBillingAdmissionSession{
+ RequestID: admission.RequestID(), APIKeyID: admission.APIKeyID(),
+ OwnerToken: admission.OwnerToken(), AttemptID: admission.AttemptID(),
+ Wallet: subscription != nil && subscription.IsWalletMode(),
+ }, nil
+}
+
+func abandonUsageBillingRequest(ctx context.Context, repo UsageBillingAdmissionRepository, session *UsageBillingAdmissionSession) error {
+ if repo == nil || session == nil {
+ return nil
+ }
+ abandonCtx, cancel := newUsageBillingLifecycleMutationContext(ctx)
+ defer cancel()
+ if err := repo.Abandon(abandonCtx, session.Ref()); err != nil && !errors.Is(err, ErrUsageBillingAdmissionLeaseLost) {
+ return err
+ }
+ return nil
+}
+
+func newUsageBillingLifecycleMutationContext(ctx context.Context) (context.Context, context.CancelFunc) {
+ baseCtx := context.Background()
+ if ctx != nil {
+ baseCtx = context.WithoutCancel(ctx)
+ }
+ return context.WithTimeout(baseCtx, usageBillingOutboxAdmissionTimeout)
+}
+
+func dispatchUsageBillingRequest(
+ ctx context.Context,
+ repo UsageBillingAdmissionRepository,
+ session *UsageBillingAdmissionSession,
+) error {
+ if session == nil {
+ return nil
+ }
+ if repo == nil {
+ return ErrUsageBillingOutboxUnavailable
+ }
+ if err := repo.MarkDispatched(ctx, session.Ref()); err != nil {
+ abandonErr := abandonUsageBillingRequest(ctx, repo, session)
+ return errors.Join(err, abandonErr)
+ }
+ return nil
+}
+
+func resolveUsageBillingAdmissionFromGin(c interface{ Get(string) (any, bool) }) (*APIKey, *UserSubscription, error) {
+ if c == nil {
+ return nil, nil, ErrUsageBillingAdmissionInvalid
+ }
+ value, ok := c.Get("api_key")
+ if !ok {
+ return nil, nil, ErrUsageBillingAdmissionInvalid
+ }
+ apiKey, ok := value.(*APIKey)
+ if !ok || apiKey == nil || apiKey.User == nil {
+ return nil, nil, ErrUsageBillingAdmissionInvalid
+ }
+ var subscription *UserSubscription
+ if value, exists := c.Get("subscription"); exists {
+ subscription, _ = value.(*UserSubscription)
+ }
+ return apiKey, subscription, nil
+}
diff --git a/backend/internal/service/usage_billing_lifecycle_contract.go b/backend/internal/service/usage_billing_lifecycle_contract.go
new file mode 100644
index 00000000000..69e19a3a778
--- /dev/null
+++ b/backend/internal/service/usage_billing_lifecycle_contract.go
@@ -0,0 +1,139 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "math"
+ "strings"
+)
+
+const usageBillingDatabaseNumericScale = 10_000_000_000.0
+
+func canonicalUsageBillingRate(value float64) float64 {
+ return math.Round(value*usageBillingDatabaseNumericScale) / usageBillingDatabaseNumericScale
+}
+
+func canonicalUsageBillingReservation(value float64) float64 {
+ return math.Ceil(value*usageBillingDatabaseNumericScale) / usageBillingDatabaseNumericScale
+}
+
+const (
+ UsageBillingLegacyEnvelopeVersion int16 = 1
+ UsageBillingFencedEnvelopeVersion int16 = 2
+)
+
+var ErrUsageBillingLifecycleContractInvalid = errors.New("usage billing lifecycle contract is invalid")
+
+// UsageBillingAdmissionRef fences mutations to the immutable admission base.
+type UsageBillingAdmissionRef struct {
+ RequestID string
+ APIKeyID int64
+ OwnerToken string
+}
+
+func (r UsageBillingAdmissionRef) Validate() error {
+ if strings.TrimSpace(r.RequestID) == "" || len(strings.TrimSpace(r.RequestID)) > 255 ||
+ r.APIKeyID <= 0 || !validFenceToken(strings.ToLower(strings.TrimSpace(r.OwnerToken))) {
+ return ErrUsageBillingLifecycleContractInvalid
+ }
+ return nil
+}
+
+// UsageBillingAttemptRef fences mutations to one concrete upstream attempt.
+// Account failover creates a new attempt; it never rewrites this identity.
+type UsageBillingAttemptRef struct {
+ RequestID string
+ APIKeyID int64
+ OwnerToken string
+ AttemptID string
+}
+
+func (r UsageBillingAttemptRef) Validate() error {
+ if err := r.Admission().Validate(); err != nil ||
+ !validFenceToken(strings.ToLower(strings.TrimSpace(r.AttemptID))) {
+ return ErrUsageBillingLifecycleContractInvalid
+ }
+ return nil
+}
+
+func (r UsageBillingAttemptRef) Admission() UsageBillingAdmissionRef {
+ return UsageBillingAdmissionRef{
+ RequestID: strings.TrimSpace(r.RequestID),
+ APIKeyID: r.APIKeyID,
+ OwnerToken: strings.ToLower(strings.TrimSpace(r.OwnerToken)),
+ }
+}
+
+// UsageBillingAdmissionAttemptRef remains the repository-facing name while
+// both repository and transport code move to the shared lifecycle contract.
+type UsageBillingAdmissionAttemptRef = UsageBillingAttemptRef
+
+// UsageBillingReservationQuote freezes the exact normalized request and price
+// evidence used for the pre-upstream affordability decision.
+type UsageBillingReservationQuote struct {
+ RequestPayloadHash string
+ BillingModel string
+ PricingSource string
+ PricingRevision string
+ PricingHash string
+ RateMultiplier float64
+ WorstCaseCostUSD float64
+
+ // Alternate* freezes one additional settlement schedule for endpoints
+ // whose result type is not known until the upstream response (for example,
+ // an OpenAI Responses turn that may return either text or an image). The
+ // reservation must cover the larger of both schedules.
+ AlternateBillingModel string
+ AlternatePricingSource string
+ AlternatePricingRevision string
+ AlternatePricingHash string
+ AlternateRateMultiplier float64
+}
+
+func (q UsageBillingReservationQuote) Validate() error {
+ if !validSHA256(strings.ToLower(strings.TrimSpace(q.RequestPayloadHash))) ||
+ !validUsageBillingEnvelopeText(q.BillingModel, 128, true) ||
+ !validUsageBillingPricingSource(q.PricingSource) ||
+ !validUsageBillingEnvelopeText(q.PricingRevision, 128, true) ||
+ !validSHA256(strings.ToLower(strings.TrimSpace(q.PricingHash))) ||
+ math.IsNaN(q.RateMultiplier) || math.IsInf(q.RateMultiplier, 0) || q.RateMultiplier <= 0 ||
+ math.IsNaN(q.WorstCaseCostUSD) || math.IsInf(q.WorstCaseCostUSD, 0) || q.WorstCaseCostUSD <= 0 {
+ return ErrUsageBillingLifecycleContractInvalid
+ }
+ hasAlternate := strings.TrimSpace(q.AlternateBillingModel) != "" ||
+ strings.TrimSpace(q.AlternatePricingSource) != "" ||
+ strings.TrimSpace(q.AlternatePricingRevision) != "" ||
+ strings.TrimSpace(q.AlternatePricingHash) != "" || q.AlternateRateMultiplier != 0
+ if hasAlternate && (!validUsageBillingEnvelopeText(q.AlternateBillingModel, 128, true) ||
+ !validUsageBillingPricingSource(q.AlternatePricingSource) ||
+ !validUsageBillingEnvelopeText(q.AlternatePricingRevision, 128, true) ||
+ !validSHA256(strings.ToLower(strings.TrimSpace(q.AlternatePricingHash))) ||
+ math.IsNaN(q.AlternateRateMultiplier) || math.IsInf(q.AlternateRateMultiplier, 0) || q.AlternateRateMultiplier <= 0) {
+ return ErrUsageBillingLifecycleContractInvalid
+ }
+ return nil
+}
+
+// UsageBillingDeliveryOutcome classifies whether account failover is safe.
+// The zero/unknown value is deliberately fail-closed.
+type UsageBillingDeliveryOutcome string
+
+const (
+ UsageBillingDeliveryUnknown UsageBillingDeliveryOutcome = "unknown"
+ UsageBillingDeliveryNotSent UsageBillingDeliveryOutcome = "not_sent"
+ UsageBillingDeliveryDefinitelyRejected UsageBillingDeliveryOutcome = "definitely_rejected"
+ UsageBillingDeliveryAccepted UsageBillingDeliveryOutcome = "accepted"
+)
+
+func (o UsageBillingDeliveryOutcome) AllowsFailover() bool {
+ return o == UsageBillingDeliveryNotSent || o == UsageBillingDeliveryDefinitelyRejected
+}
+
+func (o UsageBillingDeliveryOutcome) RequiresReconciliation() bool {
+ return o == "" || o == UsageBillingDeliveryUnknown
+}
+
+// UsageBillingCommitCallback is the response-commit gate. Non-stream bodies,
+// stream success terminals and WebSocket turn terminals must call it before
+// exposing success to the client.
+type UsageBillingCommitCallback func(context.Context, UsageBillingEnvelope) error
diff --git a/backend/internal/service/usage_billing_lifecycle_contract_test.go b/backend/internal/service/usage_billing_lifecycle_contract_test.go
new file mode 100644
index 00000000000..8609059a724
--- /dev/null
+++ b/backend/internal/service/usage_billing_lifecycle_contract_test.go
@@ -0,0 +1,91 @@
+package service
+
+import (
+ "context"
+ "math"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func validUsageBillingReservationQuote() UsageBillingReservationQuote {
+ return UsageBillingReservationQuote{
+ RequestPayloadHash: strings.Repeat("a", 64),
+ BillingModel: "gpt-5.6-sol",
+ PricingSource: PricingSourceBuiltinGPT56,
+ PricingRevision: GPT56PricingRevision,
+ PricingHash: strings.Repeat("b", 64),
+ RateMultiplier: 1.25,
+ WorstCaseCostUSD: 2.50,
+ }
+}
+
+func TestUsageBillingAttemptRefValidate(t *testing.T) {
+ ref := UsageBillingAttemptRef{
+ RequestID: "req-contract-1",
+ APIKeyID: 11,
+ OwnerToken: strings.Repeat("a", 32),
+ AttemptID: strings.Repeat("b", 32),
+ }
+ require.NoError(t, ref.Validate())
+ require.Equal(t, UsageBillingAdmissionRef{
+ RequestID: ref.RequestID,
+ APIKeyID: ref.APIKeyID,
+ OwnerToken: ref.OwnerToken,
+ }, ref.Admission())
+
+ ref.AttemptID = ""
+ require.ErrorIs(t, ref.Validate(), ErrUsageBillingLifecycleContractInvalid)
+}
+
+func TestUsageBillingReservationQuoteValidateFailsClosed(t *testing.T) {
+ require.NoError(t, validUsageBillingReservationQuote().Validate())
+
+ tests := []struct {
+ name string
+ mutate func(*UsageBillingReservationQuote)
+ }{
+ {"missing request payload hash", func(q *UsageBillingReservationQuote) { q.RequestPayloadHash = "" }},
+ {"missing billing model", func(q *UsageBillingReservationQuote) { q.BillingModel = "" }},
+ {"missing pricing source", func(q *UsageBillingReservationQuote) { q.PricingSource = "" }},
+ {"missing pricing revision", func(q *UsageBillingReservationQuote) { q.PricingRevision = "" }},
+ {"missing pricing hash", func(q *UsageBillingReservationQuote) { q.PricingHash = "" }},
+ {"zero rate multiplier", func(q *UsageBillingReservationQuote) { q.RateMultiplier = 0 }},
+ {"zero reservation", func(q *UsageBillingReservationQuote) { q.WorstCaseCostUSD = 0 }},
+ {"nan rate multiplier", func(q *UsageBillingReservationQuote) { q.RateMultiplier = math.NaN() }},
+ {"infinite reservation", func(q *UsageBillingReservationQuote) { q.WorstCaseCostUSD = math.Inf(1) }},
+ {"negative reservation", func(q *UsageBillingReservationQuote) { q.WorstCaseCostUSD = -1 }},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ quote := validUsageBillingReservationQuote()
+ tt.mutate("e)
+ require.ErrorIs(t, quote.Validate(), ErrUsageBillingLifecycleContractInvalid)
+ })
+ }
+}
+
+func TestUsageBillingDeliveryOutcomeNeverFailoversUnknownDelivery(t *testing.T) {
+ require.True(t, UsageBillingDeliveryNotSent.AllowsFailover())
+ require.True(t, UsageBillingDeliveryDefinitelyRejected.AllowsFailover())
+ require.False(t, UsageBillingDeliveryAccepted.AllowsFailover())
+ require.False(t, UsageBillingDeliveryUnknown.AllowsFailover())
+ require.False(t, UsageBillingDeliveryOutcome("").AllowsFailover())
+
+ require.True(t, UsageBillingDeliveryUnknown.RequiresReconciliation())
+ require.True(t, UsageBillingDeliveryOutcome("").RequiresReconciliation())
+ require.False(t, UsageBillingDeliveryNotSent.RequiresReconciliation())
+ require.False(t, UsageBillingDeliveryDefinitelyRejected.RequiresReconciliation())
+ require.False(t, UsageBillingDeliveryAccepted.RequiresReconciliation())
+}
+
+func TestUsageBillingCommitCallbackContract(t *testing.T) {
+ called := false
+ var callback UsageBillingCommitCallback = func(_ context.Context, _ UsageBillingEnvelope) error {
+ called = true
+ return nil
+ }
+ require.NoError(t, callback(context.Background(), UsageBillingEnvelope{}))
+ require.True(t, called)
+}
diff --git a/backend/internal/service/usage_billing_migration_179_contract_test.go b/backend/internal/service/usage_billing_migration_179_contract_test.go
new file mode 100644
index 00000000000..e77079d353e
--- /dev/null
+++ b/backend/internal/service/usage_billing_migration_179_contract_test.go
@@ -0,0 +1,49 @@
+package service
+
+import (
+ "testing"
+
+ dbmigrations "github.com/Wei-Shaw/sub2api/migrations"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUsageBillingAdmissionMigrationRequiresImmutableQuoteAndAttemptOwnership(t *testing.T) {
+ content, err := dbmigrations.FS.ReadFile("179_usage_billing_admissions.sql")
+ require.NoError(t, err)
+ sql := string(content)
+
+ for _, required := range []string{
+ "request_payload_hash VARCHAR(64) NOT NULL",
+ "request_payload_hash ~ '^[0-9a-f]{64}$'",
+ "billing_model IS NOT NULL",
+ "pricing_source IS NOT NULL",
+ "pricing_revision IS NOT NULL",
+ "pricing_hash IS NOT NULL",
+ "rate_multiplier > 0",
+ "worst_case_cost_usd > 0",
+ "alternate_billing_model VARCHAR(128)",
+ "usage_billing_admissions_alternate_quote_check",
+ "alternate_rate_multiplier IS NOT NULL AND alternate_rate_multiplier > 0",
+ "alternate_rate_multiplier NOT IN ('NaN'::numeric,'Infinity'::numeric,'-Infinity'::numeric)));",
+ "subscription_id IS NOT NULL",
+ "REFERENCES usage_billing_admissions (request_id, api_key_id)",
+ "hfc_guard_usage_billing_admission_immutable",
+ "TG_OP = 'DELETE'",
+ "OLD.binding_fingerprint",
+ "OLD.request_payload_hash",
+ "OLD.worst_case_cost_usd",
+ "OLD.alternate_pricing_hash",
+ "OLD.state = 'prepared' AND NEW.state IN ('dispatched','orphaned','abandoned')",
+ "OLD.state = 'dispatched' AND NEW.state IN ('outbox_pending','orphaned','reconcile','abandoned')",
+ "hfc_guard_usage_billing_admission_attempt_immutable",
+ "usage billing admission attempts are append-only",
+ "OLD.attempt_fingerprint",
+ "idx_usage_billing_admissions_open_wallet",
+ "idx_usage_billing_admissions_prepared_reconcile",
+ } {
+ require.Contains(t, sql, required)
+ }
+ require.NotContains(t, sql, "CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_billing_admissions_one_open_wallet")
+ require.NotContains(t, sql, "OLD.state = 'prepared' AND NEW.state IN ('dispatched','outbox_pending'")
+ require.NotContains(t, sql, "OLD.state = 'prepared' AND NEW.state IN ('dispatched','failed','finalized')")
+}
diff --git a/backend/internal/service/usage_billing_migration_184_contract_test.go b/backend/internal/service/usage_billing_migration_184_contract_test.go
new file mode 100644
index 00000000000..147f78da00e
--- /dev/null
+++ b/backend/internal/service/usage_billing_migration_184_contract_test.go
@@ -0,0 +1,28 @@
+package service
+
+import (
+ "testing"
+
+ dbmigrations "github.com/Wei-Shaw/sub2api/migrations"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUsageBillingAdmissionMigration184HardensExistingInstallations(t *testing.T) {
+ raw, err := dbmigrations.FS.ReadFile("184_usage_billing_admission_lifecycle_hardening.sql")
+ require.NoError(t, err)
+ sql := string(raw)
+
+ for _, required := range []string{
+ "CREATE OR REPLACE FUNCTION hfc_guard_usage_billing_admission_immutable()",
+ "OLD.state = 'prepared' AND NEW.state IN ('dispatched','orphaned','abandoned')",
+ "settled wallet admission requires wallet_consumed_at",
+ "abandoned wallet admission requires wallet_released_at",
+ "OLD.state = 'prepared' AND NEW.state IN ('dispatched','failed')",
+ "finalized attempt timestamp mismatch",
+ "idx_usage_billing_admissions_prepared_reconcile",
+ } {
+ require.Contains(t, sql, required)
+ }
+ require.NotContains(t, sql, "OLD.state = 'prepared' AND NEW.state IN ('dispatched','outbox_pending'")
+ require.NotContains(t, sql, "OLD.state = 'prepared' AND NEW.state IN ('dispatched','failed','finalized')")
+}
diff --git a/backend/internal/service/usage_billing_migration_185_contract_test.go b/backend/internal/service/usage_billing_migration_185_contract_test.go
new file mode 100644
index 00000000000..f070b283c68
--- /dev/null
+++ b/backend/internal/service/usage_billing_migration_185_contract_test.go
@@ -0,0 +1,32 @@
+package service
+
+import (
+ "testing"
+
+ dbmigrations "github.com/Wei-Shaw/sub2api/migrations"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUsageBillingReconciliationMigration185IsAuditableAndFailClosed(t *testing.T) {
+ raw, err := dbmigrations.FS.ReadFile("185_usage_billing_reconciliation_audit.sql")
+ require.NoError(t, err)
+ sql := string(raw)
+
+ for _, required := range []string{
+ "LOCK TABLE idempotency_records IN SHARE ROW EXCLUSIVE MODE",
+ "'admin.usage.billing_reconciliation.resolve'",
+ "CREATE TABLE IF NOT EXISTS usage_billing_reconciliation_audits",
+ "FOREIGN KEY (request_id, api_key_id)",
+ "action = 'settle_delivered'",
+ "attempt_id IS NOT NULL",
+ "to_state = 'outbox_pending'",
+ "evidence_ref ~",
+ "actual_cost_usd > 0",
+ "actual_cost_usd NOT IN ('NaN'::numeric,'Infinity'::numeric,'-Infinity'::numeric)",
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_usage_billing_reconciliation_terminal_once",
+ "BEFORE UPDATE OR DELETE ON usage_billing_reconciliation_audits",
+ "RAISE EXCEPTION 'usage billing reconciliation audits are append-only'",
+ } {
+ require.Contains(t, sql, required)
+ }
+}
diff --git a/backend/internal/service/usage_billing_migration_187_contract_test.go b/backend/internal/service/usage_billing_migration_187_contract_test.go
new file mode 100644
index 00000000000..e87c7fc6acc
--- /dev/null
+++ b/backend/internal/service/usage_billing_migration_187_contract_test.go
@@ -0,0 +1,28 @@
+package service
+
+import (
+ "testing"
+
+ dbmigrations "github.com/Wei-Shaw/sub2api/migrations"
+ "github.com/stretchr/testify/require"
+)
+
+func TestUsageBillingMigration187BlocksUnknownDeliveryWalletRelease(t *testing.T) {
+ raw, err := dbmigrations.FS.ReadFile("187_usage_billing_unknown_delivery_release_guard.sql")
+ require.NoError(t, err)
+ sql := string(raw)
+
+ for _, required := range []string{
+ "OLD.wallet_subscription_id IS NOT NULL",
+ "OLD.state IN ('orphaned', 'reconcile')",
+ "NEW.state = 'abandoned'",
+ "OLD.dispatched_at IS NOT NULL",
+ "FROM usage_billing_admission_attempts",
+ "attempt.dispatched_at IS NOT NULL",
+ "attempt.state IN ('dispatched', 'finalized')",
+ "CONSTRAINT = 'hfc_wallet_unknown_delivery_release'",
+ "BEFORE UPDATE OF state ON usage_billing_admissions",
+ } {
+ require.Contains(t, sql, required)
+ }
+}
diff --git a/backend/internal/service/usage_billing_outbox.go b/backend/internal/service/usage_billing_outbox.go
new file mode 100644
index 00000000000..036f1594136
--- /dev/null
+++ b/backend/internal/service/usage_billing_outbox.go
@@ -0,0 +1,770 @@
+package service
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "strings"
+ "time"
+)
+
+const (
+ UsageBillingEnvelopeVersion int16 = UsageBillingFencedEnvelopeVersion
+ UsageBillingEnvelopeMaxBytes = 16 * 1024
+)
+
+var (
+ ErrUsageBillingEnvelopeInvalid = errors.New("usage billing envelope is invalid")
+ ErrUsageBillingEnvelopeVersion = errors.New("usage billing envelope version is unsupported")
+ ErrUsageBillingEnvelopeFingerprintMismatch = errors.New("usage billing envelope fingerprint mismatch")
+)
+
+// UsageBillingEnvelopeInput is an explicit allow-list of immutable billing
+// facts. Request bodies, credentials, headers and client metadata have no place
+// in this type and therefore cannot be serialized into the durable outbox.
+type UsageBillingEnvelopeInput struct {
+ RequestID string
+ RequestPayloadHash string
+ AdmissionAttemptID string
+ APIKeyID int64
+ AuthCacheLocator string
+ UserID int64
+ AccountID int64
+ SubscriptionID *int64
+ GroupID *int64
+ EffectiveBillingGroupID *int64
+
+ AccountType string
+ BillingModel string
+ ServiceTier string
+ ReasoningEffort string
+ BillingType int8
+
+ InputTokens int
+ OutputTokens int
+ CacheCreationTokens int
+ CacheReadTokens int
+ ImageCount int
+ MediaType string
+ ImageSize string
+
+ ExecutionModel string
+ RequestedModel string
+ UpstreamModel string
+ ChannelID *int64
+ ModelMappingChain string
+ BillingTier string
+ BillingMode string
+ InboundEndpoint string
+ UpstreamEndpoint string
+ RequestType RequestType
+ OccurredAtUnixMs int64
+ DurationMs *int
+ FirstTokenMs *int
+ CacheTTLOverridden bool
+
+ CacheCreation5mTokens int
+ CacheCreation1hTokens int
+ ImageOutputTokens int
+
+ PricingSource string
+ PricingRevision string
+ PricingHash string
+
+ RateMultiplier float64
+ AccountRateMultiplier float64
+
+ BalanceCost float64
+ SubscriptionCost float64
+ WalletCost float64
+ APIKeyQuotaCost float64
+ APIKeyRateLimitCost float64
+ AccountQuotaCost float64
+
+ InputCost float64
+ OutputCost float64
+ CacheCreationCost float64
+ CacheReadCost float64
+ ImageOutputCost float64
+ TotalCost float64
+ ActualCost float64
+ AccountStatsCost *float64
+}
+
+// UsageBillingEnvelope is immutable after construction. Its payload is kept
+// private and accessors return values or defensive copies.
+type UsageBillingEnvelope struct {
+ payload usageBillingEnvelopePayload
+}
+
+type usageBillingEnvelopePayload struct {
+ Version int16 `json:"version"`
+ RequestID string `json:"request_id"`
+ RequestPayloadHash string `json:"request_payload_hash,omitempty"`
+ AdmissionAttemptID string `json:"admission_attempt_id,omitempty"`
+ APIKeyID int64 `json:"api_key_id"`
+ AuthCacheLocator string `json:"auth_cache_locator,omitempty"`
+ RequestFingerprint string `json:"request_fingerprint"`
+ UserID int64 `json:"user_id"`
+ AccountID int64 `json:"account_id"`
+ SubscriptionID *int64 `json:"subscription_id,omitempty"`
+ GroupID *int64 `json:"group_id,omitempty"`
+ EffectiveBillingGroupID *int64 `json:"effective_billing_group_id,omitempty"`
+
+ AccountType string `json:"account_type"`
+ BillingModel string `json:"billing_model"`
+ ServiceTier string `json:"service_tier,omitempty"`
+ ReasoningEffort string `json:"reasoning_effort,omitempty"`
+ BillingType int8 `json:"billing_type"`
+
+ InputTokens int `json:"input_tokens"`
+ OutputTokens int `json:"output_tokens"`
+ CacheCreationTokens int `json:"cache_creation_tokens"`
+ CacheReadTokens int `json:"cache_read_tokens"`
+ ImageCount int `json:"image_count"`
+ MediaType string `json:"media_type,omitempty"`
+ ImageSize string `json:"image_size,omitempty"`
+
+ ExecutionModel string `json:"execution_model,omitempty"`
+ RequestedModel string `json:"requested_model,omitempty"`
+ UpstreamModel string `json:"upstream_model,omitempty"`
+ ChannelID *int64 `json:"channel_id,omitempty"`
+ ModelMappingChain string `json:"model_mapping_chain,omitempty"`
+ BillingTier string `json:"billing_tier,omitempty"`
+ BillingMode string `json:"billing_mode,omitempty"`
+ InboundEndpoint string `json:"inbound_endpoint,omitempty"`
+ UpstreamEndpoint string `json:"upstream_endpoint,omitempty"`
+ RequestType int16 `json:"request_type,omitempty"`
+ OccurredAtUnixMs int64 `json:"occurred_at_unix_ms,omitempty"`
+ DurationMs *int `json:"duration_ms,omitempty"`
+ FirstTokenMs *int `json:"first_token_ms,omitempty"`
+ CacheTTLOverridden bool `json:"cache_ttl_overridden,omitempty"`
+
+ CacheCreation5mTokens int `json:"cache_creation_5m_tokens"`
+ CacheCreation1hTokens int `json:"cache_creation_1h_tokens"`
+ ImageOutputTokens int `json:"image_output_tokens"`
+
+ PricingSource string `json:"pricing_source"`
+ PricingRevision string `json:"pricing_revision"`
+ PricingHash string `json:"pricing_hash"`
+
+ RateMultiplier float64 `json:"rate_multiplier"`
+ AccountRateMultiplier float64 `json:"account_rate_multiplier"`
+
+ BalanceCost float64 `json:"balance_cost"`
+ SubscriptionCost float64 `json:"subscription_cost"`
+ WalletCost float64 `json:"wallet_cost"`
+ APIKeyQuotaCost float64 `json:"api_key_quota_cost"`
+ APIKeyRateLimitCost float64 `json:"api_key_rate_limit_cost"`
+ AccountQuotaCost float64 `json:"account_quota_cost"`
+
+ InputCost float64 `json:"input_cost"`
+ OutputCost float64 `json:"output_cost"`
+ CacheCreationCost float64 `json:"cache_creation_cost"`
+ CacheReadCost float64 `json:"cache_read_cost"`
+ ImageOutputCost float64 `json:"image_output_cost"`
+ TotalCost float64 `json:"total_cost"`
+ ActualCost float64 `json:"actual_cost"`
+ AccountStatsCost *float64 `json:"account_stats_cost,omitempty"`
+}
+
+func NewUsageBillingEnvelope(input UsageBillingEnvelopeInput) (UsageBillingEnvelope, error) {
+ effectiveBillingGroupID := input.EffectiveBillingGroupID
+ if effectiveBillingGroupID == nil {
+ effectiveBillingGroupID = input.GroupID
+ }
+ payload := usageBillingEnvelopePayload{
+ Version: UsageBillingEnvelopeVersion,
+ RequestID: strings.TrimSpace(input.RequestID),
+ RequestPayloadHash: strings.ToLower(strings.TrimSpace(input.RequestPayloadHash)),
+ AdmissionAttemptID: strings.ToLower(strings.TrimSpace(input.AdmissionAttemptID)),
+ APIKeyID: input.APIKeyID,
+ AuthCacheLocator: strings.ToLower(strings.TrimSpace(input.AuthCacheLocator)),
+ UserID: input.UserID,
+ AccountID: input.AccountID,
+ SubscriptionID: copyInt64(input.SubscriptionID),
+ GroupID: copyInt64(input.GroupID),
+ EffectiveBillingGroupID: copyInt64(effectiveBillingGroupID),
+ AccountType: strings.TrimSpace(input.AccountType),
+ BillingModel: strings.TrimSpace(input.BillingModel),
+ ServiceTier: strings.TrimSpace(input.ServiceTier),
+ ReasoningEffort: strings.TrimSpace(input.ReasoningEffort),
+ BillingType: input.BillingType,
+ InputTokens: input.InputTokens,
+ OutputTokens: input.OutputTokens,
+ CacheCreationTokens: input.CacheCreationTokens,
+ CacheReadTokens: input.CacheReadTokens,
+ ImageCount: input.ImageCount,
+ MediaType: strings.TrimSpace(input.MediaType),
+ ImageSize: strings.TrimSpace(input.ImageSize),
+ ExecutionModel: strings.TrimSpace(input.ExecutionModel),
+ RequestedModel: strings.TrimSpace(input.RequestedModel),
+ UpstreamModel: strings.TrimSpace(input.UpstreamModel),
+ ChannelID: copyInt64(input.ChannelID),
+ ModelMappingChain: strings.TrimSpace(input.ModelMappingChain),
+ BillingTier: strings.TrimSpace(input.BillingTier),
+ BillingMode: strings.TrimSpace(input.BillingMode),
+ InboundEndpoint: strings.TrimSpace(input.InboundEndpoint),
+ UpstreamEndpoint: strings.TrimSpace(input.UpstreamEndpoint),
+ RequestType: int16(input.RequestType.Normalize()),
+ OccurredAtUnixMs: input.OccurredAtUnixMs,
+ DurationMs: copyInt(input.DurationMs),
+ FirstTokenMs: copyInt(input.FirstTokenMs),
+ CacheTTLOverridden: input.CacheTTLOverridden,
+ CacheCreation5mTokens: input.CacheCreation5mTokens,
+ CacheCreation1hTokens: input.CacheCreation1hTokens,
+ ImageOutputTokens: input.ImageOutputTokens,
+ PricingSource: strings.TrimSpace(input.PricingSource),
+ PricingRevision: strings.TrimSpace(input.PricingRevision),
+ PricingHash: strings.ToLower(strings.TrimSpace(input.PricingHash)),
+ RateMultiplier: canonicalUsageBillingRate(input.RateMultiplier),
+ AccountRateMultiplier: input.AccountRateMultiplier,
+ BalanceCost: input.BalanceCost,
+ SubscriptionCost: input.SubscriptionCost,
+ WalletCost: input.WalletCost,
+ APIKeyQuotaCost: input.APIKeyQuotaCost,
+ APIKeyRateLimitCost: input.APIKeyRateLimitCost,
+ AccountQuotaCost: input.AccountQuotaCost,
+ InputCost: input.InputCost,
+ OutputCost: input.OutputCost,
+ CacheCreationCost: input.CacheCreationCost,
+ CacheReadCost: input.CacheReadCost,
+ ImageOutputCost: input.ImageOutputCost,
+ TotalCost: input.TotalCost,
+ ActualCost: input.ActualCost,
+ AccountStatsCost: copyFloat64(input.AccountStatsCost),
+ }
+ if err := validateUsageBillingEnvelopePayload(payload, false); err != nil {
+ return UsageBillingEnvelope{}, err
+ }
+ fingerprint, err := usageBillingEnvelopeFingerprint(payload)
+ if err != nil {
+ return UsageBillingEnvelope{}, err
+ }
+ payload.RequestFingerprint = fingerprint
+ return UsageBillingEnvelope{payload: payload}, nil
+}
+
+// NewUsageBillingEnvelopeFromUsageLog freezes the exact billing command and
+// the safe subset of usage-log facts needed for deterministic replay. Request
+// bodies, credentials, headers, IP addresses and user agents are deliberately
+// not copied.
+func NewUsageBillingEnvelopeFromUsageLog(log *UsageLog, cmd *UsageBillingCommand) (UsageBillingEnvelope, error) {
+ if log == nil || cmd == nil {
+ return UsageBillingEnvelope{}, fmt.Errorf("%w: usage log or command is nil", ErrUsageBillingEnvelopeInvalid)
+ }
+ if strings.TrimSpace(log.RequestID) == "" || strings.TrimSpace(cmd.RequestID) != strings.TrimSpace(log.RequestID) ||
+ cmd.APIKeyID != log.APIKeyID || cmd.UserID != log.UserID || cmd.AccountID != log.AccountID {
+ return UsageBillingEnvelope{}, fmt.Errorf("%w: usage log identity mismatch", ErrUsageBillingEnvelopeInvalid)
+ }
+ if log.GroupID == nil || log.CreatedAt.IsZero() {
+ return UsageBillingEnvelope{}, fmt.Errorf("%w: replay group or occurrence time is missing", ErrUsageBillingEnvelopeInvalid)
+ }
+ billingModel := optionalStringValue(log.BillingModel)
+ if billingModel == "" {
+ billingModel = strings.TrimSpace(cmd.Model)
+ }
+ executionModel := strings.TrimSpace(log.Model)
+ if executionModel == "" {
+ executionModel = billingModel
+ }
+ requestedModel := strings.TrimSpace(log.RequestedModel)
+ if requestedModel == "" {
+ requestedModel = executionModel
+ }
+ upstreamModel := optionalStringValue(log.UpstreamModel)
+ if upstreamModel == "" {
+ upstreamModel = executionModel
+ }
+ requestType := log.EffectiveRequestType()
+
+ return NewUsageBillingEnvelope(UsageBillingEnvelopeInput{
+ RequestID: log.RequestID,
+ RequestPayloadHash: cmd.RequestPayloadHash,
+ AdmissionAttemptID: log.UsageBillingAttemptID,
+ APIKeyID: log.APIKeyID,
+ AuthCacheLocator: cmd.AuthCacheLocator,
+ UserID: log.UserID,
+ AccountID: log.AccountID,
+ SubscriptionID: cmd.SubscriptionID,
+ GroupID: log.GroupID,
+ EffectiveBillingGroupID: cmd.EffectiveBillingGroupID,
+ AccountType: cmd.AccountType,
+ BillingModel: billingModel,
+ ServiceTier: optionalStringValue(log.ServiceTier),
+ ReasoningEffort: optionalStringValue(log.ReasoningEffort),
+ BillingType: cmd.BillingType,
+ InputTokens: cmd.InputTokens,
+ OutputTokens: cmd.OutputTokens,
+ CacheCreationTokens: cmd.CacheCreationTokens,
+ CacheReadTokens: cmd.CacheReadTokens,
+ ImageCount: cmd.ImageCount,
+ MediaType: cmd.MediaType,
+ ImageSize: optionalStringValue(log.ImageSize),
+ ExecutionModel: executionModel,
+ RequestedModel: requestedModel,
+ UpstreamModel: upstreamModel,
+ ChannelID: log.ChannelID,
+ ModelMappingChain: optionalStringValue(log.ModelMappingChain),
+ BillingTier: optionalStringValue(log.BillingTier),
+ BillingMode: optionalStringValue(log.BillingMode),
+ InboundEndpoint: optionalStringValue(log.InboundEndpoint),
+ UpstreamEndpoint: optionalStringValue(log.UpstreamEndpoint),
+ RequestType: requestType,
+ OccurredAtUnixMs: log.CreatedAt.UTC().UnixMilli(),
+ DurationMs: log.DurationMs,
+ FirstTokenMs: log.FirstTokenMs,
+ CacheTTLOverridden: log.CacheTTLOverridden,
+ CacheCreation5mTokens: log.CacheCreation5mTokens,
+ CacheCreation1hTokens: log.CacheCreation1hTokens,
+ ImageOutputTokens: log.ImageOutputTokens,
+ PricingSource: optionalStringValue(log.PricingSource),
+ PricingRevision: optionalStringValue(log.PricingRevision),
+ PricingHash: optionalStringValue(log.PricingHash),
+ RateMultiplier: log.RateMultiplier,
+ AccountRateMultiplier: float64Value(log.AccountRateMultiplier),
+ BalanceCost: cmd.BalanceCost,
+ SubscriptionCost: cmd.SubscriptionCost,
+ WalletCost: cmd.WalletCost,
+ APIKeyQuotaCost: cmd.APIKeyQuotaCost,
+ APIKeyRateLimitCost: cmd.APIKeyRateLimitCost,
+ AccountQuotaCost: cmd.AccountQuotaCost,
+ InputCost: log.InputCost,
+ OutputCost: log.OutputCost,
+ CacheCreationCost: log.CacheCreationCost,
+ CacheReadCost: log.CacheReadCost,
+ ImageOutputCost: log.ImageOutputCost,
+ TotalCost: log.TotalCost,
+ ActualCost: log.ActualCost,
+ AccountStatsCost: log.AccountStatsCost,
+ })
+}
+
+func DecodeUsageBillingEnvelope(data []byte) (UsageBillingEnvelope, error) {
+ if len(data) == 0 || len(data) > UsageBillingEnvelopeMaxBytes {
+ return UsageBillingEnvelope{}, fmt.Errorf("%w: invalid encoded size", ErrUsageBillingEnvelopeInvalid)
+ }
+ var payload usageBillingEnvelopePayload
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&payload); err != nil {
+ return UsageBillingEnvelope{}, fmt.Errorf("%w: decode: %v", ErrUsageBillingEnvelopeInvalid, err)
+ }
+ if err := ensureUsageBillingEnvelopeEOF(decoder); err != nil {
+ return UsageBillingEnvelope{}, err
+ }
+ if payload.Version != UsageBillingLegacyEnvelopeVersion && payload.Version != UsageBillingFencedEnvelopeVersion {
+ return UsageBillingEnvelope{}, fmt.Errorf("%w: %d", ErrUsageBillingEnvelopeVersion, payload.Version)
+ }
+ if err := validateUsageBillingEnvelopePayload(payload, true); err != nil {
+ return UsageBillingEnvelope{}, err
+ }
+ expected, err := usageBillingEnvelopeFingerprint(payload)
+ if err != nil {
+ return UsageBillingEnvelope{}, err
+ }
+ if !strings.EqualFold(expected, strings.TrimSpace(payload.RequestFingerprint)) {
+ return UsageBillingEnvelope{}, ErrUsageBillingEnvelopeFingerprintMismatch
+ }
+ payload.RequestID = strings.TrimSpace(payload.RequestID)
+ payload.RequestPayloadHash = strings.ToLower(strings.TrimSpace(payload.RequestPayloadHash))
+ payload.RequestFingerprint = strings.ToLower(strings.TrimSpace(payload.RequestFingerprint))
+ payload.SubscriptionID = copyInt64(payload.SubscriptionID)
+ payload.GroupID = copyInt64(payload.GroupID)
+ payload.EffectiveBillingGroupID = copyInt64(payload.EffectiveBillingGroupID)
+ payload.ChannelID = copyInt64(payload.ChannelID)
+ payload.DurationMs = copyInt(payload.DurationMs)
+ payload.FirstTokenMs = copyInt(payload.FirstTokenMs)
+ payload.AccountStatsCost = copyFloat64(payload.AccountStatsCost)
+ return UsageBillingEnvelope{payload: payload}, nil
+}
+
+func (e UsageBillingEnvelope) MarshalJSON() ([]byte, error) {
+ return json.Marshal(e.payload)
+}
+
+func (e UsageBillingEnvelope) Version() int16 { return e.payload.Version }
+func (e UsageBillingEnvelope) RequestID() string { return e.payload.RequestID }
+func (e UsageBillingEnvelope) RequestPayloadHash() string { return e.payload.RequestPayloadHash }
+func (e UsageBillingEnvelope) AdmissionAttemptID() string { return e.payload.AdmissionAttemptID }
+func (e UsageBillingEnvelope) APIKeyID() int64 { return e.payload.APIKeyID }
+func (e UsageBillingEnvelope) AuthCacheLocator() string { return e.payload.AuthCacheLocator }
+func (e UsageBillingEnvelope) UserID() int64 { return e.payload.UserID }
+func (e UsageBillingEnvelope) AccountID() int64 { return e.payload.AccountID }
+func (e UsageBillingEnvelope) RequestFingerprint() string { return e.payload.RequestFingerprint }
+func (e UsageBillingEnvelope) SubscriptionID() *int64 { return copyInt64(e.payload.SubscriptionID) }
+func (e UsageBillingEnvelope) GroupID() *int64 { return copyInt64(e.payload.GroupID) }
+func (e UsageBillingEnvelope) EffectiveBillingGroupID() *int64 {
+ return copyInt64(e.payload.EffectiveBillingGroupID)
+}
+func (e UsageBillingEnvelope) AccountType() string { return e.payload.AccountType }
+func (e UsageBillingEnvelope) BillingModel() string { return e.payload.BillingModel }
+func (e UsageBillingEnvelope) BillingType() int8 { return e.payload.BillingType }
+func (e UsageBillingEnvelope) PricingSource() string { return e.payload.PricingSource }
+func (e UsageBillingEnvelope) PricingRevision() string { return e.payload.PricingRevision }
+func (e UsageBillingEnvelope) PricingHash() string { return e.payload.PricingHash }
+func (e UsageBillingEnvelope) RateMultiplier() float64 { return e.payload.RateMultiplier }
+func (e UsageBillingEnvelope) AccountRateMultiplier() float64 {
+ return e.payload.AccountRateMultiplier
+}
+func (e UsageBillingEnvelope) ActualCost() float64 { return e.payload.ActualCost }
+func (e UsageBillingEnvelope) TotalCost() float64 { return e.payload.TotalCost }
+func (e UsageBillingEnvelope) PrimaryBillingCost() float64 {
+ return e.payload.BalanceCost + e.payload.SubscriptionCost + e.payload.WalletCost
+}
+func (e UsageBillingEnvelope) BalanceCost() float64 { return e.payload.BalanceCost }
+func (e UsageBillingEnvelope) SubscriptionCost() float64 { return e.payload.SubscriptionCost }
+func (e UsageBillingEnvelope) WalletCost() float64 { return e.payload.WalletCost }
+func (e UsageBillingEnvelope) APIKeyQuotaCost() float64 { return e.payload.APIKeyQuotaCost }
+func (e UsageBillingEnvelope) APIKeyRateLimitCost() float64 {
+ return e.payload.APIKeyRateLimitCost
+}
+func (e UsageBillingEnvelope) AccountQuotaCost() float64 { return e.payload.AccountQuotaCost }
+
+func (e UsageBillingEnvelope) UsageLog() *UsageLog {
+ model := strings.TrimSpace(e.payload.ExecutionModel)
+ if model == "" {
+ model = e.payload.BillingModel
+ }
+ requestedModel := strings.TrimSpace(e.payload.RequestedModel)
+ if requestedModel == "" {
+ requestedModel = model
+ }
+ billingModel := e.payload.BillingModel
+ pricingSource := e.payload.PricingSource
+ pricingRevision := e.payload.PricingRevision
+ pricingHash := e.payload.PricingHash
+ accountRateMultiplier := e.payload.AccountRateMultiplier
+ log := &UsageLog{
+ UserID: e.payload.UserID,
+ APIKeyID: e.payload.APIKeyID,
+ AccountID: e.payload.AccountID,
+ RequestID: e.payload.RequestID,
+ UsageBillingAttemptID: e.payload.AdmissionAttemptID,
+ Model: model,
+ RequestedModel: requestedModel,
+ UpstreamModel: optionalTrimmedStringPtr(e.payload.UpstreamModel),
+ BillingModel: &billingModel,
+ PricingSource: &pricingSource,
+ PricingRevision: &pricingRevision,
+ PricingHash: &pricingHash,
+ ChannelID: copyInt64(e.payload.ChannelID),
+ ModelMappingChain: optionalTrimmedStringPtr(e.payload.ModelMappingChain),
+ BillingTier: optionalTrimmedStringPtr(e.payload.BillingTier),
+ BillingMode: optionalTrimmedStringPtr(e.payload.BillingMode),
+ ServiceTier: optionalTrimmedStringPtr(e.payload.ServiceTier),
+ ReasoningEffort: optionalTrimmedStringPtr(e.payload.ReasoningEffort),
+ InboundEndpoint: optionalTrimmedStringPtr(e.payload.InboundEndpoint),
+ UpstreamEndpoint: optionalTrimmedStringPtr(e.payload.UpstreamEndpoint),
+ GroupID: copyInt64(e.payload.GroupID),
+ SubscriptionID: copyInt64(e.payload.SubscriptionID),
+ InputTokens: e.payload.InputTokens,
+ OutputTokens: e.payload.OutputTokens,
+ CacheCreationTokens: e.payload.CacheCreationTokens,
+ CacheReadTokens: e.payload.CacheReadTokens,
+ CacheCreation5mTokens: e.payload.CacheCreation5mTokens,
+ CacheCreation1hTokens: e.payload.CacheCreation1hTokens,
+ ImageOutputTokens: e.payload.ImageOutputTokens,
+ ImageOutputCost: e.payload.ImageOutputCost,
+ InputCost: e.payload.InputCost,
+ OutputCost: e.payload.OutputCost,
+ CacheCreationCost: e.payload.CacheCreationCost,
+ CacheReadCost: e.payload.CacheReadCost,
+ TotalCost: e.payload.TotalCost,
+ ActualCost: e.payload.ActualCost,
+ RateMultiplier: e.payload.RateMultiplier,
+ AccountRateMultiplier: &accountRateMultiplier,
+ AccountStatsCost: copyFloat64(e.payload.AccountStatsCost),
+ BillingType: e.payload.BillingType,
+ RequestType: RequestTypeFromInt16(e.payload.RequestType),
+ DurationMs: copyInt(e.payload.DurationMs),
+ FirstTokenMs: copyInt(e.payload.FirstTokenMs),
+ CacheTTLOverridden: e.payload.CacheTTLOverridden,
+ ImageCount: e.payload.ImageCount,
+ ImageSize: optionalTrimmedStringPtr(e.payload.ImageSize),
+ MediaType: optionalTrimmedStringPtr(e.payload.MediaType),
+ }
+ if e.payload.OccurredAtUnixMs > 0 {
+ log.CreatedAt = time.UnixMilli(e.payload.OccurredAtUnixMs).UTC()
+ }
+ log.SyncRequestTypeAndLegacyFields()
+ return log
+}
+
+func (e UsageBillingEnvelope) IsBillable() bool {
+ return e.payload.BalanceCost > 0 || e.payload.SubscriptionCost > 0 || e.payload.WalletCost > 0 ||
+ e.payload.APIKeyQuotaCost > 0 || e.payload.APIKeyRateLimitCost > 0 || e.payload.AccountQuotaCost > 0
+}
+
+func (e UsageBillingEnvelope) Validate() error {
+ if err := validateUsageBillingEnvelopePayload(e.payload, true); err != nil {
+ return err
+ }
+ expected, err := usageBillingEnvelopeFingerprint(e.payload)
+ if err != nil {
+ return err
+ }
+ if !strings.EqualFold(expected, e.payload.RequestFingerprint) {
+ return ErrUsageBillingEnvelopeFingerprintMismatch
+ }
+ return nil
+}
+
+func (e UsageBillingEnvelope) Command() *UsageBillingCommand {
+ return &UsageBillingCommand{
+ RequestID: e.payload.RequestID,
+ RequestPayloadHash: e.payload.RequestPayloadHash,
+ APIKeyID: e.payload.APIKeyID,
+ AuthCacheLocator: e.payload.AuthCacheLocator,
+ RequestFingerprint: e.payload.RequestFingerprint,
+ UserID: e.payload.UserID,
+ AccountID: e.payload.AccountID,
+ SubscriptionID: copyInt64(e.payload.SubscriptionID),
+ EffectiveBillingGroupID: copyInt64(e.payload.EffectiveBillingGroupID),
+ AccountType: e.payload.AccountType,
+ Model: e.payload.BillingModel,
+ ServiceTier: e.payload.ServiceTier,
+ ReasoningEffort: e.payload.ReasoningEffort,
+ BillingType: e.payload.BillingType,
+ BindingsFrozen: true,
+ InputTokens: e.payload.InputTokens,
+ OutputTokens: e.payload.OutputTokens,
+ CacheCreationTokens: e.payload.CacheCreationTokens,
+ CacheReadTokens: e.payload.CacheReadTokens,
+ ImageCount: e.payload.ImageCount,
+ MediaType: e.payload.MediaType,
+ BalanceCost: e.payload.BalanceCost,
+ SubscriptionCost: e.payload.SubscriptionCost,
+ WalletCost: e.payload.WalletCost,
+ APIKeyQuotaCost: e.payload.APIKeyQuotaCost,
+ APIKeyRateLimitCost: e.payload.APIKeyRateLimitCost,
+ AccountQuotaCost: e.payload.AccountQuotaCost,
+ }
+}
+
+func validateUsageBillingEnvelopePayload(payload usageBillingEnvelopePayload, requireFingerprint bool) error {
+ if payload.Version != UsageBillingLegacyEnvelopeVersion && payload.Version != UsageBillingFencedEnvelopeVersion {
+ return fmt.Errorf("%w: version=%d", ErrUsageBillingEnvelopeVersion, payload.Version)
+ }
+ if strings.TrimSpace(payload.RequestID) == "" || len(strings.TrimSpace(payload.RequestID)) > 255 ||
+ payload.APIKeyID <= 0 || payload.UserID <= 0 || payload.AccountID <= 0 {
+ return fmt.Errorf("%w: invalid identity", ErrUsageBillingEnvelopeInvalid)
+ }
+ if payload.Version == UsageBillingFencedEnvelopeVersion {
+ if !validSHA256(payload.RequestPayloadHash) {
+ return fmt.Errorf("%w: request_payload_hash is required", ErrUsageBillingEnvelopeInvalid)
+ }
+ } else if payload.RequestPayloadHash != "" && !validSHA256(payload.RequestPayloadHash) {
+ return fmt.Errorf("%w: invalid request_payload_hash", ErrUsageBillingEnvelopeInvalid)
+ }
+ if payload.AuthCacheLocator != "" && !validSHA256(payload.AuthCacheLocator) {
+ return fmt.Errorf("%w: invalid auth_cache_locator", ErrUsageBillingEnvelopeInvalid)
+ }
+ if payload.AdmissionAttemptID != "" && !validFenceToken(payload.AdmissionAttemptID) {
+ return fmt.Errorf("%w: invalid admission_attempt_id", ErrUsageBillingEnvelopeInvalid)
+ }
+ if payload.APIKeyQuotaCost > 0 && !validSHA256(payload.AuthCacheLocator) {
+ return fmt.Errorf("%w: auth_cache_locator is required for quota billing", ErrUsageBillingEnvelopeInvalid)
+ }
+ if payload.SubscriptionID != nil && *payload.SubscriptionID <= 0 {
+ return fmt.Errorf("%w: invalid subscription_id", ErrUsageBillingEnvelopeInvalid)
+ }
+ if payload.GroupID != nil && *payload.GroupID <= 0 {
+ return fmt.Errorf("%w: invalid group_id", ErrUsageBillingEnvelopeInvalid)
+ }
+ if payload.GroupID == nil {
+ return fmt.Errorf("%w: group_id is required", ErrUsageBillingEnvelopeInvalid)
+ }
+ if payload.EffectiveBillingGroupID == nil || *payload.EffectiveBillingGroupID <= 0 {
+ return fmt.Errorf("%w: effective_billing_group_id is required", ErrUsageBillingEnvelopeInvalid)
+ }
+ if payload.ChannelID != nil && *payload.ChannelID <= 0 {
+ return fmt.Errorf("%w: invalid channel_id", ErrUsageBillingEnvelopeInvalid)
+ }
+ if (payload.DurationMs != nil && *payload.DurationMs < 0) || (payload.FirstTokenMs != nil && *payload.FirstTokenMs < 0) {
+ return fmt.Errorf("%w: invalid timing", ErrUsageBillingEnvelopeInvalid)
+ }
+ if payload.OccurredAtUnixMs < 0 || !RequestType(payload.RequestType).IsValid() {
+ return fmt.Errorf("%w: invalid replay occurrence", ErrUsageBillingEnvelopeInvalid)
+ }
+ if !validUsageBillingEnvelopeText(payload.AccountType, 32, true) ||
+ !validUsageBillingEnvelopeText(payload.BillingModel, 128, true) ||
+ !validUsageBillingEnvelopeText(payload.ServiceTier, 64, false) ||
+ !validUsageBillingEnvelopeText(payload.ReasoningEffort, 64, false) ||
+ !validUsageBillingEnvelopeText(payload.MediaType, 128, false) ||
+ !validUsageBillingEnvelopeText(payload.ImageSize, 64, false) ||
+ !validUsageBillingEnvelopeText(payload.ExecutionModel, 128, false) ||
+ !validUsageBillingEnvelopeText(payload.RequestedModel, 128, false) ||
+ !validUsageBillingEnvelopeText(payload.UpstreamModel, 128, false) ||
+ !validUsageBillingEnvelopeText(payload.ModelMappingChain, 1024, false) ||
+ !validUsageBillingEnvelopeText(payload.BillingTier, 64, false) ||
+ !validUsageBillingEnvelopeText(payload.BillingMode, 32, false) ||
+ !validUsageBillingEnvelopeText(payload.InboundEndpoint, 255, false) ||
+ !validUsageBillingEnvelopeText(payload.UpstreamEndpoint, 255, false) {
+ return fmt.Errorf("%w: missing billing identity", ErrUsageBillingEnvelopeInvalid)
+ }
+ if payload.BillingType != BillingTypeBalance && payload.BillingType != BillingTypeSubscription {
+ return fmt.Errorf("%w: invalid billing_type", ErrUsageBillingEnvelopeInvalid)
+ }
+ if payload.InputTokens < 0 || payload.OutputTokens < 0 || payload.CacheCreationTokens < 0 ||
+ payload.CacheReadTokens < 0 || payload.CacheCreation5mTokens < 0 || payload.CacheCreation1hTokens < 0 ||
+ payload.ImageOutputTokens < 0 || payload.ImageCount < 0 {
+ return fmt.Errorf("%w: negative usage", ErrUsageBillingEnvelopeInvalid)
+ }
+ if !validUsageBillingPricingSource(payload.PricingSource) ||
+ !validUsageBillingEnvelopeText(payload.PricingRevision, 128, true) ||
+ !validSHA256(payload.PricingHash) {
+ return fmt.Errorf("%w: invalid pricing evidence", ErrUsageBillingEnvelopeInvalid)
+ }
+ values := []float64{
+ payload.RateMultiplier, payload.AccountRateMultiplier, payload.BalanceCost,
+ payload.SubscriptionCost, payload.WalletCost, payload.APIKeyQuotaCost,
+ payload.APIKeyRateLimitCost, payload.AccountQuotaCost,
+ payload.InputCost, payload.OutputCost, payload.CacheCreationCost, payload.CacheReadCost,
+ payload.ImageOutputCost, payload.TotalCost, payload.ActualCost,
+ }
+ if payload.AccountStatsCost != nil {
+ values = append(values, *payload.AccountStatsCost)
+ }
+ for _, value := range values {
+ if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
+ return fmt.Errorf("%w: invalid numeric value", ErrUsageBillingEnvelopeInvalid)
+ }
+ }
+ primaryCosts := 0
+ for _, value := range []float64{payload.BalanceCost, payload.SubscriptionCost, payload.WalletCost} {
+ if value > 0 {
+ primaryCosts++
+ }
+ }
+ if primaryCosts > 1 {
+ return fmt.Errorf("%w: incompatible primary billing costs", ErrUsageBillingEnvelopeInvalid)
+ }
+ if payload.OccurredAtUnixMs > 0 || strings.TrimSpace(payload.ExecutionModel) != "" {
+ primaryCost := payload.BalanceCost + payload.SubscriptionCost + payload.WalletCost
+ if math.Abs(primaryCost-payload.ActualCost) > 1e-9 {
+ return fmt.Errorf("%w: replay cost does not match billing effect", ErrUsageBillingEnvelopeInvalid)
+ }
+ }
+ if payload.BalanceCost > 0 && payload.BillingType != BillingTypeBalance {
+ return fmt.Errorf("%w: balance cost with subscription billing", ErrUsageBillingEnvelopeInvalid)
+ }
+ if (payload.SubscriptionCost > 0 || payload.WalletCost > 0) &&
+ (payload.BillingType != BillingTypeSubscription || payload.SubscriptionID == nil) {
+ return fmt.Errorf("%w: subscription cost without frozen subscription", ErrUsageBillingEnvelopeInvalid)
+ }
+ if payload.BillingType == BillingTypeSubscription && payload.SubscriptionID == nil {
+ return fmt.Errorf("%w: subscription billing without frozen subscription", ErrUsageBillingEnvelopeInvalid)
+ }
+ if payload.BillingType == BillingTypeBalance && payload.SubscriptionID != nil {
+ return fmt.Errorf("%w: balance billing contains subscription", ErrUsageBillingEnvelopeInvalid)
+ }
+ if requireFingerprint && !validSHA256(payload.RequestFingerprint) {
+ return fmt.Errorf("%w: invalid request fingerprint", ErrUsageBillingEnvelopeInvalid)
+ }
+ return nil
+}
+
+func usageBillingEnvelopeFingerprint(payload usageBillingEnvelopePayload) (string, error) {
+ payload.RequestFingerprint = ""
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return "", fmt.Errorf("%w: fingerprint: %v", ErrUsageBillingEnvelopeInvalid, err)
+ }
+ sum := sha256.Sum256(raw)
+ return hex.EncodeToString(sum[:]), nil
+}
+
+func validSHA256(value string) bool {
+ value = strings.TrimSpace(value)
+ if len(value) != sha256.Size*2 {
+ return false
+ }
+ _, err := hex.DecodeString(value)
+ return err == nil
+}
+
+func ensureUsageBillingEnvelopeEOF(decoder *json.Decoder) error {
+ var extra any
+ err := decoder.Decode(&extra)
+ if errors.Is(err, io.EOF) {
+ return nil
+ }
+ if err == nil {
+ return fmt.Errorf("%w: trailing JSON value", ErrUsageBillingEnvelopeInvalid)
+ }
+ return fmt.Errorf("%w: trailing data: %v", ErrUsageBillingEnvelopeInvalid, err)
+}
+
+func validUsageBillingEnvelopeText(value string, maxLen int, required bool) bool {
+ value = strings.TrimSpace(value)
+ if required && value == "" {
+ return false
+ }
+ if len(value) > maxLen {
+ return false
+ }
+ for _, r := range value {
+ if r < 0x20 || r == 0x7f {
+ return false
+ }
+ }
+ return true
+}
+
+func validUsageBillingPricingSource(value string) bool {
+ switch strings.TrimSpace(value) {
+ case PricingSourceChannel,
+ PricingSourceLiteLLM,
+ PricingSourceFallback,
+ PricingSourceBuiltinGPT56,
+ PricingSourceBuiltinFallback,
+ PricingSourceGroupImage:
+ return true
+ default:
+ return false
+ }
+}
+
+func copyInt64(value *int64) *int64 {
+ if value == nil {
+ return nil
+ }
+ copyValue := *value
+ return ©Value
+}
+
+func copyInt(value *int) *int {
+ if value == nil {
+ return nil
+ }
+ copyValue := *value
+ return ©Value
+}
+
+func copyFloat64(value *float64) *float64 {
+ if value == nil {
+ return nil
+ }
+ copyValue := *value
+ return ©Value
+}
+
+func optionalStringValue(value *string) string {
+ if value == nil {
+ return ""
+ }
+ return strings.TrimSpace(*value)
+}
+
+func float64Value(value *float64) float64 {
+ if value == nil {
+ return 0
+ }
+ return *value
+}
diff --git a/backend/internal/service/usage_billing_outbox_admission.go b/backend/internal/service/usage_billing_outbox_admission.go
new file mode 100644
index 00000000000..f38f80401f9
--- /dev/null
+++ b/backend/internal/service/usage_billing_outbox_admission.go
@@ -0,0 +1,71 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
+ "go.uber.org/zap"
+)
+
+const (
+ usageBillingOutboxAdmissionMaxAttempts = 3
+ usageBillingOutboxAdmissionInitialBackoff = 25 * time.Millisecond
+ usageBillingOutboxAdmissionTimeout = 750 * time.Millisecond
+)
+
+// enqueueUsageBillingOutboxDurably detaches billing identity values from the
+// client lifetime while keeping admission bounded. Production injects a
+// repository with an fsync-backed local spool, so exhausting the database
+// attempt does not turn this loop into an unbounded HTTP goroutine.
+func enqueueUsageBillingOutboxDurably(
+ ctx context.Context,
+ repo UsageBillingOutboxRepository,
+ envelope UsageBillingEnvelope,
+ component string,
+) error {
+ if repo == nil {
+ return ErrUsageBillingOutboxUnavailable
+ }
+ baseCtx := context.Background()
+ if ctx != nil {
+ baseCtx = context.WithoutCancel(ctx)
+ }
+ admissionCtx, cancel := context.WithTimeout(baseCtx, usageBillingOutboxAdmissionTimeout)
+ defer cancel()
+
+ backoff := usageBillingOutboxAdmissionInitialBackoff
+ var lastErr error
+ for attempt := 1; attempt <= usageBillingOutboxAdmissionMaxAttempts; attempt++ {
+ if _, _, err := repo.Enqueue(admissionCtx, envelope); err == nil {
+ return nil
+ } else if !errors.Is(err, ErrUsageBillingOutboxAdmissionRetryable) {
+ return err
+ } else {
+ lastErr = err
+ logger.L().With(
+ zap.String("component", component),
+ zap.String("request_id", envelope.RequestID()),
+ zap.Int64("api_key_id", envelope.APIKeyID()),
+ zap.Int("attempt", attempt),
+ zap.Error(err),
+ ).Warn("usage_billing_outbox.admission_retry")
+ }
+ if attempt == usageBillingOutboxAdmissionMaxAttempts {
+ break
+ }
+ timer := time.NewTimer(backoff)
+ select {
+ case <-admissionCtx.Done():
+ if !timer.Stop() {
+ <-timer.C
+ }
+ return fmt.Errorf("%w: %w", ErrUsageBillingOutboxAdmissionRetryable, errors.Join(lastErr, admissionCtx.Err()))
+ case <-timer.C:
+ }
+ backoff *= 2
+ }
+ return lastErr
+}
diff --git a/backend/internal/service/usage_billing_outbox_lifecycle.go b/backend/internal/service/usage_billing_outbox_lifecycle.go
new file mode 100644
index 00000000000..74cbd3a9efd
--- /dev/null
+++ b/backend/internal/service/usage_billing_outbox_lifecycle.go
@@ -0,0 +1,90 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+)
+
+const (
+ UsageBillingOutboxStatusPending = "pending"
+ UsageBillingOutboxStatusRetry = "retry"
+ UsageBillingOutboxStatusCompleted = "completed"
+ UsageBillingOutboxStatusDeadLetter = "dead_letter"
+
+ UsageBillingOutboxResultApplied = "applied"
+ UsageBillingOutboxResultDuplicate = "duplicate"
+ UsageBillingOutboxResultWalletInsufficient = "wallet_insufficient"
+
+ UsageBillingOutboxLeaseDuration = 30 * time.Second
+ UsageBillingOutboxDefaultMaxAttempts int16 = 8
+)
+
+var (
+ ErrUsageBillingOutboxLeaseLost = errors.New("usage billing outbox lease lost")
+ ErrUsageBillingCrossTenant = errors.New("usage billing binding crosses tenants")
+ ErrUsageBillingOutboxTargetNotFound = errors.New("usage billing binding target not found")
+ ErrUsageBillingOutboxUnavailable = errors.New("usage billing durable outbox is unavailable")
+ ErrUsageBillingOutboxAdmissionRetryable = errors.New("usage billing outbox admission is retryable")
+)
+
+// MarkUsageBillingOutboxAdmissionRetryable marks a storage-layer failure that
+// may succeed once the database recovers. Producers use this classification to
+// backpressure and retry instead of losing an already successful upstream use.
+func MarkUsageBillingOutboxAdmissionRetryable(err error) error {
+ if err == nil {
+ return nil
+ }
+ return fmt.Errorf("%w: %w", ErrUsageBillingOutboxAdmissionRetryable, err)
+}
+
+type UsageBillingOutboxEvent struct {
+ ID int64
+ Envelope UsageBillingEnvelope
+ EnvelopeError error
+ Status string
+ AttemptCount int16
+ MaxAttempts int16
+ AvailableAt time.Time
+ LockedAt *time.Time
+ LockedBy string
+ LeaseToken string
+ LastAttemptAt *time.Time
+ CompletedAt *time.Time
+ DeadLetteredAt *time.Time
+ ResultCode string
+ LastErrorCode string
+ LastError string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+type UsageBillingOutboxRepository interface {
+ Enqueue(ctx context.Context, envelope UsageBillingEnvelope) (*UsageBillingOutboxEvent, bool, error)
+ Claim(ctx context.Context, owner string, limit int, lease time.Duration) ([]UsageBillingOutboxEvent, error)
+ Complete(ctx context.Context, id int64, owner, leaseToken, resultCode string) error
+ Retry(ctx context.Context, id int64, owner, leaseToken string, availableAt time.Time, errorCode, errorMessage string) error
+ DeadLetter(ctx context.Context, id int64, owner, leaseToken, errorCode, errorMessage string) error
+}
+
+type UsageBillingOutboxWaker interface {
+ Wake()
+}
+
+type UsageBillingBindingValidator interface {
+ ValidateBindings(ctx context.Context, envelope UsageBillingEnvelope) error
+}
+
+// UsageBillingReplayWriter is the Task 2A seam for the eventual idempotent
+// usage-log replay. Task 2B owns the concrete producer/log wiring.
+type UsageBillingReplayWriter interface {
+ WriteUsageBillingReplay(ctx context.Context, envelope UsageBillingEnvelope) error
+}
+
+// UsageBillingReplayFinalizer performs only idempotent post-commit effects such
+// as authoritative cache invalidation. It must be safe to run again after an
+// Apply commit followed by a process crash.
+type UsageBillingReplayFinalizer interface {
+ FinalizeUsageBillingReplay(ctx context.Context, envelope UsageBillingEnvelope, result *UsageBillingApplyResult) error
+}
diff --git a/backend/internal/service/usage_billing_outbox_processor.go b/backend/internal/service/usage_billing_outbox_processor.go
new file mode 100644
index 00000000000..ef73220f013
--- /dev/null
+++ b/backend/internal/service/usage_billing_outbox_processor.go
@@ -0,0 +1,231 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ usageBillingOutboxMaxRetryBackoff = 5 * time.Minute
+ usageBillingPreparedReconcileGrace = 5 * time.Minute
+ usageBillingDispatchedOrphanReconcileGrace = 24 * time.Hour
+ usageBillingAdmissionReconcileInterval = time.Minute
+ usageBillingAdmissionReconcileBatchSize = 16
+)
+
+// UsageBillingOutboxProcessor replays durable billing facts through the
+// existing atomic UsageBillingRepository. It never invokes postUsageBilling.
+type UsageBillingOutboxProcessor struct {
+ outboxRepo UsageBillingOutboxRepository
+ bindingValidator UsageBillingBindingValidator
+ billingRepo UsageBillingRepository
+ replayWriter UsageBillingReplayWriter
+ replayFinalizer UsageBillingReplayFinalizer
+ now func() time.Time
+ reconcileMu sync.Mutex
+ nextReconcileAt time.Time
+}
+
+func NewUsageBillingOutboxProcessor(
+ outboxRepo UsageBillingOutboxRepository,
+ bindingValidator UsageBillingBindingValidator,
+ billingRepo UsageBillingRepository,
+ replayWriter UsageBillingReplayWriter,
+ replayFinalizers ...UsageBillingReplayFinalizer,
+) *UsageBillingOutboxProcessor {
+ var replayFinalizer UsageBillingReplayFinalizer
+ if len(replayFinalizers) > 0 {
+ replayFinalizer = replayFinalizers[0]
+ }
+ return &UsageBillingOutboxProcessor{
+ outboxRepo: outboxRepo,
+ bindingValidator: bindingValidator,
+ billingRepo: billingRepo,
+ replayWriter: replayWriter,
+ replayFinalizer: replayFinalizer,
+ now: time.Now,
+ }
+}
+
+func (p *UsageBillingOutboxProcessor) ProcessBatch(ctx context.Context, owner string, limit int) (int, error) {
+ if p == nil || p.outboxRepo == nil {
+ return 0, errors.New("usage billing outbox repository is nil")
+ }
+ events, err := p.outboxRepo.Claim(ctx, owner, limit, UsageBillingOutboxLeaseDuration)
+ if err != nil {
+ return 0, err
+ }
+ var processErrors []error
+ for i := range events {
+ if err := p.ProcessEvent(ctx, events[i]); err != nil {
+ processErrors = append(processErrors, fmt.Errorf("event %d: %w", events[i].ID, err))
+ }
+ }
+ processed := len(events)
+ if reconciler, ok := p.outboxRepo.(UsageBillingAdmissionReconciler); ok && p.claimUsageBillingReconcileSlot() {
+ reconciled, reconcileErr := reconciler.ReconcileStaleAdmissions(
+ ctx,
+ usageBillingPreparedReconcileGrace,
+ usageBillingDispatchedOrphanReconcileGrace,
+ usageBillingAdmissionReconcileBatchSize,
+ )
+ if reconcileErr != nil {
+ processErrors = append(processErrors, fmt.Errorf("reconcile stale admissions: %w", reconcileErr))
+ } else {
+ processed += reconciled.Total()
+ }
+ }
+ return processed, errors.Join(processErrors...)
+}
+
+func (p *UsageBillingOutboxProcessor) claimUsageBillingReconcileSlot() bool {
+ if p == nil {
+ return false
+ }
+ now := time.Now()
+ if p.now != nil {
+ now = p.now()
+ }
+ p.reconcileMu.Lock()
+ defer p.reconcileMu.Unlock()
+ if !p.nextReconcileAt.IsZero() && now.Before(p.nextReconcileAt) {
+ return false
+ }
+ p.nextReconcileAt = now.Add(usageBillingAdmissionReconcileInterval)
+ return true
+}
+
+func (p *UsageBillingOutboxProcessor) ProcessEvent(ctx context.Context, event UsageBillingOutboxEvent) error {
+ if p == nil || p.outboxRepo == nil {
+ return errors.New("usage billing outbox processor is not configured")
+ }
+ if event.ID <= 0 || strings.TrimSpace(event.LockedBy) == "" || strings.TrimSpace(event.LeaseToken) == "" {
+ return ErrUsageBillingOutboxLeaseLost
+ }
+ if event.MaxAttempts <= 0 {
+ event.MaxAttempts = UsageBillingOutboxDefaultMaxAttempts
+ }
+
+ if event.EnvelopeError != nil {
+ return p.deadLetter(ctx, event, "invalid_envelope", event.EnvelopeError)
+ }
+ if err := event.Envelope.Validate(); err != nil {
+ return p.deadLetter(ctx, event, envelopeErrorCode(err), err)
+ }
+ if p.bindingValidator == nil {
+ return p.retry(ctx, event, "binding_validator_unavailable", errors.New("usage billing binding validator is nil"))
+ }
+ if err := p.bindingValidator.ValidateBindings(ctx, event.Envelope); err != nil {
+ if errors.Is(err, ErrUsageBillingCrossTenant) {
+ return p.deadLetter(ctx, event, "cross_tenant", err)
+ }
+ if errors.Is(err, ErrUsageBillingOutboxTargetNotFound) {
+ return p.deadLetter(ctx, event, "binding_target_not_found", err)
+ }
+ return p.retry(ctx, event, "binding_validation_failed", err)
+ }
+ if p.billingRepo == nil {
+ return p.retry(ctx, event, "billing_repository_unavailable", errors.New("usage billing repository is nil"))
+ }
+
+ result, err := p.billingRepo.Apply(ctx, event.Envelope.Command())
+ if err != nil {
+ if errors.Is(err, ErrUsageBillingRequestConflict) {
+ return p.deadLetter(ctx, event, "fingerprint_conflict", err)
+ }
+ return p.retry(ctx, event, "billing_apply_failed", err)
+ }
+ if result == nil {
+ return p.retry(ctx, event, "billing_result_missing", errors.New("usage billing apply result is nil"))
+ }
+
+ // The replay hook is intentionally after Apply and before Complete. A hook
+ // failure retries the event; billing dedup makes the subsequent Apply safe.
+ if p.replayWriter != nil {
+ if err := p.replayWriter.WriteUsageBillingReplay(ctx, event.Envelope); err != nil {
+ return p.retry(ctx, event, "usage_log_replay_failed", err)
+ }
+ }
+ if p.replayFinalizer != nil {
+ if err := p.replayFinalizer.FinalizeUsageBillingReplay(ctx, event.Envelope, result); err != nil {
+ return p.retry(ctx, event, "billing_replay_finalize_failed", err)
+ }
+ }
+
+ resultCode := UsageBillingOutboxResultDuplicate
+ if result.Applied {
+ resultCode = UsageBillingOutboxResultApplied
+ }
+ if result.WalletInsufficient {
+ resultCode = UsageBillingOutboxResultWalletInsufficient
+ }
+ // An acknowledgement failure deliberately leaves the lease intact. After
+ // lease expiry another worker replays Apply and is stopped by billing dedup.
+ return p.outboxRepo.Complete(ctx, event.ID, event.LockedBy, event.LeaseToken, resultCode)
+}
+
+func (p *UsageBillingOutboxProcessor) retry(ctx context.Context, event UsageBillingOutboxEvent, code string, cause error) error {
+ now := time.Now()
+ if p.now != nil {
+ now = p.now()
+ }
+ return p.outboxRepo.Retry(
+ ctx,
+ event.ID,
+ event.LockedBy,
+ event.LeaseToken,
+ now.Add(usageBillingOutboxRetryBackoff(event.ID, event.AttemptCount)),
+ code,
+ safeUsageBillingOutboxError(cause),
+ )
+}
+
+func (p *UsageBillingOutboxProcessor) deadLetter(ctx context.Context, event UsageBillingOutboxEvent, code string, cause error) error {
+ return p.outboxRepo.DeadLetter(ctx, event.ID, event.LockedBy, event.LeaseToken, code, safeUsageBillingOutboxError(cause))
+}
+
+func usageBillingOutboxRetryBackoff(eventID int64, attempt int16) time.Duration {
+ if attempt < 1 {
+ attempt = 1
+ }
+ exponent := attempt - 1
+ if exponent > 8 {
+ exponent = 8
+ }
+ base := time.Second * time.Duration(1< usageBillingOutboxMaxRetryBackoff {
+ base = usageBillingOutboxMaxRetryBackoff
+ }
+ // Deterministic jitter in [0, 25%): stable in tests and across restarts.
+ seed := uint64(eventID)*1103515245 + uint64(attempt)*12345
+ jitter := time.Duration(seed%1000) * base / 4000
+ if base+jitter > usageBillingOutboxMaxRetryBackoff {
+ return usageBillingOutboxMaxRetryBackoff
+ }
+ return base + jitter
+}
+
+func envelopeErrorCode(err error) string {
+ switch {
+ case errors.Is(err, ErrUsageBillingEnvelopeVersion):
+ return "unknown_envelope_version"
+ case errors.Is(err, ErrUsageBillingEnvelopeFingerprintMismatch):
+ return "fingerprint_mismatch"
+ default:
+ return "invalid_envelope"
+ }
+}
+
+func safeUsageBillingOutboxError(err error) string {
+ if err == nil {
+ return ""
+ }
+ // Error sources include the future Task 2B replay writer and database
+ // adapters. Persist only an allow-listed message; the typed error code carries
+ // the actionable classification without risking credentials or request data.
+ return "usage billing outbox processing failed"
+}
diff --git a/backend/internal/service/usage_billing_outbox_processor_test.go b/backend/internal/service/usage_billing_outbox_processor_test.go
new file mode 100644
index 00000000000..1fd0f815841
--- /dev/null
+++ b/backend/internal/service/usage_billing_outbox_processor_test.go
@@ -0,0 +1,403 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+type outboxProcessorRepoStub struct {
+ claimed []UsageBillingOutboxEvent
+ claimErr error
+ completeErr error
+ retried bool
+ dead bool
+ completed bool
+ resultCode string
+ retryAt time.Time
+ errorCode string
+ errorMessage string
+ trace *[]string
+}
+
+type outboxReconcileRepoStub struct {
+ *outboxProcessorRepoStub
+ reconcileResult UsageBillingAdmissionReconcileResult
+ reconcileErr error
+ reconcileCalls int
+ reconcilePreparedGrace time.Duration
+ reconcileDispatchedGrace time.Duration
+ reconcileLimit int
+}
+
+func (s *outboxReconcileRepoStub) ReconcileStaleAdmissions(
+ _ context.Context,
+ preparedGrace time.Duration,
+ dispatchedGrace time.Duration,
+ limit int,
+) (UsageBillingAdmissionReconcileResult, error) {
+ s.reconcileCalls++
+ s.reconcilePreparedGrace = preparedGrace
+ s.reconcileDispatchedGrace = dispatchedGrace
+ s.reconcileLimit = limit
+ return s.reconcileResult, s.reconcileErr
+}
+
+func (s *outboxProcessorRepoStub) Enqueue(context.Context, UsageBillingEnvelope) (*UsageBillingOutboxEvent, bool, error) {
+ panic("not used")
+}
+
+func (s *outboxProcessorRepoStub) Claim(context.Context, string, int, time.Duration) ([]UsageBillingOutboxEvent, error) {
+ return s.claimed, s.claimErr
+}
+
+func (s *outboxProcessorRepoStub) Complete(_ context.Context, _ int64, _, _ string, resultCode string) error {
+ if s.trace != nil {
+ *s.trace = append(*s.trace, "complete")
+ }
+ if s.completeErr != nil {
+ err := s.completeErr
+ s.completeErr = nil
+ return err
+ }
+ s.completed = true
+ s.resultCode = resultCode
+ return nil
+}
+
+func (s *outboxProcessorRepoStub) Retry(_ context.Context, _ int64, _, _ string, availableAt time.Time, code, message string) error {
+ s.retried = true
+ s.retryAt = availableAt
+ s.errorCode = code
+ s.errorMessage = message
+ return nil
+}
+
+func (s *outboxProcessorRepoStub) DeadLetter(_ context.Context, _ int64, _, _ string, code, message string) error {
+ s.dead = true
+ s.errorCode = code
+ s.errorMessage = message
+ return nil
+}
+
+type outboxBindingStub struct {
+ err error
+ calls int
+}
+
+func (s *outboxBindingStub) ValidateBindings(context.Context, UsageBillingEnvelope) error {
+ s.calls++
+ return s.err
+}
+
+type outboxBillingStub struct {
+ err error
+ calls int
+ appliedCharges int
+ commands []*UsageBillingCommand
+ trace *[]string
+}
+
+func (s *outboxBillingStub) Apply(_ context.Context, cmd *UsageBillingCommand) (*UsageBillingApplyResult, error) {
+ s.calls++
+ s.commands = append(s.commands, cmd)
+ if s.trace != nil {
+ *s.trace = append(*s.trace, "apply")
+ }
+ if s.err != nil {
+ return nil, s.err
+ }
+ if s.appliedCharges == 0 {
+ s.appliedCharges++
+ return &UsageBillingApplyResult{Applied: true}, nil
+ }
+ return &UsageBillingApplyResult{Applied: false}, nil
+}
+
+type outboxReplayWriterStub struct {
+ err error
+ writes int
+ trace *[]string
+}
+
+type outboxReplayFinalizerStub struct {
+ err error
+ calls int
+ trace *[]string
+}
+
+func (s *outboxReplayFinalizerStub) FinalizeUsageBillingReplay(context.Context, UsageBillingEnvelope, *UsageBillingApplyResult) error {
+ s.calls++
+ if s.trace != nil {
+ *s.trace = append(*s.trace, "finalize")
+ }
+ return s.err
+}
+
+func (s *outboxReplayWriterStub) WriteUsageBillingReplay(context.Context, UsageBillingEnvelope) error {
+ if s.trace != nil {
+ *s.trace = append(*s.trace, "usage_log")
+ }
+ if s.err == nil && s.writes == 0 {
+ s.writes++
+ }
+ return s.err
+}
+
+func processorEnvelope(t *testing.T, requestID string) UsageBillingEnvelope {
+ t.Helper()
+ input := validUsageBillingEnvelopeInput()
+ input.RequestID = requestID
+ envelope, err := NewUsageBillingEnvelope(input)
+ require.NoError(t, err)
+ return envelope
+}
+
+func TestUsageBillingOutboxProcessor_CrossTenantDeadLettersBeforeApply(t *testing.T) {
+ repo := &outboxProcessorRepoStub{}
+ binding := &outboxBindingStub{err: ErrUsageBillingCrossTenant}
+ billing := &outboxBillingStub{}
+ processor := NewUsageBillingOutboxProcessor(repo, binding, billing, nil)
+
+ event := UsageBillingOutboxEvent{ID: 1, Envelope: processorEnvelope(t, "processor-cross-tenant"), AttemptCount: 1, MaxAttempts: 8, LockedBy: "worker", LeaseToken: "lease-1"}
+ require.NoError(t, processor.ProcessEvent(context.Background(), event))
+ require.True(t, repo.dead)
+ require.Equal(t, "cross_tenant", repo.errorCode)
+ require.Zero(t, billing.calls)
+}
+
+func TestUsageBillingOutboxProcessor_TransientRetryThenSuccess(t *testing.T) {
+ now := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
+ repo := &outboxProcessorRepoStub{}
+ binding := &outboxBindingStub{}
+ billing := &outboxBillingStub{err: errors.New("database temporarily unavailable")}
+ processor := NewUsageBillingOutboxProcessor(repo, binding, billing, nil)
+ processor.now = func() time.Time { return now }
+
+ event := UsageBillingOutboxEvent{ID: 7, Envelope: processorEnvelope(t, "processor-retry"), AttemptCount: 1, MaxAttempts: 8, LockedBy: "worker", LeaseToken: "lease-1"}
+ require.NoError(t, processor.ProcessEvent(context.Background(), event))
+ require.True(t, repo.retried)
+ require.True(t, repo.retryAt.After(now))
+
+ billing.err = nil
+ repo.retried = false
+ require.NoError(t, processor.ProcessEvent(context.Background(), event))
+ require.True(t, repo.completed)
+ require.Equal(t, UsageBillingOutboxResultApplied, repo.resultCode)
+}
+
+func TestUsageBillingOutboxProcessor_PoisonDeadLettersButTransientAtMaxAttemptsKeepsRetrying(t *testing.T) {
+ t.Run("decode poison", func(t *testing.T) {
+ repo := &outboxProcessorRepoStub{}
+ billing := &outboxBillingStub{}
+ processor := NewUsageBillingOutboxProcessor(repo, &outboxBindingStub{}, billing, nil)
+ event := UsageBillingOutboxEvent{ID: 8, EnvelopeError: ErrUsageBillingEnvelopeVersion, AttemptCount: 1, MaxAttempts: 8, LockedBy: "worker", LeaseToken: "lease-1"}
+
+ require.NoError(t, processor.ProcessEvent(context.Background(), event))
+ require.True(t, repo.dead)
+ require.Zero(t, billing.calls)
+ })
+
+ t.Run("transient at max attempts", func(t *testing.T) {
+ repo := &outboxProcessorRepoStub{}
+ billing := &outboxBillingStub{err: errors.New("still unavailable")}
+ processor := NewUsageBillingOutboxProcessor(repo, &outboxBindingStub{}, billing, nil)
+ event := UsageBillingOutboxEvent{ID: 9, Envelope: processorEnvelope(t, "processor-max"), AttemptCount: 8, MaxAttempts: 8, LockedBy: "worker", LeaseToken: "lease-1"}
+
+ require.NoError(t, processor.ProcessEvent(context.Background(), event))
+ require.False(t, repo.dead)
+ require.True(t, repo.retried)
+ require.Equal(t, "billing_apply_failed", repo.errorCode)
+ })
+}
+
+func TestUsageBillingOutboxProcessor_ApplyAckFailureReplayChargesOnce(t *testing.T) {
+ repo := &outboxProcessorRepoStub{completeErr: errors.New("ack failed")}
+ billing := &outboxBillingStub{}
+ writes := &outboxReplayWriterStub{}
+ processor := NewUsageBillingOutboxProcessor(repo, &outboxBindingStub{}, billing, writes)
+ event := UsageBillingOutboxEvent{ID: 10, Envelope: processorEnvelope(t, "processor-ack-crash"), AttemptCount: 1, MaxAttempts: 8, LockedBy: "worker-one", LeaseToken: "lease-1"}
+
+ require.Error(t, processor.ProcessEvent(context.Background(), event))
+ event.AttemptCount = 2
+ event.LockedBy = "worker-two"
+ event.LeaseToken = "lease-2"
+ require.NoError(t, processor.ProcessEvent(context.Background(), event))
+ require.Equal(t, 2, billing.calls)
+ require.Equal(t, 1, billing.appliedCharges)
+ require.Equal(t, UsageBillingOutboxResultDuplicate, repo.resultCode)
+ require.Equal(t, 1, writes.writes, "usage log callback must be idempotent across replay")
+}
+
+func TestUsageBillingOutboxProcessor_OrdersApplyUsageLogComplete(t *testing.T) {
+ trace := make([]string, 0, 4)
+ repo := &outboxProcessorRepoStub{trace: &trace}
+ billing := &outboxBillingStub{trace: &trace}
+ writes := &outboxReplayWriterStub{trace: &trace}
+ finalizer := &outboxReplayFinalizerStub{trace: &trace}
+ processor := NewUsageBillingOutboxProcessor(repo, &outboxBindingStub{}, billing, writes, finalizer)
+ event := UsageBillingOutboxEvent{ID: 11, Envelope: processorEnvelope(t, "processor-order"), AttemptCount: 1, MaxAttempts: 8, LockedBy: "worker", LeaseToken: "lease-1"}
+
+ require.NoError(t, processor.ProcessEvent(context.Background(), event))
+ require.Equal(t, []string{"apply", "usage_log", "finalize", "complete"}, trace)
+}
+
+func TestUsageBillingOutboxProcessor_FinalizerFailureRetriesBeforeComplete(t *testing.T) {
+ repo := &outboxProcessorRepoStub{}
+ billing := &outboxBillingStub{}
+ finalizer := &outboxReplayFinalizerStub{err: errors.New("cache temporarily unavailable")}
+ processor := NewUsageBillingOutboxProcessor(repo, &outboxBindingStub{}, billing, &outboxReplayWriterStub{}, finalizer)
+ event := UsageBillingOutboxEvent{ID: 12, Envelope: processorEnvelope(t, "processor-finalizer-retry"), AttemptCount: 1, MaxAttempts: 8, LockedBy: "worker", LeaseToken: "lease-1"}
+
+ require.NoError(t, processor.ProcessEvent(context.Background(), event))
+ require.True(t, repo.retried)
+ require.False(t, repo.completed)
+ require.Equal(t, "billing_replay_finalize_failed", repo.errorCode)
+}
+
+func TestUsageBillingOutboxProcessor_PreservesFrozenBalanceMonthlyWalletCommands(t *testing.T) {
+ tests := []struct {
+ name string
+ input UsageBillingEnvelopeInput
+ check func(*testing.T, *UsageBillingCommand)
+ }{
+ {
+ name: "balance",
+ input: validUsageBillingEnvelopeInput(),
+ check: func(t *testing.T, cmd *UsageBillingCommand) { require.Equal(t, 2.5, cmd.BalanceCost) },
+ },
+ {
+ name: "monthly",
+ input: func() UsageBillingEnvelopeInput {
+ subscriptionID := int64(55)
+ input := validUsageBillingEnvelopeInput()
+ input.BillingType = BillingTypeSubscription
+ input.BalanceCost = 0
+ input.SubscriptionID = &subscriptionID
+ input.SubscriptionCost = 2.5
+ return input
+ }(),
+ check: func(t *testing.T, cmd *UsageBillingCommand) {
+ require.Equal(t, int64(55), *cmd.SubscriptionID)
+ require.Equal(t, 2.5, cmd.SubscriptionCost)
+ },
+ },
+ {
+ name: "wallet",
+ input: func() UsageBillingEnvelopeInput {
+ subscriptionID := int64(66)
+ input := validUsageBillingEnvelopeInput()
+ input.BillingType = BillingTypeSubscription
+ input.BalanceCost = 0
+ input.SubscriptionID = &subscriptionID
+ input.WalletCost = 2.5
+ return input
+ }(),
+ check: func(t *testing.T, cmd *UsageBillingCommand) {
+ require.Equal(t, int64(66), *cmd.SubscriptionID)
+ require.Equal(t, 2.5, cmd.WalletCost)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tt.input.RequestID = "processor-frozen-" + tt.name
+ envelope, err := NewUsageBillingEnvelope(tt.input)
+ require.NoError(t, err)
+ billing := &outboxBillingStub{}
+ processor := NewUsageBillingOutboxProcessor(&outboxProcessorRepoStub{}, &outboxBindingStub{}, billing, nil)
+ event := UsageBillingOutboxEvent{ID: 20, Envelope: envelope, AttemptCount: 1, MaxAttempts: 8, LockedBy: "worker", LeaseToken: "lease-1"}
+ require.NoError(t, processor.ProcessEvent(context.Background(), event))
+ require.Len(t, billing.commands, 1)
+ tt.check(t, billing.commands[0])
+ })
+ }
+}
+
+func TestUsageBillingOutboxProcessor_ProcessBatchContinuesAfterOneEventFails(t *testing.T) {
+ first := UsageBillingOutboxEvent{ID: 31, Envelope: processorEnvelope(t, "processor-batch-1"), AttemptCount: 1, MaxAttempts: 8, LockedBy: "worker", LeaseToken: "lease-1"}
+ second := UsageBillingOutboxEvent{ID: 32, Envelope: processorEnvelope(t, "processor-batch-2"), AttemptCount: 1, MaxAttempts: 8, LockedBy: "worker", LeaseToken: "lease-2"}
+ repo := &outboxProcessorRepoStub{claimed: []UsageBillingOutboxEvent{first, second}, completeErr: errors.New("first ack failed")}
+ billing := &outboxBillingStub{}
+ processor := NewUsageBillingOutboxProcessor(repo, &outboxBindingStub{}, billing, nil)
+
+ processed, err := processor.ProcessBatch(context.Background(), "worker", 2)
+ require.Error(t, err)
+ require.Equal(t, 2, processed)
+ require.Equal(t, 2, billing.calls)
+ require.True(t, repo.completed, "second event must still be acknowledged")
+}
+
+func TestUsageBillingOutboxProcessor_ProcessBatchReconcilesWithIndependentBudget(t *testing.T) {
+ now := time.Date(2026, 7, 12, 8, 0, 0, 0, time.UTC)
+ first := UsageBillingOutboxEvent{ID: 41, Envelope: processorEnvelope(t, "processor-reconcile-full-1"), AttemptCount: 1, MaxAttempts: 8, LockedBy: "worker", LeaseToken: "lease-1"}
+ second := UsageBillingOutboxEvent{ID: 42, Envelope: processorEnvelope(t, "processor-reconcile-full-2"), AttemptCount: 1, MaxAttempts: 8, LockedBy: "worker", LeaseToken: "lease-2"}
+ repo := &outboxReconcileRepoStub{
+ outboxProcessorRepoStub: &outboxProcessorRepoStub{claimed: []UsageBillingOutboxEvent{first, second}},
+ reconcileResult: UsageBillingAdmissionReconcileResult{
+ AbandonedPrepared: 1,
+ OrphanedDispatched: 1,
+ },
+ }
+ processor := NewUsageBillingOutboxProcessor(repo, &outboxBindingStub{}, &outboxBillingStub{}, nil)
+ processor.now = func() time.Time { return now }
+
+ processed, err := processor.ProcessBatch(context.Background(), "worker", 2)
+
+ require.NoError(t, err)
+ require.Equal(t, 4, processed)
+ require.Equal(t, 1, repo.reconcileCalls)
+ require.Equal(t, usageBillingPreparedReconcileGrace, repo.reconcilePreparedGrace)
+ require.Equal(t, usageBillingDispatchedOrphanReconcileGrace, repo.reconcileDispatchedGrace)
+ require.Equal(t, usageBillingAdmissionReconcileBatchSize, repo.reconcileLimit)
+}
+
+func TestUsageBillingOutboxProcessor_ProcessBatchDoesNotCountRolledBackReconciliation(t *testing.T) {
+ repo := &outboxReconcileRepoStub{
+ outboxProcessorRepoStub: &outboxProcessorRepoStub{},
+ reconcileResult: UsageBillingAdmissionReconcileResult{
+ AbandonedPrepared: 2,
+ },
+ reconcileErr: errors.New("transaction rolled back"),
+ }
+ processor := NewUsageBillingOutboxProcessor(repo, &outboxBindingStub{}, &outboxBillingStub{}, nil)
+
+ processed, err := processor.ProcessBatch(context.Background(), "worker", 3)
+
+ require.ErrorContains(t, err, "reconcile stale admissions")
+ require.Zero(t, processed)
+}
+
+func TestUsageBillingOutboxProcessor_ProcessBatchThrottlesReconciliation(t *testing.T) {
+ now := time.Date(2026, 7, 12, 8, 0, 0, 0, time.UTC)
+ repo := &outboxReconcileRepoStub{outboxProcessorRepoStub: &outboxProcessorRepoStub{}}
+ processor := NewUsageBillingOutboxProcessor(repo, &outboxBindingStub{}, &outboxBillingStub{}, nil)
+ processor.now = func() time.Time { return now }
+
+ _, err := processor.ProcessBatch(context.Background(), "worker", 3)
+ require.NoError(t, err)
+ _, err = processor.ProcessBatch(context.Background(), "worker", 3)
+ require.NoError(t, err)
+ require.Equal(t, 1, repo.reconcileCalls)
+
+ now = now.Add(usageBillingAdmissionReconcileInterval)
+ _, err = processor.ProcessBatch(context.Background(), "worker", 3)
+ require.NoError(t, err)
+ require.Equal(t, 2, repo.reconcileCalls)
+}
+
+func TestUsageBillingOutboxProcessor_PersistedErrorsAreRedacted(t *testing.T) {
+ repo := &outboxProcessorRepoStub{}
+ billing := &outboxBillingStub{err: errors.New("Bearer secret prompt=private postgres://user:pass@example customer@example.com")}
+ processor := NewUsageBillingOutboxProcessor(repo, &outboxBindingStub{}, billing, nil)
+ event := UsageBillingOutboxEvent{ID: 33, Envelope: processorEnvelope(t, "processor-redact"), AttemptCount: 1, MaxAttempts: 8, LockedBy: "worker", LeaseToken: "lease-1"}
+
+ require.NoError(t, processor.ProcessEvent(context.Background(), event))
+ require.Equal(t, "usage billing outbox processing failed", repo.errorMessage)
+ require.NotContains(t, repo.errorMessage, "secret")
+ require.NotContains(t, repo.errorMessage, "private")
+}
diff --git a/backend/internal/service/usage_billing_outbox_replay.go b/backend/internal/service/usage_billing_outbox_replay.go
new file mode 100644
index 00000000000..9b703a90fa1
--- /dev/null
+++ b/backend/internal/service/usage_billing_outbox_replay.go
@@ -0,0 +1,96 @@
+package service
+
+import (
+ "context"
+ "errors"
+)
+
+type usageBillingReplayWriter struct {
+ usageLogRepo UsageLogRepository
+}
+
+func NewUsageBillingReplayWriter(usageLogRepo UsageLogRepository) UsageBillingReplayWriter {
+ return &usageBillingReplayWriter{usageLogRepo: usageLogRepo}
+}
+
+func (w *usageBillingReplayWriter) WriteUsageBillingReplay(ctx context.Context, envelope UsageBillingEnvelope) error {
+ if w == nil || w.usageLogRepo == nil {
+ return errors.New("usage billing replay log repository is unavailable")
+ }
+ _, err := w.usageLogRepo.Create(ctx, envelope.UsageLog())
+ return err
+}
+
+type UsageBillingReplayCacheInvalidator interface {
+ InvalidateUserBalance(ctx context.Context, userID int64) error
+ InvalidateSubscription(ctx context.Context, userID, groupID int64) error
+ InvalidateAPIKeyRateLimit(ctx context.Context, apiKeyID int64) error
+}
+
+type UsageBillingReplayAccountToucher interface {
+ ScheduleLastUsedUpdate(accountID int64)
+}
+
+type UsageBillingReplayAuthCacheInvalidator interface {
+ InvalidateAuthCacheByLocatorReliable(ctx context.Context, locator string) error
+}
+
+type usageBillingReplayFinalizer struct {
+ cache UsageBillingReplayCacheInvalidator
+ authCache UsageBillingReplayAuthCacheInvalidator
+ accountTouch UsageBillingReplayAccountToucher
+}
+
+func NewUsageBillingReplayFinalizer(
+ cache UsageBillingReplayCacheInvalidator,
+ authCache UsageBillingReplayAuthCacheInvalidator,
+ accountTouch UsageBillingReplayAccountToucher,
+) UsageBillingReplayFinalizer {
+ return &usageBillingReplayFinalizer{cache: cache, authCache: authCache, accountTouch: accountTouch}
+}
+
+func (f *usageBillingReplayFinalizer) FinalizeUsageBillingReplay(
+ ctx context.Context,
+ envelope UsageBillingEnvelope,
+ _ *UsageBillingApplyResult,
+) error {
+ if f == nil {
+ return errors.New("usage billing replay finalizer is unavailable")
+ }
+ var finalizationErrors []error
+ if envelope.BalanceCost() > 0 {
+ if f.cache == nil {
+ finalizationErrors = append(finalizationErrors, errors.New("usage billing balance cache invalidator is unavailable"))
+ } else if err := f.cache.InvalidateUserBalance(ctx, envelope.UserID()); err != nil {
+ finalizationErrors = append(finalizationErrors, err)
+ }
+ }
+ if envelope.SubscriptionCost() > 0 || envelope.WalletCost() > 0 {
+ groupID := envelope.EffectiveBillingGroupID()
+ if f.cache == nil || groupID == nil {
+ finalizationErrors = append(finalizationErrors, errors.New("usage billing subscription cache invalidator is unavailable"))
+ } else if err := f.cache.InvalidateSubscription(ctx, envelope.UserID(), *groupID); err != nil {
+ finalizationErrors = append(finalizationErrors, err)
+ }
+ }
+ if envelope.APIKeyRateLimitCost() > 0 {
+ if f.cache == nil {
+ finalizationErrors = append(finalizationErrors, errors.New("usage billing rate limit cache invalidator is unavailable"))
+ } else if err := f.cache.InvalidateAPIKeyRateLimit(ctx, envelope.APIKeyID()); err != nil {
+ finalizationErrors = append(finalizationErrors, err)
+ }
+ }
+ if envelope.APIKeyQuotaCost() > 0 {
+ if f.authCache == nil {
+ finalizationErrors = append(finalizationErrors, errors.New("usage billing auth cache invalidator is unavailable"))
+ } else if err := f.authCache.InvalidateAuthCacheByLocatorReliable(ctx, envelope.AuthCacheLocator()); err != nil {
+ finalizationErrors = append(finalizationErrors, err)
+ }
+ }
+ if f.accountTouch == nil {
+ finalizationErrors = append(finalizationErrors, errors.New("usage billing account touch service is unavailable"))
+ } else {
+ f.accountTouch.ScheduleLastUsedUpdate(envelope.AccountID())
+ }
+ return errors.Join(finalizationErrors...)
+}
diff --git a/backend/internal/service/usage_billing_outbox_replay_test.go b/backend/internal/service/usage_billing_outbox_replay_test.go
new file mode 100644
index 00000000000..3d73d34236d
--- /dev/null
+++ b/backend/internal/service/usage_billing_outbox_replay_test.go
@@ -0,0 +1,133 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+type usageBillingReplayLogRepoStub struct {
+ UsageLogRepository
+ logs []*UsageLog
+}
+
+func (s *usageBillingReplayLogRepoStub) Create(_ context.Context, log *UsageLog) (bool, error) {
+ s.logs = append(s.logs, log)
+ return len(s.logs) == 1, nil
+}
+
+type usageBillingReplayCacheStub struct {
+ balances []int64
+ subscriptions [][2]int64
+ rateLimits []int64
+}
+
+func (s *usageBillingReplayCacheStub) InvalidateUserBalance(_ context.Context, userID int64) error {
+ s.balances = append(s.balances, userID)
+ return nil
+}
+
+func (s *usageBillingReplayCacheStub) InvalidateSubscription(_ context.Context, userID, groupID int64) error {
+ s.subscriptions = append(s.subscriptions, [2]int64{userID, groupID})
+ return nil
+}
+
+func (s *usageBillingReplayCacheStub) InvalidateAPIKeyRateLimit(_ context.Context, apiKeyID int64) error {
+ s.rateLimits = append(s.rateLimits, apiKeyID)
+ return nil
+}
+
+type usageBillingReplayAuthStub struct {
+ users []int64
+ locators []string
+ err error
+}
+
+func (*usageBillingReplayAuthStub) InvalidateAuthCacheByKey(context.Context, string) {}
+func (s *usageBillingReplayAuthStub) InvalidateAuthCacheByUserID(_ context.Context, userID int64) {
+ s.users = append(s.users, userID)
+}
+func (*usageBillingReplayAuthStub) InvalidateAuthCacheByGroupID(context.Context, int64) {}
+func (s *usageBillingReplayAuthStub) InvalidateAuthCacheByUserIDReliable(_ context.Context, userID int64) error {
+ s.users = append(s.users, userID)
+ return s.err
+}
+func (s *usageBillingReplayAuthStub) InvalidateAuthCacheByLocatorReliable(_ context.Context, locator string) error {
+ s.locators = append(s.locators, locator)
+ return s.err
+}
+
+type usageBillingReplayTouchStub struct {
+ accounts []int64
+}
+
+func (s *usageBillingReplayTouchStub) ScheduleLastUsedUpdate(accountID int64) {
+ s.accounts = append(s.accounts, accountID)
+}
+
+func TestUsageBillingReplayWriter_WritesFrozenSnapshot(t *testing.T) {
+ envelope, err := NewUsageBillingEnvelope(validUsageBillingEnvelopeInput())
+ require.NoError(t, err)
+ repo := &usageBillingReplayLogRepoStub{}
+ writer := NewUsageBillingReplayWriter(repo)
+
+ require.NoError(t, writer.WriteUsageBillingReplay(context.Background(), envelope))
+ require.Len(t, repo.logs, 1)
+ require.Equal(t, "gpt-5.6-sol", repo.logs[0].Model)
+ require.Equal(t, "gpt-5.6-sol", *repo.logs[0].BillingModel)
+ require.Equal(t, envelope.RequestID(), repo.logs[0].RequestID)
+}
+
+func TestUsageBillingReplayFinalizer_UsesOnlyRepeatableInvalidations(t *testing.T) {
+ cache := &usageBillingReplayCacheStub{}
+ auth := &usageBillingReplayAuthStub{}
+ touch := &usageBillingReplayTouchStub{}
+ finalizer := NewUsageBillingReplayFinalizer(cache, auth, touch)
+
+ balanceEnvelope, err := NewUsageBillingEnvelope(validUsageBillingEnvelopeInput())
+ require.NoError(t, err)
+ monthlyInput := validUsageBillingEnvelopeInput()
+ monthlyInput.RequestID = "replay-monthly"
+ monthlyInput.BillingType = BillingTypeSubscription
+ monthlyInput.BalanceCost = 0
+ monthlyInput.APIKeyQuotaCost = 0
+ monthlyInput.APIKeyRateLimitCost = 0
+ monthlyInput.SubscriptionCost = 2.5
+ subscriptionID := int64(55)
+ effectiveBillingGroupID := int64(77)
+ monthlyInput.SubscriptionID = &subscriptionID
+ monthlyInput.EffectiveBillingGroupID = &effectiveBillingGroupID
+ monthlyEnvelope, err := NewUsageBillingEnvelope(monthlyInput)
+ require.NoError(t, err)
+
+ for i := 0; i < 2; i++ {
+ require.NoError(t, finalizer.FinalizeUsageBillingReplay(context.Background(), balanceEnvelope, &UsageBillingApplyResult{Applied: i == 0}))
+ require.NoError(t, finalizer.FinalizeUsageBillingReplay(context.Background(), monthlyEnvelope, &UsageBillingApplyResult{Applied: i == 0}))
+ }
+ require.Equal(t, []int64{22, 22}, cache.balances)
+ require.Equal(t, [][2]int64{{22, 77}, {22, 77}}, cache.subscriptions)
+ require.Equal(t, []int64{11, 11}, cache.rateLimits)
+ require.Empty(t, auth.users)
+ require.Equal(t, []string{strings.Repeat("c", 64), strings.Repeat("c", 64)}, auth.locators)
+ require.Equal(t, []int64{33, 33, 33, 33}, touch.accounts)
+}
+
+func TestUsageBillingReplayFinalizer_RetriesWhenReliableAuthInvalidationFails(t *testing.T) {
+ input := validUsageBillingEnvelopeInput()
+ input.APIKeyQuotaCost = 1
+ envelope, err := NewUsageBillingEnvelope(input)
+ require.NoError(t, err)
+
+ sentinel := errors.New("redis publish unavailable")
+ finalizer := NewUsageBillingReplayFinalizer(
+ &usageBillingReplayCacheStub{},
+ &usageBillingReplayAuthStub{err: sentinel},
+ &usageBillingReplayTouchStub{},
+ )
+
+ err = finalizer.FinalizeUsageBillingReplay(context.Background(), envelope, &UsageBillingApplyResult{Applied: true})
+ require.ErrorIs(t, err, sentinel)
+}
diff --git a/backend/internal/service/usage_billing_outbox_test.go b/backend/internal/service/usage_billing_outbox_test.go
new file mode 100644
index 00000000000..5fa2c16e0fa
--- /dev/null
+++ b/backend/internal/service/usage_billing_outbox_test.go
@@ -0,0 +1,276 @@
+package service
+
+import (
+ "encoding/json"
+ "math"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func validUsageBillingEnvelopeInput() UsageBillingEnvelopeInput {
+ groupID := int64(44)
+ return UsageBillingEnvelopeInput{
+ RequestID: "req-outbox-1",
+ RequestPayloadHash: strings.Repeat("d", 64),
+ APIKeyID: 11,
+ AuthCacheLocator: strings.Repeat("c", 64),
+ UserID: 22,
+ AccountID: 33,
+ GroupID: &groupID,
+ EffectiveBillingGroupID: &groupID,
+ AccountType: AccountTypeAPIKey,
+ BillingModel: "gpt-5.6-sol",
+ ServiceTier: "priority",
+ ReasoningEffort: "high",
+ BillingType: BillingTypeBalance,
+ InputTokens: 100,
+ OutputTokens: 20,
+ CacheReadTokens: 10,
+ PricingSource: PricingSourceBuiltinGPT56,
+ PricingRevision: GPT56PricingRevision,
+ PricingHash: strings.Repeat("a", 64),
+ RateMultiplier: 1.25,
+ AccountRateMultiplier: 1.1,
+ BalanceCost: 2.5,
+ APIKeyQuotaCost: 2.5,
+ APIKeyRateLimitCost: 2.5,
+ AccountQuotaCost: 2,
+ }
+}
+
+func TestUsageBillingEnvelope_DecodeRejectsUnknownFieldsAndOversizedPayload(t *testing.T) {
+ envelope, err := NewUsageBillingEnvelope(validUsageBillingEnvelopeInput())
+ require.NoError(t, err)
+ raw, err := envelope.MarshalJSON()
+ require.NoError(t, err)
+
+ var payload map[string]any
+ require.NoError(t, json.Unmarshal(raw, &payload))
+ payload["prompt"] = "must never be accepted"
+ withUnknownField, err := json.Marshal(payload)
+ require.NoError(t, err)
+ _, err = DecodeUsageBillingEnvelope(withUnknownField)
+ require.ErrorIs(t, err, ErrUsageBillingEnvelopeInvalid)
+
+ _, err = DecodeUsageBillingEnvelope(make([]byte, UsageBillingEnvelopeMaxBytes+1))
+ require.ErrorIs(t, err, ErrUsageBillingEnvelopeInvalid)
+}
+
+func TestUsageBillingEnvelope_RejectsUnboundedTextAndUnknownPricingSource(t *testing.T) {
+ input := validUsageBillingEnvelopeInput()
+ input.RequestID = strings.Repeat("r", 256)
+ _, err := NewUsageBillingEnvelope(input)
+ require.ErrorIs(t, err, ErrUsageBillingEnvelopeInvalid)
+
+ input = validUsageBillingEnvelopeInput()
+ input.PricingSource = "unverified_provider"
+ _, err = NewUsageBillingEnvelope(input)
+ require.ErrorIs(t, err, ErrUsageBillingEnvelopeInvalid)
+}
+
+func TestUsageBillingEnvelope_SerializesOnlyExplicitBillingFacts(t *testing.T) {
+ envelope, err := NewUsageBillingEnvelope(validUsageBillingEnvelopeInput())
+ require.NoError(t, err)
+
+ raw, err := envelope.MarshalJSON()
+ require.NoError(t, err)
+
+ var payload map[string]any
+ require.NoError(t, json.Unmarshal(raw, &payload))
+ for _, forbidden := range []string{
+ "key", "api_key", "credentials", "cookie", "headers", "body", "prompt",
+ "messages", "tools", "ip", "ip_address", "user_agent",
+ } {
+ _, exists := payload[forbidden]
+ require.Falsef(t, exists, "serialized envelope must not contain %q", forbidden)
+ }
+ require.Equal(t, "gpt-5.6-sol", payload["billing_model"])
+ require.Equal(t, strings.Repeat("a", 64), payload["pricing_hash"])
+ require.Equal(t, strings.Repeat("c", 64), payload["auth_cache_locator"])
+ require.Equal(t, strings.Repeat("d", 64), payload["request_payload_hash"])
+ require.NotEmpty(t, payload["request_fingerprint"])
+}
+
+func TestUsageBillingEnvelope_RejectsMissingAuthCacheLocatorForQuotaEffect(t *testing.T) {
+ input := validUsageBillingEnvelopeInput()
+ input.AuthCacheLocator = ""
+
+ _, err := NewUsageBillingEnvelope(input)
+ require.ErrorIs(t, err, ErrUsageBillingEnvelopeInvalid)
+}
+
+func TestUsageBillingEnvelope_DecodeRejectsTamperingAndUnknownVersion(t *testing.T) {
+ envelope, err := NewUsageBillingEnvelope(validUsageBillingEnvelopeInput())
+ require.NoError(t, err)
+ raw, err := envelope.MarshalJSON()
+ require.NoError(t, err)
+
+ var payload map[string]any
+ require.NoError(t, json.Unmarshal(raw, &payload))
+ payload["balance_cost"] = 9.5
+ tampered, err := json.Marshal(payload)
+ require.NoError(t, err)
+ _, err = DecodeUsageBillingEnvelope(tampered)
+ require.ErrorIs(t, err, ErrUsageBillingEnvelopeFingerprintMismatch)
+
+ payload["version"] = float64(99)
+ unknownVersion, err := json.Marshal(payload)
+ require.NoError(t, err)
+ _, err = DecodeUsageBillingEnvelope(unknownVersion)
+ require.ErrorIs(t, err, ErrUsageBillingEnvelopeVersion)
+}
+
+func TestUsageBillingEnvelope_FingerprintIncludesAuthCacheLocator(t *testing.T) {
+ firstInput := validUsageBillingEnvelopeInput()
+ secondInput := validUsageBillingEnvelopeInput()
+ secondInput.AuthCacheLocator = strings.Repeat("d", 64)
+
+ first, err := NewUsageBillingEnvelope(firstInput)
+ require.NoError(t, err)
+ second, err := NewUsageBillingEnvelope(secondInput)
+ require.NoError(t, err)
+ require.NotEqual(t, first.RequestFingerprint(), second.RequestFingerprint())
+}
+
+func TestUsageBillingCommandFingerprint_IgnoresAuthCacheLocatorForLegacyCompatibility(t *testing.T) {
+ first := validUsageBillingEnvelopeInput()
+ firstEnvelope, err := NewUsageBillingEnvelope(first)
+ require.NoError(t, err)
+ firstCommand := firstEnvelope.Command()
+ firstCommand.RequestFingerprint = ""
+ firstCommand.Normalize()
+
+ secondCommand := *firstCommand
+ secondCommand.AuthCacheLocator = strings.Repeat("d", 64)
+ secondCommand.RequestFingerprint = ""
+ secondCommand.Normalize()
+
+ require.Equal(t, firstCommand.RequestFingerprint, secondCommand.RequestFingerprint)
+}
+
+func TestUsageBillingEnvelope_RejectsInvalidCostsAndMutuallyExclusiveBilling(t *testing.T) {
+ input := validUsageBillingEnvelopeInput()
+ input.BalanceCost = math.NaN()
+ _, err := NewUsageBillingEnvelope(input)
+ require.ErrorIs(t, err, ErrUsageBillingEnvelopeInvalid)
+
+ input = validUsageBillingEnvelopeInput()
+ input.BalanceCost = 1
+ input.SubscriptionCost = 1
+ _, err = NewUsageBillingEnvelope(input)
+ require.ErrorIs(t, err, ErrUsageBillingEnvelopeInvalid)
+
+ input = validUsageBillingEnvelopeInput()
+ input.BalanceCost = -1
+ _, err = NewUsageBillingEnvelope(input)
+ require.ErrorIs(t, err, ErrUsageBillingEnvelopeInvalid)
+}
+
+func TestUsageBillingEnvelope_CommandReturnsIndependentSubscriptionID(t *testing.T) {
+ subscriptionID := int64(44)
+ input := validUsageBillingEnvelopeInput()
+ input.BillingType = BillingTypeSubscription
+ input.BalanceCost = 0
+ input.SubscriptionID = &subscriptionID
+ input.WalletCost = 2.5
+
+ envelope, err := NewUsageBillingEnvelope(input)
+ require.NoError(t, err)
+ first := envelope.Command()
+ require.NotNil(t, first.SubscriptionID)
+ *first.SubscriptionID = 999
+
+ second := envelope.Command()
+ require.Equal(t, int64(44), *second.SubscriptionID)
+ require.Equal(t, int64(44), *second.EffectiveBillingGroupID)
+ require.Equal(t, 2.5, second.WalletCost)
+}
+
+func TestUsageBillingEnvelope_ZeroBalanceCostWithQuotaEffectIsBillable(t *testing.T) {
+ input := validUsageBillingEnvelopeInput()
+ input.BalanceCost = 0
+ input.APIKeyQuotaCost = 0.75
+
+ envelope, err := NewUsageBillingEnvelope(input)
+ require.NoError(t, err)
+ require.True(t, envelope.IsBillable())
+}
+
+func TestUsageBillingEnvelopeFromUsageLog_RoundTripsSafeReplaySnapshot(t *testing.T) {
+ groupID := int64(44)
+ effectiveBillingGroupID := int64(77)
+ subscriptionID := int64(55)
+ channelID := int64(66)
+ billingModel := "gpt-5.6-sol"
+ upstreamModel := "gpt-5.6-sol"
+ pricingSource := PricingSourceBuiltinGPT56
+ pricingRevision := GPT56PricingRevision
+ pricingHash := strings.Repeat("b", 64)
+ mappingChain := "claude-sonnet-4-6 -> gpt-5.6-sol"
+ billingMode := string(BillingModeToken)
+ accountRate := 1.2
+ accountStatsCost := 0.42
+ durationMs := 1234
+ firstTokenMs := 210
+ serviceTier := "priority"
+ reasoningEffort := "high"
+ inbound := "/v1/messages"
+ upstream := "/v1/responses"
+ userAgent := "must-not-persist"
+ ipAddress := "203.0.113.10"
+ occurredAt := time.Date(2026, 7, 10, 15, 4, 5, 123000000, time.UTC)
+ log := &UsageLog{
+ UserID: 22, APIKeyID: 11, AccountID: 33, RequestID: "req-outbox-replay",
+ Model: billingModel, RequestedModel: "claude-sonnet-4-6", UpstreamModel: &upstreamModel,
+ BillingModel: &billingModel, PricingSource: &pricingSource, PricingRevision: &pricingRevision,
+ PricingHash: &pricingHash, GroupID: &groupID, SubscriptionID: &subscriptionID,
+ ChannelID: &channelID, ModelMappingChain: &mappingChain, BillingMode: &billingMode,
+ ServiceTier: &serviceTier, ReasoningEffort: &reasoningEffort,
+ InboundEndpoint: &inbound, UpstreamEndpoint: &upstream,
+ InputTokens: 100, OutputTokens: 20, CacheCreationTokens: 3, CacheReadTokens: 10,
+ CacheCreation5mTokens: 2, CacheCreation1hTokens: 1, ImageOutputTokens: 4,
+ InputCost: 0.1, OutputCost: 0.2, CacheCreationCost: 0.03, CacheReadCost: 0.01,
+ ImageOutputCost: 0.04, TotalCost: 0.38, ActualCost: 0.57,
+ RateMultiplier: 1.5, AccountRateMultiplier: &accountRate, AccountStatsCost: &accountStatsCost,
+ BillingType: BillingTypeSubscription, RequestType: RequestTypeStream,
+ DurationMs: &durationMs, FirstTokenMs: &firstTokenMs, UserAgent: &userAgent, IPAddress: &ipAddress,
+ CreatedAt: occurredAt,
+ }
+ cmd := &UsageBillingCommand{
+ RequestID: log.RequestID, APIKeyID: log.APIKeyID, UserID: log.UserID, AccountID: log.AccountID,
+ RequestPayloadHash: strings.Repeat("d", 64),
+ AuthCacheLocator: strings.Repeat("c", 64),
+ SubscriptionID: &subscriptionID, EffectiveBillingGroupID: &effectiveBillingGroupID,
+ AccountType: AccountTypeAPIKey, Model: billingModel,
+ ServiceTier: serviceTier, ReasoningEffort: reasoningEffort, BillingType: BillingTypeSubscription,
+ InputTokens: 100, OutputTokens: 20, CacheCreationTokens: 3, CacheReadTokens: 10,
+ SubscriptionCost: 0.57, APIKeyQuotaCost: 0.57, APIKeyRateLimitCost: 0.57,
+ AccountQuotaCost: 0.456,
+ }
+
+ envelope, err := NewUsageBillingEnvelopeFromUsageLog(log, cmd)
+ require.NoError(t, err)
+ raw, err := envelope.MarshalJSON()
+ require.NoError(t, err)
+ require.NotContains(t, string(raw), userAgent)
+ require.NotContains(t, string(raw), ipAddress)
+
+ decoded, err := DecodeUsageBillingEnvelope(raw)
+ require.NoError(t, err)
+ require.Equal(t, effectiveBillingGroupID, *decoded.EffectiveBillingGroupID())
+ replayed := decoded.UsageLog()
+ require.Equal(t, billingModel, replayed.Model)
+ require.Equal(t, "claude-sonnet-4-6", replayed.RequestedModel)
+ require.Equal(t, upstreamModel, *replayed.UpstreamModel)
+ require.Equal(t, billingModel, *replayed.BillingModel)
+ require.Equal(t, mappingChain, *replayed.ModelMappingChain)
+ require.Equal(t, pricingHash, *replayed.PricingHash)
+ require.Equal(t, RequestTypeStream, replayed.RequestType)
+ require.Equal(t, occurredAt, replayed.CreatedAt)
+ require.InDelta(t, 0.57, replayed.ActualCost, 0.000001)
+ require.Nil(t, replayed.UserAgent)
+ require.Nil(t, replayed.IPAddress)
+}
diff --git a/backend/internal/service/usage_billing_outbox_worker.go b/backend/internal/service/usage_billing_outbox_worker.go
new file mode 100644
index 00000000000..08e456781eb
--- /dev/null
+++ b/backend/internal/service/usage_billing_outbox_worker.go
@@ -0,0 +1,273 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/util/logredact"
+)
+
+const (
+ defaultUsageBillingOutboxPollInterval = 500 * time.Millisecond
+ defaultUsageBillingOutboxErrorBackoff = time.Second
+ defaultUsageBillingOutboxBatchSize = 64
+ usageBillingOutboxErrorLogInterval = 30 * time.Second
+ usageBillingOutboxLastErrorMaxBytes = 512
+)
+
+var ErrUsageBillingOutboxWorkerUnavailable = errors.New("usage billing outbox worker is unavailable")
+
+type UsageBillingBatchProcessor interface {
+ ProcessBatch(ctx context.Context, owner string, limit int) (int, error)
+}
+
+type UsageBillingOutboxWorkerOptions struct {
+ Owner string
+ BatchSize int
+ PollInterval time.Duration
+ ErrorBackoff time.Duration
+}
+
+type UsageBillingOutboxWorkerHealth struct {
+ Running bool `json:"running"`
+ StartedAt time.Time `json:"started_at"`
+ LastAttemptAt time.Time `json:"last_attempt_at"`
+ LastSuccessAt time.Time `json:"last_success_at"`
+ LastErrorAt time.Time `json:"last_error_at"`
+ LastError string `json:"last_error"`
+ ConsecutiveFailures uint64 `json:"consecutive_failures"`
+ ProcessedTotal uint64 `json:"processed_total"`
+}
+
+type UsageBillingOutboxWorker struct {
+ processor UsageBillingBatchProcessor
+ options UsageBillingOutboxWorkerOptions
+ wake chan struct{}
+ done chan struct{}
+
+ startOnce sync.Once
+ mu sync.Mutex
+ cancel context.CancelFunc
+ startErr error
+ health UsageBillingOutboxWorkerHealth
+ lastErrorLogAt time.Time
+}
+
+func NewUsageBillingOutboxWorker(processor *UsageBillingOutboxProcessor) *UsageBillingOutboxWorker {
+ return NewUsageBillingOutboxWorkerWithOptions(processor, UsageBillingOutboxWorkerOptions{})
+}
+
+func NewUsageBillingOutboxWorkerWithOptions(
+ processor UsageBillingBatchProcessor,
+ options UsageBillingOutboxWorkerOptions,
+) *UsageBillingOutboxWorker {
+ options = normalizeUsageBillingOutboxWorkerOptions(options)
+ return &UsageBillingOutboxWorker{
+ processor: processor,
+ options: options,
+ wake: make(chan struct{}, 1),
+ done: make(chan struct{}),
+ }
+}
+
+func (w *UsageBillingOutboxWorker) Start() error {
+ if w == nil {
+ return ErrUsageBillingOutboxWorkerUnavailable
+ }
+ w.startOnce.Do(func() {
+ if w.processor == nil {
+ w.mu.Lock()
+ w.startErr = fmt.Errorf("%w: processor is nil", ErrUsageBillingOutboxWorkerUnavailable)
+ w.health.LastErrorAt = time.Now().UTC()
+ w.health.LastError = w.startErr.Error()
+ w.mu.Unlock()
+ slog.Error("usage billing outbox worker failed to start",
+ "component", "service.usage_billing_outbox_worker",
+ "error", w.startErr,
+ )
+ return
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ w.mu.Lock()
+ w.cancel = cancel
+ w.health.Running = true
+ w.health.StartedAt = time.Now().UTC()
+ w.mu.Unlock()
+ go w.run(ctx)
+ })
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.startErr
+}
+
+func (w *UsageBillingOutboxWorker) Wake() {
+ if w == nil {
+ return
+ }
+ select {
+ case w.wake <- struct{}{}:
+ default:
+ }
+}
+
+func (w *UsageBillingOutboxWorker) Stop(ctx context.Context) error {
+ if w == nil {
+ return nil
+ }
+ w.mu.Lock()
+ cancel := w.cancel
+ w.mu.Unlock()
+ if cancel == nil {
+ return nil
+ }
+ cancel()
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ select {
+ case <-w.done:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
+func (w *UsageBillingOutboxWorker) run(ctx context.Context) {
+ defer func() {
+ w.mu.Lock()
+ w.health.Running = false
+ w.mu.Unlock()
+ close(w.done)
+ }()
+ delay := time.Duration(0)
+ for {
+ if delay > 0 {
+ timer := time.NewTimer(delay)
+ select {
+ case <-ctx.Done():
+ if !timer.Stop() {
+ <-timer.C
+ }
+ return
+ case <-w.wake:
+ if !timer.Stop() {
+ <-timer.C
+ }
+ case <-timer.C:
+ }
+ }
+
+ processed, err := w.processor.ProcessBatch(ctx, w.options.Owner, w.options.BatchSize)
+ if ctx.Err() != nil {
+ return
+ }
+ w.recordBatchResult(processed, err)
+ switch {
+ case err != nil:
+ delay = w.options.ErrorBackoff
+ case processed >= w.options.BatchSize:
+ delay = 0
+ default:
+ delay = w.options.PollInterval
+ }
+ }
+}
+
+func (w *UsageBillingOutboxWorker) recordBatchResult(processed int, err error) {
+ now := time.Now().UTC()
+ shouldLogError := false
+ recovered := false
+ consecutiveFailures := uint64(0)
+ safeError := ""
+
+ w.mu.Lock()
+ w.health.LastAttemptAt = now
+ if err != nil {
+ w.health.ConsecutiveFailures++
+ w.health.LastErrorAt = now
+ w.health.LastError = sanitizeUsageBillingOutboxWorkerError(err)
+ consecutiveFailures = w.health.ConsecutiveFailures
+ safeError = w.health.LastError
+ if w.lastErrorLogAt.IsZero() || now.Sub(w.lastErrorLogAt) >= usageBillingOutboxErrorLogInterval {
+ shouldLogError = true
+ w.lastErrorLogAt = now
+ }
+ } else {
+ recovered = w.health.ConsecutiveFailures > 0
+ w.health.ConsecutiveFailures = 0
+ w.health.LastSuccessAt = now
+ if processed > 0 {
+ w.health.ProcessedTotal += uint64(processed)
+ }
+ }
+ w.mu.Unlock()
+
+ if shouldLogError {
+ slog.Error("usage billing outbox batch failed",
+ "component", "service.usage_billing_outbox_worker",
+ "owner", w.options.Owner,
+ "consecutive_failures", consecutiveFailures,
+ "error", safeError,
+ )
+ }
+ if recovered {
+ slog.Info("usage billing outbox worker recovered",
+ "component", "service.usage_billing_outbox_worker",
+ "owner", w.options.Owner,
+ )
+ }
+}
+
+func sanitizeUsageBillingOutboxWorkerError(err error) string {
+ if err == nil {
+ return ""
+ }
+ redacted := strings.TrimSpace(logredact.RedactText(err.Error()))
+ if len(redacted) <= usageBillingOutboxLastErrorMaxBytes {
+ return redacted
+ }
+ return redacted[:usageBillingOutboxLastErrorMaxBytes]
+}
+
+func (w *UsageBillingOutboxWorker) Health() UsageBillingOutboxWorkerHealth {
+ if w == nil {
+ return UsageBillingOutboxWorkerHealth{}
+ }
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.health
+}
+
+func normalizeUsageBillingOutboxWorkerOptions(options UsageBillingOutboxWorkerOptions) UsageBillingOutboxWorkerOptions {
+ options.Owner = strings.TrimSpace(options.Owner)
+ if options.Owner == "" {
+ options.Owner = defaultUsageBillingOutboxWorkerOwner()
+ }
+ if len(options.Owner) > 128 {
+ options.Owner = options.Owner[:128]
+ }
+ if options.BatchSize <= 0 || options.BatchSize > 100 {
+ options.BatchSize = defaultUsageBillingOutboxBatchSize
+ }
+ if options.PollInterval <= 0 {
+ options.PollInterval = defaultUsageBillingOutboxPollInterval
+ }
+ if options.ErrorBackoff <= 0 {
+ options.ErrorBackoff = defaultUsageBillingOutboxErrorBackoff
+ }
+ return options
+}
+
+func defaultUsageBillingOutboxWorkerOwner() string {
+ hostname, err := os.Hostname()
+ if err != nil || strings.TrimSpace(hostname) == "" {
+ hostname = "unknown-host"
+ }
+ return fmt.Sprintf("%s:%d:%s", hostname, os.Getpid(), generateRequestID())
+}
diff --git a/backend/internal/service/usage_billing_outbox_worker_test.go b/backend/internal/service/usage_billing_outbox_worker_test.go
new file mode 100644
index 00000000000..ca31d00febd
--- /dev/null
+++ b/backend/internal/service/usage_billing_outbox_worker_test.go
@@ -0,0 +1,116 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+type usageBillingBatchProcessorStub struct {
+ mu sync.Mutex
+ pending int
+ processed chan struct{}
+}
+
+type usageBillingFailureProcessorStub struct {
+ mu sync.Mutex
+ calls int
+}
+
+func (s *usageBillingFailureProcessorStub) ProcessBatch(context.Context, string, int) (int, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.calls++
+ if s.calls == 1 {
+ return 0, errors.New("database unavailable access_token=secret-worker-token")
+ }
+ return 0, nil
+}
+
+func (s *usageBillingBatchProcessorStub) ProcessBatch(context.Context, string, int) (int, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.pending == 0 {
+ return 0, nil
+ }
+ s.pending--
+ select {
+ case s.processed <- struct{}{}:
+ default:
+ }
+ return 1, nil
+}
+
+func TestUsageBillingOutboxWorker_StartImmediatelyProcessesPendingBeforeTicker(t *testing.T) {
+ processor := &usageBillingBatchProcessorStub{pending: 1, processed: make(chan struct{}, 1)}
+ worker := NewUsageBillingOutboxWorkerWithOptions(processor, UsageBillingOutboxWorkerOptions{
+ Owner: "worker-test", BatchSize: 1, PollInterval: time.Hour, ErrorBackoff: time.Hour,
+ })
+ worker.Start()
+ t.Cleanup(func() { _ = worker.Stop(context.Background()) })
+
+ select {
+ case <-processor.processed:
+ case <-time.After(time.Second):
+ t.Fatal("pending outbox event was not processed immediately on start")
+ }
+}
+
+func TestUsageBillingOutboxWorker_StopThenNewWorkerProcessesPersistedPendingEvent(t *testing.T) {
+ processor := &usageBillingBatchProcessorStub{pending: 0, processed: make(chan struct{}, 1)}
+ first := NewUsageBillingOutboxWorkerWithOptions(processor, UsageBillingOutboxWorkerOptions{
+ Owner: "worker-one", BatchSize: 1, PollInterval: time.Hour, ErrorBackoff: time.Hour,
+ })
+ first.Start()
+ require.NoError(t, first.Stop(context.Background()))
+
+ processor.mu.Lock()
+ processor.pending = 1
+ processor.mu.Unlock()
+ second := NewUsageBillingOutboxWorkerWithOptions(processor, UsageBillingOutboxWorkerOptions{
+ Owner: "worker-two", BatchSize: 1, PollInterval: time.Hour, ErrorBackoff: time.Hour,
+ })
+ second.Start()
+ t.Cleanup(func() { _ = second.Stop(context.Background()) })
+
+ select {
+ case <-processor.processed:
+ case <-time.After(time.Second):
+ t.Fatal("new worker did not recover the persisted pending event")
+ }
+}
+
+func TestUsageBillingOutboxWorker_StartRejectsNilProcessor(t *testing.T) {
+ worker := NewUsageBillingOutboxWorkerWithOptions(nil, UsageBillingOutboxWorkerOptions{})
+ require.Error(t, worker.Start())
+ health := worker.Health()
+ require.False(t, health.Running)
+ require.NotEmpty(t, health.LastError)
+}
+
+func TestUsageBillingOutboxWorker_HealthReportsFailureAndRecoveryWithoutSecrets(t *testing.T) {
+ processor := &usageBillingFailureProcessorStub{}
+ worker := NewUsageBillingOutboxWorkerWithOptions(processor, UsageBillingOutboxWorkerOptions{
+ Owner: "worker-health-test", PollInterval: time.Millisecond, ErrorBackoff: time.Millisecond,
+ })
+ require.NoError(t, worker.Start())
+ t.Cleanup(func() { _ = worker.Stop(context.Background()) })
+
+ require.Eventually(t, func() bool {
+ health := worker.Health()
+ return !health.LastErrorAt.IsZero()
+ }, time.Second, time.Millisecond)
+ failureHealth := worker.Health()
+ require.NotContains(t, failureHealth.LastError, "secret-worker-token")
+ require.Contains(t, failureHealth.LastError, "access_token=***")
+
+ require.Eventually(t, func() bool {
+ health := worker.Health()
+ return health.ConsecutiveFailures == 0 && !health.LastSuccessAt.IsZero()
+ }, time.Second, time.Millisecond)
+ require.True(t, worker.Health().Running)
+}
diff --git a/backend/internal/service/usage_billing_reconciliation.go b/backend/internal/service/usage_billing_reconciliation.go
new file mode 100644
index 00000000000..0d5fdc3b888
--- /dev/null
+++ b/backend/internal/service/usage_billing_reconciliation.go
@@ -0,0 +1,185 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "math"
+ "regexp"
+ "strings"
+ "time"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+)
+
+const (
+ UsageBillingReconciliationIdempotencyScope = "admin.usage.billing_reconciliation.resolve"
+ UsageBillingReconciliationActionRetryDeadLetter = "retry_dead_letter"
+ UsageBillingReconciliationActionReleaseUndelivered = "release_undelivered"
+ UsageBillingReconciliationActionSettleDelivered = "settle_delivered"
+)
+
+var (
+ ErrUsageBillingReconciliationInvalid = infraerrors.BadRequest(
+ "USAGE_BILLING_RECONCILIATION_INVALID", "invalid usage billing reconciliation request",
+ )
+ ErrUsageBillingReconciliationConflict = infraerrors.Conflict(
+ "USAGE_BILLING_RECONCILIATION_CONFLICT", "usage billing reconciliation state changed; reload the case",
+ )
+ ErrUsageBillingReconciliationNotFound = infraerrors.NotFound(
+ "USAGE_BILLING_RECONCILIATION_NOT_FOUND", "usage billing reconciliation case not found",
+ )
+ ErrUsageBillingReconciliationUnavailable = infraerrors.ServiceUnavailable(
+ "USAGE_BILLING_RECONCILIATION_UNAVAILABLE", "usage billing reconciliation is unavailable",
+ )
+
+ usageBillingEvidenceRefPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:/#-]{7,499}$`)
+)
+
+type UsageBillingReconciliationCase struct {
+ RequestID string `json:"request_id"`
+ APIKeyID int64 `json:"api_key_id"`
+ State string `json:"state"`
+ UserID int64 `json:"user_id"`
+ SubscriptionID *int64 `json:"subscription_id,omitempty"`
+ WalletSubscriptionID *int64 `json:"wallet_subscription_id,omitempty"`
+ WalletReservedUSD float64 `json:"wallet_reserved_usd"`
+ OutboxID *int64 `json:"outbox_id,omitempty"`
+ OutboxStatus string `json:"outbox_status,omitempty"`
+ LastErrorCode string `json:"last_error_code,omitempty"`
+ PreparedAttempts int `json:"prepared_attempts"`
+ DispatchedAttempts int `json:"dispatched_attempts"`
+ FailedAttempts int `json:"failed_attempts"`
+ FinalizedAttempts int `json:"finalized_attempts"`
+ EverDispatchedAttempts int `json:"ever_dispatched_attempts"`
+ PreparedAt time.Time `json:"prepared_at"`
+ DispatchedAt *time.Time `json:"dispatched_at,omitempty"`
+ OrphanedAt *time.Time `json:"orphaned_at,omitempty"`
+ ReconcileStartedAt *time.Time `json:"reconcile_started_at,omitempty"`
+ AllowedActions []string `json:"allowed_actions"`
+}
+
+type UsageBillingReconciliationResolveInput struct {
+ RequestID string `json:"request_id"`
+ APIKeyID int64 `json:"api_key_id"`
+ OperatorID int64 `json:"-"`
+ Action string `json:"action"`
+ AttemptID string `json:"attempt_id,omitempty"`
+ BillingEnvelope json.RawMessage `json:"billing_envelope,omitempty"`
+ EvidenceRef string `json:"evidence_ref"`
+ Envelope UsageBillingEnvelope `json:"-"`
+}
+
+type UsageBillingReconciliationRepository interface {
+ ListUsageBillingReconciliationCases(ctx context.Context, limit int) ([]UsageBillingReconciliationCase, error)
+ ResolveUsageBillingReconciliation(ctx context.Context, input UsageBillingReconciliationResolveInput) error
+}
+
+type UsageBillingReconciliationService struct {
+ repo UsageBillingReconciliationRepository
+}
+
+func NewUsageBillingReconciliationService(repo UsageBillingReconciliationRepository) *UsageBillingReconciliationService {
+ return &UsageBillingReconciliationService{repo: repo}
+}
+
+func (s *UsageBillingReconciliationService) List(
+ ctx context.Context,
+ limit int,
+) ([]UsageBillingReconciliationCase, error) {
+ if s == nil || s.repo == nil {
+ return nil, ErrUsageBillingReconciliationUnavailable
+ }
+ if limit <= 0 {
+ limit = 50
+ }
+ if limit > 100 {
+ limit = 100
+ }
+ items, err := s.repo.ListUsageBillingReconciliationCases(ctx, limit)
+ if err != nil {
+ return nil, err
+ }
+ return append([]UsageBillingReconciliationCase(nil), items...), nil
+}
+
+func (s *UsageBillingReconciliationService) Resolve(
+ ctx context.Context,
+ input UsageBillingReconciliationResolveInput,
+) error {
+ if s == nil || s.repo == nil {
+ return ErrUsageBillingReconciliationUnavailable
+ }
+ normalized, err := normalizeUsageBillingReconciliationInput(input)
+ if err != nil {
+ return err
+ }
+ return s.repo.ResolveUsageBillingReconciliation(ctx, normalized)
+}
+
+func normalizeUsageBillingReconciliationInput(
+ input UsageBillingReconciliationResolveInput,
+) (UsageBillingReconciliationResolveInput, error) {
+ input.RequestID = strings.TrimSpace(input.RequestID)
+ input.Action = strings.ToLower(strings.TrimSpace(input.Action))
+ input.AttemptID = strings.ToLower(strings.TrimSpace(input.AttemptID))
+ input.EvidenceRef = strings.TrimSpace(input.EvidenceRef)
+ if input.RequestID == "" || len(input.RequestID) > 255 || input.APIKeyID <= 0 || input.OperatorID <= 0 ||
+ !usageBillingEvidenceRefPattern.MatchString(input.EvidenceRef) {
+ return UsageBillingReconciliationResolveInput{}, ErrUsageBillingReconciliationInvalid
+ }
+ switch input.Action {
+ case UsageBillingReconciliationActionRetryDeadLetter, UsageBillingReconciliationActionReleaseUndelivered:
+ if input.AttemptID != "" || len(input.BillingEnvelope) != 0 {
+ return UsageBillingReconciliationResolveInput{}, ErrUsageBillingReconciliationInvalid
+ }
+ case UsageBillingReconciliationActionSettleDelivered:
+ if !validFenceToken(input.AttemptID) || len(input.BillingEnvelope) == 0 {
+ return UsageBillingReconciliationResolveInput{}, ErrUsageBillingReconciliationInvalid
+ }
+ envelope, err := DecodeUsageBillingEnvelope(input.BillingEnvelope)
+ if err != nil || envelope.RequestID() != input.RequestID || envelope.APIKeyID() != input.APIKeyID ||
+ envelope.AdmissionAttemptID() != input.AttemptID || envelope.SubscriptionID() == nil ||
+ ValidateUsageBillingReconciliationWalletCosts(envelope) != nil {
+ return UsageBillingReconciliationResolveInput{}, ErrUsageBillingReconciliationInvalid
+ }
+ canonical, err := envelope.MarshalJSON()
+ if err != nil {
+ return UsageBillingReconciliationResolveInput{}, ErrUsageBillingReconciliationInvalid
+ }
+ input.BillingEnvelope = canonical
+ input.Envelope = envelope
+ default:
+ return UsageBillingReconciliationResolveInput{}, ErrUsageBillingReconciliationInvalid
+ }
+ return input, nil
+}
+
+func ValidateUsageBillingReconciliationWalletCosts(envelope UsageBillingEnvelope) error {
+ if err := envelope.Validate(); err != nil {
+ return ErrUsageBillingReconciliationInvalid
+ }
+ walletCost := envelope.WalletCost()
+ totalCost := envelope.TotalCost()
+ actualCost := envelope.ActualCost()
+ if envelope.BalanceCost() != 0 || envelope.SubscriptionCost() != 0 || walletCost <= 0 ||
+ totalCost <= 0 || actualCost <= 0 || math.IsNaN(walletCost) || math.IsInf(walletCost, 0) ||
+ !usageBillingReconciliationCostsEqual(walletCost, actualCost) ||
+ !usageBillingReconciliationCostsEqual(actualCost, totalCost*envelope.RateMultiplier()) ||
+ !usageBillingReconciliationOptionalCostMatches(envelope.APIKeyQuotaCost(), actualCost) ||
+ !usageBillingReconciliationOptionalCostMatches(envelope.APIKeyRateLimitCost(), actualCost) ||
+ !usageBillingReconciliationOptionalCostMatches(
+ envelope.AccountQuotaCost(), totalCost*envelope.AccountRateMultiplier(),
+ ) {
+ return ErrUsageBillingReconciliationInvalid
+ }
+ return nil
+}
+
+func usageBillingReconciliationOptionalCostMatches(actual, expected float64) bool {
+ return actual == 0 || usageBillingReconciliationCostsEqual(actual, expected)
+}
+
+func usageBillingReconciliationCostsEqual(left, right float64) bool {
+ scale := math.Max(1, math.Max(math.Abs(left), math.Abs(right)))
+ return math.Abs(left-right) <= 1e-9*scale
+}
diff --git a/backend/internal/service/usage_billing_reconciliation_test.go b/backend/internal/service/usage_billing_reconciliation_test.go
new file mode 100644
index 00000000000..986afd0cf92
--- /dev/null
+++ b/backend/internal/service/usage_billing_reconciliation_test.go
@@ -0,0 +1,198 @@
+package service
+
+import (
+ "context"
+ "testing"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+ "github.com/stretchr/testify/require"
+)
+
+type usageBillingReconciliationRepoStub struct {
+ items []UsageBillingReconciliationCase
+ resolved *UsageBillingReconciliationResolveInput
+ listErr error
+ resolveErr error
+}
+
+func (s *usageBillingReconciliationRepoStub) ListUsageBillingReconciliationCases(
+ context.Context,
+ int,
+) ([]UsageBillingReconciliationCase, error) {
+ return s.items, s.listErr
+}
+
+func (s *usageBillingReconciliationRepoStub) ResolveUsageBillingReconciliation(
+ _ context.Context,
+ input UsageBillingReconciliationResolveInput,
+) error {
+ cloned := input
+ s.resolved = &cloned
+ return s.resolveErr
+}
+
+func TestUsageBillingReconciliationServiceRequiresAuditableEvidence(t *testing.T) {
+ repo := &usageBillingReconciliationRepoStub{}
+ svc := NewUsageBillingReconciliationService(repo)
+
+ err := svc.Resolve(context.Background(), UsageBillingReconciliationResolveInput{
+ RequestID: "req-1", APIKeyID: 2, OperatorID: 3,
+ Action: UsageBillingReconciliationActionReleaseUndelivered,
+ })
+
+ require.ErrorIs(t, err, ErrUsageBillingReconciliationInvalid)
+ require.Nil(t, repo.resolved)
+}
+
+func TestUsageBillingReconciliationServiceNormalizesEvidenceAndDispatchesAction(t *testing.T) {
+ repo := &usageBillingReconciliationRepoStub{}
+ svc := NewUsageBillingReconciliationService(repo)
+
+ err := svc.Resolve(context.Background(), UsageBillingReconciliationResolveInput{
+ RequestID: " req-1 ", APIKeyID: 2, OperatorID: 3,
+ Action: UsageBillingReconciliationActionSettleDelivered,
+ AttemptID: " ABCDEF0123456789ABCDEF0123456789 ",
+ BillingEnvelope: reconciliationEnvelopeJSON(t, "req-1", 2, "abcdef0123456789abcdef0123456789"),
+ EvidenceRef: " incident:HFC-2026-0712 ",
+ })
+
+ require.NoError(t, err)
+ require.NotNil(t, repo.resolved)
+ require.Equal(t, "req-1", repo.resolved.RequestID)
+ require.Equal(t, "abcdef0123456789abcdef0123456789", repo.resolved.AttemptID)
+ require.Equal(t, "incident:HFC-2026-0712", repo.resolved.EvidenceRef)
+ require.Equal(t, 0.75, repo.resolved.Envelope.WalletCost())
+}
+
+func TestUsageBillingReconciliationServiceRejectsInvalidActionShape(t *testing.T) {
+ svc := NewUsageBillingReconciliationService(&usageBillingReconciliationRepoStub{})
+
+ err := svc.Resolve(context.Background(), UsageBillingReconciliationResolveInput{
+ RequestID: "req-1", APIKeyID: 2, OperatorID: 3,
+ Action: UsageBillingReconciliationActionSettleDelivered,
+ AttemptID: "short",
+ EvidenceRef: "incident:HFC-2026-0712",
+ })
+
+ require.ErrorIs(t, err, ErrUsageBillingReconciliationInvalid)
+ require.Equal(t, 400, infraerrors.Code(err))
+}
+
+func TestUsageBillingReconciliationServiceRejectsDeliveredSettlementWithoutEnvelope(t *testing.T) {
+ svc := NewUsageBillingReconciliationService(&usageBillingReconciliationRepoStub{})
+
+ err := svc.Resolve(context.Background(), UsageBillingReconciliationResolveInput{
+ RequestID: "req-1", APIKeyID: 2, OperatorID: 3,
+ Action: UsageBillingReconciliationActionSettleDelivered,
+ AttemptID: "abcdef0123456789abcdef0123456789",
+ EvidenceRef: "incident:HFC-2026-0712",
+ })
+
+ require.ErrorIs(t, err, ErrUsageBillingReconciliationInvalid)
+}
+
+func TestUsageBillingReconciliationServiceRejectsZeroCostDeliveredSettlement(t *testing.T) {
+ repo := &usageBillingReconciliationRepoStub{}
+ svc := NewUsageBillingReconciliationService(repo)
+
+ err := svc.Resolve(context.Background(), UsageBillingReconciliationResolveInput{
+ RequestID: "req-1", APIKeyID: 2, OperatorID: 3,
+ Action: UsageBillingReconciliationActionSettleDelivered,
+ AttemptID: "abcdef0123456789abcdef0123456789",
+ BillingEnvelope: reconciliationEnvelopeJSONWithWalletCost(t, "req-1", 2, "abcdef0123456789abcdef0123456789", 0),
+ EvidenceRef: "incident:HFC-2026-0712",
+ })
+
+ require.ErrorIs(t, err, ErrUsageBillingReconciliationInvalid)
+ require.Nil(t, repo.resolved)
+}
+
+func TestUsageBillingReconciliationServiceRejectsInflatedSecondaryCosts(t *testing.T) {
+ for _, testCase := range []struct {
+ name string
+ apiKeyQuotaCost float64
+ accountQuotaCost float64
+ }{
+ {name: "api key quota", apiKeyQuotaCost: 99, accountQuotaCost: 0.66},
+ {name: "account quota", apiKeyQuotaCost: 0.75, accountQuotaCost: 99},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ repo := &usageBillingReconciliationRepoStub{}
+ svc := NewUsageBillingReconciliationService(repo)
+
+ err := svc.Resolve(context.Background(), UsageBillingReconciliationResolveInput{
+ RequestID: "req-1", APIKeyID: 2, OperatorID: 3,
+ Action: UsageBillingReconciliationActionSettleDelivered,
+ AttemptID: "abcdef0123456789abcdef0123456789",
+ BillingEnvelope: reconciliationEnvelopeJSONWithCosts(
+ t, "req-1", 2, "abcdef0123456789abcdef0123456789",
+ 0.75, testCase.apiKeyQuotaCost, 0.75, testCase.accountQuotaCost,
+ ),
+ EvidenceRef: "incident:HFC-2026-0712",
+ })
+
+ require.ErrorIs(t, err, ErrUsageBillingReconciliationInvalid)
+ require.Nil(t, repo.resolved)
+ })
+ }
+}
+
+func TestUsageBillingReconciliationServiceFailsClosedWhenUnavailable(t *testing.T) {
+ svc := NewUsageBillingReconciliationService(nil)
+
+ _, err := svc.List(context.Background(), 50)
+ require.ErrorIs(t, err, ErrUsageBillingReconciliationUnavailable)
+ require.Equal(t, 503, infraerrors.Code(err))
+}
+
+func reconciliationEnvelopeJSON(t *testing.T, requestID string, apiKeyID int64, attemptID string) []byte {
+ return reconciliationEnvelopeJSONWithWalletCost(t, requestID, apiKeyID, attemptID, 0.75)
+}
+
+func reconciliationEnvelopeJSONWithWalletCost(
+ t *testing.T,
+ requestID string,
+ apiKeyID int64,
+ attemptID string,
+ walletCost float64,
+) []byte {
+ totalCost := walletCost / 1.25
+ accountQuotaCost := totalCost * 1.1
+ return reconciliationEnvelopeJSONWithCosts(
+ t, requestID, apiKeyID, attemptID,
+ walletCost, walletCost, walletCost, accountQuotaCost,
+ )
+}
+
+func reconciliationEnvelopeJSONWithCosts(
+ t *testing.T,
+ requestID string,
+ apiKeyID int64,
+ attemptID string,
+ walletCost float64,
+ apiKeyQuotaCost float64,
+ apiKeyRateLimitCost float64,
+ accountQuotaCost float64,
+) []byte {
+ t.Helper()
+ input := validUsageBillingEnvelopeInput()
+ walletID := int64(71)
+ input.RequestID = requestID
+ input.APIKeyID = apiKeyID
+ input.AdmissionAttemptID = attemptID
+ input.SubscriptionID = &walletID
+ input.BillingType = BillingTypeSubscription
+ input.BalanceCost = 0
+ input.SubscriptionCost = 0
+ input.WalletCost = walletCost
+ input.APIKeyQuotaCost = apiKeyQuotaCost
+ input.APIKeyRateLimitCost = apiKeyRateLimitCost
+ input.AccountQuotaCost = accountQuotaCost
+ input.TotalCost = walletCost / input.RateMultiplier
+ input.ActualCost = walletCost
+ envelope, err := NewUsageBillingEnvelope(input)
+ require.NoError(t, err)
+ raw, err := envelope.MarshalJSON()
+ require.NoError(t, err)
+ return raw
+}
diff --git a/backend/internal/service/usage_billing_reservation_quote.go b/backend/internal/service/usage_billing_reservation_quote.go
new file mode 100644
index 00000000000..130a2c61ad7
--- /dev/null
+++ b/backend/internal/service/usage_billing_reservation_quote.go
@@ -0,0 +1,221 @@
+package service
+
+import (
+ "math"
+ "strings"
+
+ "github.com/tidwall/gjson"
+)
+
+const (
+ usageBillingReservationDefaultMaxOutputTokens = 131072
+ usageBillingReservationMaxTokenBound = 10_000_000
+ usageBillingReservationMaxRequestCount = 10_000
+ usageBillingReservationMinimumUSD = 0.0000000001
+ usageBillingReservationMaximumUSD = 9_000_000_000
+)
+
+// UsageBillingReservationQuoteBuildInput contains only facts available before
+// any upstream bytes are sent. Pricing is an immutable snapshot; RequestBody
+// is used only to derive conservative token/request-count bounds and is never
+// persisted in the admission or outbox.
+type UsageBillingReservationQuoteBuildInput struct {
+ RequestPayloadHash string
+ BillingModel string
+ Pricing *PricingQuote
+ RateMultiplier float64
+ RequestBody []byte
+ MaxOutputTokens int
+ RequestCount int
+}
+
+func BuildUsageBillingReservationQuote(input UsageBillingReservationQuoteBuildInput) (UsageBillingReservationQuote, error) {
+ if !validSHA256(strings.ToLower(strings.TrimSpace(input.RequestPayloadHash))) ||
+ !validUsageBillingEnvelopeText(input.BillingModel, 128, true) ||
+ input.Pricing == nil || len(input.RequestBody) == 0 ||
+ math.IsNaN(input.RateMultiplier) || math.IsInf(input.RateMultiplier, 0) || input.RateMultiplier <= 0 {
+ return UsageBillingReservationQuote{}, ErrUsageBillingLifecycleContractInvalid
+ }
+ resolved := input.Pricing.CloneResolved()
+ evidence := input.Pricing.Evidence
+ if resolved == nil || !validUsageBillingPricingSource(evidence.Source) ||
+ !validUsageBillingEnvelopeText(evidence.Revision, 128, true) || !validSHA256(evidence.Hash) {
+ return UsageBillingReservationQuote{}, ErrUsageBillingLifecycleContractInvalid
+ }
+ if resolved.Mode != BillingModeImage && resolved.Mode != BillingModePerRequest && !gjson.ValidBytes(input.RequestBody) {
+ return UsageBillingReservationQuote{}, ErrUsageBillingLifecycleContractInvalid
+ }
+ if (resolved.Mode == BillingModeImage || resolved.Mode == BillingModePerRequest) && input.RequestCount <= 0 && !gjson.ValidBytes(input.RequestBody) {
+ return UsageBillingReservationQuote{}, ErrUsageBillingLifecycleContractInvalid
+ }
+ input.RateMultiplier = canonicalUsageBillingRate(input.RateMultiplier)
+
+ worstCaseUSD, err := estimateUsageBillingWorstCaseUSD(input, resolved)
+ if err != nil {
+ return UsageBillingReservationQuote{}, err
+ }
+ quote := UsageBillingReservationQuote{
+ RequestPayloadHash: strings.ToLower(strings.TrimSpace(input.RequestPayloadHash)),
+ BillingModel: strings.TrimSpace(input.BillingModel),
+ PricingSource: strings.TrimSpace(evidence.Source),
+ PricingRevision: strings.TrimSpace(evidence.Revision),
+ PricingHash: strings.ToLower(strings.TrimSpace(evidence.Hash)),
+ RateMultiplier: input.RateMultiplier,
+ WorstCaseCostUSD: canonicalUsageBillingReservation(worstCaseUSD),
+ }
+ if err := quote.Validate(); err != nil {
+ return UsageBillingReservationQuote{}, ErrUsageBillingLifecycleContractInvalid
+ }
+ return quote, nil
+}
+
+func estimateUsageBillingWorstCaseUSD(input UsageBillingReservationQuoteBuildInput, resolved *ResolvedPricing) (float64, error) {
+ var cost float64
+ switch resolved.Mode {
+ case BillingModeImage, BillingModePerRequest:
+ count, err := usageBillingReservationRequestCount(input)
+ if err != nil {
+ return 0, err
+ }
+ unitPrice := maxPositiveFinite(resolved.DefaultPerRequestPrice)
+ for _, tier := range resolved.RequestTiers {
+ unitPrice = maxPositiveFinite(unitPrice, float64PtrValue(tier.PerRequestPrice))
+ }
+ if unitPrice <= 0 {
+ return 0, ErrUsageBillingLifecycleContractInvalid
+ }
+ cost = unitPrice * float64(count) * input.RateMultiplier
+ case BillingModeToken, "":
+ maxOutputTokens, err := usageBillingReservationMaxOutputTokens(input)
+ if err != nil {
+ return 0, err
+ }
+ inputPrice, outputPrice := usageBillingReservationTokenPrices(resolved)
+ if inputPrice <= 0 || outputPrice <= 0 {
+ return 0, ErrUsageBillingLifecycleContractInvalid
+ }
+ // A token contains at least one encoded byte. The complete JSON byte
+ // length therefore safely over-bounds input tokens without retaining or
+ // tokenizing sensitive request content.
+ inputTokens := len(input.RequestBody)
+ if inputTokens > usageBillingReservationMaxTokenBound {
+ return 0, ErrUsageBillingLifecycleContractInvalid
+ }
+ cost = (float64(inputTokens)*inputPrice + float64(maxOutputTokens)*outputPrice) * input.RateMultiplier
+ default:
+ return 0, ErrUsageBillingLifecycleContractInvalid
+ }
+ if math.IsNaN(cost) || math.IsInf(cost, 0) || cost <= 0 || cost > usageBillingReservationMaximumUSD {
+ return 0, ErrUsageBillingLifecycleContractInvalid
+ }
+ if cost < usageBillingReservationMinimumUSD {
+ cost = usageBillingReservationMinimumUSD
+ }
+ return cost, nil
+}
+
+func usageBillingReservationMaxOutputTokens(input UsageBillingReservationQuoteBuildInput) (int, error) {
+ maxTokens := input.MaxOutputTokens
+ for _, path := range []string{
+ "max_output_tokens", "max_tokens", "generationConfig.maxOutputTokens", "generation_config.max_output_tokens",
+ } {
+ value := gjson.GetBytes(input.RequestBody, path)
+ if !value.Exists() {
+ continue
+ }
+ if value.Type != gjson.Number || value.Num != math.Trunc(value.Num) || value.Int() <= 0 {
+ return 0, ErrUsageBillingLifecycleContractInvalid
+ }
+ if intValue := int(value.Int()); intValue > maxTokens {
+ maxTokens = intValue
+ }
+ }
+ // A compatible upstream may clamp, reinterpret, or ignore a small client
+ // limit, and reasoning tokens can exceed the visible response budget. The
+ // wallet hold therefore keeps the same conservative output floor used when
+ // no limit is supplied instead of trusting an unenforceable lower bound.
+ if maxTokens < usageBillingReservationDefaultMaxOutputTokens {
+ maxTokens = usageBillingReservationDefaultMaxOutputTokens
+ }
+ if maxTokens > usageBillingReservationMaxTokenBound {
+ return 0, ErrUsageBillingLifecycleContractInvalid
+ }
+ return maxTokens, nil
+}
+
+func usageBillingReservationRequestCount(input UsageBillingReservationQuoteBuildInput) (int, error) {
+ count := input.RequestCount
+ for _, path := range []string{"n", "num_images", "image_count"} {
+ value := gjson.GetBytes(input.RequestBody, path)
+ if !value.Exists() {
+ continue
+ }
+ if value.Type != gjson.Number || value.Num != math.Trunc(value.Num) || value.Int() <= 0 {
+ return 0, ErrUsageBillingLifecycleContractInvalid
+ }
+ if intValue := int(value.Int()); intValue > count {
+ count = intValue
+ }
+ }
+ if count <= 0 {
+ count = 1
+ }
+ if count > usageBillingReservationMaxRequestCount {
+ return 0, ErrUsageBillingLifecycleContractInvalid
+ }
+ return count, nil
+}
+
+func usageBillingReservationTokenPrices(resolved *ResolvedPricing) (float64, float64) {
+ if resolved == nil {
+ return 0, 0
+ }
+ inputPrice := 0.0
+ outputPrice := 0.0
+ collect := func(pricing *ModelPricing) {
+ if pricing == nil {
+ return
+ }
+ inputMultiplier := maxPositiveFinite(1, pricing.LongContextInputMultiplier)
+ outputMultiplier := maxPositiveFinite(1, pricing.LongContextOutputMultiplier)
+ inputPrice = maxPositiveFinite(inputPrice,
+ pricing.InputPricePerToken*inputMultiplier,
+ pricing.InputPricePerTokenPriority*inputMultiplier,
+ pricing.CacheCreationPricePerToken,
+ pricing.CacheReadPricePerToken,
+ pricing.CacheReadPricePerTokenPriority,
+ pricing.CacheCreation5mPrice,
+ pricing.CacheCreation1hPrice,
+ )
+ outputPrice = maxPositiveFinite(outputPrice,
+ pricing.OutputPricePerToken*outputMultiplier,
+ pricing.OutputPricePerTokenPriority*outputMultiplier,
+ pricing.ImageOutputPricePerToken*outputMultiplier,
+ )
+ }
+ collect(resolved.BasePricing)
+ for _, interval := range resolved.Intervals {
+ inputPrice = maxPositiveFinite(inputPrice,
+ float64PtrValue(interval.InputPrice), float64PtrValue(interval.CacheWritePrice), float64PtrValue(interval.CacheReadPrice),
+ )
+ outputPrice = maxPositiveFinite(outputPrice, float64PtrValue(interval.OutputPrice))
+ }
+ return inputPrice, outputPrice
+}
+
+func maxPositiveFinite(values ...float64) float64 {
+ maxValue := 0.0
+ for _, value := range values {
+ if value > maxValue && !math.IsNaN(value) && !math.IsInf(value, 0) {
+ maxValue = value
+ }
+ }
+ return maxValue
+}
+
+func float64PtrValue(value *float64) float64 {
+ if value == nil {
+ return 0
+ }
+ return *value
+}
diff --git a/backend/internal/service/usage_billing_reservation_quote_test.go b/backend/internal/service/usage_billing_reservation_quote_test.go
new file mode 100644
index 00000000000..43077d17191
--- /dev/null
+++ b/backend/internal/service/usage_billing_reservation_quote_test.go
@@ -0,0 +1,119 @@
+package service
+
+import (
+ "math"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestBuildUsageBillingReservationQuoteBoundsTokenRequest(t *testing.T) {
+ pricing, err := freezeResolvedPricingQuote(&ResolvedPricing{
+ Mode: BillingModeToken, Source: PricingSourceBuiltinGPT56, Revision: GPT56PricingRevision,
+ BasePricing: &ModelPricing{
+ InputPricePerToken: 0.000001,
+ OutputPricePerToken: 0.000002,
+ CacheCreationPricePerToken: 0.000003,
+ },
+ })
+ require.NoError(t, err)
+ body := []byte(`{"model":"gpt-5.6-terra","max_output_tokens":10,"input":"hello"}`)
+
+ quote, err := BuildUsageBillingReservationQuote(UsageBillingReservationQuoteBuildInput{
+ RequestPayloadHash: HashUsageRequestPayload(body),
+ BillingModel: "gpt-5.6-terra",
+ Pricing: pricing,
+ RateMultiplier: 1.5,
+ RequestBody: body,
+ })
+ require.NoError(t, err)
+ require.Equal(t, pricing.Evidence.Hash, quote.PricingHash)
+ require.Equal(t, 1.5, quote.RateMultiplier)
+ // Every request-body byte is a conservative upper bound for one input
+ // token; cache creation is the most expensive input dimension here.
+ wantFloor := (float64(len(body))*0.000003 + 10*0.000002) * 1.5
+ require.GreaterOrEqual(t, quote.WorstCaseCostUSD, wantFloor)
+ require.NoError(t, quote.Validate())
+}
+
+func TestBuildUsageBillingReservationQuoteUsesConservativeOutputDefault(t *testing.T) {
+ pricing, err := freezeResolvedPricingQuote(&ResolvedPricing{
+ Mode: BillingModeToken, Source: PricingSourceBuiltinGPT56, Revision: GPT56PricingRevision,
+ BasePricing: &ModelPricing{InputPricePerToken: 0.000001, OutputPricePerToken: 0.000002},
+ })
+ require.NoError(t, err)
+ body := []byte(`{"model":"gpt-5.6-terra","input":"hello"}`)
+
+ quote, err := BuildUsageBillingReservationQuote(UsageBillingReservationQuoteBuildInput{
+ RequestPayloadHash: HashUsageRequestPayload(body), BillingModel: "gpt-5.6-terra",
+ Pricing: pricing, RateMultiplier: 1, RequestBody: body,
+ })
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, quote.WorstCaseCostUSD, float64(usageBillingReservationDefaultMaxOutputTokens)*0.000002)
+}
+
+func TestBuildUsageBillingReservationQuoteDoesNotTrustSmallClientOutputLimit(t *testing.T) {
+ pricing, err := freezeResolvedPricingQuote(&ResolvedPricing{
+ Mode: BillingModeToken, Source: PricingSourceBuiltinGPT56, Revision: GPT56PricingRevision,
+ BasePricing: &ModelPricing{InputPricePerToken: 0.000001, OutputPricePerToken: 0.000002},
+ })
+ require.NoError(t, err)
+ body := []byte(`{"model":"gpt-5.6-luna","max_output_tokens":1,"input":"hello"}`)
+
+ quote, err := BuildUsageBillingReservationQuote(UsageBillingReservationQuoteBuildInput{
+ RequestPayloadHash: HashUsageRequestPayload(body), BillingModel: "gpt-5.6-luna",
+ Pricing: pricing, RateMultiplier: 1, RequestBody: body, MaxOutputTokens: 1,
+ })
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, quote.WorstCaseCostUSD,
+ float64(usageBillingReservationDefaultMaxOutputTokens)*0.000002,
+ "wallet reservation must remain safe when a compatible upstream clamps or ignores a tiny client limit",
+ )
+}
+
+func TestBuildUsageBillingReservationQuoteBoundsHighestPerRequestTier(t *testing.T) {
+ cheap := 0.1
+ expensive := 0.75
+ pricing, err := freezeResolvedPricingQuote(&ResolvedPricing{
+ Mode: BillingModeImage, Source: PricingSourceChannel, Revision: channelPricingRevision,
+ DefaultPerRequestPrice: cheap,
+ RequestTiers: []PricingInterval{{TierLabel: "1K", PerRequestPrice: &cheap}, {TierLabel: "4K", PerRequestPrice: &expensive}},
+ })
+ require.NoError(t, err)
+ body := []byte(`{"model":"gpt-image-2","n":3,"size":"1K"}`)
+
+ quote, err := BuildUsageBillingReservationQuote(UsageBillingReservationQuoteBuildInput{
+ RequestPayloadHash: HashUsageRequestPayload(body), BillingModel: "gpt-image-2",
+ Pricing: pricing, RateMultiplier: 2, RequestBody: body,
+ })
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, quote.WorstCaseCostUSD, 0.75*3*2)
+}
+
+func TestBuildUsageBillingReservationQuoteFailsClosedOnInvalidFacts(t *testing.T) {
+ pricing, err := freezeResolvedPricingQuote(&ResolvedPricing{
+ Mode: BillingModeToken, Source: PricingSourceBuiltinGPT56, Revision: GPT56PricingRevision,
+ BasePricing: &ModelPricing{InputPricePerToken: 0.000001, OutputPricePerToken: 0.000002},
+ })
+ require.NoError(t, err)
+ body := []byte(`{"model":"gpt-5.6-terra"}`)
+ valid := UsageBillingReservationQuoteBuildInput{
+ RequestPayloadHash: strings.Repeat("a", 64), BillingModel: "gpt-5.6-terra",
+ Pricing: pricing, RateMultiplier: 1, RequestBody: body,
+ }
+
+ for _, mutate := range []func(*UsageBillingReservationQuoteBuildInput){
+ func(input *UsageBillingReservationQuoteBuildInput) { input.RequestPayloadHash = "" },
+ func(input *UsageBillingReservationQuoteBuildInput) { input.BillingModel = "" },
+ func(input *UsageBillingReservationQuoteBuildInput) { input.Pricing = nil },
+ func(input *UsageBillingReservationQuoteBuildInput) { input.RateMultiplier = 0 },
+ func(input *UsageBillingReservationQuoteBuildInput) { input.RateMultiplier = math.Inf(1) },
+ func(input *UsageBillingReservationQuoteBuildInput) { input.RequestBody = nil },
+ } {
+ input := valid
+ mutate(&input)
+ _, err := BuildUsageBillingReservationQuote(input)
+ require.ErrorIs(t, err, ErrUsageBillingLifecycleContractInvalid)
+ }
+}
diff --git a/backend/internal/service/usage_billing_test.go b/backend/internal/service/usage_billing_test.go
new file mode 100644
index 00000000000..d70fd421dad
--- /dev/null
+++ b/backend/internal/service/usage_billing_test.go
@@ -0,0 +1,50 @@
+//go:build unit
+
+package service
+
+import "testing"
+
+// TestUsageBillingFingerprint_WalletCostContributes 钱包模式扣款必须参与 dedup
+// fingerprint,否则同 request_id 不同 WalletCost 会被误判为重复。
+func TestUsageBillingFingerprint_WalletCostContributes(t *testing.T) {
+ t.Parallel()
+
+ subID := int64(42)
+ base := UsageBillingCommand{
+ RequestID: "req-1",
+ APIKeyID: 1,
+ UserID: 2,
+ AccountID: 3,
+ AccountType: AccountTypeAPIKey,
+ Model: "gpt-5",
+ BillingType: 1,
+ InputTokens: 100,
+ OutputTokens: 50,
+ SubscriptionID: &subID,
+ WalletCost: 1.5,
+ }
+ other := base
+ other.WalletCost = 2.0
+
+ base.Normalize()
+ other.Normalize()
+
+ if base.RequestFingerprint == other.RequestFingerprint {
+ t.Fatalf("expected different fingerprints when WalletCost differs, got %q == %q",
+ base.RequestFingerprint, other.RequestFingerprint)
+ }
+}
+
+// TestUsageBillingApplyResult_WalletInsufficientFlag 用例:钱包余额不足时
+// repo 不应回滚整个 billing 事务,而应通过 result.WalletInsufficient 通知调用方。
+func TestUsageBillingApplyResult_WalletInsufficientFlag(t *testing.T) {
+ t.Parallel()
+
+ r := &UsageBillingApplyResult{Applied: true, WalletInsufficient: true}
+ if !r.Applied || !r.WalletInsufficient {
+ t.Fatal("flags must be readable")
+ }
+ if r.NewWalletBalance != nil {
+ t.Fatal("WalletInsufficient case must leave NewWalletBalance nil")
+ }
+}
diff --git a/backend/internal/service/usage_billing_type_test.go b/backend/internal/service/usage_billing_type_test.go
new file mode 100644
index 00000000000..4716dd7d87c
--- /dev/null
+++ b/backend/internal/service/usage_billing_type_test.go
@@ -0,0 +1,118 @@
+//go:build unit
+
+package service
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestShouldUseSubscriptionBilling_CoveringMonthly 验证 2026-05-17 follow-up:
+// 月卡用户切 key 到 plan_groups 链内 standard group 时,billing 应该走订阅扣 sub quota
+// 而不是 fallback 主余额(老 bug,818 次静默扣 balance 调用)。
+//
+// 见 docs/plans/2026-05-16-wallet-v4-group-switch-billing-fix.md。
+func TestShouldUseSubscriptionBilling_CoveringMonthly(t *testing.T) {
+ bal := 100.0
+ subGroupID := int64(13)
+ subGroup := &Group{
+ ID: 13,
+ Name: "paid-trial-v3",
+ Status: StatusActive,
+ SubscriptionType: SubscriptionTypeSubscription,
+ }
+ standardGroup := &Group{
+ ID: 3,
+ Name: "openai-default",
+ Status: StatusActive,
+ SubscriptionType: SubscriptionTypeStandard,
+ }
+
+ t.Run("nil_subscription_uses_balance", func(t *testing.T) {
+ isSubBilling, _ := EffectiveBillingContext(standardGroup, nil)
+ require.False(t, isSubBilling)
+ })
+
+ t.Run("wallet_subscription_any_group_uses_subscription", func(t *testing.T) {
+ walletSub := &UserSubscription{
+ ID: 201,
+ UserID: 42,
+ WalletBalanceUSD: &bal,
+ Status: SubscriptionStatusActive,
+ }
+ isSubBilling, _ := EffectiveBillingContext(standardGroup, walletSub)
+ require.True(t, isSubBilling,
+ "钱包覆盖一切非订阅 group")
+ })
+
+ t.Run("monthly_exact_match_uses_subscription", func(t *testing.T) {
+ monthlySub := &UserSubscription{
+ ID: 301,
+ UserID: 42,
+ GroupID: &subGroupID,
+ Group: subGroup,
+ Status: SubscriptionStatusActive,
+ }
+ isSubBilling, _ := EffectiveBillingContext(subGroup, monthlySub)
+ require.True(t, isSubBilling,
+ "exact match 走订阅 (经典 v3 path)")
+ })
+
+ t.Run("monthly_covering_standard_group_uses_subscription", func(t *testing.T) {
+ // 核心场景:月卡 sub.Group=13 (paid-trial-v3),用户切 key 到 group 3 (openai-default standard)
+ // middleware 通过 GetActiveSubscriptionCoveringGroup 找到 sub,billing 必须走订阅
+ monthlySub := &UserSubscription{
+ ID: 401,
+ UserID: 42,
+ GroupID: &subGroupID,
+ Group: subGroup,
+ Status: SubscriptionStatusActive,
+ }
+ isSubBilling, _ := EffectiveBillingContext(standardGroup, monthlySub)
+ require.True(t, isSubBilling,
+ "plan_groups 覆盖时月卡也要走订阅,不能 fallback balance")
+ })
+}
+
+func TestEffectiveBillingGroup(t *testing.T) {
+ bal := 100.0
+ subGroupID := int64(13)
+ subGroup := &Group{ID: 13, Name: "paid-trial-v3", SubscriptionType: SubscriptionTypeSubscription}
+ standardGroup := &Group{ID: 3, Name: "openai-default", SubscriptionType: SubscriptionTypeStandard}
+
+ t.Run("nil_subscription_returns_called", func(t *testing.T) {
+ _, effectiveGroup := EffectiveBillingContext(standardGroup, nil)
+ require.Equal(t, standardGroup, effectiveGroup)
+ })
+
+ t.Run("wallet_keeps_called_group", func(t *testing.T) {
+ walletSub := &UserSubscription{WalletBalanceUSD: &bal, Status: SubscriptionStatusActive}
+ _, effectiveGroup := EffectiveBillingContext(standardGroup, walletSub)
+ require.Equal(t, standardGroup, effectiveGroup,
+ "钱包模式不绑 group,limits 检查不依赖 group")
+ })
+
+ t.Run("monthly_returns_sub_primary_group", func(t *testing.T) {
+ monthlySub := &UserSubscription{
+ GroupID: &subGroupID,
+ Group: subGroup,
+ Status: SubscriptionStatusActive,
+ }
+ // 关键:返回 sub 主 group 而不是 called group → limits 用 sub.Group 的 limits 检查
+ _, effectiveGroup := EffectiveBillingContext(standardGroup, monthlySub)
+ require.Equal(t, subGroup, effectiveGroup,
+ "月卡 covering 用 sub.Group 不是 called group")
+ })
+
+ t.Run("monthly_with_nil_group_falls_back_to_called", func(t *testing.T) {
+ // 防御:sub.Group 没 preload → fallback 老行为
+ subWithoutGroup := &UserSubscription{
+ GroupID: &subGroupID,
+ Group: nil,
+ Status: SubscriptionStatusActive,
+ }
+ _, effectiveGroup := EffectiveBillingContext(standardGroup, subWithoutGroup)
+ require.Equal(t, standardGroup, effectiveGroup)
+ })
+}
diff --git a/backend/internal/service/usage_cleanup_service_test.go b/backend/internal/service/usage_cleanup_service_test.go
index 17f21bef369..aa2e93ea85c 100644
--- a/backend/internal/service/usage_cleanup_service_test.go
+++ b/backend/internal/service/usage_cleanup_service_test.go
@@ -7,6 +7,7 @@ import (
"net/http"
"strings"
"sync"
+ "sync/atomic"
"testing"
"time"
@@ -57,7 +58,7 @@ type cleanupRepoStub struct {
type dashboardRepoStub struct {
recomputeErr error
- recomputeCalls int
+ recomputeCalls atomic.Int32
}
func (s *dashboardRepoStub) AggregateRange(ctx context.Context, start, end time.Time) error {
@@ -65,7 +66,7 @@ func (s *dashboardRepoStub) AggregateRange(ctx context.Context, start, end time.
}
func (s *dashboardRepoStub) RecomputeRange(ctx context.Context, start, end time.Time) error {
- s.recomputeCalls++
+ s.recomputeCalls.Add(1)
return s.recomputeErr
}
@@ -580,7 +581,7 @@ func TestUsageCleanupServiceExecuteTaskDashboardRecomputeError(t *testing.T) {
repo.mu.Lock()
defer repo.mu.Unlock()
require.Len(t, repo.markSucceeded, 1)
- require.Eventually(t, func() bool { return dashboardRepo.recomputeCalls == 1 }, time.Second, 10*time.Millisecond)
+ require.Eventually(t, func() bool { return dashboardRepo.recomputeCalls.Load() == 1 }, time.Second, 10*time.Millisecond)
}
func TestUsageCleanupServiceExecuteTaskDashboardRecomputeSuccess(t *testing.T) {
@@ -608,7 +609,7 @@ func TestUsageCleanupServiceExecuteTaskDashboardRecomputeSuccess(t *testing.T) {
repo.mu.Lock()
defer repo.mu.Unlock()
require.Len(t, repo.markSucceeded, 1)
- require.Eventually(t, func() bool { return dashboardRepo.recomputeCalls == 1 }, time.Second, 10*time.Millisecond)
+ require.Eventually(t, func() bool { return dashboardRepo.recomputeCalls.Load() == 1 }, time.Second, 10*time.Millisecond)
}
func TestUsageCleanupServiceExecuteTaskCanceled(t *testing.T) {
diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go
index e29d282eb80..2e703a31fe7 100644
--- a/backend/internal/service/usage_log.go
+++ b/backend/internal/service/usage_log.go
@@ -97,13 +97,22 @@ type UsageLog struct {
APIKeyID int64
AccountID int64
RequestID string
- Model string
- // RequestedModel is the client-requested model name recorded for stable user/admin display.
+ // UsageBillingAttemptID is an in-memory fencing fact for durable admission
+ // settlement. It is carried inside the outbox envelope, not persisted as a
+ // usage_logs column.
+ UsageBillingAttemptID string `gorm:"-"`
+ Model string
+ // RequestedModel is the client-requested model name before channel/account mapping.
// Empty should be treated as Model for backward compatibility with historical rows.
RequestedModel string
// UpstreamModel is the actual model sent to the upstream provider after mapping.
- // Nil means no mapping was applied (requested model was used as-is).
+ // Nil means it was not captured or the upstream model matched the requested model.
UpstreamModel *string
+ BillingModel *string
+ // Pricing evidence is nullable for historical rows and admin-visible only.
+ PricingSource *string
+ PricingRevision *string
+ PricingHash *string
// ChannelID 渠道 ID
ChannelID *int64
// ModelMappingChain 模型映射链,如 "a→b→c"
diff --git a/backend/internal/service/usage_log_helpers.go b/backend/internal/service/usage_log_helpers.go
index 7cc8a713cd8..e3a3e435cce 100644
--- a/backend/internal/service/usage_log_helpers.go
+++ b/backend/internal/service/usage_log_helpers.go
@@ -27,6 +27,59 @@ func forwardResultBillingModel(requestedModel, upstreamModel string) string {
return strings.TrimSpace(upstreamModel)
}
+func mappedBillingModelOverRequested(originalModel string, candidates ...string) string {
+ original := normalizeBillingGuardModel(originalModel)
+ if !isClaudeCompatBillingModel(original) {
+ return ""
+ }
+
+ for _, candidate := range candidates {
+ normalized := normalizeBillingGuardModel(candidate)
+ if normalized == "" || normalized == original || strings.HasPrefix(normalized, "claude") {
+ continue
+ }
+ return strings.TrimSpace(candidate)
+ }
+ return ""
+}
+
+// usageLogModelForMappedClaudeCompat keeps protocol aliases in requested_model
+// while storing the non-Claude execution model as the product-visible model.
+func usageLogModelForMappedClaudeCompat(requestedModel, fallbackModel string, candidates ...string) string {
+ if mappedModel := mappedBillingModelOverRequested(requestedModel, candidates...); mappedModel != "" {
+ return mappedModel
+ }
+ if trimmed := strings.TrimSpace(fallbackModel); trimmed != "" {
+ return trimmed
+ }
+ return strings.TrimSpace(requestedModel)
+}
+
+func isClaudeCompatBillingModel(normalized string) bool {
+ if strings.HasPrefix(normalized, "claude") {
+ return true
+ }
+ switch normalized {
+ case "opus", "sonnet", "default", "haiku":
+ return true
+ default:
+ return false
+ }
+}
+
+func normalizeBillingGuardModel(model string) string {
+ normalized := strings.ToLower(strings.TrimSpace(model))
+ if normalized == "" {
+ return ""
+ }
+ if strings.Contains(normalized, "/") {
+ parts := strings.Split(normalized, "/")
+ normalized = strings.TrimSpace(parts[len(parts)-1])
+ }
+ normalized = strings.TrimSuffix(normalized, "[1m]")
+ return strings.TrimSpace(normalized)
+}
+
func optionalInt64Ptr(v int64) *int64 {
if v == 0 {
return nil
diff --git a/backend/internal/service/usage_record_worker_pool.go b/backend/internal/service/usage_record_worker_pool.go
index 5da0b89023c..697bdaca3d6 100644
--- a/backend/internal/service/usage_record_worker_pool.go
+++ b/backend/internal/service/usage_record_worker_pool.go
@@ -15,10 +15,13 @@ import (
)
const (
- defaultUsageRecordWorkerCount = 128
- defaultUsageRecordQueueSize = 16384
- defaultUsageRecordTaskTimeoutSeconds = 5
- defaultUsageRecordOverflowPolicy = config.UsageRecordOverflowPolicySample
+ defaultUsageRecordWorkerCount = 128
+ defaultUsageRecordQueueSize = 16384
+ defaultUsageRecordTaskTimeoutSeconds = 5
+ // Billable usage must never be sampled or dropped. Queue overflow is handled
+ // by synchronous backpressure so the caller cannot turn a successful request
+ // into free usage merely by saturating this in-memory pool.
+ defaultUsageRecordOverflowPolicy = config.UsageRecordOverflowPolicySync
defaultUsageRecordOverflowSampleRatio = 10
defaultUsageRecordAutoScaleEnabled = true
defaultUsageRecordAutoScaleMinWorkers = 128
@@ -141,15 +144,20 @@ func NewUsageRecordWorkerPoolWithOptions(opts UsageRecordWorkerPoolOptions) *Usa
}
// Submit 提交一个使用量记录任务。
-// 提交失败(队列满)时按 overflowPolicy 执行降级策略:drop/sample/sync。
+// 队列满或池已停止时同步执行,保证已接收的计费任务不会静默丢失。
+// overflowPolicy 只为旧配置兼容保留,不再允许削弱该完整性边界。
func (p *UsageRecordWorkerPool) Submit(task UsageRecordTask) UsageRecordSubmitMode {
- if p == nil || task == nil {
+ if task == nil {
return UsageRecordSubmitModeDropped
}
+ if p == nil {
+ executeUsageRecordTask(task)
+ return UsageRecordSubmitModeSync
+ }
if p.pool == nil || p.pool.Stopped() {
- p.droppedPoolStopped.Add(1)
- p.logDrop("stopped")
- return UsageRecordSubmitModeDropped
+ p.syncFallback.Add(1)
+ p.execute(task)
+ return UsageRecordSubmitModeSync
}
_, ok := p.pool.TrySubmit(func() {
@@ -160,27 +168,14 @@ func (p *UsageRecordWorkerPool) Submit(task UsageRecordTask) UsageRecordSubmitMo
}
if p.pool.Stopped() {
- p.droppedPoolStopped.Add(1)
- p.logDrop("stopped")
- return UsageRecordSubmitModeDropped
- }
-
- switch p.overflowPolicy {
- case config.UsageRecordOverflowPolicySync:
p.syncFallback.Add(1)
p.execute(task)
return UsageRecordSubmitModeSync
- case config.UsageRecordOverflowPolicySample:
- if p.shouldSyncFallback() {
- p.syncFallback.Add(1)
- p.execute(task)
- return UsageRecordSubmitModeSync
- }
}
- p.droppedQueueFull.Add(1)
- p.logDrop("full")
- return UsageRecordSubmitModeDropped
+ p.syncFallback.Add(1)
+ p.execute(task)
+ return UsageRecordSubmitModeSync
}
// Stats 返回当前池状态与计数器。
@@ -315,9 +310,10 @@ func (p *UsageRecordWorkerPool) shouldSyncFallback() bool {
}
func (p *UsageRecordWorkerPool) execute(task UsageRecordTask) {
- ctx, cancel := context.WithTimeout(context.Background(), p.taskTimeout)
- defer cancel()
+ executeUsageRecordTask(task)
+}
+func executeUsageRecordTask(task UsageRecordTask) {
defer func() {
if recovered := recover(); recovered != nil {
logger.L().With(
@@ -327,7 +323,11 @@ func (p *UsageRecordWorkerPool) execute(task UsageRecordTask) {
}
}()
- task(ctx)
+ // A short in-memory worker deadline is unsafe for billing: a slow database
+ // would cancel RecordUsage after the upstream response already succeeded.
+ // Backpressure is intentional here; durable producers apply their own retry
+ // semantics after the billing fact has been constructed.
+ task(context.Background())
}
func (p *UsageRecordWorkerPool) logDrop(reason string) {
diff --git a/backend/internal/service/usage_record_worker_pool_test.go b/backend/internal/service/usage_record_worker_pool_test.go
index f896e41d0ab..739ea935f0e 100644
--- a/backend/internal/service/usage_record_worker_pool_test.go
+++ b/backend/internal/service/usage_record_worker_pool_test.go
@@ -38,7 +38,7 @@ func TestUsageRecordWorkerPool_SubmitEnqueued(t *testing.T) {
}, time.Second, 10*time.Millisecond)
}
-func TestUsageRecordWorkerPool_OverflowDrop(t *testing.T) {
+func TestUsageRecordWorkerPool_OverflowDropPolicyFallsBackSynchronously(t *testing.T) {
pool := NewUsageRecordWorkerPoolWithOptions(UsageRecordWorkerPoolOptions{
WorkerCount: 1,
QueueSize: 1,
@@ -51,6 +51,7 @@ func TestUsageRecordWorkerPool_OverflowDrop(t *testing.T) {
block := make(chan struct{})
started := make(chan struct{})
secondDone := make(chan struct{})
+ var overflowExecuted atomic.Bool
require.Equal(t, UsageRecordSubmitModeEnqueued, pool.Submit(func(ctx context.Context) {
close(started)
@@ -61,9 +62,13 @@ func TestUsageRecordWorkerPool_OverflowDrop(t *testing.T) {
require.Equal(t, UsageRecordSubmitModeEnqueued, pool.Submit(func(ctx context.Context) {
close(secondDone)
}))
- require.Equal(t, UsageRecordSubmitModeDropped, pool.Submit(func(ctx context.Context) {}))
-
+ overflowMode := pool.Submit(func(ctx context.Context) {
+ overflowExecuted.Store(true)
+ })
close(block)
+ require.Equal(t, UsageRecordSubmitModeSync, overflowMode)
+ require.True(t, overflowExecuted.Load(), "billable usage must never be dropped when the queue is full")
+
select {
case <-secondDone:
case <-time.After(time.Second):
@@ -71,7 +76,8 @@ func TestUsageRecordWorkerPool_OverflowDrop(t *testing.T) {
}
require.Eventually(t, func() bool {
- return pool.Stats().DroppedQueueFull >= 1
+ stats := pool.Stats()
+ return stats.SyncFallbackTasks >= 1 && stats.DroppedQueueFull == 0
}, time.Second, 10*time.Millisecond)
}
@@ -149,10 +155,14 @@ func TestUsageRecordWorkerPool_OverflowSample(t *testing.T) {
require.Equal(t, UsageRecordSubmitModeSync, firstOverflow)
require.True(t, syncExecuted.Load())
- secondOverflow := pool.Submit(func(ctx context.Context) {})
- require.Equal(t, UsageRecordSubmitModeDropped, secondOverflow)
-
+ var secondOverflowExecuted atomic.Bool
+ secondOverflow := pool.Submit(func(ctx context.Context) {
+ secondOverflowExecuted.Store(true)
+ })
close(block)
+ require.Equal(t, UsageRecordSubmitModeSync, secondOverflow)
+ require.True(t, secondOverflowExecuted.Load())
+
select {
case <-secondDone:
case <-time.After(time.Second):
@@ -161,11 +171,11 @@ func TestUsageRecordWorkerPool_OverflowSample(t *testing.T) {
require.Eventually(t, func() bool {
stats := pool.Stats()
- return stats.SyncFallbackTasks >= 1 && stats.DroppedQueueFull >= 1
+ return stats.SyncFallbackTasks >= 2 && stats.DroppedQueueFull == 0
}, time.Second, 10*time.Millisecond)
}
-func TestUsageRecordWorkerPool_SubmitAfterStop(t *testing.T) {
+func TestUsageRecordWorkerPool_SubmitAfterStopFallsBackSynchronously(t *testing.T) {
pool := NewUsageRecordWorkerPoolWithOptions(UsageRecordWorkerPoolOptions{
WorkerCount: 1,
QueueSize: 1,
@@ -175,9 +185,13 @@ func TestUsageRecordWorkerPool_SubmitAfterStop(t *testing.T) {
})
pool.Stop()
- mode := pool.Submit(func(ctx context.Context) {})
- require.Equal(t, UsageRecordSubmitModeDropped, mode)
- require.GreaterOrEqual(t, pool.Stats().DroppedPoolStopped, uint64(1))
+ var executed atomic.Bool
+ mode := pool.Submit(func(ctx context.Context) {
+ executed.Store(true)
+ })
+ require.Equal(t, UsageRecordSubmitModeSync, mode)
+ require.True(t, executed.Load(), "pool shutdown must not turn accepted usage into a free request")
+ require.Zero(t, pool.Stats().DroppedPoolStopped)
}
func TestUsageRecordWorkerPool_AutoScaleUpAndDown(t *testing.T) {
@@ -261,7 +275,11 @@ func TestUsageRecordWorkerPool_AutoScaleDownRequiresLowRunningUtilization(t *tes
func TestUsageRecordWorkerPool_SubmitNilReceiverAndNilTask(t *testing.T) {
var nilPool *UsageRecordWorkerPool
- require.Equal(t, UsageRecordSubmitModeDropped, nilPool.Submit(func(ctx context.Context) {}))
+ var nilPoolExecuted atomic.Bool
+ require.Equal(t, UsageRecordSubmitModeSync, nilPool.Submit(func(ctx context.Context) {
+ nilPoolExecuted.Store(true)
+ }))
+ require.True(t, nilPoolExecuted.Load())
pool := NewUsageRecordWorkerPoolWithOptions(UsageRecordWorkerPoolOptions{
WorkerCount: 1,
@@ -355,7 +373,7 @@ func TestUsageRecordWorkerPool_OptionsFromConfig_NilConfig(t *testing.T) {
require.Equal(t, defaultUsageRecordWorkerCount, opts.WorkerCount)
require.Equal(t, defaultUsageRecordQueueSize, opts.QueueSize)
require.Equal(t, time.Duration(defaultUsageRecordTaskTimeoutSeconds)*time.Second, opts.TaskTimeout)
- require.Equal(t, defaultUsageRecordOverflowPolicy, opts.OverflowPolicy)
+ require.Equal(t, config.UsageRecordOverflowPolicySync, opts.OverflowPolicy)
require.Equal(t, defaultUsageRecordOverflowSampleRatio, opts.OverflowSamplePercent)
require.True(t, opts.AutoScaleEnabled)
require.Equal(t, defaultUsageRecordAutoScaleMinWorkers, opts.AutoScaleMinWorkers)
@@ -445,7 +463,7 @@ func TestUsageRecordWorkerPool_StatsAndStop_NilBranches(t *testing.T) {
require.NotPanics(t, func() { emptyPool.Stop() })
}
-func TestUsageRecordWorkerPool_Execute_PanicAndTimeout(t *testing.T) {
+func TestUsageRecordWorkerPool_Execute_PanicAndSlowTaskIsNotCanceled(t *testing.T) {
pool := &UsageRecordWorkerPool{taskTimeout: 30 * time.Millisecond}
require.NotPanics(t, func() {
@@ -456,7 +474,11 @@ func TestUsageRecordWorkerPool_Execute_PanicAndTimeout(t *testing.T) {
done := make(chan struct{})
pool.execute(func(ctx context.Context) {
- <-ctx.Done()
+ if _, ok := ctx.Deadline(); ok {
+ t.Error("billing task context must not have the legacy short deadline")
+ }
+ time.Sleep(60 * time.Millisecond)
+ require.NoError(t, ctx.Err())
close(done)
})
select {
diff --git a/backend/internal/service/usage_service.go b/backend/internal/service/usage_service.go
index d64f01e086e..ad71108b3fa 100644
--- a/backend/internal/service/usage_service.go
+++ b/backend/internal/service/usage_service.go
@@ -155,6 +155,32 @@ func (s *UsageService) GetByID(ctx context.Context, id int64) (*UsageLog, error)
return log, nil
}
+type usageLogOwnerScopedReader interface {
+ GetByIDForUser(ctx context.Context, id, userID int64) (*UsageLog, error)
+}
+
+// GetByIDForUser returns the same not-found result for absent and foreign rows.
+// Production repositories bind the authenticated owner in the SQL predicate;
+// the fallback preserves the response invariant for alternate implementations.
+func (s *UsageService) GetByIDForUser(ctx context.Context, id, userID int64) (*UsageLog, error) {
+ if scoped, ok := s.usageRepo.(usageLogOwnerScopedReader); ok {
+ log, err := scoped.GetByIDForUser(ctx, id, userID)
+ if err != nil {
+ return nil, fmt.Errorf("get usage log: %w", err)
+ }
+ return log, nil
+ }
+
+ log, err := s.usageRepo.GetByID(ctx, id)
+ if err != nil {
+ return nil, fmt.Errorf("get usage log: %w", err)
+ }
+ if log == nil || log.UserID != userID {
+ return nil, ErrUsageLogNotFound
+ }
+ return log, nil
+}
+
// ListByUser 获取用户的使用日志列表
func (s *UsageService) ListByUser(ctx context.Context, userID int64, params pagination.PaginationParams) ([]UsageLog, *pagination.PaginationResult, error) {
logs, pagination, err := s.usageRepo.ListByUser(ctx, userID, params)
diff --git a/backend/internal/service/usage_service_owner_scope_test.go b/backend/internal/service/usage_service_owner_scope_test.go
new file mode 100644
index 00000000000..7415137fad4
--- /dev/null
+++ b/backend/internal/service/usage_service_owner_scope_test.go
@@ -0,0 +1,61 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+type usageOwnerScopeRepoStub struct {
+ UsageLogRepository
+ globalRecord *UsageLog
+ scopedRecord *UsageLog
+ scopedErr error
+ scopedCalled bool
+}
+
+func (s *usageOwnerScopeRepoStub) GetByID(context.Context, int64) (*UsageLog, error) {
+ if s.globalRecord == nil {
+ return nil, ErrUsageLogNotFound
+ }
+ return s.globalRecord, nil
+}
+
+func (s *usageOwnerScopeRepoStub) GetByIDForUser(context.Context, int64, int64) (*UsageLog, error) {
+ s.scopedCalled = true
+ return s.scopedRecord, s.scopedErr
+}
+
+type usageGlobalOnlyRepoStub struct {
+ UsageLogRepository
+ record *UsageLog
+}
+
+func (s *usageGlobalOnlyRepoStub) GetByID(context.Context, int64) (*UsageLog, error) {
+ return s.record, nil
+}
+
+func TestUsageServiceGetByIDForUserUsesOwnerScopedRepositoryLookup(t *testing.T) {
+ repo := &usageOwnerScopeRepoStub{scopedErr: ErrUsageLogNotFound}
+ svc := NewUsageService(repo, nil, nil, nil)
+
+ record, err := svc.GetByIDForUser(context.Background(), 41, 7)
+
+ require.Nil(t, record)
+ require.ErrorIs(t, err, ErrUsageLogNotFound)
+ require.True(t, repo.scopedCalled)
+}
+
+func TestUsageServiceGetByIDForUserFallbackHidesForeignRecordExistence(t *testing.T) {
+ repo := &usageGlobalOnlyRepoStub{record: &UsageLog{ID: 41, UserID: 8}}
+ svc := NewUsageService(repo, nil, nil, nil)
+
+ record, err := svc.GetByIDForUser(context.Background(), 41, 7)
+
+ require.Nil(t, record)
+ require.True(t, errors.Is(err, ErrUsageLogNotFound))
+}
diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go
index f98336111ba..9aebfa262d6 100644
--- a/backend/internal/service/user.go
+++ b/backend/internal/service/user.go
@@ -25,13 +25,21 @@ type User struct {
TokenVersion int64 // Incremented on password change to invalidate existing tokens
// TokenVersionResolved indicates TokenVersion already contains the fingerprint-derived
// value expected in JWT claims and refresh-token state.
- TokenVersionResolved bool
- SignupSource string
- LastLoginAt *time.Time
- LastActiveAt *time.Time
- LastUsedAt *time.Time
- CreatedAt time.Time
- UpdatedAt time.Time
+ TokenVersionResolved bool
+ SignupSource string
+ SignupIP string
+ SignupIPPrefix string
+ SignupUserAgentHash string
+ SignupDeviceFingerprintHash string
+ SignupRiskRecorded bool
+ TrialBonusEligible bool
+ TrialBonusHoldReason string
+ TrialBonusRiskScore int
+ LastLoginAt *time.Time
+ LastActiveAt *time.Time
+ LastUsedAt *time.Time
+ CreatedAt time.Time
+ UpdatedAt time.Time
// GroupRates 用户专属分组倍率配置
// map[groupID]rateMultiplier
diff --git a/backend/internal/service/user_group_rate.go b/backend/internal/service/user_group_rate.go
index f069eb7e415..e1e312223ec 100644
--- a/backend/internal/service/user_group_rate.go
+++ b/backend/internal/service/user_group_rate.go
@@ -42,13 +42,14 @@ type UserGroupRateRepository interface {
// GetByGroupID 获取指定分组下所有用户的专属配置(rate 与 rpm_override 任一非 NULL 即返回)
GetByGroupID(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error)
- // SyncUserGroupRates 同步用户的分组专属倍率;nil 表示清空该分组的 rate_multiplier
+ // SyncUserGroupRates 原子同步用户的分组专属倍率;nil 表示清空该分组的 rate_multiplier。
+ // 若 ctx 携带 Ent 事务,必须加入该事务,供管理员用户策略跨表同成同败。
SyncUserGroupRates(ctx context.Context, userID int64, rates map[int64]*float64) error
- // SyncGroupRateMultipliers 批量同步分组的用户专属倍率(替换整组 rate 部分)
+ // SyncGroupRateMultipliers 原子批量同步分组的用户专属倍率(替换整组 rate 部分)
SyncGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error
- // SyncGroupRPMOverrides 批量同步分组的用户专属 RPM(替换整组 rpm_override 部分)。
+ // SyncGroupRPMOverrides 原子批量同步分组的用户专属 RPM(替换整组 rpm_override 部分)。
// 条目中 RPMOverride 为 nil 时清空对应行的 rpm_override;非 nil 时 upsert。
SyncGroupRPMOverrides(ctx context.Context, groupID int64, entries []GroupRPMOverrideInput) error
diff --git a/backend/internal/service/user_group_rate_resolver_test.go b/backend/internal/service/user_group_rate_resolver_test.go
index 064ef7ba27f..3b449a74ba1 100644
--- a/backend/internal/service/user_group_rate_resolver_test.go
+++ b/backend/internal/service/user_group_rate_resolver_test.go
@@ -2,9 +2,12 @@ package service
import (
"context"
+ "math"
+ "net/http"
"testing"
"time"
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
gocache "github.com/patrickmn/go-cache"
"github.com/stretchr/testify/require"
)
@@ -81,3 +84,28 @@ func TestGatewayServiceGetUserGroupRateMultiplier_FallbacksAndUsesExistingResolv
require.Equal(t, rate, got)
require.Equal(t, 1, repo.calls)
}
+
+type groupRPMOverflowRepoStub struct {
+ UserGroupRateRepository
+ syncCalled bool
+}
+
+func (s *groupRPMOverflowRepoStub) SyncGroupRPMOverrides(context.Context, int64, []GroupRPMOverrideInput) error {
+ s.syncCalled = true
+ return nil
+}
+
+func TestAdminServiceBatchSetGroupRPMOverridesRejectsPostgresIntegerOverflow(t *testing.T) {
+ repo := &groupRPMOverflowRepoStub{}
+ svc := &adminServiceImpl{userGroupRateRepo: repo}
+ overflow := int(math.MaxInt32) + 1
+
+ err := svc.BatchSetGroupRPMOverrides(context.Background(), 10, []GroupRPMOverrideInput{
+ {UserID: 20, RPMOverride: &overflow},
+ })
+
+ require.Error(t, err)
+ require.Equal(t, http.StatusBadRequest, infraerrors.Code(err))
+ require.Equal(t, "INVALID_RPM_OVERRIDE", infraerrors.Reason(err))
+ require.False(t, repo.syncCalled, "invalid input must be rejected before any repository write")
+}
diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go
index a7279e6a7d7..f84e6f0ab06 100644
--- a/backend/internal/service/user_service.go
+++ b/backend/internal/service/user_service.go
@@ -96,6 +96,8 @@ type UserRepository interface {
UpdateBalance(ctx context.Context, id int64, amount float64) error
DeductBalance(ctx context.Context, id int64, amount float64) error
UpdateConcurrency(ctx context.Context, id int64, amount int) error
+ BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error)
+ BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error)
ExistsByEmail(ctx context.Context, email string) (bool, error)
RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error)
// AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups(幂等,冲突忽略)
diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go
index ff55c2a50ab..0fef0f87d8c 100644
--- a/backend/internal/service/user_service_test.go
+++ b/backend/internal/service/user_service_test.go
@@ -199,7 +199,10 @@ func (m *mockUserRepo) ExistsByEmail(context.Context, string) (bool, error) { re
func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
return 0, nil
}
-func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
+
+func (m *mockUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
+func (m *mockUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
+func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
func (m *mockUserRepo) ListUserAuthIdentities(context.Context, int64) ([]UserAuthIdentityRecord, error) {
out := make([]UserAuthIdentityRecord, len(m.identities))
copy(out, m.identities)
diff --git a/backend/internal/service/user_subscription.go b/backend/internal/service/user_subscription.go
index ec547d81a5a..9153fc939ce 100644
--- a/backend/internal/service/user_subscription.go
+++ b/backend/internal/service/user_subscription.go
@@ -1,11 +1,16 @@
package service
-import "time"
+import (
+ "strconv"
+ "time"
+)
type UserSubscription struct {
- ID int64
- UserID int64
- GroupID int64
+ ID int64
+ UserID int64
+ // GroupID 钱包模式 (v4) 下为 nil;老的单 group 订阅 (v3) 下必填。
+ // 详见 ai-relay-infra/docs/plans/2026-05-10-wallet-mode-design.md §1.3
+ GroupID *int64
StartsAt time.Time
ExpiresAt time.Time
@@ -19,6 +24,14 @@ type UserSubscription struct {
WeeklyUsageUSD float64
MonthlyUsageUSD float64
+ // WalletBalanceUSD / WalletInitialUSD 钱包模式 (v4) 字段;nil = 老 group 订阅。
+ WalletBalanceUSD *float64
+ WalletInitialUSD *float64
+
+ // LockedRates 订阅级锁定倍率:group_id 字符串 -> rate_multiplier。
+ // 用于老用户永久旧价、月卡 F+ 不随 group/user rate 配置漂移。
+ LockedRates map[string]float64
+
AssignedBy *int64
AssignedAt time.Time
Notes string
@@ -29,6 +42,52 @@ type UserSubscription struct {
User *User
Group *Group
AssignedByUser *User
+
+ // Runtime-only assignment metadata used by admin wallet activation responses.
+ //
+ // 5/14 反转决策(参见 docs/plans/2026-05-14-wallet-single-key-reversal.md):
+ // 激活走单 key 路径 → 用 WalletUniversalKey/WalletUniversalKeyCreated 两字段。
+ // WalletGroupKeys/...Count 多 key 字段保留作历史兼容(不再写入,DTO 已下线)。
+ WalletGroupKeys []APIKey
+ WalletGroupKeysCreatedCount int
+
+ WalletUniversalKey *APIKey
+ WalletUniversalKeyCreated bool
+
+ // WalletCreditDeltaUSD is runtime-only assignment metadata. For a credits
+ // activation or top-up it records the amount applied by this operation, not
+ // the wallet's cumulative wallet_initial_usd total.
+ WalletCreditDeltaUSD *float64
+
+ // AssignmentPlanType is the transaction-resolved plan type for the current
+ // assignment. It is runtime-only and must not be inferred from a plan ID.
+ AssignmentPlanType string
+}
+
+// IsWalletMode 钱包模式订阅判别。
+func (s *UserSubscription) IsWalletMode() bool {
+ return s.WalletBalanceUSD != nil
+}
+
+// IsUniversalWalletMode distinguishes permanent credits wallets from legacy
+// finite wallet-shaped monthly subscriptions. Only credits wallets may use a
+// group_id=NULL key and model-based routing.
+func (s *UserSubscription) IsUniversalWalletMode() bool {
+ if s == nil || !s.IsWalletMode() {
+ return false
+ }
+ return !s.ExpiresAt.Before(MaxExpiresAt.Add(-24 * time.Hour))
+}
+
+func (s *UserSubscription) LockedRateForGroup(groupID int64) (float64, bool) {
+ if s == nil || groupID <= 0 || len(s.LockedRates) == 0 {
+ return 0, false
+ }
+ rate, ok := s.LockedRates[strconv.FormatInt(groupID, 10)]
+ if !ok || rate < 0 {
+ return 0, false
+ }
+ return rate, true
}
func (s *UserSubscription) IsActive() bool {
diff --git a/backend/internal/service/user_subscription_port.go b/backend/internal/service/user_subscription_port.go
index 4484fae8f4e..b6a358d05d2 100644
--- a/backend/internal/service/user_subscription_port.go
+++ b/backend/internal/service/user_subscription_port.go
@@ -7,11 +7,47 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
)
+type SubscriptionUsageWindow string
+
+const (
+ SubscriptionUsageWindowDaily SubscriptionUsageWindow = "daily"
+ SubscriptionUsageWindowWeekly SubscriptionUsageWindow = "weekly"
+ SubscriptionUsageWindowMonthly SubscriptionUsageWindow = "monthly"
+)
+
+// SubscriptionUsageWindowAdvance is an optimistic state transition for one
+// usage window. ExpectedStart=nil means the stored window must still be NULL.
+// ResetUsage is false for first activation so already-recorded concurrent usage
+// is never erased; expired-window transitions set it true.
+type SubscriptionUsageWindowAdvance struct {
+ Window SubscriptionUsageWindow
+ ExpectedStart *time.Time
+ NewStart time.Time
+ ResetUsage bool
+}
+
type UserSubscriptionRepository interface {
Create(ctx context.Context, sub *UserSubscription) error
GetByID(ctx context.Context, id int64) (*UserSubscription, error)
GetByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*UserSubscription, error)
GetActiveByUserIDAndGroupID(ctx context.Context, userID, groupID int64) (*UserSubscription, error)
+ // 钱包模式 (v4):返回用户最快到期的 active 钱包订阅(先到期先消费),
+ // 与具体 group 解耦,gateway 中间件先 lookup 钱包再 fallback (user, group)。
+ GetActiveWalletByUserID(ctx context.Context, userID int64) (*UserSubscription, error)
+ // GetActiveCreditsWalletByUserID returns the user's permanent credits
+ // wallet and excludes finite legacy wallet-shaped monthly rows.
+ GetActiveCreditsWalletByUserID(ctx context.Context, userID int64) (*UserSubscription, error)
+ // GetActiveByPlanCoveringGroup 查询用户有没有 active 月卡订阅,其 plan 通过
+ // subscription_plan_groups 间接覆盖了 targetGroupID。
+ //
+ // 用例:用户在 admin 把 api_key.group_id 切到非订阅主 group 时,middleware
+ // 调用本方法看 plan_groups 是否覆盖;覆盖则使用该订阅 quota 计费
+ // (2026-05-16 方案 C,docs/plans/2026-05-16-wallet-v4-group-switch-billing-fix.md)。
+ GetActiveByPlanCoveringGroup(ctx context.Context, userID, targetGroupID int64) (*UserSubscription, error)
+ // HasAnyActiveSubscription 用户是否有任何 active 订阅(含钱包 / 月卡)。
+ // middleware fallback 用:有订阅但当前 group 不覆盖 → 403 拒绝;
+ // 无订阅 → 允许走老 user.balance 兼容路径。
+ HasAnyActiveSubscription(ctx context.Context, userID int64) (bool, error)
Update(ctx context.Context, sub *UserSubscription) error
Delete(ctx context.Context, id int64) error
@@ -26,6 +62,7 @@ type UserSubscriptionRepository interface {
UpdateNotes(ctx context.Context, subscriptionID int64, notes string) error
ActivateWindows(ctx context.Context, id int64, start time.Time) error
+ AdvanceUsageWindow(ctx context.Context, id int64, advance SubscriptionUsageWindowAdvance) (bool, error)
ResetDailyUsage(ctx context.Context, id int64, newWindowStart time.Time) error
ResetWeeklyUsage(ctx context.Context, id int64, newWindowStart time.Time) error
ResetMonthlyUsage(ctx context.Context, id int64, newWindowStart time.Time) error
diff --git a/backend/internal/service/vertex_service_account.go b/backend/internal/service/vertex_service_account.go
index 4430cf819d5..18d5f8a19c6 100644
--- a/backend/internal/service/vertex_service_account.go
+++ b/backend/internal/service/vertex_service_account.go
@@ -158,11 +158,12 @@ func getVertexServiceAccountAccessToken(ctx context.Context, cache GeminiTokenCa
}
locked := false
+ ownershipToken := ""
if cache != nil {
var lockErr error
- locked, lockErr = cache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
+ locked, ownershipToken, lockErr = cache.AcquireRefreshLock(ctx, cacheKey, 30*time.Second)
if lockErr == nil && locked {
- defer func() { _ = cache.ReleaseRefreshLock(ctx, cacheKey) }()
+ defer func() { _ = cache.ReleaseRefreshLock(ctx, cacheKey, ownershipToken) }()
} else if lockErr != nil {
slog.Warn("vertex_service_account_token_lock_failed", "account_id", account.ID, "error", lockErr)
} else {
diff --git a/backend/internal/service/wallet_metrics.go b/backend/internal/service/wallet_metrics.go
new file mode 100644
index 00000000000..7999f5a4e39
--- /dev/null
+++ b/backend/internal/service/wallet_metrics.go
@@ -0,0 +1,59 @@
+package service
+
+import "sync/atomic"
+
+// 钱包模式 (v4) 运行时指标。原则同 OpenAI WS v2:
+// 不引 prometheus 客户端,atomic 计数器 + 周期日志即可,前端 / ops 通过
+// SnapshotWalletMetrics 拉快照。
+//
+// 三个核心信号:
+// - WalletInsufficientTotal — 用户撞 402 的次数(gateway pre-check 或
+// billing 后置阶段命中 ErrWalletInsufficient 都会 +1)
+// - WalletBalanceLowTotal — 预检命中「余额低于阈值」的次数,用于
+// 提醒前端弹「即将耗尽」横幅;阈值由 walletBalanceLowThresholdUSD 控制
+// - WalletLedgerDriftTotal — 对账 cron 发现 cached vs ledger SUM 漂移
+// >$0.01 的订阅条数;任何 >0 都该告警
+
+const walletBalanceLowThresholdUSD = 150.0
+
+var (
+ walletInsufficientTotal atomic.Int64
+ walletBalanceLowTotal atomic.Int64
+ walletLedgerDriftTotal atomic.Int64
+)
+
+// WalletMetricsSnapshot 钱包指标快照。
+type WalletMetricsSnapshot struct {
+ InsufficientTotal int64 `json:"insufficient_total"`
+ BalanceLowTotal int64 `json:"balance_low_total"`
+ LedgerDriftTotal int64 `json:"ledger_drift_total"`
+}
+
+// SnapshotWalletMetrics 返回当前钱包指标快照。
+func SnapshotWalletMetrics() WalletMetricsSnapshot {
+ return WalletMetricsSnapshot{
+ InsufficientTotal: walletInsufficientTotal.Load(),
+ BalanceLowTotal: walletBalanceLowTotal.Load(),
+ LedgerDriftTotal: walletLedgerDriftTotal.Load(),
+ }
+}
+
+// IncWalletInsufficientTotal handler 层 HTTP 402 边界统一调用一次。
+// 暴露给 handler 包;service 内部 (checkWalletEligibility) 不再单独 +1,
+// 避免双计。
+func IncWalletInsufficientTotal() {
+ walletInsufficientTotal.Add(1)
+}
+
+// recordWalletBalanceLow 用户预检通过但余额已经低于阈值时调用。
+func recordWalletBalanceLow() {
+ walletBalanceLowTotal.Add(1)
+}
+
+// recordWalletLedgerDrift 对账 cron 每发现一条漂移订阅 +1。
+func recordWalletLedgerDrift(n int64) {
+ if n <= 0 {
+ return
+ }
+ walletLedgerDriftTotal.Add(n)
+}
diff --git a/backend/internal/service/wallet_metrics_test_helpers_test.go b/backend/internal/service/wallet_metrics_test_helpers_test.go
new file mode 100644
index 00000000000..d62e1e1843f
--- /dev/null
+++ b/backend/internal/service/wallet_metrics_test_helpers_test.go
@@ -0,0 +1,10 @@
+//go:build unit
+
+package service
+
+// resetWalletMetricsForTest 单测重置计数器。
+func resetWalletMetricsForTest() {
+ walletInsufficientTotal.Store(0)
+ walletBalanceLowTotal.Store(0)
+ walletLedgerDriftTotal.Store(0)
+}
diff --git a/backend/internal/service/wallet_port.go b/backend/internal/service/wallet_port.go
new file mode 100644
index 00000000000..334f507cdef
--- /dev/null
+++ b/backend/internal/service/wallet_port.go
@@ -0,0 +1,150 @@
+package service
+
+import (
+ "context"
+ "net/http"
+
+ infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
+)
+
+// WalletLedgerReason 流水原因枚举(与 SQL chk_wallet_ledger_reason 约束一致)。
+const (
+ WalletLedgerReasonActivation = "activation"
+ WalletLedgerReasonUsage = "usage"
+ WalletLedgerReasonRefund = "refund"
+ WalletLedgerReasonAdjustment = "adjustment"
+ WalletLedgerReasonExpiration = "expiration"
+ // WalletLedgerReasonTopup 额度卡叠加 (B2.4):用户已有 active 钱包订阅时
+ // 再买一张额度卡,quota 合入现有钱包,wallet_balance_usd 和 wallet_initial_usd
+ // 双双 +delta。migration 154 已扩 CHECK。
+ WalletLedgerReasonTopup = "topup"
+)
+
+// 钱包模式 (v4) 错误码。
+//
+// ErrWalletInsufficient 余额不足,扣费失败。映射到 HTTP 402 Payment Required;
+// 网关层捕获后给客户端返回 402 + hint 文案,详见 design §2.3。
+//
+// ErrWalletNotFound 订阅不存在或不是钱包模式(wallet_balance_usd IS NULL)。
+//
+// ErrWalletNegativeDelta DeductBalance 入参 costUSD ≤ 0;防止误把扣费当退款。
+var (
+ ErrWalletInsufficient = infraerrors.New(http.StatusPaymentRequired, "WALLET_INSUFFICIENT", "wallet balance insufficient, please renew at /dashboard")
+ ErrWalletAdmissionBusy = infraerrors.TooManyRequests("WALLET_ADMISSION_BUSY", "another wallet request is still being settled; please retry shortly")
+ ErrWalletNotFound = infraerrors.NotFound("WALLET_NOT_FOUND", "wallet subscription not found or not wallet-mode")
+ ErrWalletNegativeDelta = infraerrors.BadRequest("WALLET_NEGATIVE_DELTA", "deduct/credit amount must be > 0")
+)
+
+// WalletLedgerEntry 钱包流水落库后回读的记录(也用于审计接口)。
+type WalletLedgerEntry struct {
+ ID int64
+ SubscriptionID int64
+ DeltaUSD float64
+ BalanceAfter float64
+ Reason string
+ PaymentOrderID *int64
+ UsageLogID *int64
+ OperatorID *int64
+ Notes string
+}
+
+// WalletDeductCommand 扣款入参。
+//
+// 在事务中执行:
+// 1. SELECT wallet_balance_usd FROM user_subscriptions WHERE id=$1 FOR UPDATE
+// 2. UsageLogID 非空且已有 usage 流水时返回原流水,不重复扣款
+// 3. 如 balance < CostUSD → 回滚返 ErrWalletInsufficient
+// 4. UPDATE wallet_balance_usd -= CostUSD
+// 5. INSERT subscription_wallet_ledger(reason='usage', delta=-CostUSD, usage_log_id=UsageLogID)
+type WalletDeductCommand struct {
+ SubscriptionID int64
+ CostUSD float64
+ UsageLogID *int64
+ // PostpaidSettlement is set only after an upstream response has already been
+ // delivered. In that case the full actual cost must be ledgered even when it
+ // takes the wallet negative; ordinary/preflight deductions still fail closed.
+ PostpaidSettlement bool
+}
+
+// WalletAdjustCommand admin 调整钱包余额(手动退款 / 补偿 / 补充)。
+// DeltaUSD > 0 → 加余额;< 0 → 扣余额;= 0 视为参数错误。
+// Reason 必须是 WalletLedgerReason* 之一。
+type WalletAdjustCommand struct {
+ SubscriptionID int64
+ DeltaUSD float64
+ Reason string
+ OperatorID *int64
+ Notes string
+}
+
+// WalletActivationCommand records the opening balance of a newly created
+// wallet. The subscription row already contains that balance, so activation
+// appends the authoritative ledger entry without updating the balance again.
+type WalletActivationCommand struct {
+ SubscriptionID int64
+ InitialUSD float64
+ PaymentOrderID *int64
+ OperatorID *int64
+ Notes string
+}
+
+// WalletTopupCommand 额度卡叠加充值入参 (B2.4)。
+//
+// 在事务中执行:
+// 1. SELECT wallet_balance_usd, wallet_initial_usd FROM user_subscriptions
+// WHERE id=$1 FOR UPDATE(must be a wallet-mode subscription)
+// 2. UPDATE wallet_balance_usd += DeltaUSD
+// UPDATE wallet_initial_usd += DeltaUSD
+// 3. INSERT subscription_wallet_ledger(reason='topup', delta=+DeltaUSD)
+//
+// DeltaUSD 必须 > 0;非钱包模式订阅返 ErrWalletNotFound。
+type WalletTopupCommand struct {
+ SubscriptionID int64
+ DeltaUSD float64
+ PaymentOrderID *int64
+ OperatorID *int64
+ Notes string
+}
+
+// WalletReconcileDrift 对账发现的单条漂移记录。
+type WalletReconcileDrift struct {
+ SubscriptionID int64
+ Cached float64
+ LedgerSum float64
+ Drift float64
+}
+
+// WalletRepository 钱包扣款/审计仓储端口。
+//
+// 关键不变量:
+// - 所有方法都在自己开的事务里跑,调用方不需要事先 BeginTx。
+// - Deduct 必须使用 SELECT ... FOR UPDATE 锁住 user_subscriptions 行,
+// 防止同 user 多 key 并发把余额扣穿。
+// - 同一个非空 UsageLogID 只能产生一条 reason=usage 流水;重放必须返回
+// 首次已提交的流水,且不能再次修改余额。UsageLogID=nil 保留逐次扣款语义。
+// - Usage settlement may take the wallet negative after an already delivered
+// response. Pre-upstream admission limits that exposure to one in-flight
+// request per wallet; subsequent requests fail closed until settlement.
+type WalletRepository interface {
+ // Deduct 钱包扣款。余额不足返 ErrWalletInsufficient。
+ Deduct(ctx context.Context, cmd WalletDeductCommand) (WalletLedgerEntry, error)
+
+ // RecordActivation appends exactly one reason=activation entry and must not
+ // add InitialUSD to wallet_balance_usd a second time.
+ RecordActivation(ctx context.Context, cmd WalletActivationCommand) (WalletLedgerEntry, error)
+
+ // Adjust 余额调整(含初始充值)。
+ Adjust(ctx context.Context, cmd WalletAdjustCommand) (WalletLedgerEntry, error)
+
+ // Topup 额度卡叠加 (B2.4):同时把 wallet_balance_usd 和 wallet_initial_usd
+ // +DeltaUSD,写一条 reason='topup' 流水。区别于 Adjust(只动 balance)。
+ Topup(ctx context.Context, cmd WalletTopupCommand) (WalletLedgerEntry, error)
+
+ // ListLedger 查询订阅的流水(最近 N 条,倒序)。
+ ListLedger(ctx context.Context, subscriptionID int64, limit int) ([]WalletLedgerEntry, error)
+
+ // ReconcileBalances 比对所有钱包模式订阅的 cached wallet_balance_usd 与
+ // ledger 累计 SUM(delta_usd);返回漂移 > tolerance 的条目。tolerance 推荐
+ // 0.01(DB CHECK 约束允许的浮点抖动)。
+ ReconcileBalances(ctx context.Context, tolerance float64) ([]WalletReconcileDrift, error)
+}
diff --git a/backend/internal/service/wallet_reconcile_service.go b/backend/internal/service/wallet_reconcile_service.go
new file mode 100644
index 00000000000..1bb58318c31
--- /dev/null
+++ b/backend/internal/service/wallet_reconcile_service.go
@@ -0,0 +1,100 @@
+package service
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
+ "go.uber.org/zap"
+)
+
+// WalletReconcileService 周期性比对 user_subscriptions.wallet_balance_usd
+// 与 subscription_wallet_ledger.SUM(delta_usd) 的一致性。任何漂移 > tolerance
+// 都告警 + 累计到 walletLedgerDriftTotal 指标,由 ops 决定后续动作(人工
+// Adjust 修正或回滚)。
+//
+// 设计同 SubscriptionExpiryService:单 ticker、Start/Stop、首次启动立即跑一次。
+// 间隔 5 分钟够用——钱包扣款是热路径,但 ledger 写入与 balance 更新在同一事务里,
+// 漂移只可能源于直接 SQL 改动(admin 手贱)或代码 bug,5 分钟足够发现。
+type WalletReconcileService struct {
+ repo WalletRepository
+ interval time.Duration
+ tolerance float64
+ stopCh chan struct{}
+ stopOnce sync.Once
+ wg sync.WaitGroup
+}
+
+const walletReconcileTimeout = 30 * time.Second
+
+// NewWalletReconcileService 创建对账服务。interval ≤ 0 → 永不跑(用于测试 /
+// 关闭功能);tolerance ≤ 0 → 默认 $0.01。
+func NewWalletReconcileService(repo WalletRepository, interval time.Duration, tolerance float64) *WalletReconcileService {
+ return &WalletReconcileService{
+ repo: repo,
+ interval: interval,
+ tolerance: tolerance,
+ stopCh: make(chan struct{}),
+ }
+}
+
+func (s *WalletReconcileService) Start() {
+ if s == nil || s.repo == nil || s.interval <= 0 {
+ return
+ }
+ s.wg.Add(1)
+ go func() {
+ defer s.wg.Done()
+ ticker := time.NewTicker(s.interval)
+ defer ticker.Stop()
+
+ s.runOnce()
+ for {
+ select {
+ case <-ticker.C:
+ s.runOnce()
+ case <-s.stopCh:
+ return
+ }
+ }
+ }()
+}
+
+func (s *WalletReconcileService) Stop() {
+ if s == nil {
+ return
+ }
+ s.stopOnce.Do(func() {
+ close(s.stopCh)
+ })
+ s.wg.Wait()
+}
+
+// runOnce 执行一次对账;外部测试也走这个入口。
+func (s *WalletReconcileService) runOnce() {
+ ctx, cancel := context.WithTimeout(context.Background(), walletReconcileTimeout)
+ defer cancel()
+
+ drifts, err := s.repo.ReconcileBalances(ctx, s.tolerance)
+ if err != nil {
+ logger.L().With(
+ zap.String("component", "service.wallet_reconcile"),
+ zap.Error(err),
+ ).Warn("wallet.reconcile_query_failed")
+ return
+ }
+ if len(drifts) == 0 {
+ return
+ }
+ recordWalletLedgerDrift(int64(len(drifts)))
+ for _, d := range drifts {
+ logger.L().With(
+ zap.String("component", "service.wallet_reconcile"),
+ zap.Int64("subscription_id", d.SubscriptionID),
+ zap.Float64("cached_balance_usd", d.Cached),
+ zap.Float64("ledger_sum_usd", d.LedgerSum),
+ zap.Float64("drift_usd", d.Drift),
+ ).Error("wallet.reconcile_drift_detected")
+ }
+}
diff --git a/backend/internal/service/wallet_reconcile_service_test.go b/backend/internal/service/wallet_reconcile_service_test.go
new file mode 100644
index 00000000000..d51dd4887d5
--- /dev/null
+++ b/backend/internal/service/wallet_reconcile_service_test.go
@@ -0,0 +1,94 @@
+//go:build unit
+
+package service
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+type stubReconcileRepo struct {
+ fakeWalletRepo
+ drifts []WalletReconcileDrift
+ err error
+ calls int
+}
+
+func (s *stubReconcileRepo) ReconcileBalances(_ context.Context, _ float64) ([]WalletReconcileDrift, error) {
+ s.calls++
+ return s.drifts, s.err
+}
+
+// TestWalletReconcile_DriftIncrementsMetric 漂移记录必须 +1
+// walletLedgerDriftTotal,并以漂移条数累加。
+func TestWalletReconcile_DriftIncrementsMetric(t *testing.T) {
+ resetWalletMetricsForTest()
+
+ repo := &stubReconcileRepo{
+ drifts: []WalletReconcileDrift{
+ {SubscriptionID: 1, Cached: 100, LedgerSum: 99, Drift: 1},
+ {SubscriptionID: 2, Cached: 50, LedgerSum: 47, Drift: 3},
+ },
+ }
+ svc := NewWalletReconcileService(repo, 0, 0.01) // interval=0 → 不起 goroutine
+ svc.runOnce()
+
+ snap := SnapshotWalletMetrics()
+ require.Equal(t, int64(2), snap.LedgerDriftTotal)
+ require.Equal(t, 1, repo.calls)
+}
+
+// TestWalletReconcile_NoDriftLeavesMetricUntouched 全部对账通过时不应递增。
+func TestWalletReconcile_NoDriftLeavesMetricUntouched(t *testing.T) {
+ resetWalletMetricsForTest()
+
+ repo := &stubReconcileRepo{drifts: nil}
+ svc := NewWalletReconcileService(repo, 0, 0.01)
+ svc.runOnce()
+
+ snap := SnapshotWalletMetrics()
+ require.Equal(t, int64(0), snap.LedgerDriftTotal)
+}
+
+// TestWalletReconcile_RepoErrorIsLoggedNotPanic repo 报错时静默继续,不影响
+// 后续 tick;也不增加 drift 计数(错误 ≠ 漂移)。
+func TestWalletReconcile_RepoErrorIsLoggedNotPanic(t *testing.T) {
+ resetWalletMetricsForTest()
+
+ repo := &stubReconcileRepo{err: errors.New("db down")}
+ svc := NewWalletReconcileService(repo, 0, 0.01)
+ require.NotPanics(t, svc.runOnce)
+ require.Equal(t, int64(0), SnapshotWalletMetrics().LedgerDriftTotal)
+}
+
+// TestSnapshotWalletMetrics_HandlerInsufficientCounter 公开的
+// IncWalletInsufficientTotal 必须直接反映在 snapshot.InsufficientTotal。
+func TestSnapshotWalletMetrics_HandlerInsufficientCounter(t *testing.T) {
+ resetWalletMetricsForTest()
+
+ IncWalletInsufficientTotal()
+ IncWalletInsufficientTotal()
+
+ require.Equal(t, int64(2), SnapshotWalletMetrics().InsufficientTotal)
+}
+
+// TestCheckWalletEligibility_LowBalanceIncrementsMetric 余额 > 0 但 ≤ 阈值
+// 应通过预检(不返错误)但 +1 walletBalanceLowTotal,让前端弹「即将耗尽」横幅。
+func TestCheckWalletEligibility_LowBalanceIncrementsMetric(t *testing.T) {
+ resetWalletMetricsForTest()
+
+ low := walletBalanceLowThresholdUSD - 0.01
+ high := walletBalanceLowThresholdUSD + 100.0
+
+ s := &BillingCacheService{}
+ require.NoError(t, s.checkWalletEligibility(&UserSubscription{ID: 1, WalletBalanceUSD: &high}))
+ require.Equal(t, int64(0), SnapshotWalletMetrics().BalanceLowTotal,
+ "high balance must not trigger low-balance counter")
+
+ require.NoError(t, s.checkWalletEligibility(&UserSubscription{ID: 1, WalletBalanceUSD: &low}))
+ require.Equal(t, int64(1), SnapshotWalletMetrics().BalanceLowTotal,
+ "low balance must trigger low-balance counter")
+}
diff --git a/backend/internal/service/wallet_revoke_migration_186_contract_test.go b/backend/internal/service/wallet_revoke_migration_186_contract_test.go
new file mode 100644
index 00000000000..6ffc5ceb3ee
--- /dev/null
+++ b/backend/internal/service/wallet_revoke_migration_186_contract_test.go
@@ -0,0 +1,27 @@
+package service
+
+import (
+ "testing"
+
+ dbmigrations "github.com/Wei-Shaw/sub2api/migrations"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWalletRevokeMigration186BlocksOpenUsageBillingHold(t *testing.T) {
+ raw, err := dbmigrations.FS.ReadFile("186_wallet_open_admission_revoke_guard.sql")
+ require.NoError(t, err)
+ sql := string(raw)
+
+ for _, required := range []string{
+ "BEFORE UPDATE OF deleted_at ON user_subscriptions",
+ "OLD.wallet_balance_usd IS NOT NULL",
+ "NEW.deleted_at IS NOT NULL",
+ "FROM usage_billing_admissions",
+ "wallet_subscription_id = OLD.id",
+ "wallet_consumed_at IS NULL",
+ "wallet_released_at IS NULL",
+ "CONSTRAINT = 'hfc_wallet_open_admission_revoke'",
+ } {
+ require.Contains(t, sql, required)
+ }
+}
diff --git a/backend/internal/service/wallet_routing_policy.go b/backend/internal/service/wallet_routing_policy.go
new file mode 100644
index 00000000000..f7b4ae39f71
--- /dev/null
+++ b/backend/internal/service/wallet_routing_policy.go
@@ -0,0 +1,26 @@
+package service
+
+const (
+ WalletDefaultOpenAIGroupName = "openai-default"
+ WalletDefaultVIPGroupName = "vip"
+)
+
+// CanUseWalletGroup applies the HFC credits-wallet authorization boundary.
+// Credits are public only through openai-default. Other standard groups must
+// be exclusive and explicitly granted to the user by an operator.
+func CanUseWalletGroup(user *User, group *Group) bool {
+ if user == nil || group == nil {
+ return false
+ }
+ if err := validateReservedBusinessGroupState(group, true); err != nil {
+ return false
+ }
+ switch group.Name {
+ case WalletDefaultOpenAIGroupName:
+ return true
+ case WalletDefaultVIPGroupName:
+ return user.CanBindGroup(group.ID, true)
+ default:
+ return false
+ }
+}
diff --git a/backend/internal/service/wallet_routing_policy_test.go b/backend/internal/service/wallet_routing_policy_test.go
new file mode 100644
index 00000000000..fd011f60352
--- /dev/null
+++ b/backend/internal/service/wallet_routing_policy_test.go
@@ -0,0 +1,100 @@
+package service
+
+import "testing"
+
+func TestCanUseWalletGroupRequiresExactHFCGroupIdentity(t *testing.T) {
+ fallbackID := int64(99)
+ tests := []struct {
+ name string
+ user *User
+ group *Group
+ want bool
+ }{
+ {
+ name: "openai default",
+ user: &User{ID: 1},
+ group: &Group{ID: 3, Name: WalletDefaultOpenAIGroupName, Platform: PlatformOpenAI, Status: StatusActive,
+ Hydrated: true, SubscriptionType: SubscriptionTypeStandard},
+ want: true,
+ },
+ {
+ name: "openai name on wrong platform",
+ user: &User{ID: 1},
+ group: &Group{ID: 30, Name: WalletDefaultOpenAIGroupName, Platform: PlatformAnthropic, Status: StatusActive,
+ Hydrated: true, SubscriptionType: SubscriptionTypeStandard},
+ want: false,
+ },
+ {
+ name: "exclusive openai default fails closed",
+ user: &User{ID: 1},
+ group: &Group{ID: 31, Name: WalletDefaultOpenAIGroupName, Platform: PlatformOpenAI, Status: StatusActive,
+ Hydrated: true, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard},
+ want: false,
+ },
+ {
+ name: "openai default with fallback fails closed",
+ user: &User{ID: 1},
+ group: &Group{ID: 32, Name: WalletDefaultOpenAIGroupName, Platform: PlatformOpenAI, Status: StatusActive,
+ Hydrated: true, SubscriptionType: SubscriptionTypeStandard, FallbackGroupID: &fallbackID},
+ want: false,
+ },
+ {
+ name: "exact vip with explicit grant",
+ user: &User{ID: 1, AllowedGroups: []int64{22}},
+ group: &Group{ID: 22, Name: WalletDefaultVIPGroupName, Platform: PlatformAnthropic, Status: StatusActive,
+ Hydrated: true, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard},
+ want: true,
+ },
+ {
+ name: "vip without grant",
+ user: &User{ID: 1},
+ group: &Group{ID: 22, Name: WalletDefaultVIPGroupName, Platform: PlatformAnthropic, Status: StatusActive,
+ Hydrated: true, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard},
+ want: false,
+ },
+ {
+ name: "vip with invalid request fallback fails closed",
+ user: &User{ID: 1, AllowedGroups: []int64{224}},
+ group: &Group{ID: 224, Name: WalletDefaultVIPGroupName, Platform: PlatformAnthropic, Status: StatusActive,
+ Hydrated: true, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard,
+ FallbackGroupIDOnInvalidRequest: &fallbackID},
+ want: false,
+ },
+ {
+ name: "case variant is not the dedicated vip group",
+ user: &User{ID: 1, AllowedGroups: []int64{220}},
+ group: &Group{ID: 220, Name: "VIP", Platform: PlatformAnthropic, Status: StatusActive,
+ Hydrated: true, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard},
+ want: false,
+ },
+ {
+ name: "whitespace variant is not openai default",
+ user: &User{ID: 1},
+ group: &Group{ID: 222, Name: " openai-default ", Platform: PlatformOpenAI, Status: StatusActive,
+ Hydrated: true, SubscriptionType: SubscriptionTypeStandard},
+ want: false,
+ },
+ {
+ name: "whitespace variant is not vip",
+ user: &User{ID: 1, AllowedGroups: []int64{223}},
+ group: &Group{ID: 223, Name: " vip ", Platform: PlatformAnthropic, Status: StatusActive,
+ Hydrated: true, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard},
+ want: false,
+ },
+ {
+ name: "vip on wrong platform",
+ user: &User{ID: 1, AllowedGroups: []int64{221}},
+ group: &Group{ID: 221, Name: WalletDefaultVIPGroupName, Platform: PlatformOpenAI, Status: StatusActive,
+ Hydrated: true, IsExclusive: true, SubscriptionType: SubscriptionTypeStandard},
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := CanUseWalletGroup(tt.user, tt.group); got != tt.want {
+ t.Fatalf("CanUseWalletGroup() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/backend/internal/service/wallet_service.go b/backend/internal/service/wallet_service.go
new file mode 100644
index 00000000000..3a363313caf
--- /dev/null
+++ b/backend/internal/service/wallet_service.go
@@ -0,0 +1,119 @@
+package service
+
+import (
+ "context"
+ "strings"
+)
+
+// WalletService 钱包模式 (v4) 服务。
+// 薄封装:业务参数校验 + 调 WalletRepository。
+//
+// 职责边界:
+// - 不负责激活流程(在 SubscriptionActivationService 内调 Adjust(reason=activation))
+// - 不负责调 BillingService 算 actualCost(gateway 算好后直接传 CostUSD)
+// - 不负责返 HTTP 402(gateway 中间件根据 ErrWalletInsufficient 映射)
+type WalletService struct {
+ repo WalletRepository
+}
+
+func NewWalletService(repo WalletRepository) *WalletService {
+ return &WalletService{repo: repo}
+}
+
+// Deduct 钱包扣款。CostUSD 必须 > 0;余额不足返 ErrWalletInsufficient。
+func (s *WalletService) Deduct(ctx context.Context, subscriptionID int64, costUSD float64, usageLogID *int64) (WalletLedgerEntry, error) {
+ if costUSD <= 0 {
+ return WalletLedgerEntry{}, ErrWalletNegativeDelta
+ }
+ return s.repo.Deduct(ctx, WalletDeductCommand{
+ SubscriptionID: subscriptionID,
+ CostUSD: costUSD,
+ UsageLogID: usageLogID,
+ })
+}
+
+// Activate 激活时写一条 reason=activation 的流水。
+// 假设 user_subscriptions 行已经先一步在事务里建好且 wallet_balance_usd = initialUSD。
+// 本方法仅追加 ledger 记录,不再动余额(balance_after = initialUSD)。
+func (s *WalletService) Activate(ctx context.Context, subscriptionID int64, initialUSD float64, operatorID *int64, notes string) (WalletLedgerEntry, error) {
+ if initialUSD <= 0 {
+ return WalletLedgerEntry{}, ErrWalletNegativeDelta
+ }
+ return s.repo.RecordActivation(ctx, WalletActivationCommand{
+ SubscriptionID: subscriptionID,
+ InitialUSD: initialUSD,
+ OperatorID: operatorID,
+ Notes: strings.TrimSpace(notes),
+ })
+}
+
+func (s *WalletService) ActivateFromPayment(ctx context.Context, subscriptionID int64, initialUSD float64, paymentOrderID int64, notes string) (WalletLedgerEntry, error) {
+ if initialUSD <= 0 || paymentOrderID <= 0 {
+ return WalletLedgerEntry{}, ErrWalletNegativeDelta
+ }
+ return s.repo.RecordActivation(ctx, WalletActivationCommand{
+ SubscriptionID: subscriptionID,
+ InitialUSD: initialUSD,
+ PaymentOrderID: &paymentOrderID,
+ Notes: strings.TrimSpace(notes),
+ })
+}
+
+// Adjust 管理员调整。DeltaUSD ≠ 0;Reason 必须合法。
+func (s *WalletService) Adjust(ctx context.Context, cmd WalletAdjustCommand) (WalletLedgerEntry, error) {
+ if cmd.DeltaUSD == 0 {
+ return WalletLedgerEntry{}, ErrWalletNegativeDelta
+ }
+ if !isValidWalletLedgerReason(cmd.Reason) {
+ return WalletLedgerEntry{}, ErrWalletNegativeDelta
+ }
+ cmd.Notes = strings.TrimSpace(cmd.Notes)
+ return s.repo.Adjust(ctx, cmd)
+}
+
+// Topup 额度卡叠加充值 (B2.4)。同时 +balance 和 +initial,写一条 reason='topup' 流水。
+// DeltaUSD 必须 > 0;非钱包模式订阅返 ErrWalletNotFound。
+func (s *WalletService) Topup(ctx context.Context, subscriptionID int64, deltaUSD float64, operatorID *int64, notes string) (WalletLedgerEntry, error) {
+ if deltaUSD <= 0 {
+ return WalletLedgerEntry{}, ErrWalletNegativeDelta
+ }
+ return s.repo.Topup(ctx, WalletTopupCommand{
+ SubscriptionID: subscriptionID,
+ DeltaUSD: deltaUSD,
+ OperatorID: operatorID,
+ Notes: strings.TrimSpace(notes),
+ })
+}
+
+func (s *WalletService) TopupFromPayment(ctx context.Context, subscriptionID int64, deltaUSD float64, paymentOrderID int64, notes string) (WalletLedgerEntry, error) {
+ if deltaUSD <= 0 || paymentOrderID <= 0 {
+ return WalletLedgerEntry{}, ErrWalletNegativeDelta
+ }
+ return s.repo.Topup(ctx, WalletTopupCommand{
+ SubscriptionID: subscriptionID,
+ DeltaUSD: deltaUSD,
+ PaymentOrderID: &paymentOrderID,
+ Notes: strings.TrimSpace(notes),
+ })
+}
+
+// ListLedger 查最近 limit 条流水。limit ≤ 0 默认 50;> 500 截断到 500。
+func (s *WalletService) ListLedger(ctx context.Context, subscriptionID int64, limit int) ([]WalletLedgerEntry, error) {
+ if limit <= 0 {
+ limit = 50
+ }
+ if limit > 500 {
+ limit = 500
+ }
+ return s.repo.ListLedger(ctx, subscriptionID, limit)
+}
+
+func isValidWalletLedgerReason(reason string) bool {
+ switch reason {
+ case WalletLedgerReasonRefund,
+ WalletLedgerReasonAdjustment,
+ WalletLedgerReasonExpiration:
+ return true
+ }
+ return false
+}
diff --git a/backend/internal/service/wallet_service_test.go b/backend/internal/service/wallet_service_test.go
new file mode 100644
index 00000000000..d65a593ea04
--- /dev/null
+++ b/backend/internal/service/wallet_service_test.go
@@ -0,0 +1,196 @@
+package service
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+type fakeWalletRepo struct {
+ deductCalls []WalletDeductCommand
+ activationCalls []WalletActivationCommand
+ adjustCalls []WalletAdjustCommand
+ topupCalls []WalletTopupCommand
+ deductResult WalletLedgerEntry
+ deductErr error
+ adjustResult WalletLedgerEntry
+ adjustErr error
+ topupResult WalletLedgerEntry
+ topupErr error
+}
+
+func (f *fakeWalletRepo) RecordActivation(_ context.Context, cmd WalletActivationCommand) (WalletLedgerEntry, error) {
+ f.activationCalls = append(f.activationCalls, cmd)
+ return f.adjustResult, f.adjustErr
+}
+
+func (f *fakeWalletRepo) Deduct(_ context.Context, cmd WalletDeductCommand) (WalletLedgerEntry, error) {
+ f.deductCalls = append(f.deductCalls, cmd)
+ return f.deductResult, f.deductErr
+}
+
+func (f *fakeWalletRepo) Adjust(_ context.Context, cmd WalletAdjustCommand) (WalletLedgerEntry, error) {
+ f.adjustCalls = append(f.adjustCalls, cmd)
+ return f.adjustResult, f.adjustErr
+}
+
+func (f *fakeWalletRepo) Topup(_ context.Context, cmd WalletTopupCommand) (WalletLedgerEntry, error) {
+ f.topupCalls = append(f.topupCalls, cmd)
+ return f.topupResult, f.topupErr
+}
+
+func (f *fakeWalletRepo) ListLedger(_ context.Context, _ int64, _ int) ([]WalletLedgerEntry, error) {
+ return nil, nil
+}
+
+func (f *fakeWalletRepo) ReconcileBalances(_ context.Context, _ float64) ([]WalletReconcileDrift, error) {
+ return nil, nil
+}
+
+func TestWalletService_Deduct_RejectsNonPositive(t *testing.T) {
+ repo := &fakeWalletRepo{}
+ svc := NewWalletService(repo)
+
+ _, err := svc.Deduct(context.Background(), 1, 0, nil)
+ require.ErrorIs(t, err, ErrWalletNegativeDelta)
+
+ _, err = svc.Deduct(context.Background(), 1, -1, nil)
+ require.ErrorIs(t, err, ErrWalletNegativeDelta)
+
+ require.Empty(t, repo.deductCalls, "non-positive deduct must short-circuit")
+}
+
+func TestWalletService_Deduct_PassesThroughInsufficient(t *testing.T) {
+ repo := &fakeWalletRepo{deductErr: ErrWalletInsufficient}
+ svc := NewWalletService(repo)
+
+ _, err := svc.Deduct(context.Background(), 1, 5, nil)
+ require.ErrorIs(t, err, ErrWalletInsufficient)
+ require.Len(t, repo.deductCalls, 1)
+}
+
+func TestWalletService_Deduct_ForwardsUsageLogID(t *testing.T) {
+ repo := &fakeWalletRepo{deductResult: WalletLedgerEntry{ID: 7}}
+ svc := NewWalletService(repo)
+
+ logID := int64(123)
+ entry, err := svc.Deduct(context.Background(), 1, 5, &logID)
+ require.NoError(t, err)
+ require.Equal(t, int64(7), entry.ID)
+ require.Len(t, repo.deductCalls, 1)
+ require.NotNil(t, repo.deductCalls[0].UsageLogID)
+ require.Equal(t, int64(123), *repo.deductCalls[0].UsageLogID)
+}
+
+func TestWalletService_Activate_WritesActivationReason(t *testing.T) {
+ repo := &fakeWalletRepo{adjustResult: WalletLedgerEntry{ID: 11}}
+ svc := NewWalletService(repo)
+
+ op := int64(99)
+ entry, err := svc.Activate(context.Background(), 1, 1500, &op, " initial recharge ")
+ require.NoError(t, err)
+ require.Equal(t, int64(11), entry.ID)
+ require.Empty(t, repo.adjustCalls, "activation must not add the initial balance a second time")
+ require.Len(t, repo.activationCalls, 1)
+ got := repo.activationCalls[0]
+ require.Equal(t, 1500.0, got.InitialUSD)
+ require.Equal(t, "initial recharge", got.Notes, "notes must be trimmed")
+}
+
+func TestWalletService_Activate_RejectsNonPositive(t *testing.T) {
+ repo := &fakeWalletRepo{}
+ svc := NewWalletService(repo)
+ _, err := svc.Activate(context.Background(), 1, 0, nil, "")
+ require.ErrorIs(t, err, ErrWalletNegativeDelta)
+ require.Empty(t, repo.activationCalls)
+}
+
+func TestWalletService_Adjust_RejectsInvalidReason(t *testing.T) {
+ repo := &fakeWalletRepo{}
+ svc := NewWalletService(repo)
+ _, err := svc.Adjust(context.Background(), WalletAdjustCommand{
+ SubscriptionID: 1,
+ DeltaUSD: 5,
+ Reason: "bogus",
+ })
+ require.ErrorIs(t, err, ErrWalletNegativeDelta)
+ require.Empty(t, repo.adjustCalls)
+}
+
+func TestWalletService_Adjust_RejectsLifecycleOwnedReasons(t *testing.T) {
+ for _, reason := range []string{
+ WalletLedgerReasonActivation,
+ WalletLedgerReasonUsage,
+ WalletLedgerReasonTopup,
+ } {
+ t.Run(reason, func(t *testing.T) {
+ repo := &fakeWalletRepo{}
+ svc := NewWalletService(repo)
+ _, err := svc.Adjust(context.Background(), WalletAdjustCommand{
+ SubscriptionID: 1,
+ DeltaUSD: 5,
+ Reason: reason,
+ })
+ require.ErrorIs(t, err, ErrWalletNegativeDelta)
+ require.Empty(t, repo.adjustCalls)
+ })
+ }
+}
+
+func TestWalletService_Adjust_RejectsZeroDelta(t *testing.T) {
+ repo := &fakeWalletRepo{}
+ svc := NewWalletService(repo)
+ _, err := svc.Adjust(context.Background(), WalletAdjustCommand{
+ SubscriptionID: 1,
+ DeltaUSD: 0,
+ Reason: WalletLedgerReasonAdjustment,
+ })
+ require.ErrorIs(t, err, ErrWalletNegativeDelta)
+ require.Empty(t, repo.adjustCalls)
+}
+
+func TestWalletService_Adjust_PassesThroughRepoError(t *testing.T) {
+ stub := errors.New("db down")
+ repo := &fakeWalletRepo{adjustErr: stub}
+ svc := NewWalletService(repo)
+ _, err := svc.Adjust(context.Background(), WalletAdjustCommand{
+ SubscriptionID: 1,
+ DeltaUSD: 5,
+ Reason: WalletLedgerReasonAdjustment,
+ })
+ require.ErrorIs(t, err, stub)
+}
+
+// TestWalletService_Topup_RejectsNonPositive 验证 B2.4 Topup 入参兜底。
+func TestWalletService_Topup_RejectsNonPositive(t *testing.T) {
+ repo := &fakeWalletRepo{}
+ svc := NewWalletService(repo)
+
+ for _, bad := range []float64{0, -1, -500} {
+ _, err := svc.Topup(context.Background(), 1, bad, nil, "")
+ require.ErrorIs(t, err, ErrWalletNegativeDelta, "delta=%v 应被拒", bad)
+ }
+ require.Empty(t, repo.topupCalls, "non-positive topup must short-circuit")
+}
+
+// TestWalletService_Topup_WritesTopupReason 验证 Topup 走 repo 并 trim notes。
+func TestWalletService_Topup_WritesTopupReason(t *testing.T) {
+ repo := &fakeWalletRepo{topupResult: WalletLedgerEntry{ID: 42, BalanceAfter: 2000}}
+ svc := NewWalletService(repo)
+
+ op := int64(7)
+ entry, err := svc.Topup(context.Background(), 99, 500, &op, " credits-500 ¥149 ")
+ require.NoError(t, err)
+ require.Equal(t, int64(42), entry.ID)
+ require.Equal(t, 2000.0, entry.BalanceAfter)
+ require.Len(t, repo.topupCalls, 1)
+
+ got := repo.topupCalls[0]
+ require.Equal(t, int64(99), got.SubscriptionID)
+ require.Equal(t, 500.0, got.DeltaUSD)
+ require.Equal(t, "credits-500 ¥149", got.Notes, "notes 必须被 trim")
+ require.NotNil(t, got.OperatorID)
+ require.Equal(t, int64(7), *got.OperatorID)
+}
diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go
index 8b50e478382..5815a1964e9 100644
--- a/backend/internal/service/wire.go
+++ b/backend/internal/service/wire.go
@@ -156,6 +156,14 @@ func ProvideSubscriptionExpiryService(userSubRepo UserSubscriptionRepository) *S
return svc
}
+// ProvideWalletReconcileService creates and starts WalletReconcileService.
+// 间隔 5 分钟、tolerance $0.01 — 与 design 文档一致。
+func ProvideWalletReconcileService(walletRepo WalletRepository) *WalletReconcileService {
+ svc := NewWalletReconcileService(walletRepo, 5*time.Minute, 0.01)
+ svc.Start()
+ return svc
+}
+
// ProvideTimingWheelService creates and starts TimingWheelService
func ProvideTimingWheelService() (*TimingWheelService, error) {
svc, err := NewTimingWheelService()
@@ -173,6 +181,40 @@ func ProvideDeferredService(accountRepo AccountRepository, timingWheel *TimingWh
return svc
}
+func ProvideUsageBillingReplayCacheInvalidator(service *BillingCacheService) UsageBillingReplayCacheInvalidator {
+ return service
+}
+
+func ProvideUsageBillingReplayAccountToucher(service *DeferredService) UsageBillingReplayAccountToucher {
+ return service
+}
+
+func ProvideUsageBillingReplayAuthCacheInvalidator(service *APIKeyService) UsageBillingReplayAuthCacheInvalidator {
+ return service
+}
+
+func ProvideUsageBillingOutboxProcessor(
+ outboxRepo UsageBillingOutboxRepository,
+ bindingValidator UsageBillingBindingValidator,
+ billingRepo UsageBillingRepository,
+ replayWriter UsageBillingReplayWriter,
+ replayFinalizer UsageBillingReplayFinalizer,
+) *UsageBillingOutboxProcessor {
+ return NewUsageBillingOutboxProcessor(outboxRepo, bindingValidator, billingRepo, replayWriter, replayFinalizer)
+}
+
+func ProvideUsageBillingOutboxWorker(processor *UsageBillingOutboxProcessor) (*UsageBillingOutboxWorker, error) {
+ worker := NewUsageBillingOutboxWorker(processor)
+ if err := worker.Start(); err != nil {
+ return nil, err
+ }
+ return worker, nil
+}
+
+func ProvideModelRouterGroupRepository(groupRepo GroupRepository) ModelRouterGroupRepository {
+ return groupRepo
+}
+
// ProvideConcurrencyService creates ConcurrencyService and starts slot cleanup worker.
func ProvideConcurrencyService(cache ConcurrencyCache, accountRepo AccountRepository, cfg *config.Config) *ConcurrencyService {
svc := NewConcurrencyService(cache)
@@ -271,15 +313,22 @@ func ProvideOpsAlertEvaluatorService(
// ProvideOpsCleanupService creates and starts OpsCleanupService (cron scheduled).
// channelMonitorSvc 让维护任务(聚合 + 历史/聚合软删)跟随 ops 清理 cron 一起跑,
// 共享 leader lock + heartbeat。
+// settingRepo 让 cleanup service 自己读 ops_advanced_settings.data_retention 覆盖 cfg;
+// opsService 用来反向注入 cleanup hook,以便 UI 改清理设置时能 Reload cron。
func ProvideOpsCleanupService(
opsRepo OpsRepository,
db *sql.DB,
redisClient *redis.Client,
cfg *config.Config,
channelMonitorSvc *ChannelMonitorService,
+ settingRepo SettingRepository,
+ opsService *OpsService,
) *OpsCleanupService {
- svc := NewOpsCleanupService(opsRepo, db, redisClient, cfg, channelMonitorSvc)
+ svc := NewOpsCleanupService(opsRepo, db, redisClient, cfg, channelMonitorSvc, settingRepo)
svc.Start()
+ if opsService != nil {
+ opsService.SetCleanupReloader(svc)
+ }
return svc
}
@@ -420,10 +469,61 @@ func ProvideAPIKeyService(
return svc
}
+func ProvideAuthService(
+ entClient *dbent.Client,
+ userRepo UserRepository,
+ redeemRepo RedeemCodeRepository,
+ refreshTokenCache RefreshTokenCache,
+ cfg *config.Config,
+ settingService *SettingService,
+ emailService *EmailService,
+ turnstileService *TurnstileService,
+ emailQueueService *EmailQueueService,
+ promoService *PromoService,
+ defaultSubAssigner DefaultSubscriptionAssigner,
+ affiliateService *AffiliateService,
+ abuseRiskService *HFCAbuseRiskService,
+) *AuthService {
+ svc := NewAuthService(entClient, userRepo, redeemRepo, refreshTokenCache, cfg, settingService, emailService, turnstileService, emailQueueService, promoService, defaultSubAssigner, affiliateService)
+ svc.SetHFCAbuseRiskRecorder(abuseRiskService)
+ return svc
+}
+
+func ProvideContentModerationService(
+ settingRepo SettingRepository,
+ repo ContentModerationRepository,
+ hashCache ContentModerationHashCache,
+ groupRepo GroupRepository,
+ userRepo UserRepository,
+ authCacheInvalidator APIKeyAuthCacheInvalidator,
+ emailService *EmailService,
+ secretEncryptor SecretEncryptor,
+ abuseRiskService *HFCAbuseRiskService,
+) *ContentModerationService {
+ svc := NewContentModerationServiceWithEncryptor(settingRepo, repo, hashCache, groupRepo, userRepo, authCacheInvalidator, emailService, secretEncryptor)
+ svc.SetHFCAbuseRiskRecorder(abuseRiskService)
+ return svc
+}
+
+func ProvideSubscriptionService(
+ groupRepo GroupRepository,
+ userSubRepo UserSubscriptionRepository,
+ billingCacheService *BillingCacheService,
+ entClient *dbent.Client,
+ cfg *config.Config,
+ apiKeyService *APIKeyService,
+ walletService *WalletService,
+) *SubscriptionService {
+ svc := NewSubscriptionService(groupRepo, userSubRepo, billingCacheService, entClient, cfg)
+ svc.SetWalletGroupKeyService(apiKeyService)
+ svc.SetWalletTopupService(walletService)
+ return svc
+}
+
// ProviderSet is the Wire provider set for all services
var ProviderSet = wire.NewSet(
// Core services
- NewAuthService,
+ ProvideAuthService,
NewUserService,
ProvideAPIKeyService,
ProvideAPIKeyAuthCacheInvalidator,
@@ -434,9 +534,21 @@ var ProviderSet = wire.NewSet(
NewPromoService,
NewUsageService,
NewDashboardService,
+ ProvideModelRouterGroupRepository,
+ NewModelRouterService,
+ wire.Bind(new(ModelRouter), new(*ModelRouterService)),
+ wire.Bind(new(ModelRouteProvider), new(*ModelRouterService)),
ProvidePricingService,
NewBillingService,
ProvideBillingCacheService,
+ NewUsageBillingReplayWriter,
+ ProvideUsageBillingReplayCacheInvalidator,
+ ProvideUsageBillingReplayAccountToucher,
+ ProvideUsageBillingReplayAuthCacheInvalidator,
+ NewUsageBillingReplayFinalizer,
+ ProvideUsageBillingOutboxProcessor,
+ ProvideUsageBillingOutboxWorker,
+ NewUsageBillingReconciliationService,
NewAnnouncementService,
NewAdminService,
NewGatewayService,
@@ -471,18 +583,19 @@ var ProviderSet = wire.NewSet(
NewEmailService,
ProvideEmailQueueService,
NewTurnstileService,
- NewSubscriptionService,
+ ProvideSubscriptionService,
+ NewWalletService,
wire.Bind(new(DefaultSubscriptionAssigner), new(*SubscriptionService)),
ProvideConcurrencyService,
ProvideUserMessageQueueService,
NewUsageRecordWorkerPool,
ProvideSchedulerSnapshotService,
- NewIdentityService,
NewCRSSyncService,
ProvideUpdateService,
ProvideTokenRefreshService,
ProvideAccountExpiryService,
ProvideSubscriptionExpiryService,
+ ProvideWalletReconcileService,
ProvideTimingWheelService,
ProvideDashboardAggregationService,
ProvideUsageCleanupService,
@@ -502,6 +615,8 @@ var ProviderSet = wire.NewSet(
NewGroupCapacityService,
NewChannelService,
NewModelPricingResolver,
+ NewHFCAbuseRiskService,
+ ProvideContentModerationService,
NewAffiliateService,
ProvidePaymentConfigService,
NewPaymentService,
diff --git a/backend/internal/setup/handler.go b/backend/internal/setup/handler.go
index c2944cedfb3..3178f925683 100644
--- a/backend/internal/setup/handler.go
+++ b/backend/internal/setup/handler.go
@@ -1,20 +1,27 @@
package setup
import (
+ "errors"
"fmt"
+ "mime"
+ "net"
"net/http"
"net/mail"
+ "net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
- "github.com/Wei-Shaw/sub2api/internal/pkg/sysutil"
-
"github.com/gin-gonic/gin"
)
+const (
+ setupRequestBodyMaxBytes = 64 * 1024
+ setupRequestsPerMinute = 30
+)
+
// installMutex prevents concurrent installation attempts (TOCTOU protection)
var installMutex sync.Mutex
@@ -27,7 +34,12 @@ func RegisterRoutes(r *gin.Engine) {
// All modification endpoints are protected by setupGuard
protected := setup.Group("")
- protected.Use(setupGuard())
+ protected.Use(
+ setupGuard(),
+ setupRequestSecurity(),
+ setupRequestBodyLimit(setupRequestBodyMaxBytes),
+ newSetupRateLimiter(setupRequestsPerMinute, time.Minute).middleware(),
+ )
{
protected.POST("/test-db", testDatabase)
protected.POST("/test-redis", testRedis)
@@ -36,6 +48,121 @@ func RegisterRoutes(r *gin.Engine) {
}
}
+func setupRequestSecurity() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if !isSetupLoopbackHost(c.Request.Host) {
+ response.Error(c, http.StatusForbidden, "Setup Host must be loopback")
+ c.Abort()
+ return
+ }
+ mediaType, _, err := mime.ParseMediaType(c.GetHeader("Content-Type"))
+ if err != nil || !strings.EqualFold(mediaType, "application/json") {
+ response.Error(c, http.StatusUnsupportedMediaType, "Setup requests require application/json")
+ c.Abort()
+ return
+ }
+ if origin := strings.TrimSpace(c.GetHeader("Origin")); origin != "" && !isSameSetupOrigin(origin, c.Request.Host) {
+ response.Error(c, http.StatusForbidden, "Cross-origin setup request rejected")
+ c.Abort()
+ return
+ }
+ if c.GetHeader("Origin") == "" {
+ if referer := strings.TrimSpace(c.GetHeader("Referer")); referer != "" && !isSameSetupOrigin(referer, c.Request.Host) {
+ response.Error(c, http.StatusForbidden, "Cross-origin setup request rejected")
+ c.Abort()
+ return
+ }
+ }
+ c.Next()
+ }
+}
+
+func isSetupLoopbackHost(hostPort string) bool {
+ hostPort = strings.TrimSpace(hostPort)
+ if hostPort == "" {
+ return false
+ }
+ host := hostPort
+ if parsedHost, _, err := net.SplitHostPort(hostPort); err == nil {
+ host = parsedHost
+ }
+ host = strings.Trim(strings.TrimSpace(host), "[]")
+ if strings.EqualFold(host, "localhost") {
+ return true
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
+}
+
+func isSameSetupOrigin(raw, requestHost string) bool {
+ parsed, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil || parsed.User != nil || parsed.Host == "" {
+ return false
+ }
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return false
+ }
+ return strings.EqualFold(parsed.Host, strings.TrimSpace(requestHost)) && isSetupLoopbackHost(parsed.Host)
+}
+
+type setupRateLimitEntry struct {
+ windowStart time.Time
+ count int
+}
+
+type setupRateLimiter struct {
+ mu sync.Mutex
+ entries map[string]setupRateLimitEntry
+ limit int
+ window time.Duration
+}
+
+func newSetupRateLimiter(limit int, window time.Duration) *setupRateLimiter {
+ return &setupRateLimiter{entries: make(map[string]setupRateLimitEntry), limit: limit, window: window}
+}
+
+func (l *setupRateLimiter) middleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ now := time.Now()
+ key := c.ClientIP()
+ l.mu.Lock()
+ entry := l.entries[key]
+ if entry.windowStart.IsZero() || now.Sub(entry.windowStart) >= l.window {
+ entry = setupRateLimitEntry{windowStart: now}
+ }
+ if entry.count >= l.limit {
+ l.mu.Unlock()
+ response.Error(c, http.StatusTooManyRequests, "Too many setup requests")
+ c.Abort()
+ return
+ }
+ entry.count++
+ l.entries[key] = entry
+ l.mu.Unlock()
+ c.Next()
+ }
+}
+
+func setupRequestBodyLimit(maxBytes int64) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
+ c.Next()
+ }
+}
+
+func bindSetupJSON(c *gin.Context, dst any) bool {
+ if err := c.ShouldBindJSON(dst); err != nil {
+ var maxBytesErr *http.MaxBytesError
+ if errors.As(err, &maxBytesErr) {
+ response.Error(c, http.StatusRequestEntityTooLarge, "Setup request body is too large")
+ return false
+ }
+ response.Error(c, http.StatusBadRequest, "Invalid request: "+err.Error())
+ return false
+ }
+ return true
+}
+
// SetupStatus represents the current setup state
type SetupStatus struct {
NeedsSetup bool `json:"needs_setup"`
@@ -126,8 +253,7 @@ type TestDatabaseRequest struct {
// testDatabase tests database connection
func testDatabase(c *gin.Context) {
var req TestDatabaseRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.Error(c, http.StatusBadRequest, "Invalid request: "+err.Error())
+ if !bindSetupJSON(c, &req) {
return
}
@@ -186,8 +312,7 @@ type TestRedisRequest struct {
// testRedis tests Redis connection
func testRedis(c *gin.Context) {
var req TestRedisRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.Error(c, http.StatusBadRequest, "Invalid request: "+err.Error())
+ if !bindSetupJSON(c, &req) {
return
}
@@ -242,8 +367,7 @@ func install(c *gin.Context) {
}
var req InstallRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.Error(c, http.StatusBadRequest, "Invalid request: "+err.Error())
+ if !bindSetupJSON(c, &req) {
return
}
@@ -340,16 +464,8 @@ func install(c *gin.Context) {
return
}
- // Schedule service restart in background after sending response
- // This ensures the client receives the success response before the service restarts
- go func() {
- // Wait a moment to ensure the response is sent
- time.Sleep(500 * time.Millisecond)
- sysutil.RestartServiceAsync()
- }()
-
response.Success(c, gin.H{
- "message": "Installation completed successfully. Service will restart automatically.",
- "restart": true,
+ "message": "Installation completed successfully. Restart the service manually when ready.",
+ "restart": false,
})
}
diff --git a/backend/internal/setup/handler_security_test.go b/backend/internal/setup/handler_security_test.go
new file mode 100644
index 00000000000..35fe8e3be52
--- /dev/null
+++ b/backend/internal/setup/handler_security_test.go
@@ -0,0 +1,80 @@
+package setup
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+)
+
+func TestSetupRoutesRejectOversizedBody(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ t.Setenv("DATA_DIR", t.TempDir())
+ router := gin.New()
+ RegisterRoutes(router)
+
+ body := `{"padding":"` + strings.Repeat("x", setupRequestBodyMaxBytes) + `"}`
+ req := httptest.NewRequest(http.MethodPost, "/setup/install", strings.NewReader(body))
+ req.Host = "127.0.0.1:8080"
+ req.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, req)
+
+ if recorder.Code != http.StatusRequestEntityTooLarge {
+ t.Fatalf("status=%d body=%s, want 413", recorder.Code, recorder.Body.String())
+ }
+}
+
+func TestSetupRoutesRejectCrossOriginPlainTextAndReboundHost(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ t.Setenv("DATA_DIR", t.TempDir())
+ router := gin.New()
+ RegisterRoutes(router)
+ body := `{"admin":{"email":"attacker@example.com","password":"attacker-password"}}`
+
+ tests := []struct {
+ name string
+ host string
+ contentType string
+ origin string
+ want int
+ }{
+ {name: "cross origin", host: "127.0.0.1:8080", contentType: "application/json", origin: "https://evil.example", want: http.StatusForbidden},
+ {name: "simple content type", host: "127.0.0.1:8080", contentType: "text/plain", origin: "http://127.0.0.1:8080", want: http.StatusUnsupportedMediaType},
+ {name: "dns rebinding host", host: "evil.example:8080", contentType: "application/json", origin: "http://evil.example:8080", want: http.StatusForbidden},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodPost, "/setup/install", strings.NewReader(body))
+ req.Host = tt.host
+ req.Header.Set("Content-Type", tt.contentType)
+ req.Header.Set("Origin", tt.origin)
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, req)
+ if recorder.Code != tt.want {
+ t.Fatalf("status=%d body=%s, want %d", recorder.Code, recorder.Body.String(), tt.want)
+ }
+ })
+ }
+}
+
+func TestSetupRateLimiterRejectsBurst(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ router.Use(newSetupRateLimiter(2, time.Minute).middleware())
+ router.POST("/setup", func(c *gin.Context) { c.Status(http.StatusNoContent) })
+
+ for i, want := range []int{http.StatusNoContent, http.StatusNoContent, http.StatusTooManyRequests} {
+ req := httptest.NewRequest(http.MethodPost, "/setup", nil)
+ req.RemoteAddr = "127.0.0.1:12345"
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, req)
+ if recorder.Code != want {
+ t.Fatalf("request %d status=%d, want %d", i+1, recorder.Code, want)
+ }
+ }
+}
diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go
index 9256d24575b..d201dd48601 100644
--- a/backend/internal/setup/setup.go
+++ b/backend/internal/setup/setup.go
@@ -385,14 +385,8 @@ func createAdminUser(cfg *SetupConfig) (bool, string, error) {
return false, decision.reason, nil
}
- if strings.TrimSpace(cfg.Admin.Password) == "" {
- password, genErr := generateSecret(16)
- if genErr != nil {
- return false, "", fmt.Errorf("failed to generate admin password: %w", genErr)
- }
- cfg.Admin.Password = password
- fmt.Printf("Generated admin password (one-time): %s\n", cfg.Admin.Password)
- fmt.Println("IMPORTANT: Save this password! It will not be shown again.")
+ if err := validatePassword(cfg.Admin.Password); err != nil {
+ return false, "", fmt.Errorf("invalid admin bootstrap password: %w", err)
}
admin := &service.User{
@@ -575,6 +569,9 @@ func AutoSetupFromEnv() error {
},
Timezone: tz,
}
+ if err := validatePassword(cfg.Admin.Password); err != nil {
+ return fmt.Errorf("ADMIN_PASSWORD is required for auto setup: %w", err)
+ }
// Generate JWT secret if not provided
if cfg.JWT.Secret == "" {
diff --git a/backend/internal/setup/setup_test.go b/backend/internal/setup/setup_test.go
index a01dd00c4d7..08c33e8c268 100644
--- a/backend/internal/setup/setup_test.go
+++ b/backend/internal/setup/setup_test.go
@@ -70,6 +70,19 @@ func TestSetupDefaultAdminConcurrency(t *testing.T) {
})
}
+func TestAutoSetupRejectsMissingOrWeakAdminPasswordBeforeConnections(t *testing.T) {
+ for _, password := range []string{"", "short"} {
+ t.Run(password, func(t *testing.T) {
+ t.Setenv("ADMIN_PASSWORD", password)
+ t.Setenv("DATA_DIR", t.TempDir())
+ err := AutoSetupFromEnv()
+ if err == nil || !strings.Contains(err.Error(), "ADMIN_PASSWORD is required") {
+ t.Fatalf("AutoSetupFromEnv() error = %v, want ADMIN_PASSWORD validation error", err)
+ }
+ })
+ }
+}
+
func TestWriteConfigFileKeepsDefaultUserConcurrency(t *testing.T) {
t.Setenv("RUN_MODE", "simple")
t.Setenv("DATA_DIR", t.TempDir())
diff --git a/backend/internal/util/logredact/redact.go b/backend/internal/util/logredact/redact.go
index 9249b761c79..4caef7a6981 100644
--- a/backend/internal/util/logredact/redact.go
+++ b/backend/internal/util/logredact/redact.go
@@ -2,6 +2,7 @@ package logredact
import (
"encoding/json"
+ "net/url"
"regexp"
"sort"
"strings"
@@ -19,7 +20,10 @@ var defaultSensitiveKeys = map[string]struct{}{
"refresh_token": {},
"id_token": {},
"client_secret": {},
+ "api_key": {},
+ "custom_key": {},
"password": {},
+ "verification": {},
}
var defaultSensitiveKeyList = []string{
@@ -30,7 +34,10 @@ var defaultSensitiveKeyList = []string{
"refresh_token",
"id_token",
"client_secret",
+ "api_key",
+ "custom_key",
"password",
+ "verification",
}
type textRedactPatterns struct {
@@ -42,6 +49,7 @@ type textRedactPatterns struct {
var (
reGOCSPX = regexp.MustCompile(`GOCSPX-[0-9A-Za-z_-]{24,}`)
reAIza = regexp.MustCompile(`AIza[0-9A-Za-z_-]{35}`)
+ reURL = regexp.MustCompile(`(?i)(?:https?|socks5h?)://[^\s"'<>]+`)
defaultTextRedactPatterns = compileTextRedactPatterns(nil)
extraTextPatternCache sync.Map // map[string]*textRedactPatterns
@@ -96,7 +104,7 @@ func RedactText(input string, extraKeys ...string) string {
patterns := getTextRedactPatterns(extraKeys)
- out := input
+ out := redactURLUserinfo(input)
out = reGOCSPX.ReplaceAllString(out, "GOCSPX-***")
out = reAIza.ReplaceAllString(out, "AIza***")
out = patterns.reJSONLike.ReplaceAllString(out, `$1***$3`)
@@ -217,11 +225,29 @@ func redactValueWithDepth(value any, keys map[string]struct{}, depth int) any {
out[i] = redactValueWithDepth(item, keys, depth+1)
}
return out
+ case string:
+ return redactURLUserinfo(v)
default:
return value
}
}
+func redactURLUserinfo(input string) string {
+ return reURL.ReplaceAllStringFunc(input, func(candidate string) string {
+ parsed, err := url.Parse(candidate)
+ if err == nil && parsed.User != nil {
+ parsed.User = nil
+ return parsed.String()
+ }
+ schemeEnd := strings.Index(candidate, "://")
+ userinfoEnd := strings.LastIndex(candidate, "@")
+ if schemeEnd >= 0 && userinfoEnd > schemeEnd+3 {
+ return candidate[:schemeEnd+3] + candidate[userinfoEnd+1:]
+ }
+ return candidate
+ })
+}
+
func isSensitiveKey(key string, keys map[string]struct{}) bool {
_, ok := keys[normalizeKey(key)]
return ok
diff --git a/backend/internal/util/logredact/redact_test.go b/backend/internal/util/logredact/redact_test.go
index 266db69dbd7..65768540a25 100644
--- a/backend/internal/util/logredact/redact_test.go
+++ b/backend/internal/util/logredact/redact_test.go
@@ -27,6 +27,14 @@ func TestRedactText_QueryLike(t *testing.T) {
}
}
+func TestRedactTextAPIKeyFields(t *testing.T) {
+ in := `{"api_key":"sk-secret-value","custom_key":"custom-secret","verification":"fresh-password","other":"ok"}`
+ out := RedactText(in)
+ if strings.Contains(out, "sk-secret-value") || strings.Contains(out, "custom-secret") || strings.Contains(out, "fresh-password") {
+ t.Fatalf("expected API key fields redacted, got %q", out)
+ }
+}
+
func TestRedactText_GOCSPX(t *testing.T) {
in := "client_secret=GOCSPX-your-client-secret"
out := RedactText(in)
@@ -67,6 +75,28 @@ func TestRedactText_DefaultPathDoesNotUseExtraCache(t *testing.T) {
}
}
+func TestRedactTextRemovesProxyURLUserInfo(t *testing.T) {
+ input := `proxy=http://alice:p%40ssword@proxy.example.com:8080/path?mode=ok`
+ out := RedactText(input)
+ if strings.Contains(out, "alice") || strings.Contains(out, "ssword") {
+ t.Fatalf("expected proxy userinfo removed, got %q", out)
+ }
+ if !strings.Contains(out, "http://proxy.example.com:8080/path?mode=ok") {
+ t.Fatalf("expected non-secret endpoint retained, got %q", out)
+ }
+}
+
+func TestRedactJSONRemovesProxyURLUserInfoFromValues(t *testing.T) {
+ input := `{"proxy":"socks5h://alice:secret@socks.example.com:1080","other":"ok"}`
+ out := RedactText(input)
+ if strings.Contains(out, "alice") || strings.Contains(out, "secret") {
+ t.Fatalf("expected proxy userinfo removed from JSON value, got %q", out)
+ }
+ if !strings.Contains(out, "socks5h://socks.example.com:1080") {
+ t.Fatalf("expected non-secret endpoint retained, got %q", out)
+ }
+}
+
func clearExtraTextPatternCache() {
extraTextPatternCache.Range(func(key, value any) bool {
extraTextPatternCache.Delete(key)
diff --git a/backend/internal/util/urlvalidator/validator.go b/backend/internal/util/urlvalidator/validator.go
index fc2b9bc4d7e..acb9cbaa690 100644
--- a/backend/internal/util/urlvalidator/validator.go
+++ b/backend/internal/util/urlvalidator/validator.go
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net"
+ "net/netip"
"net/url"
"strconv"
"strings"
@@ -17,6 +18,19 @@ type ValidationOptions struct {
AllowPrivate bool
}
+const safeDialTimeout = 5 * time.Second
+
+var blockedSpecialPrefixes = []netip.Prefix{
+ netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT
+ netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments
+ netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1
+ netip.MustParsePrefix("198.18.0.0/15"), // benchmark networks
+ netip.MustParsePrefix("198.51.100.0/24"),
+ netip.MustParsePrefix("203.0.113.0/24"),
+ netip.MustParsePrefix("240.0.0.0/4"),
+ netip.MustParsePrefix("2001:db8::/32"), // IPv6 documentation range
+}
+
// ValidateHTTPURL validates an outbound HTTP/HTTPS URL.
//
// It provides a single validation entry point that supports:
@@ -33,7 +47,10 @@ func ValidateHTTPURL(raw string, allowInsecureHTTP bool, opts ValidationOptions)
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
- return "", fmt.Errorf("invalid url: %s", trimmed)
+ return "", errors.New("invalid url")
+ }
+ if parsed.User != nil {
+ return "", errors.New("url userinfo is not allowed")
}
scheme := strings.ToLower(parsed.Scheme)
@@ -78,7 +95,10 @@ func ValidateURLFormat(raw string, allowInsecureHTTP bool) (string, error) {
parsed, err := url.Parse(trimmed)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
- return "", fmt.Errorf("invalid url: %s", trimmed)
+ return "", errors.New("invalid url")
+ }
+ if parsed.User != nil {
+ return "", errors.New("url userinfo is not allowed")
}
scheme := strings.ToLower(parsed.Scheme)
@@ -115,16 +135,70 @@ func ValidateResolvedIP(host string) error {
if err != nil {
return fmt.Errorf("dns resolution failed: %w", err)
}
+ if err := validateResolvedAddresses(ips); err != nil {
+ return err
+ }
+ return nil
+}
+// NewSafeDialContext resolves a hostname once, rejects every non-public answer,
+// and dials one of the exact validated IPs. This closes the validate-then-
+// resolve TOCTOU window that otherwise permits DNS rebinding between a
+// preflight lookup and net.Dialer.
+func NewSafeDialContext(allowPrivate bool) func(context.Context, string, string) (net.Conn, error) {
+ dialer := &net.Dialer{Timeout: safeDialTimeout}
+ if allowPrivate {
+ return dialer.DialContext
+ }
+ return func(ctx context.Context, network, address string) (net.Conn, error) {
+ host, port, err := net.SplitHostPort(address)
+ if err != nil {
+ return nil, fmt.Errorf("invalid dial address: %w", err)
+ }
+ ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
+ if err != nil {
+ return nil, fmt.Errorf("dns resolution failed: %w", err)
+ }
+ if err := validateResolvedAddresses(ips); err != nil {
+ return nil, err
+ }
+ return dialValidatedAddresses(ctx, dialer, network, port, ips)
+ }
+}
+
+func validateResolvedAddresses(ips []net.IP) error {
+ if len(ips) == 0 {
+ return errors.New("dns resolution returned no addresses")
+ }
for _, ip := range ips {
- if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
- ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
- return fmt.Errorf("resolved ip %s is not allowed", ip.String())
+ if isBlockedIP(ip) {
+ return errors.New("resolved address is not publicly routable")
}
}
return nil
}
+func dialValidatedAddresses(ctx context.Context, dialer *net.Dialer, network, port string, ips []net.IP) (net.Conn, error) {
+ var lastErr error
+ for _, ip := range ips {
+ if network == "tcp4" && ip.To4() == nil {
+ continue
+ }
+ if network == "tcp6" && ip.To4() != nil {
+ continue
+ }
+ conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
+ if err == nil {
+ return conn, nil
+ }
+ lastErr = err
+ }
+ if lastErr == nil {
+ lastErr = errors.New("no resolved address matches the requested network")
+ }
+ return nil, lastErr
+}
+
func normalizeAllowlist(values []string) []string {
if len(values) == 0 {
return nil
@@ -167,7 +241,28 @@ func isBlockedHost(host string) bool {
return true
}
if ip := net.ParseIP(host); ip != nil {
- if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
+ if isBlockedIP(ip) {
+ return true
+ }
+ }
+ return false
+}
+
+func isBlockedIP(ip net.IP) bool {
+ if ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
+ ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() {
+ return true
+ }
+ addr, ok := netip.AddrFromSlice(ip)
+ if !ok {
+ return true
+ }
+ addr = addr.Unmap()
+ if !addr.IsGlobalUnicast() {
+ return true
+ }
+ for _, prefix := range blockedSpecialPrefixes {
+ if prefix.Contains(addr) {
return true
}
}
diff --git a/backend/internal/util/urlvalidator/validator_test.go b/backend/internal/util/urlvalidator/validator_test.go
index bec9bb2110d..03b5d71cf82 100644
--- a/backend/internal/util/urlvalidator/validator_test.go
+++ b/backend/internal/util/urlvalidator/validator_test.go
@@ -1,6 +1,11 @@
package urlvalidator
-import "testing"
+import (
+ "context"
+ "net"
+ "strings"
+ "testing"
+)
func TestValidateURLFormat(t *testing.T) {
if _, err := ValidateURLFormat("", false); err == nil {
@@ -72,4 +77,40 @@ func TestValidateHTTPURL(t *testing.T) {
if _, err := ValidateHTTPURL("https://localhost", false, ValidationOptions{AllowPrivate: false}); err == nil {
t.Fatalf("expected localhost to be blocked when allow_private_hosts is false")
}
+ if _, err := ValidateHTTPURL("https://user:secret@example.com", false, ValidationOptions{}); err == nil {
+ t.Fatal("expected URL userinfo to be rejected")
+ } else if strings.Contains(err.Error(), "secret") {
+ t.Fatalf("userinfo secret leaked in validation error: %v", err)
+ }
+ if _, err := ValidateHTTPURL("https://user:secret@%zz", false, ValidationOptions{}); err == nil {
+ t.Fatal("expected malformed URL to be rejected")
+ } else if strings.Contains(err.Error(), "secret") {
+ t.Fatalf("malformed URL secret leaked in validation error: %v", err)
+ }
+}
+
+func TestValidateResolvedAddressesRejectsNonPublicNetworks(t *testing.T) {
+ for _, raw := range []string{
+ "127.0.0.1", "10.0.0.1", "169.254.169.254", "100.64.0.1",
+ "198.18.0.1", "192.0.2.1", "0.0.0.0", "::1", "fc00::1", "2001:db8::1",
+ } {
+ t.Run(raw, func(t *testing.T) {
+ if err := validateResolvedAddresses([]net.IP{net.ParseIP(raw)}); err == nil {
+ t.Fatalf("non-public address %s was accepted", raw)
+ }
+ })
+ }
+ if err := validateResolvedAddresses([]net.IP{net.ParseIP("8.8.8.8"), net.ParseIP("2606:4700:4700::1111")}); err != nil {
+ t.Fatalf("public addresses rejected: %v", err)
+ }
+ if err := validateResolvedAddresses([]net.IP{net.ParseIP("8.8.8.8"), net.ParseIP("127.0.0.1")}); err == nil {
+ t.Fatal("mixed public/private DNS answer was accepted")
+ }
+}
+
+func TestSafeDialContextRejectsLoopbackBeforeConnect(t *testing.T) {
+ _, err := NewSafeDialContext(false)(context.Background(), "tcp", "127.0.0.1:1")
+ if err == nil || !strings.Contains(err.Error(), "not publicly routable") {
+ t.Fatalf("safe dial error = %v", err)
+ }
}
diff --git a/backend/internal/web/embed_on.go b/backend/internal/web/embed_on.go
index 2279d913209..4c5970b0685 100644
--- a/backend/internal/web/embed_on.go
+++ b/backend/internal/web/embed_on.go
@@ -7,6 +7,7 @@ import (
"context"
"embed"
"encoding/json"
+ htmlstd "html"
"io"
"io/fs"
"net/http"
@@ -230,7 +231,7 @@ func injectSiteTitle(html, settingsJSON []byte) []byte {
return html
}
- newTitle := []byte("" + cfg.SiteName + " - AI API Gateway ")
+ newTitle := []byte("" + htmlstd.EscapeString(cfg.SiteName) + " - AI API Gateway ")
var buf bytes.Buffer
buf.Write(html[:titleStart])
buf.Write(newTitle)
@@ -303,6 +304,8 @@ func shouldBypassEmbeddedFrontend(path string) bool {
strings.HasPrefix(trimmed, "/v1beta/") ||
strings.HasPrefix(trimmed, "/backend-api/") ||
strings.HasPrefix(trimmed, "/antigravity/") ||
+ strings.HasPrefix(trimmed, "/kiro/") ||
+ strings.HasPrefix(trimmed, "/cursor/") ||
strings.HasPrefix(trimmed, "/setup/") ||
trimmed == "/health" ||
trimmed == "/responses" ||
diff --git a/backend/internal/web/embed_test.go b/backend/internal/web/embed_test.go
index 583d98a0b9b..a9d84f74eba 100644
--- a/backend/internal/web/embed_test.go
+++ b/backend/internal/web/embed_test.go
@@ -90,6 +90,18 @@ func TestInjectSiteTitle(t *testing.T) {
assert.Contains(t, string(result), `
`)
assert.Contains(t, string(result), "TestSite - AI API Gateway ")
})
+
+ t.Run("escapes_site_name_before_nonce_replacement", func(t *testing.T) {
+ html := []byte(`Sub2API `)
+ settingsJSON := []byte(`{"site_name":""}`)
+
+ result := injectSiteTitle(html, settingsJSON)
+ result = replaceNoncePlaceholder(result, "request-nonce")
+
+ assert.NotContains(t, string(result), `
diff --git a/frontend/src/components/admin/monitor/MonitorFormDialog.vue b/frontend/src/components/admin/monitor/MonitorFormDialog.vue
index 21fa4715d03..5d17aad805e 100644
--- a/frontend/src/components/admin/monitor/MonitorFormDialog.vue
+++ b/frontend/src/components/admin/monitor/MonitorFormDialog.vue
@@ -47,7 +47,8 @@
@@ -114,7 +115,26 @@
{{ t('admin.channelMonitor.templateField.applyHint') }}
+
+
{{ t('admin.channelMonitor.advanced.writeOnlyNotice') }}
+
+
+ {{ t('admin.channelMonitor.advanced.replaceExisting') }}
+
+
+
+
+ {{ t('admin.channelMonitor.advanced.templateAppliedServerSide') }}
+
+
body_override_mode: BodyOverrideMode
body_override: Record | null
+ replace_request_customization: boolean
}
const form = reactive({
@@ -243,6 +266,7 @@ const form = reactive({
provider: PROVIDER_ANTHROPIC,
endpoint: '',
api_key: '',
+ api_key_id: null,
primary_model: '',
extra_models: [],
group_name: '',
@@ -252,6 +276,7 @@ const form = reactive({
extra_headers: {},
body_override_mode: 'off',
body_override: null,
+ replace_request_customization: false,
})
// 可用模板列表(进入 dialog 时一次性拉取 cache;按 provider 过滤)。
@@ -284,20 +309,17 @@ async function loadTemplates() {
const templateSelectValue = computed({
get: () => (form.template_id == null ? '' : String(form.template_id)),
set: (raw: string) => {
+ const previous = form.template_id
if (raw === '') {
form.template_id = null
+ if (editing.value && previous !== null) form.replace_request_customization = true
return
}
const id = Number(raw)
if (!Number.isFinite(id)) return
form.template_id = id
- // 应用模板 = 拷贝快照
- const tpl = templatesCache.value.find((t) => t.id === id)
- if (tpl) {
- form.extra_headers = { ...(tpl.extra_headers || {}) }
- form.body_override_mode = tpl.body_override_mode
- form.body_override = tpl.body_override ? { ...tpl.body_override } : null
- }
+ // 模板内容是 write-only,由后端按 template_id 安全拷贝快照。
+ if (editing.value && previous !== id) form.replace_request_customization = true
},
})
@@ -317,16 +339,26 @@ const providerOptions = computed(() => [
// typing, so clearing on provider change is always a safe no-op until the user
// picks a new key.
// 同时清空 template_id(模板有 provider 归属,跨平台不通用)。
-watch(() => form.provider, () => {
- form.api_key = ''
- form.template_id = null
-})
+watch(
+ () => form.provider,
+ (provider) => {
+ // loadFromMonitor 会先恢复 provider、随后恢复 template_id;加载原值时不能触发清空。
+ if (editing.value && provider === editing.value.provider) return
+ form.api_key = ''
+ form.api_key_id = null
+ if (editing.value) {
+ form.replace_request_customization = true
+ }
+ form.template_id = null
+ },
+)
function resetForm() {
form.name = ''
form.provider = PROVIDER_ANTHROPIC
form.endpoint = ''
form.api_key = ''
+ form.api_key_id = null
form.primary_model = ''
form.extra_models = []
form.group_name = ''
@@ -336,6 +368,7 @@ function resetForm() {
form.extra_headers = {}
form.body_override_mode = 'off'
form.body_override = null
+ form.replace_request_customization = false
}
function loadFromMonitor(m: ChannelMonitor) {
@@ -343,15 +376,18 @@ function loadFromMonitor(m: ChannelMonitor) {
form.provider = m.provider
form.endpoint = m.endpoint
form.api_key = ''
+ form.api_key_id = null
form.primary_model = m.primary_model
form.extra_models = [...(m.extra_models || [])]
form.group_name = m.group_name || ''
form.interval_seconds = m.interval_seconds || systemDefaultInterval.value
form.enabled = m.enabled
form.template_id = m.template_id ?? null
- form.extra_headers = { ...(m.extra_headers || {}) }
- form.body_override_mode = m.body_override_mode || 'off'
- form.body_override = m.body_override ? { ...m.body_override } : null
+ // 请求自定义字段不从 API 回显;编辑时默认保留原配置。
+ form.extra_headers = {}
+ form.body_override_mode = 'off'
+ form.body_override = null
+ form.replace_request_customization = false
}
// Re-sync form whenever the dialog is opened or the target monitor changes.
@@ -396,26 +432,33 @@ async function openMyKeyPicker() {
}
function pickMyKey(k: ApiKey) {
- form.api_key = k.key
+ // 只把已认证账号下的 key ID 交给后端解析;不要把 key 值复制进表单或提交体。
+ form.api_key = ''
+ form.api_key_id = k.id
showKeyPicker.value = false
}
function buildPayload(): CreateParams {
- return {
+ const payload: CreateParams = {
name: form.name.trim(),
provider: form.provider,
endpoint: form.endpoint.trim(),
- api_key: form.api_key.trim(),
primary_model: form.primary_model.trim(),
extra_models: form.extra_models,
group_name: form.group_name.trim(),
enabled: form.enabled,
interval_seconds: form.interval_seconds,
- template_id: form.template_id,
- extra_headers: form.extra_headers,
- body_override_mode: form.body_override_mode,
- body_override: form.body_override,
}
+ if (form.api_key.trim()) payload.api_key = form.api_key.trim()
+ if (form.api_key_id != null) payload.api_key_id = form.api_key_id
+ if (form.template_id != null) {
+ payload.template_id = form.template_id
+ } else {
+ payload.extra_headers = form.extra_headers
+ payload.body_override_mode = form.body_override_mode
+ payload.body_override = form.body_override
+ }
+ return payload
}
async function handleSubmit() {
@@ -433,15 +476,27 @@ async function handleSubmit() {
try {
const target = editing.value
if (target) {
- const { api_key, ...rest } = buildPayload()
- const req: UpdateParams = { ...rest }
+ const {
+ api_key,
+ api_key_id,
+ template_id: _templateID,
+ extra_headers: _extraHeaders,
+ body_override_mode: _bodyOverrideMode,
+ body_override: _bodyOverride,
+ ...rest
+ } = buildPayload()
+ let req: UpdateParams = { ...rest }
// Only send api_key if user typed a new value
if (api_key) req.api_key = api_key
- // template_id=null 用 clear_template=true 明确告诉后端清空(pointer 语义)
- if (form.template_id == null) {
- req.clear_template = true
- delete req.template_id
- }
+ if (api_key_id) req.api_key_id = api_key_id
+
+ req = applyWriteOnlyMonitorCustomization(req, target.template_id ?? null, {
+ templateId: form.template_id,
+ replace: form.replace_request_customization,
+ extraHeaders: form.extra_headers,
+ bodyOverrideMode: form.body_override_mode,
+ bodyOverride: form.body_override,
+ })
await adminAPI.channelMonitor.update(target.id, req)
appStore.showSuccess(t('admin.channelMonitor.updateSuccess'))
} else {
diff --git a/frontend/src/components/admin/monitor/MonitorTemplateManagerDialog.vue b/frontend/src/components/admin/monitor/MonitorTemplateManagerDialog.vue
index 3a03f5bc455..9de195cbecd 100644
--- a/frontend/src/components/admin/monitor/MonitorTemplateManagerDialog.vue
+++ b/frontend/src/components/admin/monitor/MonitorTemplateManagerDialog.vue
@@ -77,7 +77,7 @@
{{ t('admin.channelMonitor.template.headersSummary', {
- n: Object.keys(tpl.extra_headers || {}).length,
+ n: tpl.extra_header_count,
}) }}
@@ -149,7 +149,19 @@
/>
+
+
{{ t('admin.channelMonitor.advanced.writeOnlyNotice') }}
+
+
+ {{ t('admin.channelMonitor.advanced.replaceExisting') }}
+
+
+
body_override_mode: BodyOverrideMode
body_override: Record | null
+ replace_request_customization: boolean
}
const editing = ref(null) // null = list view; 'new' = create; = edit
@@ -282,6 +297,7 @@ function emptyForm(provider: Provider): TemplateForm {
extra_headers: {},
body_override_mode: 'off',
body_override: null,
+ replace_request_customization: false,
}
}
@@ -290,13 +306,16 @@ function loadForm(tpl: ChannelMonitorTemplate) {
form.name = tpl.name
form.provider = tpl.provider
form.description = tpl.description
- form.extra_headers = { ...(tpl.extra_headers || {}) }
- form.body_override_mode = tpl.body_override_mode
- form.body_override = tpl.body_override ? { ...tpl.body_override } : null
+ // 请求自定义字段不从 API 回显;默认保留原配置。
+ form.extra_headers = {}
+ form.body_override_mode = 'off'
+ form.body_override = null
+ form.replace_request_customization = false
}
function openCreateForm() {
Object.assign(form, emptyForm(activeProvider.value))
+ form.replace_request_customization = true
editing.value = 'new'
}
@@ -353,13 +372,17 @@ async function handleSubmit() {
})
appStore.showSuccess(t('admin.channelMonitor.template.createSuccess'))
} else if (typeof editing.value === 'number') {
- await adminAPI.channelMonitorTemplate.update(editing.value, {
+ const baseUpdate: TemplateUpdateParams = {
name: form.name.trim(),
description: form.description.trim(),
- extra_headers: form.extra_headers,
- body_override_mode: form.body_override_mode,
- body_override: form.body_override,
+ }
+ const update = applyWriteOnlyTemplateCustomization(baseUpdate, {
+ replace: form.replace_request_customization,
+ extraHeaders: form.extra_headers,
+ bodyOverrideMode: form.body_override_mode,
+ bodyOverride: form.body_override,
})
+ await adminAPI.channelMonitorTemplate.update(editing.value, update)
appStore.showSuccess(t('admin.channelMonitor.template.updateSuccess'))
}
await fetchTemplates()
diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue
index adcb3cc627c..ae325b4eb42 100644
--- a/frontend/src/components/admin/usage/UsageTable.vue
+++ b/frontend/src/components/admin/usage/UsageTable.vue
@@ -1,6 +1,6 @@